native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
8.6 kB · 215 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216import ComposableArchitectureimport Foundationimport ProwlCLIShared
typealias CLIInstallStatus = SymlinkInstallStatus
extension CLIInstallStatus { /// The workflow banners' button for this slot: a fresh install, a repair of a dangling link, /// or a reinstall over a foreign link. nil when a real file or directory occupies the path — /// the installer refuses to replace those, so no button is honest there. nonisolated var installActionTitle: String? { switch self { case .notInstalled: "Install" case .broken: "Repair" case .installedDifferentSource(_, let destination): destination == nil ? nil : "Reinstall" case .installed: "Reinstall" } }
/// Why a workflow cannot start with this slot (docs-ai 063 D1 preflight), for the Settings /// and start-sheet banners; says what to do when no button can. nonisolated var workflowBlockerCopy: String { switch self { case .notInstalled: return String( localized: "Participants deliver their results through prowl, so a run cannot start until it is installed." ) case .broken(let path, _): return String( localized: """ The link at \(path) points at an app that is gone. \ Participants deliver their results through prowl, so a run cannot start until it is repaired. """ ) case .installedDifferentSource(let path, let destination): if destination == nil { return String( localized: """ \(path) is a file or folder that is not an executable prowl command, and Prowl never replaces \ one. Remove it, then install the command from Settings › Agents › CLI & Skills. """ ) } return String( localized: """ The link at \(path) points at something that is not an executable command. \ Participants deliver their results through prowl, so a run cannot start until it is replaced. """ ) case .installed(let path): return String( localized: """ \(path) is not executable. \ Participants deliver their results through prowl, so a run cannot start until it is reinstalled. """ ) } }}
struct CLIInstallError: Error, Equatable, Sendable, LocalizedError { let message: String
var errorDescription: String? { message }}
let cliDefaultInstallPath = URL(fileURLWithPath: "/usr/local/bin/prowl")
struct CLIInstallClient: Sendable { var bundledCLIURL: @Sendable () -> URL? var installationStatus: @Sendable (_ installPath: URL) -> CLIInstallStatus /// Whether a shell can run `prowl` from the slot (docs-ai 063 D1): the path — through any /// symlink — is an executable regular file. Another build's live link qualifies (063.011); /// a dangling link, a directory, or a non-executable file in the way does not, whatever /// `installationStatus` calls it. var isUsable: @Sendable (_ installPath: URL) -> Bool var install: @Sendable (_ installPath: URL) async throws -> Void var uninstall: @Sendable (_ installPath: URL) async throws -> Void
nonisolated static func isExecutableCommand(at path: String, fileManager: FileManager = .default) -> Bool { var isDirectory: ObjCBool = false // fileExists follows symlinks, so a dangling link reports absent. guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue else { return false } return fileManager.isExecutableFile(atPath: path) }}
extension CLIInstallClient: DependencyKey { static let liveValue = CLIInstallClient( bundledCLIURL: { Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") }, installationStatus: { installPath in let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") return SymlinkInstaller.status( linkPath: installPath.path(percentEncoded: false), source: bundledURL?.path(percentEncoded: false) ?? "" ) }, isUsable: { installPath in CLIInstallClient.isExecutableCommand(at: installPath.path(percentEncoded: false)) }, install: { installPath in guard let bundledURL = Bundle.main.resourceURL?.appendingPathComponent("prowl-cli/prowl") else { throw CLIInstallError(message: String(localized: "Could not locate bundled CLI binary.")) } let bundledPath = bundledURL.path(percentEncoded: false) guard FileManager.default.fileExists(atPath: bundledPath) else { throw CLIInstallError(message: String(localized: "Bundled CLI binary not found at \(bundledPath).")) } try cliSymlinkInstall(source: bundledPath, destination: installPath.path(percentEncoded: false)) }, uninstall: { installPath in try cliSymlinkUninstall(path: installPath.path(percentEncoded: false)) } )
static let testValue = CLIInstallClient( bundledCLIURL: { nil }, installationStatus: { _ in .notInstalled }, isUsable: { _ in true }, install: { _ in }, uninstall: { _ in } )}
// MARK: - Symlink operations with privilege escalation
/// Creates the CLI symlink through the shared installer. Falls back to osascript privilege/// escalation on permission failure; conflicts and other typed failures surface as-is.private nonisolated func cliSymlinkInstall(source: String, destination: String) throws { do { try SymlinkInstaller.install(source: source, linkPath: destination) return } catch SymlinkInstallError.conflict { throw CLIInstallError( message: String( localized: "A file already exists at \(destination) and is not a symlink. Remove it manually before installing." ) ) } catch let error as SymlinkInstallError { throw CLIInstallError(message: error.localizedDescription) } catch let error as NSError where isPermissionError(error) { // Fall through to privilege escalation }
let dir = (destination as NSString).deletingLastPathComponent let script = "mkdir -p '\(shellEscape(dir))' && " + "rm -f '\(shellEscape(destination))' && " + "ln -s '\(shellEscape(source))' '\(shellEscape(destination))'" try runPrivileged(script: script)}
/// Removes the CLI symlink through the shared installer. Falls back to osascript privilege/// escalation on permission failure; real files are refused before any attempt.private nonisolated func cliSymlinkUninstall(path: String) throws { do { try SymlinkInstaller.uninstall(linkPath: path) return } catch SymlinkInstallError.notInstalled { throw CLIInstallError(message: String(localized: "No CLI tool found at \(path).")) } catch SymlinkInstallError.conflict { throw CLIInstallError( message: String(localized: "File at \(path) is not a symlink. Refusing to remove for safety.") ) } catch let error as SymlinkInstallError { throw CLIInstallError(message: error.localizedDescription) } catch let error as NSError where isPermissionError(error) { // Fall through to privilege escalation }
let script = "rm -f '\(shellEscape(path))'" try runPrivileged(script: script)}
private nonisolated func isPermissionError(_ error: NSError) -> Bool { (error.domain == NSCocoaErrorDomain && error.code == NSFileWriteNoPermissionError) || (error.domain == NSPOSIXErrorDomain && error.code == 13)}
/// Runs a shell command with administrator privileges via osascript.private nonisolated func runPrivileged(script: String) throws { let osa = Process() osa.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") osa.arguments = ["-e", "do shell script \"\(script)\" with administrator privileges"] let pipe = Pipe() osa.standardError = pipe do { try osa.run() } catch { throw CLIInstallError( message: String(localized: "Failed to launch authorization prompt: \(error.localizedDescription)") ) } osa.waitUntilExit() guard osa.terminationStatus == 0 else { let stderr = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" if stderr.contains("User canceled") || stderr.contains("-128") { throw CLIInstallError(message: String(localized: "Installation was canceled.")) } throw CLIInstallError(message: String(localized: "Installation failed: \(stderr)")) }}
private nonisolated func shellEscape(_ value: String) -> String { value.replacing("'", with: "'\\''")}
extension DependencyValues { var cliInstallClient: CLIInstallClient { get { self[CLIInstallClient.self] } set { self[CLIInstallClient.self] = newValue } }}