feat(macos): install CLI via app script

This commit is contained in:
Peter Steinberger
2026-01-11 10:31:15 +00:00
parent 6d2928888c
commit a83f86a4a1
7 changed files with 87 additions and 9 deletions

View File

@@ -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

View File

@@ -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?
}

View File

@@ -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<String>()
// 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] = []

View File

@@ -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) }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)