From a83f86a4a17bfb7cc333a2a74471f1a987e7c52f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 10:31:15 +0000 Subject: [PATCH] feat(macos): install CLI via app script --- CHANGELOG.md | 2 +- .../macos/Sources/Clawdbot/CLIInstaller.swift | 66 ++++++++++++++++++- .../Sources/Clawdbot/CommandResolver.swift | 20 +++++- .../Sources/Clawdbot/GatewayEnvironment.swift | 2 +- .../Clawdbot/GatewayLaunchAgentManager.swift | 2 +- .../Sources/Clawdbot/GeneralSettings.swift | 2 +- .../Clawdbot/OnboardingView+Pages.swift | 2 +- 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7b81806..ec9e59601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 2026.1.11 (Unreleased) ### Changes -- macOS: prompt to install the global `clawdbot` CLI when missing in local mode, and use external launchd/CLI instead of the embedded gateway runtime. +- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. ## 2026.1.10 diff --git a/apps/macos/Sources/Clawdbot/CLIInstaller.swift b/apps/macos/Sources/Clawdbot/CLIInstaller.swift index efe92fb7d..546e8c053 100644 --- a/apps/macos/Sources/Clawdbot/CLIInstaller.swift +++ b/apps/macos/Sources/Clawdbot/CLIInstaller.swift @@ -35,9 +35,69 @@ enum CLIInstaller { } static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { - let expected = GatewayEnvironment.expectedGatewayVersion() - await GatewayEnvironment.installGlobal(version: expected) { message in - Task { @MainActor in await statusHandler(message) } + let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest" + let prefix = Self.installPrefix() + await statusHandler("Installing clawdbot CLI…") + let cmd = self.installScriptCommand(version: expected, prefix: prefix) + let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900) + + if response.success { + let parsed = self.parseInstallEvents(response.stdout) + let installedVersion = parsed.last { $0.event == "done" }?.version + let summary = installedVersion.map { "Installed clawdbot \($0)." } ?? "Installed clawdbot." + await statusHandler(summary) + return } + + let parsed = self.parseInstallEvents(response.stdout) + if let error = parsed.last(where: { $0.event == "error" })?.message { + await statusHandler("Install failed: \(error)") + return + } + + let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + let fallback = response.errorMessage ?? "install failed" + await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)") + } + + private static func installPrefix() -> String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdbot") + .path + } + + private static func installScriptCommand(version: String, prefix: String) -> [String] { + let escapedVersion = self.shellEscape(version) + let escapedPrefix = self.shellEscape(prefix) + let script = """ + curl -fsSL https://clawd.bot/install-cli.sh | \ + bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion) + """ + return ["/bin/bash", "-lc", script] + } + + private static func parseInstallEvents(_ output: String) -> [InstallEvent] { + let decoder = JSONDecoder() + let lines = output + .split(whereSeparator: \.isNewline) + .map { String($0) } + var events: [InstallEvent] = [] + for line in lines { + guard let data = line.data(using: .utf8) else { continue } + if let event = try? decoder.decode(InstallEvent.self, from: data) { + events.append(event) + } + } + return events + } + + private static func shellEscape(_ raw: String) -> String { + "'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } } + +private struct InstallEvent: Decodable { + let event: String + let version: String? + let message: String? +} diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index 9c88cf5da..4f7e054fe 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -84,12 +84,30 @@ enum CommandResolver { "/bin", ] extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) - extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1) + 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] = [] diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index 9397c5c77..0e04b13af 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -119,7 +119,7 @@ enum GatewayEnvironment { nodeVersion: runtime.version.description, gatewayVersion: nil, requiredGateway: expected?.description, - message: "clawdbot CLI not found in PATH; install the global package.") + message: "clawdbot CLI not found in PATH; install the CLI.") } let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 32dada0d0..27bb221dc 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -50,7 +50,7 @@ enum GatewayLaunchAgentManager { return .success(cmd) } - return .failure("clawdbot CLI not found in PATH; install the global package.") + return .failure("clawdbot CLI not found in PATH; install the CLI.") } static func isLoaded() async -> Bool { diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 08375695e..084f5f591 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -393,7 +393,7 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) .lineLimit(2) } else { - Text("Installs via npm/pnpm/bun; requires Node 22+.") + Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index f415314f0..6d5d8b01a 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -541,7 +541,7 @@ extension OnboardingView { } else if !self.cliInstalled, self.cliInstallLocation == nil { Text( """ - Uses npm/pnpm/bun. Requires Node 22+ on this Mac. + Installs a user-space Node 22+ runtime and the CLI (no Homebrew). Rerun anytime to reinstall or update. """) .font(.footnote)