import Foundation enum CommandResolver { private static let projectRootDefaultsKey = "clawdbot.gatewayProjectRootPath" private static let helperName = "clawdbot" static func gatewayEntrypoint(in root: URL) -> String? { let distEntry = root.appendingPathComponent("dist/index.js").path if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry } let binEntry = root.appendingPathComponent("bin/clawdbot.js").path if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry } return nil } static func runtimeResolution() -> Result { RuntimeLocator.resolve(searchPaths: self.preferredPaths()) } static func runtimeResolution(searchPaths: [String]?) -> Result { RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths()) } static func makeRuntimeCommand( runtime: RuntimeResolution, entrypoint: String, subcommand: String, extraArgs: [String]) -> [String] { [runtime.path, entrypoint, subcommand] + extraArgs } static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] { let message = RuntimeLocator.describeFailure(error) return self.errorCommand(with: message) } static func errorCommand(with message: String) -> [String] { let script = """ cat <<'__CLAWDBOT_ERR__' >&2 \(message) __CLAWDBOT_ERR__ exit 1 """ return ["/bin/sh", "-c", script] } static func projectRoot() -> URL { if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), let url = self.expandPath(stored), FileManager.default.fileExists(atPath: url.path) { return url } let fallback = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Projects/clawdbot") if FileManager.default.fileExists(atPath: fallback.path) { return fallback } return FileManager.default.homeDirectoryForCurrentUser } static func setProjectRoot(_ path: String) { UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) } static func projectRootPath() -> String { self.projectRoot().path } static func preferredPaths() -> [String] { let current = ProcessInfo.processInfo.environment["PATH"]? .split(separator: ":").map(String.init) ?? [] let home = FileManager.default.homeDirectoryForCurrentUser let projectRoot = self.projectRoot() return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) } static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] { var extras = [ home.appendingPathComponent("Library/pnpm").path, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", ] extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) let clawdbotPaths = self.clawdbotManagedPaths(home: home) if !clawdbotPaths.isEmpty { extras.insert(contentsOf: clawdbotPaths, at: 1) } extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + clawdbotPaths.count) var seen = Set() // Preserve order while stripping duplicates so PATH lookups remain deterministic. return (extras + current).filter { seen.insert($0).inserted } } private static func clawdbotManagedPaths(home: URL) -> [String] { let base = home.appendingPathComponent(".clawdbot") let bin = base.appendingPathComponent("bin") let nodeBin = base.appendingPathComponent("tools/node/bin") var paths: [String] = [] if FileManager.default.fileExists(atPath: bin.path) { paths.append(bin.path) } if FileManager.default.fileExists(atPath: nodeBin.path) { paths.append(nodeBin.path) } return paths } private static func nodeManagerBinPaths(home: URL) -> [String] { var bins: [String] = [] // Volta let volta = home.appendingPathComponent(".volta/bin") if FileManager.default.fileExists(atPath: volta.path) { bins.append(volta.path) } // asdf let asdf = home.appendingPathComponent(".asdf/shims") if FileManager.default.fileExists(atPath: asdf.path) { bins.append(asdf.path) } // fnm bins.append(contentsOf: self.versionedNodeBinPaths( base: home.appendingPathComponent(".local/share/fnm/node-versions"), suffix: "installation/bin")) // nvm bins.append(contentsOf: self.versionedNodeBinPaths( base: home.appendingPathComponent(".nvm/versions/node"), suffix: "bin")) return bins } private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { guard FileManager.default.fileExists(atPath: base.path) else { return [] } let entries: [String] do { entries = try FileManager.default.contentsOfDirectory(atPath: base.path) } catch { return [] } func parseVersion(_ name: String) -> [Int] { let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name return trimmed.split(separator: ".").compactMap { Int($0) } } let sorted = entries.sorted { a, b in let va = parseVersion(a) let vb = parseVersion(b) let maxCount = max(va.count, vb.count) for i in 0.. bi } } // If identical numerically, keep stable ordering. return a > b } var paths: [String] = [] for entry in sorted { let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) let node = binDir.appendingPathComponent("node") if FileManager.default.isExecutableFile(atPath: node.path) { paths.append(binDir.path) } } return paths } static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { for dir in searchPaths ?? self.preferredPaths() { let candidate = (dir as NSString).appendingPathComponent(name) if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } return nil } static func clawdbotExecutable(searchPaths: [String]? = nil) -> String? { self.findExecutable(named: self.helperName, searchPaths: searchPaths) } static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? { let root = projectRoot ?? self.projectRoot() let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil } static func nodeCliPath() -> String? { let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil } static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool { if self.clawdbotExecutable(searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, self.nodeCliPath() != nil { return true } return false } static func clawdbotNodeCommand( subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, searchPaths: [String]? = nil) -> [String] { let settings = self.connectionSettings(defaults: defaults) if settings.mode == .remote, let ssh = self.sshNodeCommand( subcommand: subcommand, extraArgs: extraArgs, settings: settings) { return ssh } let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) switch runtimeResult { case let .success(runtime): let root = self.projectRoot() if let clawdbotPath = self.projectClawdbotExecutable(projectRoot: root) { return [clawdbotPath, subcommand] + extraArgs } if let entry = self.gatewayEntrypoint(in: root) { return self.makeRuntimeCommand( runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs) } if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. return [pnpm, "--silent", "clawdbot", subcommand] + extraArgs } if let clawdbotPath = self.clawdbotExecutable(searchPaths: searchPaths) { return [clawdbotPath, subcommand] + extraArgs } let missingEntry = """ clawdbot entrypoint missing (looked for dist/index.js or bin/clawdbot.js); run pnpm build. """ return self.errorCommand(with: missingEntry) case let .failure(error): return self.runtimeErrorCommand(error) } } // Existing callers still refer to clawdbotCommand; keep it as node alias. static func clawdbotCommand( subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, searchPaths: [String]? = nil) -> [String] { self.clawdbotNodeCommand( subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, searchPaths: searchPaths) } // MARK: - SSH helpers private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { guard !settings.target.isEmpty else { return nil } guard let parsed = self.parseSSHTarget(settings.target) else { return nil } var args: [String] = [ "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "UpdateHostKeys=yes", ] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { args.append(contentsOf: ["-i", settings.identity]) } let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host args.append(userHost) // Run the real clawdbot CLI on the remote host. let exportedPath = [ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "$HOME/Library/pnpm", "$PATH", ].joined(separator: ":") let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines) let projectSection = if userPRJ.isEmpty { """ DEFAULT_PRJ="$HOME/Projects/clawdbot" if [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ" cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } fi """ } else { """ PRJ=\(self.shellQuote(userPRJ)) cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; } """ } let cliSection = if userCLI.isEmpty { "" } else { """ CLI_HINT=\(self.shellQuote(userCLI)) if [ -n "$CLI_HINT" ]; then if [ -x "$CLI_HINT" ]; then CLI="$CLI_HINT" "$CLI_HINT" \(quotedArgs); exit $?; elif [ -f "$CLI_HINT" ]; then if command -v node >/dev/null 2>&1; then CLI="node $CLI_HINT" node "$CLI_HINT" \(quotedArgs); exit $?; fi fi fi """ } let scriptBody = """ PATH=\(exportedPath); CLI=""; \(cliSection) \(projectSection) if command -v clawdbot >/dev/null 2>&1; then CLI="$(command -v clawdbot)" clawdbot \(quotedArgs); elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then if command -v node >/dev/null 2>&1; then CLI="node $PRJ/dist/index.js" node "$PRJ/dist/index.js" \(quotedArgs); else echo "Node >=22 required on remote host"; exit 127; fi elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/clawdbot.js" ]; then if command -v node >/dev/null 2>&1; then CLI="node $PRJ/bin/clawdbot.js" node "$PRJ/bin/clawdbot.js" \(quotedArgs); else echo "Node >=22 required on remote host"; exit 127; fi elif command -v pnpm >/dev/null 2>&1; then CLI="pnpm --silent clawdbot" pnpm --silent clawdbot \(quotedArgs); else echo "clawdbot CLI missing on remote host"; exit 127; fi """ args.append(contentsOf: ["/bin/sh", "-c", scriptBody]) return ["/usr/bin/ssh"] + args } struct RemoteSettings { let mode: AppState.ConnectionMode let target: String let identity: String let projectRoot: String let cliPath: String } static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { let root = ClawdbotConfigFile.loadDict() let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" return RemoteSettings( mode: mode, target: self.sanitizedTarget(target), identity: identity, projectRoot: projectRoot, cliPath: cliPath) } static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { self.connectionSettings(defaults: defaults).mode == .remote } private static func sanitizedTarget(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("ssh ") { return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) } return trimmed } struct SSHParsedTarget { let user: String? let host: String let port: Int } static func parseSSHTarget(_ target: String) -> SSHParsedTarget? { let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let userHostPort: String let user: String? if let atRange = trimmed.range(of: "@") { user = String(trimmed[.. String { if text.isEmpty { return "''" } let escaped = text.replacingOccurrences(of: "'", with: "'\\''") return "'\(escaped)'" } private static func expandPath(_ path: String) -> URL? { var expanded = path if expanded.hasPrefix("~") { let home = FileManager.default.homeDirectoryForCurrentUser.path expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) } return URL(fileURLWithPath: expanded) } #if SWIFT_PACKAGE static func _testNodeManagerBinPaths(home: URL) -> [String] { self.nodeManagerBinPaths(home: home) } #endif }