Compare commits

...
49 changed files with 1838 additions and 603 deletions

No files matched your search

+7
View File
@@ -1,5 +1,11 @@
# Changelog
## 2026.1.11 (Unreleased)
### Changes
- 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.
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
## 2026.1.10
### Highlights
@@ -15,6 +21,7 @@
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
- CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test.
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete.
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
+100
View File
@@ -2,6 +2,106 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.10</title>
<pubDate>Sun, 11 Jan 2026 10:26:34 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>4946</sparkle:version>
<sparkle:shortVersionString>2026.1.10</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.10</h2>
<h3>Highlights</h3>
<ul>
<li>CLI: <code>clawdbot status</code> now table-based + shows OS/update/gateway/daemon/agents/sessions; <code>status --all</code> adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).</li>
<li>CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.</li>
<li>CLI: add <code>clawdbot update</code> (safe-ish git checkout update) + <code>--update</code> shorthand. (#673) — thanks @fm1randa.</li>
<li>Gateway: add OpenAI-compatible <code>/v1/chat/completions</code> HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Onboarding/Models: add first-class Z.AI (GLM) auth choice (<code>zai-api-key</code>) + <code>--zai-api-key</code> flag.</li>
<li>CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.</li>
<li>Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.</li>
<li>Agents/Browser: add <code>browser.target</code> (sandbox/host/custom) with sandbox host-control gating via <code>agents.defaults.sandbox.browser.allowHostControl</code>, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).</li>
<li>Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.</li>
<li>Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.</li>
<li>Providers: unify group history context wrappers across providers with per-provider/per-account <code>historyLimit</code> overrides (fallback to <code>messages.groupChat.historyLimit</code>). Set <code>0</code> to disable. (#672) — thanks @steipete.</li>
<li>Gateway/Heartbeat: optionally deliver heartbeat <code>Reasoning:</code> output (<code>agents.defaults.heartbeat.includeReasoning</code>). (#690)</li>
<li>Docker: allow optional home volume + extra bind mounts in <code>docker-setup.sh</code>. (#679) — thanks @gabriel-trigo.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in <code>status</code> while keeping <code>status --all</code> redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden <code>tailscale status --json</code> parsing; make <code>status --all</code> scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).</li>
<li>CLI/Gateway: clarify that <code>clawdbot gateway status</code> reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.</li>
<li>CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.</li>
<li>Telegram: add <code>/whoami</code> + <code>/id</code> commands to reveal sender id for allowlists; allow <code>@username</code> and prefixed ids in <code>allowFrom</code> prompts (with stability warning).</li>
<li>Control UI: stop auto-writing <code>telegram.groups["*"]</code> and warn/confirm before enabling wildcard groups.</li>
<li>WhatsApp: send ack reactions only for handled messages and ignore legacy <code>messages.ackReaction</code> (doctor copies to <code>whatsapp.ackReaction</code>). (#629) — thanks @pasogott.</li>
<li>Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.</li>
<li>Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.</li>
<li>Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.</li>
<li>Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is <code>HEARTBEAT_OK</code>. (#694) — thanks @antons.</li>
<li>macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.</li>
<li>Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).</li>
<li>Docs: make <code>clawdbot status</code> the first diagnostic step, clarify <code>status --deep</code> behavior, and document <code>/whoami</code> + <code>/id</code>.</li>
<li>Docs/Testing: clarify live tool+image probes and how to list your testable <code>provider/model</code> ids.</li>
<li>Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.</li>
<li>WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.</li>
<li>Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.</li>
<li>Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.</li>
<li>Agents: strip <code><thought></code>/<code><antthinking></code> tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.</li>
<li>macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.</li>
<li>Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.</li>
<li>CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.</li>
<li>Commands: disable <code>/config</code> + <code>/debug</code> by default; gate via <code>commands.config</code>/<code>commands.debug</code> and hide from native registration/help output.</li>
<li>Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.</li>
<li>Gateway: disable the OpenAI-compatible <code>/v1/chat/completions</code> endpoint by default; enable via <code>gateway.http.endpoints.chatCompletions.enabled=true</code>.</li>
<li>macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.</li>
<li>Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.</li>
<li>Auto-reply: prefer <code>RawBody</code> for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.</li>
<li>WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.</li>
<li>WhatsApp: expose group participant IDs to the model so reactions can target the right sender.</li>
<li>Cron: <code>wakeMode: "now"</code> waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.</li>
<li>Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone <code>reasoning</code> items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.</li>
<li>Sandbox: add <code>clawdbot sandbox explain</code> (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.</li>
<li>Hooks/Gmail: keep Tailscale serve path at <code>/</code> while preserving the public path. (#668) — thanks @antons.</li>
<li>Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.</li>
<li>Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.</li>
<li>Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.</li>
<li>CLI: respect <code>CLAWDBOT_STATE_DIR</code> for node pairing + voice wake settings storage. (#664) — thanks @azade-c.</li>
<li>Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.</li>
<li>Gateway/Control UI: make <code>chat.send</code> non-blocking, wire Stop to <code>chat.abort</code>, and treat <code>/stop</code> as an out-of-band abort. (#653)</li>
<li>Gateway/Control UI: allow <code>chat.abort</code> without <code>runId</code> (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)</li>
<li>Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.</li>
<li>macOS: force <code>restart-mac.sh --sign</code> to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.</li>
<li>Gateway/Agent: accept image attachments on <code>agent</code> (multimodal message) and add live gateway image probe (<code>CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1</code>).</li>
<li>CLI: <code>clawdbot sessions</code> now includes <code>elev:*</code> + <code>usage:*</code> flags in the table output.</li>
<li>CLI/Pairing: accept positional provider for <code>pairing list|approve</code> (npm-run compatible); update docs/bot hints.</li>
<li>Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).</li>
<li>Auto-reply: fix native <code>/model</code> not updating the actual chat session (Telegram/Slack/Discord). (#646)</li>
<li>Doctor: offer to run <code>clawdbot update</code> first on git installs (keeps doctor output aligned with latest).</li>
<li>Doctor: avoid false legacy workspace warning when install dir is <code>~/clawdbot</code>. (#660)</li>
<li>iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.</li>
<li>Models/Auth: allow MiniMax API configs without <code>models.providers.minimax.apiKey</code> (auth profiles / <code>MINIMAX_API_KEY</code>). (#656) — thanks @mneves75.</li>
<li>Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.</li>
<li>Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.</li>
<li>Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).</li>
<li>Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.</li>
<li>Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.</li>
<li>Telegram: add <code>telegram.draftChunk</code> to tune draft streaming chunking for <code>streamMode: "block"</code>. (#667) — thanks @rubyrunsstuff.</li>
<li>Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.</li>
<li>iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.</li>
<li>iOS/macOS: share <code>AsyncTimeout</code>, require explicit <code>bridgeStableID</code> on connect, and harden tool display defaults (avoids missing-resource label fallbacks).</li>
<li>Telegram: serialize media-group processing to avoid missed albums under load.</li>
<li>Signal: handle <code>dataMessage.reaction</code> events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.</li>
<li>Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.</li>
<li>Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).</li>
<li>Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.10/Clawdbot-2026.1.10.zip" length="199586798" type="application/octet-stream" sparkle:edSignature="Ntb22WL4b0Hu3Ntqm1iqnD+5bUqqa36iSQ8vbT/50C5FnL5IjF4GoHWrfanJ58JhBCQ4eKdK+K0U4OBZiSzZBw=="/>
</item>
<item>
<title>2026.1.9</title>
<pubDate>Sat, 10 Jan 2026 05:23:02 +0100</pubDate>
@@ -0,0 +1,71 @@
import AppKit
import Foundation
import OSLog
@MainActor
final class CLIInstallPrompter {
static let shared = CLIInstallPrompter()
private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt")
private var isPrompting = false
func checkAndPromptIfNeeded(reason: String) {
guard self.shouldPrompt() else { return }
guard let version = Self.appVersion() else { return }
self.isPrompting = true
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
let alert = NSAlert()
alert.messageText = "Install Clawdbot CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not now")
alert.addButton(withTitle: "Open Settings")
let response = alert.runModal()
switch response {
case .alertFirstButtonReturn:
Task { await self.installCLI() }
case .alertThirdButtonReturn:
self.openSettings(tab: .general)
default:
break
}
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
self.isPrompting = false
}
private func shouldPrompt() -> Bool {
guard !self.isPrompting else { return false }
guard AppStateStore.shared.onboardingSeen else { return false }
guard AppStateStore.shared.connectionMode == .local else { return false }
guard !AppStateStore.shared.attachExistingGatewayOnly else { return false }
guard CLIInstaller.installedLocation() == nil else { return false }
guard let version = Self.appVersion() else { return false }
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
return lastPrompt != version
}
private func installCLI() async {
var lastStatus: String?
await CLIInstaller.install { message in
lastStatus = message
}
if let message = lastStatus {
let alert = NSAlert()
alert.messageText = "CLI install finished"
alert.informativeText = message
alert.runModal()
}
}
private func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab)
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
NSApp.sendAction(#selector(NSApplication.showSettingsWindow), to: nil, from: nil)
}
private static func appVersion() -> String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
}
}
+60 -59
View File
@@ -2,24 +2,16 @@ import Foundation
@MainActor
enum CLIInstaller {
private static func embeddedHelperURL() -> URL {
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
}
static func installedLocation() -> String? {
self.installedLocation(
searchPaths: cliHelperSearchPaths,
embeddedHelper: self.embeddedHelperURL(),
searchPaths: CommandResolver.preferredPaths(),
fileManager: .default)
}
static func installedLocation(
searchPaths: [String],
embeddedHelper: URL,
fileManager: FileManager) -> String?
{
let embedded = embeddedHelper.resolvingSymlinksInPath()
for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
var isDirectory: ObjCBool = false
@@ -32,10 +24,7 @@ enum CLIInstaller {
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
if resolved == embedded {
return candidate
}
return candidate
}
return nil
@@ -46,57 +35,69 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
let helper = self.embeddedHelperURL()
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
await statusHandler(
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
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 targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
await statusHandler(result)
}
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
let escapedSource = self.shellEscape(source)
let targetList = targets.map(self.shellEscape).joined(separator: " ")
let cmds = [
"mkdir -p /usr/local/bin /opt/homebrew/bin",
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
].joined(separator: "; ")
let script = """
do shell script "\(cmds)" with administrator privileges
"""
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = pipe
do {
try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
}
if output.lowercased().contains("user canceled") {
return "Install canceled"
}
return "Failed to install CLI helper: \(output)"
} catch {
return "Failed to run installer: \(error.localizedDescription)"
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 shellEscape(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
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?
}
@@ -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] = []
+1 -1
View File
@@ -33,8 +33,8 @@ let deepLinkKeyKey = "clawdbot.deepLinkKey"
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion"
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
let appLogLevelKey = "clawdbot.debug.appLogLevel"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
@@ -107,7 +107,7 @@ struct DebugSettings: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("CLI helper")
self.gridLabel("CLI")
let loc = CLIInstaller.installedLocation()
Text(loc ?? "missing")
.font(.caption.monospaced())
@@ -64,19 +64,6 @@ struct GatewayCommandResolution {
enum GatewayEnvironment {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let bundledGatewayLabel = "Bundled gateway"
static func bundledGatewayExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/clawdbot").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func bundledNodeExecutable() -> String? {
guard let res = Bundle.main.resourceURL else { return nil }
let path = res.appendingPathComponent("Relay/node").path
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
}
static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
@@ -112,32 +99,6 @@ enum GatewayEnvironment {
}
let expected = self.expectedGatewayVersion()
if let bundled = self.bundledGatewayExecutable() {
let installed = self.readGatewayVersion(binary: bundled)
let bundledNode = self.bundledNodeExecutable()
let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) }
if let expected, let installed, !installed.compatible(with: expected) {
let message = self.bundledGatewayIncompatibleMessage(
installed: installed,
expected: expected)
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: bundledNodeVersion,
gatewayVersion: installed.description,
requiredGateway: expected.description,
message: message)
}
let gatewayVersionText = installed?.description ?? "unknown"
return GatewayEnvironmentStatus(
kind: .ok,
nodeVersion: bundledNodeVersion,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
message: self.bundledGatewayStatusMessage(
gatewayVersion: gatewayVersionText,
nodeVersion: bundledNodeVersion))
}
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -158,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) }
@@ -208,7 +169,6 @@ enum GatewayEnvironment {
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
let status = self.check()
let gatewayBin = CommandResolver.clawdbotExecutable()
let bundled = self.bundledGatewayExecutable()
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
guard case .ok = status.kind else {
@@ -216,20 +176,17 @@ enum GatewayEnvironment {
}
let port = self.gatewayPort()
if let bundled {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
if let gatewayBin {
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
if let entry = projectEntrypoint,
case let .success(resolvedRuntime) = runtime
{
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -363,40 +320,4 @@ enum GatewayEnvironment {
return Semver.parse(version)
}
private static func readNodeVersion(binary: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["--version"]
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
return raw?.isEmpty == false ? raw : nil
} catch {
return nil
}
}
private static func bundledGatewayStatusMessage(
gatewayVersion: String,
nodeVersion: String?) -> String
{
"\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))"
}
private static func bundledGatewayIncompatibleMessage(
installed: Semver,
expected: Semver) -> String
{
"\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " +
"\(expected.description); rebuild the app bundle."
}
}
@@ -16,32 +16,41 @@ enum GatewayLaunchAgentManager {
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
}
private static func gatewayExecutablePath(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay/clawdbot"
}
private static func relayDir(bundlePath: String) -> String {
"\(bundlePath)/Contents/Resources/Relay"
}
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
private static func gatewayProgramArguments(port: Int, bind: String) -> Result<[String], String> {
#if DEBUG
let projectRoot = CommandResolver.projectRoot()
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution()
{
return CommandResolver.makeRuntimeCommand(
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway",
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
}
#endif
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let searchPaths = CommandResolver.preferredPaths()
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
let projectRoot = CommandResolver.projectRoot()
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
{
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
}
return .failure("clawdbot CLI not found in PATH; install the CLI.")
}
static func isLoaded() async -> Bool {
@@ -51,6 +60,7 @@ enum GatewayLaunchAgentManager {
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath
if enabled, self.isLaunchAgentWriteDisabled() {
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
return nil
@@ -58,11 +68,6 @@ enum GatewayLaunchAgentManager {
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken()
@@ -72,6 +77,14 @@ enum GatewayLaunchAgentManager {
bind: desiredBind,
token: desiredToken,
password: desiredPassword)
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
guard case let .success(programArguments) = programArgumentsResult else {
if case let .failure(message) = programArgumentsResult {
self.logger.error("launchd enable failed: \(message)")
return message
}
return "Failed to resolve gateway command."
}
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
@@ -87,7 +100,7 @@ enum GatewayLaunchAgentManager {
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(bundlePath: bundlePath, port: port)
self.writePlist(programArguments: programArguments)
await self.ensureEnabled()
if loaded {
@@ -117,18 +130,13 @@ enum GatewayLaunchAgentManager {
}
private static func writePlist(bundlePath: String, port: Int) {
let relayDir = self.relayDir(bundlePath: bundlePath)
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
.joined(separator: ":")
let bind = self.preferredGatewayBind() ?? "loopback"
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
private static func writePlist(programArguments: [String]) {
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
<key>CLAWDBOT_IMAGE_BACKEND</key>
<string>sips</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
@@ -319,14 +327,6 @@ extension GatewayLaunchAgentManager {
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testGatewayExecutablePath(bundlePath: String) -> String {
self.gatewayExecutablePath(bundlePath: bundlePath)
}
static func _testRelayDir(bundlePath: String) -> String {
self.relayDir(bundlePath: bundlePath)
}
static func _testPreferredGatewayBind() -> String? {
self.preferredGatewayBind()
}
@@ -354,7 +354,7 @@ struct GeneralSettings: View {
Button {
Task { await self.installCLI() }
} label: {
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
ZStack {
Text(title)
.opacity(self.isInstallingCLI ? 0 : 1)
@@ -393,7 +393,7 @@ struct GeneralSettings: View {
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
+1 -1
View File
@@ -29,7 +29,7 @@ enum LogLocator {
stdoutLog.path
}
/// Path to use for the embedded Gateway launchd job stdout/err.
/// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
gatewayLog.path
}
@@ -70,6 +70,7 @@ struct ClawdbotApp: App {
}
.onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
}
Settings {
@@ -262,6 +263,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
self.scheduleFirstRunOnboardingIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
}
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
+11 -7
View File
@@ -120,22 +120,26 @@ struct OnboardingView: View {
let permissionsPageIndex = 5
static func pageOrder(
for mode: AppState.ConnectionMode,
needsBootstrap: Bool) -> [Int]
showOnboardingChat: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
case .unconfigured:
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
case .local:
needsBootstrap ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
}
}
var showOnboardingChat: Bool {
self.state.connectionMode == .local && self.needsBootstrap
}
var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat)
}
var pageCount: Int { self.pageOrder.count }
@@ -151,8 +155,8 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String {
let bundlePath = Bundle.main.bundlePath
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdbot' /usr/local/bin/clawdbot"
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
return "npm install -g clawdbot@\(version)"
}
struct LocalGatewayProbe: Equatable {
@@ -3,7 +3,7 @@ import Foundation
extension OnboardingView {
func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return }
guard self.needsBootstrap else { return }
guard self.showOnboardingChat else { return }
guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true
@@ -494,9 +494,9 @@ extension OnboardingView {
func cliPage() -> some View {
self.onboardingPage {
Text("Install the helper CLI")
Text("Install the CLI")
.font(.largeTitle.weight(.semibold))
Text("Optional, but recommended: link `clawdbot` so scripts can reach the local gateway.")
Text("Required for local mode: installs `clawdbot` so launchd can run the gateway.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -522,7 +522,7 @@ extension OnboardingView {
.buttonStyle(.borderedProminent)
.disabled(self.installingCLI)
Button(self.copied ? "Copied" : "Copy dev link") {
Button(self.copied ? "Copied" : "Copy install command") {
self.copyToPasteboard(self.devLinkCommand)
}
.disabled(self.installingCLI)
@@ -541,8 +541,8 @@ extension OnboardingView {
} else if !self.cliInstalled, self.cliInstallLocation == nil {
Text(
"""
We install into /usr/local/bin and /opt/homebrew/bin.
Rerun anytime if you move the build output.
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
Rerun anytime to reinstall or update.
""")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -5,39 +5,30 @@ import Testing
@Suite(.serialized)
@MainActor
struct CLIInstallerTests {
@Test func installedLocationOnlyAcceptsEmbeddedHelper() throws {
@Test func installedLocationFindsExecutable() throws {
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent(
"clawdbot-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) }
let embedded = root.appendingPathComponent("Relay/clawdbot")
try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true)
fm.createFile(atPath: embedded.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path)
let binDir = root.appendingPathComponent("bin")
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
let link = binDir.appendingPathComponent("clawdbot")
try fm.createSymbolicLink(at: link, withDestinationURL: embedded)
let cli = binDir.appendingPathComponent("clawdbot")
fm.createFile(atPath: cli.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path)
let found = CLIInstaller.installedLocation(
searchPaths: [binDir.path],
embeddedHelper: embedded,
fileManager: fm)
#expect(found == link.path)
#expect(found == cli.path)
try fm.removeItem(at: link)
let other = root.appendingPathComponent("Other/clawdbot")
try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true)
fm.createFile(atPath: other.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path)
try fm.createSymbolicLink(at: link, withDestinationURL: other)
try fm.removeItem(at: cli)
fm.createFile(atPath: cli.path, contents: Data())
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path)
let rejected = CLIInstaller.installedLocation(
let missing = CLIInstaller.installedLocation(
searchPaths: [binDir.path],
embeddedHelper: embedded,
fileManager: fm)
#expect(rejected == nil)
#expect(missing == nil)
}
}
@@ -123,10 +123,6 @@ struct LowCoverageHelperTests {
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
#expect(GatewayLaunchAgentManager
._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
}
+32
View File
@@ -48,6 +48,8 @@ clawdbot [--dev] [--profile <name>] <command>
onboard
configure (alias: config)
doctor
reset
uninstall
update
providers
list
@@ -442,6 +444,36 @@ Options:
- `--store <path>`
- `--active <minutes>`
## Reset / Uninstall
### `reset`
Reset local config/state (keeps the CLI installed).
Options:
- `--scope <config|config+creds+sessions|full>`
- `--yes`
- `--non-interactive`
- `--dry-run`
Notes:
- `--non-interactive` requires `--scope` and `--yes`.
### `uninstall`
Uninstall the gateway service + local data (CLI remains).
Options:
- `--service`
- `--state`
- `--workspace`
- `--app`
- `--all`
- `--yes`
- `--non-interactive`
- `--dry-run`
Notes:
- `--non-interactive` requires `--yes` and explicit scopes (or `--all`).
## Gateway
### `gateway`
+6 -2
View File
@@ -339,8 +339,12 @@ sleep 1
kill -9 <PID> # last resort
```
**Fix 3: Check embedded gateway**
Ensure the gateway relay was properly bundled. Run [`./scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) and ensure Node is available (the script downloads a bundled runtime by default).
**Fix 3: Check the CLI install**
Ensure the global `clawdbot` CLI is installed and matches the app version:
```bash
clawdbot --version
npm install -g clawdbot@<version>
```
## Debug Mode
+125
View File
@@ -0,0 +1,125 @@
---
summary: "Uninstall Clawdbot completely (CLI, service, state, workspace)"
read_when:
- You want to remove Clawdbot from a machine
- The gateway service is still running after uninstall
---
# Uninstall
Two paths:
- **Easy path** if `clawdbot` is still installed.
- **Manual service removal** if the CLI is gone but the service is still running.
## Easy path (CLI still installed)
Recommended: use the built-in uninstaller:
```bash
clawdbot uninstall
```
Non-interactive (automation / npx):
```bash
clawdbot uninstall --all --yes --non-interactive
npx -y clawdbot uninstall --all --yes --non-interactive
```
Manual steps (same result):
1) Stop the gateway service:
```bash
clawdbot daemon stop
```
2) Uninstall the gateway service (launchd/systemd/schtasks):
```bash
clawdbot daemon uninstall
```
3) Delete state + config:
```bash
rm -rf "${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}"
```
If you set `CLAWDBOT_CONFIG_PATH` to a custom location outside the state dir, delete that file too.
4) Delete your workspace (optional, removes agent files):
```bash
rm -rf ~/clawd
```
5) Remove the CLI install (pick the one you used):
```bash
npm rm -g clawdbot
pnpm remove -g clawdbot
bun remove -g clawdbot
```
6) If you installed the macOS app:
```bash
rm -rf /Applications/Clawdbot.app
```
Notes:
- If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), repeat step 3 for each state dir (defaults are `~/.clawdbot-<profile>`).
- In remote mode, the state dir lives on the **gateway host**, so run steps 1-4 there too.
## Manual service removal (CLI not installed)
Use this if the gateway service keeps running but `clawdbot` is missing.
### macOS (launchd)
Default label is `com.clawdbot.gateway` (or `com.clawdbot.<profile>`):
```bash
launchctl bootout gui/$UID/com.clawdbot.gateway
rm -f ~/Library/LaunchAgents/com.clawdbot.gateway.plist
```
If you used a profile, replace the label and plist name with `com.clawdbot.<profile>`.
### Linux (systemd user unit)
Default unit name is `clawdbot-gateway.service` (or `clawdbot-gateway-<profile>.service`):
```bash
systemctl --user disable --now clawdbot-gateway.service
rm -f ~/.config/systemd/user/clawdbot-gateway.service
systemctl --user daemon-reload
```
### Windows (Scheduled Task)
Default task name is `Clawdbot Gateway` (or `Clawdbot Gateway (<profile>)`).
The task script lives under your state dir.
```powershell
schtasks /Delete /F /TN "Clawdbot Gateway"
Remove-Item -Force "$env:USERPROFILE\.clawdbot\gateway.cmd"
```
If you used a profile, delete the matching task name and `~\.clawdbot-<profile>\gateway.cmd`.
## Normal install vs source checkout
### Normal install (install.sh / npm / pnpm / bun)
If you used `https://clawd.bot/install.sh` or `install.ps1`, the CLI was installed with `npm install -g clawdbot@latest`.
Remove it with `npm rm -g clawdbot` (or `pnpm remove -g` / `bun remove -g` if you installed that way).
### Source checkout (git clone)
If you run from a repo checkout (`git clone` + `pnpm clawdbot ...` / `bun run clawdbot ...`):
1) Uninstall the gateway service **before** deleting the repo (use the easy path above or manual service removal).
2) Delete the repo directory.
3) Remove state + workspace as shown above.
+25 -80
View File
@@ -1,118 +1,63 @@
---
summary: "Bundled gateway runtime: packaging, launchd, signing, and bundling"
summary: "Gateway runtime on macOS (external launchd service)"
read_when:
- Packaging Clawdbot.app
- Debugging the bundled gateway binary
- Changing relay bundling flags or codesigning
- Debugging the macOS gateway launchd service
- Installing the gateway CLI for macOS
---
# Bundled Gateway (macOS)
# Gateway on macOS (external launchd)
Goal: ship **Clawdbot.app** with a self-contained relay that can run the CLI and
Gateway daemon. No global `npm install -g clawdbot`, no system Node requirement.
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
service to keep the Gateway running.
## What gets bundled
## Install the CLI (required for local mode)
App bundle layout:
You need Node 22+ on the Mac, then install `clawdbot` globally:
- `Clawdbot.app/Contents/Resources/Relay/node`
- Node runtime binary (downloaded during packaging, stripped for size)
- `Clawdbot.app/Contents/Resources/Relay/dist/`
- Compiled CLI/gateway payload from `pnpm exec tsc`
- `Clawdbot.app/Contents/Resources/Relay/node_modules/`
- Production dependencies staged via `pnpm deploy --prod --legacy` (includes optional native addons)
- `Clawdbot.app/Contents/Resources/Relay/clawdbot`
- Wrapper script that execs the bundled Node + dist entrypoint
- `Clawdbot.app/Contents/Resources/Relay/package.json`
- tiny “Pi runtime compatibility” file (see below, includes `"type": "module"`)
- `Clawdbot.app/Contents/Resources/Relay/skills/`
- Bundled skills payload (required for Pi tools)
- `Clawdbot.app/Contents/Resources/Relay/theme/`
- Pi TUI theme payload (optional, but strongly recommended)
- `Clawdbot.app/Contents/Resources/Relay/a2ui/`
- A2UI host assets (served by the gateway)
- `Clawdbot.app/Contents/Resources/Relay/control-ui/`
- Control UI build output (served by the gateway)
```bash
npm install -g clawdbot@<version>
```
Why the sidecar files matter:
- The embedded Pi runtime detects “bundled relay mode” and then looks for
`package.json` + `theme/` **next to `process.execPath`** (i.e. next to
`node`). Keep the sidecar files.
## Build pipeline
Packaging script:
- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh)
It builds:
- TS: `pnpm exec tsc`
- Swift app + helper: `swift build …`
- Relay payload: `pnpm deploy --prod --legacy` + copy `dist/`
- Node runtime: downloads the latest Node release (override via `NODE_VERSION`)
Important knobs:
- `NODE_VERSION=22.12.0` → pin a specific Node version
- `NODE_DIST_MIRROR=…` → mirror for downloads (default: nodejs.org)
- `STRIP_NODE=0` → keep symbols (default strips to reduce size)
- `BUNDLED_RUNTIME=bun` → switch the relay build back to Bun (`bun --compile`)
Version injection:
- The relay wrapper exports `CLAWDBOT_BUNDLED_VERSION` so `--version` works
without reading `package.json` at runtime.
The macOS app’s **Install CLI** button runs the same flow via npm/pnpm/bun.
## Launchd (Gateway as LaunchAgent)
Label:
- `com.clawdbot.gateway` (or `com.clawdbot.<profile>`)
Plist location (per-user):
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` (or `.../com.clawdbot.<profile>.plist`)
Plist location (per‑user):
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
Manager:
- The macOS app owns LaunchAgent install/update for the bundled gateway.
- The macOS app owns LaunchAgent install/update in Local mode.
- The CLI can also install it: `clawdbot daemon install`.
Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent.
- App quit does **not** stop the gateway (launchd keeps it alive).
- CLI install (`clawdbot daemon install`) writes the same LaunchAgent; `--force` rewrites it.
Logging:
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
Default LaunchAgent env:
- `CLAWDBOT_IMAGE_BACKEND=sips` (avoid sharp native addon inside the bundle)
## Version compatibility
## Codesigning (hardened runtime + Node)
The macOS app checks the gateway version against its own version. If they’re
incompatible, update the global CLI to match the app version.
Node uses JIT. The bundled runtime is signed with:
- `com.apple.security.cs.allow-jit`
- `com.apple.security.cs.allow-unsigned-executable-memory`
This is applied by `scripts/codesign-mac-app.sh`.
Note: because the relay runs under hardened runtime, any bundled `*.node` native
addons must be signed with the same Team ID as the relay `node` binary.
`scripts/codesign-mac-app.sh` re-signs `Contents/Resources/Relay/**/*.node` for this.
## Image processing
To avoid shipping native `sharp` addons inside the bundle, the gateway defaults
to `/usr/bin/sips` for image ops when run from the app (via launchd env + wrapper).
## Tests / smoke checks
From a packaged app (local build):
## Smoke check
```bash
dist/Clawdbot.app/Contents/Resources/Relay/clawdbot --version
clawdbot --version
CLAWDBOT_SKIP_PROVIDERS=1 \
CLAWDBOT_SKIP_CANVAS_HOST=1 \
dist/Clawdbot.app/Contents/Resources/Relay/clawdbot gateway --port 18999 --bind loopback
clawdbot gateway --port 18999 --bind loopback
```
Then, in another shell:
Then:
```bash
pnpm -s clawdbot gateway call health --url ws://127.0.0.1:18999 --timeout 3000
clawdbot gateway call health --url ws://127.0.0.1:18999 --timeout 3000
```
+3 -2
View File
@@ -5,8 +5,9 @@ read_when:
---
# Gateway lifecycle on macOS
The macOS app **manages the Gateway via launchd** by default. This gives you
reliable auto‑start at login and restart on crashes.
The macOS app **manages the Gateway via launchd** by default. The launchd job
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
auto‑start at login and restart on crashes.
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
If you need tighter coupling to the UI, use **Attach‑only** and run the Gateway
+7 -10
View File
@@ -12,8 +12,7 @@ This guide covers the necessary steps to build and run the Clawdbot macOS applic
Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js & pnpm**: Required for the gateway and CLI components.
3. **Node**: Required to package the embedded gateway relay (the script can download a bundled runtime).
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
## 1. Initialize Submodules
@@ -39,24 +38,22 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
./scripts/package-mac-app.sh
```
Use `BUNDLED_RUNTIME=node|bun` to switch the embedded gateway runtime (default: node).
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 4. Install the CLI Helper
## 4. Install the CLI
The macOS app requires a symlink named `clawdbot` in `/usr/local/bin` or `/opt/homebrew/bin` to manage background tasks.
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
**To install it:**
**To install it (recommended):**
1. Open the Clawdbot app.
2. Go to the **General** settings tab.
3. Click **"Install CLI helper"** (requires administrator privileges).
3. Click **"Install CLI"**.
Alternatively, you can manually link it from your Admin account:
Alternatively, install it manually:
```bash
sudo ln -sf "/Users/$(whoami)/Projects/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot
npm install -g clawdbot@<version>
```
## Troubleshooting
+2 -2
View File
@@ -9,10 +9,10 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- sets a stable debug bundle identifier: `com.clawdbot.mac.debug`
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Node**: The embedded gateway relay is bundled with Node. Ensure Node is available for the packaging script (or set `NODE_VERSION` to pin the download).
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
## Usage
+4 -6
View File
@@ -18,8 +18,7 @@ node.
- Runs or connects to the Gateway (local or remote).
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
- Optionally hosts **PeekabooBridge** for UI automation.
- Installs a helper CLI (`clawdbot`) into `/usr/local/bin` and
`/opt/homebrew/bin` on request.
- Installs the global CLI (`clawdbot`) via npm/pnpm/bun on request.
## Local vs remote mode
@@ -84,14 +83,13 @@ Safety:
1) Install and launch **Clawdbot.app**.
2) Complete the permissions checklist (TCC prompts).
3) Ensure **Local** mode is active and the Gateway is running.
4) Install the CLI helper if you want terminal access.
4) Install the CLI if you want terminal access.
## Build & dev workflow (native)
- `cd apps/macos && swift build`
- `swift run Clawdbot` (or Xcode)
- Package app + CLI: `scripts/package-mac-app.sh`
- Switch bundled gateway runtime with `BUNDLED_RUNTIME=node|bun` (default: node).
- Package app: `scripts/package-mac-app.sh`
## Debug gateway discovery (macOS CLI)
@@ -115,6 +113,6 @@ the Node CLI’s `dns-sd` based discovery.
## Related docs
- [Gateway runbook](/gateway)
- [Bundled Node Gateway](/platforms/mac/bundled-gateway)
- [Gateway (macOS)](/platforms/mac/bundled-gateway)
- [macOS permissions](/platforms/mac/permissions)
- [Canvas](/platforms/mac/canvas)
+4
View File
@@ -30,6 +30,10 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm run build` (last sanity check after tests)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
- `pnpm test:install:e2e` (requires both keys; runs both providers)
- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths.
5) **macOS app (Sparkle)**
+29
View File
@@ -162,6 +162,10 @@ Legacy single‑agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor`
Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agents.defaults.workspace` (default: `~/clawd`).
### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall).
### Can agents work outside the workspace?
Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox.
@@ -315,6 +319,31 @@ This runs your login shell and imports only missing expected keys (never overrid
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
### How do I completely reset Clawdbot (but keep it installed)?
Use the reset command:
```bash
clawdbot reset
```
Non-interactive full reset:
```bash
clawdbot reset --scope full --yes --non-interactive
```
Then re-run onboarding:
```bash
clawdbot onboard --install-daemon
```
Notes:
- The onboarding wizard also offers **Reset** if it sees an existing config. See [Wizard](/start/wizard).
- If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), reset each state dir (defaults are `~/.clawdbot-<profile>`).
- Dev reset: `clawdbot gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace).
### Do I need to add a “bot account” to a WhatsApp group?
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
+12 -2
View File
@@ -47,10 +47,20 @@ Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native
## 1) Install the CLI (recommended)
```bash
npm install -g clawdbot@latest
curl -fsSL https://clawd.bot/install.sh | bash
```
Or:
Windows (PowerShell):
```powershell
iwr -useb https://clawd.bot/install.ps1 | iex
```
Alternative (global install):
```bash
npm install -g clawdbot@latest
```
```bash
pnpm add -g clawdbot@latest
+1 -1
View File
@@ -143,7 +143,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS remote](/platforms/mac/remote)
- [macOS signing](/platforms/mac/signing)
- [macOS release](/platforms/mac/release)
- [macOS bundled gateway (Node)](/platforms/mac/bundled-gateway)
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
- [macOS XPC](/platforms/mac/xpc)
- [macOS skills](/platforms/mac/skills)
- [macOS Peekaboo](/platforms/mac/peekaboo)
+4 -4
View File
@@ -17,7 +17,7 @@ wizard, and let the agent bootstrap itself.
3) **Auth (Anthropic OAuth)** — local only
4) **Setup Wizard** (Gateway‑driven)
5) **Permissions** (TCC prompts)
6) **CLI helper** (optional)
6) **CLI** (optional)
7) **Onboarding chat** (dedicated session)
8) Ready
@@ -62,10 +62,10 @@ Onboarding requests TCC permissions needed for:
- Microphone / Speech Recognition
- Automation (AppleScript)
## 5) CLI helper (optional)
## 5) CLI (optional)
The app can symlink the bundled `clawdbot` CLI into `/usr/local/bin` and
`/opt/homebrew/bin` so terminal workflows work out of the box.
The app can install the global `clawdbot` CLI via npm/pnpm/bun so terminal
workflows and launchd tasks work out of the box.
## 6) Onboarding chat (dedicated session)
+2 -2
View File
@@ -157,8 +157,8 @@ All endpoints accept `?profile=<name>`.
### Playwright requirement
Some features (navigate/act/ai snapshot, element screenshots, PDF) require
Playwright. In embedded gateway builds, Playwright may be unavailable; those
endpoints return a clear 501 error. ARIA snapshots and basic screenshots still work.
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
error. ARIA snapshots and basic screenshots still work.
## How it works (internal)
+4
View File
@@ -104,6 +104,10 @@
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
"test:install:e2e": "bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
"protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
+21
View File
@@ -0,0 +1,21 @@
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
COPY scripts/postinstall.js ./scripts/postinstall.js
RUN corepack enable \
&& pnpm install --frozen-lockfile
COPY . .
COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/clawdbot-cleanup-smoke
RUN chmod +x /usr/local/bin/clawdbot-cleanup-smoke
ENTRYPOINT ["/usr/local/bin/clawdbot-cleanup-smoke"]
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
cd /repo
export CLAWDBOT_STATE_DIR="/tmp/clawdbot-test"
export CLAWDBOT_CONFIG_PATH="${CLAWDBOT_STATE_DIR}/clawdbot.json"
echo "==> Seed state"
mkdir -p "${CLAWDBOT_STATE_DIR}/credentials"
mkdir -p "${CLAWDBOT_STATE_DIR}/agents/main/sessions"
echo '{}' >"${CLAWDBOT_CONFIG_PATH}"
echo 'creds' >"${CLAWDBOT_STATE_DIR}/credentials/marker.txt"
echo 'session' >"${CLAWDBOT_STATE_DIR}/agents/main/sessions/sessions.json"
echo "==> Reset (config+creds+sessions)"
pnpm clawdbot reset --scope config+creds+sessions --yes --non-interactive
test ! -f "${CLAWDBOT_CONFIG_PATH}"
test ! -d "${CLAWDBOT_STATE_DIR}/credentials"
test ! -d "${CLAWDBOT_STATE_DIR}/agents/main/sessions"
echo "==> Recreate minimal config"
mkdir -p "${CLAWDBOT_STATE_DIR}/credentials"
echo '{}' >"${CLAWDBOT_CONFIG_PATH}"
echo "==> Uninstall (state only)"
pnpm clawdbot uninstall --state --yes --non-interactive
test ! -d "${CLAWDBOT_STATE_DIR}"
echo "OK"
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY run.sh /usr/local/bin/clawdbot-install-e2e
RUN chmod +x /usr/local/bin/clawdbot-install-e2e
ENTRYPOINT ["/usr/local/bin/clawdbot-install-e2e"]
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
MODELS_MODE="${CLAWDBOT_E2E_MODELS:-both}" # both|openai|anthropic
if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS must be one of: both|openai|anthropic" >&2
exit 2
fi
if [[ "$MODELS_MODE" == "both" ]]; then
if [[ -z "${OPENAI_API_KEY:-}" || -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=both requires OPENAI_API_KEY and ANTHROPIC_API_KEY." >&2
exit 2
fi
elif [[ "$MODELS_MODE" == "openai" && -z "${OPENAI_API_KEY:-}" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=openai requires OPENAI_API_KEY." >&2
exit 2
elif [[ "$MODELS_MODE" == "anthropic" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ERROR: CLAWDBOT_E2E_MODELS=anthropic requires ANTHROPIC_API_KEY." >&2
exit 2
fi
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
if (!Array.isArray(versions) || versions.length === 0) process.exit(1);
process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]);
NODE
)"
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)"
npm install -g "clawdbot@${PREVIOUS_VERSION}"
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash
echo "==> Verify installed version"
INSTALLED_VERSION="$(clawdbot --version 2>/dev/null | head -n 1 | tr -d '\r')"
echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
echo "ERROR: expected clawdbot@$LATEST_VERSION, got clawdbot@$INSTALLED_VERSION" >&2
exit 1
fi
set_image_model() {
local profile="$1"
shift
local candidate
for candidate in "$@"; do
if clawdbot --profile "$profile" models set-image "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set an image model (tried: $*)" >&2
return 1
}
set_agent_model() {
local profile="$1"
local candidate
shift
for candidate in "$@"; do
if clawdbot --profile "$profile" models set "$candidate" >/dev/null 2>&1; then
echo "$candidate"
return 0
fi
done
echo "ERROR: could not set agent model (tried: $*)" >&2
return 1
}
write_png_lr_rg() {
local out="$1"
node - <<'NODE' "$out"
const fs = require("node:fs");
const zlib = require("node:zlib");
const out = process.argv[2];
const width = 96;
const height = 64;
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const rows = [];
for (let y = 0; y < height; y++) {
const row = Buffer.alloc(1 + width * 3);
row[0] = 0; // filter: none
for (let x = 0; x < width; x++) {
const i = 1 + x * 3;
const left = x < width / 2;
row[i + 0] = left ? 255 : 0;
row[i + 1] = left ? 0 : 255;
row[i + 2] = 0;
}
rows.push(row);
}
const raw = Buffer.concat(rows);
const idat = zlib.deflateSync(raw, { level: 9 });
const png = Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", Buffer.alloc(0)),
]);
fs.writeFileSync(out, png);
NODE
}
run_agent_turn() {
local profile="$1"
local session_id="$2"
local prompt="$3"
local out_json="$4"
clawdbot --profile "$profile" agent \
--session-id "$session_id" \
--message "$prompt" \
--thinking off \
--json >"$out_json"
}
assert_agent_json_has_text() {
local path="$1"
node - <<'NODE' "$path"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean);
if (texts.length === 0) process.exit(1);
NODE
}
assert_agent_json_ok() {
local json_path="$1"
local expect_provider="$2"
node - <<'NODE' "$json_path" "$expect_provider"
const fs = require("node:fs");
const jsonPath = process.argv[2];
const expectProvider = process.argv[3];
const p = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
if (typeof p?.status === "string" && p.status !== "ok" && p.status !== "accepted") {
console.error(`ERROR: gateway status=${p.status}`);
process.exit(1);
}
const result = p?.result ?? p;
const payloads = Array.isArray(result?.payloads) ? result.payloads : [];
const anyError = payloads.some((pl) => pl && pl.isError === true);
const combinedText = payloads.map((pl) => String(pl?.text ?? "")).filter(Boolean).join("\n").trim();
if (anyError) {
console.error(`ERROR: agent returned error payload: ${combinedText}`);
process.exit(1);
}
if (/rate_limit_error/i.test(combinedText) || /^429\\b/.test(combinedText)) {
console.error(`ERROR: agent rate limited: ${combinedText}`);
process.exit(1);
}
const meta = result?.meta;
const provider =
(typeof meta?.agentMeta?.provider === "string" && meta.agentMeta.provider.trim()) ||
(typeof meta?.provider === "string" && meta.provider.trim()) ||
"";
if (expectProvider && provider && provider !== expectProvider) {
console.error(`ERROR: expected provider=${expectProvider}, got provider=${provider}`);
process.exit(1);
}
NODE
}
extract_first_text() {
local path="$1"
node - <<'NODE' "$path"
const fs = require("node:fs");
const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const payloads =
Array.isArray(p?.result?.payloads) ? p.result.payloads :
Array.isArray(p?.payloads) ? p.payloads :
[];
const text = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean)[0] ?? "";
process.stdout.write(text);
NODE
}
assert_session_used_tools() {
local jsonl="$1"
shift
node - <<'NODE' "$jsonl" "$@"
const fs = require("node:fs");
const jsonl = process.argv[2];
const required = new Set(process.argv.slice(3));
const raw = fs.readFileSync(jsonl, "utf8");
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
const seen = new Set();
const toolTypes = new Set([
"tool_use",
"tool_result",
"tool",
"tool-call",
"tool_call",
"tooluse",
"tool-use",
"toolresult",
"tool-result",
]);
function walk(node, parent) {
if (!node) return;
if (Array.isArray(node)) {
for (const item of node) walk(item, node);
return;
}
if (typeof node !== "object") return;
const obj = node;
const t = typeof obj.type === "string" ? obj.type : null;
if (t && (toolTypes.has(t) || /tool/i.test(t))) {
const name =
typeof obj.name === "string" ? obj.name :
typeof obj.toolName === "string" ? obj.toolName :
typeof obj.tool_name === "string" ? obj.tool_name :
(obj.tool && typeof obj.tool.name === "string") ? obj.tool.name :
null;
if (name) seen.add(name);
}
if (typeof obj.name === "string" && typeof obj.input === "object" && obj.input) {
// Many tool-use blocks look like { type: "...", name: "bash", input: {...} }
// but some transcripts omit/rename type.
seen.add(obj.name);
}
// OpenAI-ish tool call shapes.
if (Array.isArray(obj.tool_calls)) {
for (const c of obj.tool_calls) {
const fn = c?.function;
if (fn && typeof fn.name === "string") seen.add(fn.name);
}
}
if (obj.function && typeof obj.function.name === "string") seen.add(obj.function.name);
for (const v of Object.values(obj)) walk(v, obj);
}
for (const line of lines) {
try {
const entry = JSON.parse(line);
walk(entry, null);
} catch {
// ignore unparsable lines
}
}
const missing = [...required].filter((t) => !seen.has(t));
if (missing.length > 0) {
console.error(`Missing tools in transcript: ${missing.join(", ")}`);
console.error(`Seen tools: ${[...seen].sort().join(", ")}`);
console.error("Transcript head:");
console.error(lines.slice(0, 5).join("\n"));
process.exit(1);
}
NODE
}
run_profile() {
local profile="$1"
local port="$2"
local workspace="$3"
local agent_model_provider="$4" # "openai"|"anthropic"
echo "==> Onboard ($profile)"
if [[ "$agent_model_provider" == "openai" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice openai-api-key \
--openai-api-key "$OPENAI_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
else
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
fi
echo "==> Verify workspace identity files ($profile)"
test -f "$workspace/AGENTS.md"
test -f "$workspace/IDENTITY.md"
test -f "$workspace/USER.md"
test -f "$workspace/SOUL.md"
test -f "$workspace/TOOLS.md"
echo "==> Configure models ($profile)"
local agent_model
local image_model
if [[ "$agent_model_provider" == "openai" ]]; then
agent_model="$(set_agent_model "$profile" \
"openai/gpt-4.1-mini" \
"openai/gpt-4.1" \
"openai/gpt-4o-mini" \
"openai/gpt-4o")"
image_model="$(set_image_model "$profile" \
"openai/gpt-4.1" \
"openai/gpt-4o-mini" \
"openai/gpt-4o" \
"openai/gpt-4.1-mini")"
else
agent_model="$(set_agent_model "$profile" \
"anthropic/claude-opus-4-5" \
"claude-opus-4-5")"
image_model="$(set_image_model "$profile" \
"anthropic/claude-opus-4-5" \
"claude-opus-4-5")"
fi
echo "model=$agent_model"
echo "imageModel=$image_model"
echo "==> Prepare tool fixtures ($profile)"
PROOF_TXT="$workspace/proof.txt"
PROOF_COPY="$workspace/copy.txt"
HOSTNAME_TXT="$workspace/hostname.txt"
IMAGE_PNG="$workspace/proof.png"
IMAGE_TXT="$workspace/image.txt"
SESSION_ID="e2e-tools-${profile}"
SESSION_JSONL="/root/.clawdbot-${profile}/agents/main/sessions/${SESSION_ID}.jsonl"
PROOF_VALUE="$(node -e 'console.log(require("node:crypto").randomBytes(16).toString("hex"))')"
echo -n "$PROOF_VALUE" >"$PROOF_TXT"
write_png_lr_rg "$IMAGE_PNG"
EXPECTED_HOSTNAME="$(cat /etc/hostname | tr -d '\r\n')"
echo "==> Start gateway ($profile)"
GATEWAY_LOG="$workspace/gateway.log"
clawdbot --profile "$profile" gateway --port "$port" --bind loopback >"$GATEWAY_LOG" 2>&1 &
GATEWAY_PID="$!"
cleanup_profile() {
if kill -0 "$GATEWAY_PID" 2>/dev/null; then
kill "$GATEWAY_PID" 2>/dev/null || true
wait "$GATEWAY_PID" 2>/dev/null || true
fi
}
trap cleanup_profile EXIT
echo "==> Wait for health ($profile)"
for _ in $(seq 1 60); do
if clawdbot --profile "$profile" health --timeout 2000 --json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
clawdbot --profile "$profile" health --timeout 10000 --json >/dev/null
echo "==> Agent turns ($profile)"
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
TURN4_JSON="/tmp/agent-${profile}-4.json"
run_agent_turn "$profile" "$SESSION_ID" \
"Use the read tool (not bash) to read proof.txt. Reply with the exact contents only (no extra whitespace)." \
"$TURN1_JSON"
assert_agent_json_has_text "$TURN1_JSON"
assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider"
local reply1
reply1="$(extract_first_text "$TURN1_JSON" | tr -d '\r\n')"
if [[ "$reply1" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2
exit 1
fi
local prompt2
prompt2=$'Use the write tool (not bash) to write exactly this string into copy.txt:\n'"${reply1}"$'\nThen use the read tool (not bash) to read copy.txt and reply with the exact contents only (no extra whitespace).'
run_agent_turn "$profile" "$SESSION_ID" "$prompt2" "$TURN2_JSON"
assert_agent_json_has_text "$TURN2_JSON"
assert_agent_json_ok "$TURN2_JSON" "$agent_model_provider"
local copy_value
copy_value="$(cat "$PROOF_COPY" 2>/dev/null | tr -d '\r\n' || true)"
if [[ "$copy_value" != "$PROOF_VALUE" ]]; then
echo "ERROR: copy.txt did not match proof.txt ($profile)" >&2
exit 1
fi
local reply2
reply2="$(extract_first_text "$TURN2_JSON" | tr -d '\r\n')"
if [[ "$reply2" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read copy.txt correctly ($profile): $reply2" >&2
exit 1
fi
local prompt3
prompt3=$'Use the bash tool to run: cat /etc/hostname\nThen use the write tool to write the exact stdout (trim trailing newline) into hostname.txt. Reply with the hostname only.'
run_agent_turn "$profile" "$SESSION_ID" "$prompt3" "$TURN3_JSON"
assert_agent_json_has_text "$TURN3_JSON"
assert_agent_json_ok "$TURN3_JSON" "$agent_model_provider"
if [[ "$(cat "$HOSTNAME_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "$EXPECTED_HOSTNAME" ]]; then
echo "ERROR: hostname.txt did not match /etc/hostname ($profile)" >&2
exit 1
fi
run_agent_turn "$profile" "$SESSION_ID" \
"Use the image tool on proof.png. Determine which color is on the left half and which is on the right half. Then use the write tool to write exactly: LEFT=RED RIGHT=GREEN into image.txt. Reply with exactly: LEFT=RED RIGHT=GREEN" \
"$TURN4_JSON"
assert_agent_json_has_text "$TURN4_JSON"
assert_agent_json_ok "$TURN4_JSON" "$agent_model_provider"
if [[ "$(cat "$IMAGE_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: image.txt did not contain expected marker ($profile)" >&2
exit 1
fi
local reply4
reply4="$(extract_first_text "$TURN4_JSON")"
if [[ "$reply4" != "LEFT=RED RIGHT=GREEN" ]]; then
echo "ERROR: agent reply did not contain expected marker ($profile): $reply4" >&2
exit 1
fi
echo "==> Verify tool usage via session transcript ($profile)"
# Give the gateway a moment to flush transcripts.
sleep 1
if [[ ! -f "$SESSION_JSONL" ]]; then
echo "ERROR: missing session transcript ($profile): $SESSION_JSONL" >&2
ls -la "/root/.clawdbot-${profile}/agents/main/sessions" >&2 || true
exit 1
fi
assert_session_used_tools "$SESSION_JSONL" read write bash image
cleanup_profile
trap - EXIT
}
if [[ "$MODELS_MODE" == "openai" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-openai" "18789" "/tmp/clawd-e2e-openai" "openai"
fi
if [[ "$MODELS_MODE" == "anthropic" || "$MODELS_MODE" == "both" ]]; then
run_profile "e2e-anthropic" "18799" "/tmp/clawd-e2e-anthropic" "anthropic"
fi
echo "OK"
@@ -0,0 +1,14 @@
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
COPY run.sh /usr/local/bin/clawdbot-install-smoke
RUN chmod +x /usr/local/bin/clawdbot-install-smoke
ENTRYPOINT ["/usr/local/bin/clawdbot-install-smoke"]
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
if (!Array.isArray(versions) || versions.length === 0) {
process.exit(1);
}
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
process.stdout.write(previous);
NODE
)"
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
echo "==> Preinstall previous (forces installer upgrade path)"
npm install -g "clawdbot@${PREVIOUS_VERSION}"
echo "==> Run official installer one-liner"
curl -fsSL "$INSTALL_URL" | bash
echo "==> Verify installed version"
INSTALLED_VERSION="$(clawdbot --version 2>/dev/null | head -n 1 | tr -d '\r')"
echo "installed=$INSTALLED_VERSION expected=$LATEST_VERSION"
if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then
echo "ERROR: expected clawdbot@$LATEST_VERSION, got clawdbot@$INSTALLED_VERSION" >&2
exit 1
fi
echo "==> Sanity: CLI runs"
clawdbot --help >/dev/null
echo "OK"
+17 -5
View File
@@ -27,17 +27,27 @@ if [[ -z "$VERSION" ]]; then
fi
fi
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
if [[ "${KEEP_SPARKLE_NOTES:-0}" != "1" ]]; then
rm -f "$NOTES_HTML"
fi
}
trap cleanup EXIT
cp -f "$ZIP" "$TMP_DIR/$ZIP_NAME"
if [[ -f "$ROOT/appcast.xml" ]]; then
cp -f "$ROOT/appcast.xml" "$TMP_DIR/appcast.xml"
fi
NOTES_HTML="${ZIP_DIR}/${ZIP_BASE}.html"
KEEP_NOTES=${KEEP_SPARKLE_NOTES:-0}
if [[ -x "$ROOT/scripts/changelog-to-html.sh" ]]; then
"$ROOT/scripts/changelog-to-html.sh" "$VERSION" >"$NOTES_HTML"
else
echo "Missing scripts/changelog-to-html.sh; cannot generate HTML release notes." >&2
exit 1
fi
if [[ "$KEEP_NOTES" != "1" ]]; then
trap 'rm -f "$NOTES_HTML"' EXIT
fi
cp -f "$NOTES_HTML" "$TMP_DIR/${ZIP_BASE}.html"
DOWNLOAD_URL_PREFIX=${SPARKLE_DOWNLOAD_URL_PREFIX:-"https://github.com/clawdbot/clawdbot/releases/download/v${VERSION}/"}
@@ -52,6 +62,8 @@ generate_appcast \
--download-url-prefix "$DOWNLOAD_URL_PREFIX" \
--embed-release-notes \
--link "$FEED_URL" \
"$ZIP_DIR"
"$TMP_DIR"
cp -f "$TMP_DIR/appcast.xml" "$ROOT/appcast.xml"
echo "Appcast generated (appcast.xml). Upload alongside $ZIP at $FEED_URL"
-261
View File
@@ -22,7 +22,6 @@ if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
fi
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
BUNDLED_RUNTIME="${BUNDLED_RUNTIME:-node}"
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml}"
AUTO_CHECKS=true
@@ -108,210 +107,6 @@ merge_framework_machos() {
done < <(find "$primary" -type f -print0)
}
build_relay_binary() {
local arch="$1"
local out="$2"
local define_arg="__CLAWDBOT_VERSION__=\\\"$PKG_VERSION\\\""
local bun_bin="bun"
local -a cmd=("$bun_bin" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
if [[ "$arch" == "x86_64" ]]; then
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "ERROR: Rosetta is required to build the x86_64 relay. Install Rosetta and retry." >&2
exit 1
fi
local bun_x86="${BUN_X86_64_BIN:-$HOME/.bun-x64/bun-darwin-x64/bun}"
if [[ ! -x "$bun_x86" ]]; then
bun_x86="$HOME/.bun-x64/bin/bun"
fi
if [[ "$bun_x86" == *baseline* ]]; then
echo "ERROR: x86_64 relay builds are locked to AVX2; baseline Bun is not allowed." >&2
echo "Set BUN_X86_64_BIN to a non-baseline Bun (bun-darwin-x64)." >&2
exit 1
fi
if [[ -x "$bun_x86" ]]; then
cmd=("$bun_x86" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
fi
arch -x86_64 "${cmd[@]}"
else
"${cmd[@]}"
fi
}
resolve_node_version() {
if [[ -n "${NODE_VERSION:-}" ]]; then
echo "${NODE_VERSION#v}"
return
fi
local mirror="${NODE_DIST_MIRROR:-https://nodejs.org/dist}"
local latest
if latest="$(/usr/bin/curl -fsSL "$mirror/index.tab" 2>/dev/null | /usr/bin/awk 'NR==2 {print $1}')" && [[ -n "$latest" ]]; then
echo "${latest#v}"
return
fi
if command -v node >/dev/null 2>&1; then
node -p "process.versions.node"
return
fi
echo "22.12.0"
}
node_dist_filename() {
local version="$1"
local arch="$2"
local node_arch="$arch"
if [[ "$arch" == "x86_64" ]]; then
node_arch="x64"
fi
echo "node-v${version}-darwin-${node_arch}.tar.gz"
}
download_node_binary() {
local version="$1"
local arch="$2"
local out="$3"
local mirror="${NODE_DIST_MIRROR:-https://nodejs.org/dist}"
local tarball
tarball="$(node_dist_filename "$version" "$arch")"
local tmp_dir
tmp_dir="$(mktemp -d)"
local url="$mirror/v${version}/${tarball}"
echo "⬇️ Downloading Node ${version} (${arch})"
/usr/bin/curl -fsSL "$url" -o "$tmp_dir/node.tgz"
/usr/bin/tar -xzf "$tmp_dir/node.tgz" -C "$tmp_dir"
local node_arch="$arch"
if [[ "$arch" == "x86_64" ]]; then
node_arch="x64"
fi
local node_src="$tmp_dir/node-v${version}-darwin-${node_arch}/bin/node"
if [[ ! -f "$node_src" ]]; then
echo "ERROR: Node binary missing in $tarball" >&2
rm -rf "$tmp_dir"
exit 1
fi
cp "$node_src" "$out"
chmod +x "$out"
rm -rf "$tmp_dir"
}
stage_relay_deps() {
local relay_dir="$1"
if [[ "${SKIP_RELAY_DEPS:-0}" == "1" ]]; then
echo "📦 Skipping relay dependency staging (SKIP_RELAY_DEPS=1)"
return
fi
local stage_dir="$relay_dir/.relay-deploy"
rm -rf "$stage_dir"
mkdir -p "$stage_dir"
echo "📦 Staging relay dependencies (pnpm deploy --prod --legacy)"
(cd "$ROOT_DIR" && pnpm --filter . deploy "$stage_dir" --prod --legacy)
rm -rf "$relay_dir/node_modules"
cp -a "$stage_dir/node_modules" "$relay_dir/node_modules"
rm -rf "$stage_dir"
}
stage_relay_dist() {
local relay_dir="$1"
echo "📦 Copying relay dist payload"
rm -rf "$relay_dir/dist"
cp -R "$ROOT_DIR/dist" "$relay_dir/dist"
}
stage_relay_payload() {
local relay_dir="$1"
stage_relay_deps "$relay_dir"
stage_relay_dist "$relay_dir"
}
write_relay_wrapper() {
local relay_dir="$1"
local wrapper="$relay_dir/clawdbot"
cat > "$wrapper" <<SH
#!/bin/sh
set -e
DIR="\$(cd "\$(dirname "\$0")" && pwd)"
NODE="\$DIR/node"
REL="\$DIR/dist/macos/relay.js"
export CLAWDBOT_BUNDLED_VERSION="\${CLAWDBOT_BUNDLED_VERSION:-$PKG_VERSION}"
export CLAWDBOT_IMAGE_BACKEND="\${CLAWDBOT_IMAGE_BACKEND:-sips}"
NODE_PATH="\$DIR/node_modules\${NODE_PATH:+:\$NODE_PATH}"
export NODE_PATH
exec "\$NODE" "\$REL" "\$@"
SH
chmod +x "$wrapper"
}
package_relay_bun() {
local relay_dir="$1"
RELAY_CMD="$relay_dir/clawdbot"
if ! command -v bun >/dev/null 2>&1; then
echo "ERROR: bun missing. Install bun or set BUNDLED_RUNTIME=node." >&2
exit 1
fi
echo "🧰 Building bundled relay (bun --compile)"
local relay_build_dir="$relay_dir/.relay-build"
rm -rf "$relay_build_dir"
mkdir -p "$relay_build_dir"
for arch in "${BUILD_ARCHS[@]}"; do
local relay_arch_out="$relay_build_dir/clawdbot-$arch"
build_relay_binary "$arch" "$relay_arch_out"
chmod +x "$relay_arch_out"
done
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
/usr/bin/lipo -create "$relay_build_dir"/clawdbot-* -output "$RELAY_CMD"
else
cp "$relay_build_dir/clawdbot-${BUILD_ARCHS[0]}" "$RELAY_CMD"
fi
rm -rf "$relay_build_dir"
}
package_relay_node() {
local relay_dir="$1"
RELAY_CMD="$relay_dir/clawdbot"
local node_version
node_version="$(resolve_node_version)"
echo "🧰 Preparing bundled Node runtime (v${node_version})"
local relay_node="$relay_dir/node"
local relay_node_build_dir="$relay_dir/.node-build"
rm -rf "$relay_node_build_dir"
mkdir -p "$relay_node_build_dir"
for arch in "${BUILD_ARCHS[@]}"; do
local node_arch_out="$relay_node_build_dir/node-$arch"
download_node_binary "$node_version" "$arch" "$node_arch_out"
done
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
/usr/bin/lipo -create "$relay_node_build_dir"/node-* -output "$relay_node"
else
cp "$relay_node_build_dir/node-${BUILD_ARCHS[0]}" "$relay_node"
fi
chmod +x "$relay_node"
if [[ "${STRIP_NODE:-0}" == "1" ]]; then
/usr/bin/strip -x "$relay_node" 2>/dev/null || true
fi
rm -rf "$relay_node_build_dir"
stage_relay_payload "$relay_dir"
write_relay_wrapper "$relay_dir"
}
validate_bundled_runtime() {
case "$BUNDLED_RUNTIME" in
node|bun) return 0 ;;
*)
echo "ERROR: Unsupported BUNDLED_RUNTIME=$BUNDLED_RUNTIME (use node|bun)" >&2
exit 1
;;
esac
}
echo "📦 Ensuring deps (pnpm install)"
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
if [[ "${SKIP_TSC:-0}" != "1" ]]; then
@@ -342,7 +137,6 @@ echo "🧹 Cleaning old app bundle"
rm -rf "$APP_ROOT"
mkdir -p "$APP_ROOT/Contents/MacOS"
mkdir -p "$APP_ROOT/Contents/Resources"
mkdir -p "$APP_ROOT/Contents/Resources/Relay"
mkdir -p "$APP_ROOT/Contents/Frameworks"
echo "📄 Copying Info.plist template"
@@ -422,61 +216,6 @@ else
echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2
fi
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
validate_bundled_runtime
mkdir -p "$RELAY_DIR"
if [[ "$BUNDLED_RUNTIME" == "bun" ]]; then
package_relay_bun "$RELAY_DIR"
else
package_relay_node "$RELAY_DIR"
fi
echo "🧪 Verifying bundled relay (version)"
"$RELAY_CMD" --version >/dev/null
echo "🎨 Copying gateway A2UI host assets"
rm -rf "$RELAY_DIR/a2ui"
cp -R "$ROOT_DIR/src/canvas-host/a2ui" "$RELAY_DIR/a2ui"
echo "🎛 Copying Control UI assets"
rm -rf "$RELAY_DIR/control-ui"
cp -R "$ROOT_DIR/dist/control-ui" "$RELAY_DIR/control-ui"
echo "🧠 Copying bundled skills"
rm -rf "$RELAY_DIR/skills"
cp -R "$ROOT_DIR/skills" "$RELAY_DIR/skills"
echo "📄 Writing embedded runtime package.json (Pi compatibility)"
cat > "$RELAY_DIR/package.json" <<JSON
{
"name": "clawdbot-embedded",
"version": "$PKG_VERSION",
"type": "module",
"piConfig": {
"name": "pi",
"configDir": ".pi"
}
}
JSON
echo "🎨 Copying Pi theme payload (optional)"
PI_ENTRY_URL="$(cd "$ROOT_DIR" && node --input-type=module -e "console.log(import.meta.resolve('@mariozechner/pi-coding-agent'))")"
PI_ENTRY="$(cd "$ROOT_DIR" && node --input-type=module -e "console.log(new URL(process.argv[1]).pathname)" "$PI_ENTRY_URL")"
PI_DIR="$(cd "$(dirname "$PI_ENTRY")/.." && pwd)"
THEME_SRC="$PI_DIR/dist/modes/interactive/theme"
if [ -d "$THEME_SRC" ]; then
rm -rf "$RELAY_DIR/theme"
cp -R "$THEME_SRC" "$RELAY_DIR/theme"
else
echo "WARN: Pi theme dir missing at $THEME_SRC (continuing)" >&2
fi
else
echo "🧰 Skipping gateway payload packaging (SKIP_GATEWAY_PACKAGE=1)"
fi
echo "⏹ Stopping any running Clawdbot"
killall -q Clawdbot 2>/dev/null || true
+4 -5
View File
@@ -177,8 +177,8 @@ elif [ "$SIGN" -eq 1 ]; then
unset SIGN_IDENTITY
fi
# 3) Package app (default to bundling the embedded gateway + CLI).
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} SKIP_GATEWAY_PACKAGE=${SKIP_GATEWAY_PACKAGE:-0} '${ROOT_DIR}/scripts/package-mac-app.sh'"
# 3) Package app (no embedded gateway).
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'"
choose_app_bundle() {
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then
@@ -205,7 +205,7 @@ choose_app_bundle
APP_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${APP_BUNDLE}/Contents/Info.plist" 2>/dev/null || true)"
# When unsigned, avoid the app overwriting the LaunchAgent with the relay binary.
# When unsigned, avoid the app overwriting the LaunchAgent while iterating.
if [ "$NO_SIGN" -eq 1 ]; then
if [[ -n "${APP_BUNDLE_ID}" ]]; then
run_step "set attach-existing-only" \
@@ -239,8 +239,7 @@ else
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
fi
# When unsigned, launchd cannot exec the app relay binary. Ensure the gateway
# LaunchAgent targets the repo CLI instead (after the app has launched).
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
if [ "$NO_SIGN" -eq 1 ]; then
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${CLAWDBOT_CLEANUP_SMOKE_IMAGE:-clawdbot-cleanup-smoke:local}"
echo "==> Build image: $IMAGE_NAME"
docker build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/docker/cleanup-smoke/Dockerfile" \
"$ROOT_DIR"
echo "==> Run cleanup smoke test"
docker run --rm -t "$IMAGE_NAME"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
echo "==> Build image: $IMAGE_NAME"
docker build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-smoke"
echo "==> Run installer smoke test: $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
"$IMAGE_NAME"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_NAME="${CLAWDBOT_INSTALL_E2E_IMAGE:-clawdbot-install-e2e:local}"
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
CLAWDBOT_E2E_MODELS="${CLAWDBOT_E2E_MODELS:-}"
echo "==> Build image: $IMAGE_NAME"
docker build \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/scripts/docker/install-sh-e2e/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-e2e"
echo "==> Run E2E installer test"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_E2E_MODELS="$CLAWDBOT_E2E_MODELS" \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
"$IMAGE_NAME"
+21 -1
View File
@@ -17,7 +17,11 @@ Setup (once)
Common commands
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
- Calendar list events: `gog calendar events <calendarId> --from <iso> --to <iso>`
- Calendar create event: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso>`
- Calendar create with color: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso> --event-color 7`
- Calendar update event: `gog calendar update <calendarId> <eventId> --summary "New Title" --event-color 4`
- Calendar show colors: `gog calendar colors`
- Drive search: `gog drive search "query" --max 10`
- Contacts: `gog contacts list --max 20`
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
@@ -28,6 +32,22 @@ Common commands
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
- Docs cat: `gog docs cat <docId>`
Calendar Colors
- Use `gog calendar colors` to see all available event colors (IDs 1-11)
- Add colors to events with `--event-color <id>` flag
- Event color IDs (from `gog calendar colors` output):
- 1: #a4bdfc
- 2: #7ae7bf
- 3: #dbadff
- 4: #ff887c
- 5: #fbd75b
- 6: #ffb878
- 7: #46d6db
- 8: #e1e1e1
- 9: #5484ed
- 10: #51b749
- 11: #dc2127
Notes
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
- For scripting, prefer `--json` plus `--no-input`.
+59
View File
@@ -14,9 +14,11 @@ import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
import { messageCommand } from "../commands/message.js";
import { onboardCommand } from "../commands/onboard.js";
import { resetCommand } from "../commands/reset.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js";
import { uninstallCommand } from "../commands/uninstall.js";
import {
isNixMode,
loadConfig,
@@ -468,6 +470,63 @@ export function buildProgram() {
}
});
program
.command("reset")
.description("Reset local config/state (keeps the CLI installed)")
.option(
"--scope <scope>",
"config|config+creds+sessions|full (default: interactive prompt)",
)
.option("--yes", "Skip confirmation prompts", false)
.option(
"--non-interactive",
"Disable prompts (requires --scope + --yes)",
false,
)
.option("--dry-run", "Print actions without removing files", false)
.action(async (opts) => {
try {
await resetCommand(defaultRuntime, {
scope: opts.scope,
yes: Boolean(opts.yes),
nonInteractive: Boolean(opts.nonInteractive),
dryRun: Boolean(opts.dryRun),
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program
.command("uninstall")
.description("Uninstall the gateway service + local data (CLI remains)")
.option("--service", "Remove the gateway service", false)
.option("--state", "Remove state + config", false)
.option("--workspace", "Remove workspace dirs", false)
.option("--app", "Remove the macOS app", false)
.option("--all", "Remove service + state + workspace + app", false)
.option("--yes", "Skip confirmation prompts", false)
.option("--non-interactive", "Disable prompts (requires --yes)", false)
.option("--dry-run", "Print actions without removing files", false)
.action(async (opts) => {
try {
await uninstallCommand(defaultRuntime, {
service: Boolean(opts.service),
state: Boolean(opts.state),
workspace: Boolean(opts.workspace),
app: Boolean(opts.app),
all: Boolean(opts.all),
yes: Boolean(opts.yes),
nonInteractive: Boolean(opts.nonInteractive),
dryRun: Boolean(opts.dryRun),
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
// Deprecated hidden aliases: use `clawdbot providers login/logout`. Remove in a future major.
program
.command("login", { hidden: true })
+90
View File
@@ -0,0 +1,90 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir, resolveUserPath } from "../utils.js";
export type RemovalResult = {
ok: boolean;
skipped?: boolean;
};
export function collectWorkspaceDirs(
cfg: ClawdbotConfig | undefined,
): string[] {
const dirs = new Set<string>();
const defaults = cfg?.agents?.defaults;
if (typeof defaults?.workspace === "string" && defaults.workspace.trim()) {
dirs.add(resolveUserPath(defaults.workspace));
}
const list = Array.isArray(cfg?.agents?.list) ? cfg?.agents?.list : [];
for (const agent of list) {
const workspace = (agent as { workspace?: unknown }).workspace;
if (typeof workspace === "string" && workspace.trim()) {
dirs.add(resolveUserPath(workspace));
}
}
if (dirs.size === 0) {
dirs.add(resolveDefaultAgentWorkspaceDir());
}
return [...dirs];
}
export function isPathWithin(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function isUnsafeRemovalTarget(target: string): boolean {
if (!target.trim()) return true;
const resolved = path.resolve(target);
const root = path.parse(resolved).root;
if (resolved === root) return true;
const home = resolveHomeDir();
if (home && resolved === path.resolve(home)) return true;
return false;
}
export async function removePath(
target: string,
runtime: RuntimeEnv,
opts?: { dryRun?: boolean; label?: string },
): Promise<RemovalResult> {
if (!target?.trim()) return { ok: false, skipped: true };
const resolved = path.resolve(target);
const label = opts?.label ?? resolved;
if (isUnsafeRemovalTarget(resolved)) {
runtime.error(`Refusing to remove unsafe path: ${label}`);
return { ok: false };
}
if (opts?.dryRun) {
runtime.log(`[dry-run] remove ${label}`);
return { ok: true, skipped: true };
}
try {
await fs.rm(resolved, { recursive: true, force: true });
runtime.log(`Removed ${label}`);
return { ok: true };
} catch (err) {
runtime.error(`Failed to remove ${label}: ${String(err)}`);
return { ok: false };
}
}
export async function listAgentSessionDirs(
stateDir: string,
): Promise<string[]> {
const root = path.join(stateDir, "agents");
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(root, entry.name, "sessions"));
} catch {
return [];
}
}
+173
View File
@@ -0,0 +1,173 @@
import { cancel, confirm, isCancel, select } from "@clack/prompts";
import {
isNixMode,
loadConfig,
resolveConfigPath,
resolveOAuthDir,
resolveStateDir,
} from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import {
collectWorkspaceDirs,
isPathWithin,
listAgentSessionDirs,
removePath,
} from "./cleanup-utils.js";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type ResetOptions = {
scope?: ResetScope;
yes?: boolean;
nonInteractive?: boolean;
dryRun?: boolean;
};
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
select({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
async function stopGatewayIfRunning(runtime: RuntimeEnv) {
if (isNixMode) return;
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return;
}
if (!loaded) return;
try {
await service.stop({ profile, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}
}
export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
const interactive = !opts.nonInteractive;
if (!interactive && !opts.yes) {
runtime.error("Non-interactive mode requires --yes.");
runtime.exit(1);
return;
}
let scope = opts.scope;
if (!scope) {
if (!interactive) {
runtime.error("Non-interactive mode requires --scope.");
runtime.exit(1);
return;
}
const selection = await selectStyled<ResetScope>({
message: "Reset scope",
options: [
{
value: "config",
label: "Config only",
hint: "clawdbot.json",
},
{
value: "config+creds+sessions",
label: "Config + credentials + sessions",
hint: "keeps workspace + auth profiles",
},
{
value: "full",
label: "Full reset",
hint: "state dir + workspace",
},
],
initialValue: "config+creds+sessions",
});
if (isCancel(selection)) {
cancel(stylePromptTitle("Reset cancelled.") ?? "Reset cancelled.");
runtime.exit(0);
return;
}
scope = selection;
}
if (!["config", "config+creds+sessions", "full"].includes(scope)) {
runtime.error(
'Invalid --scope. Expected "config", "config+creds+sessions", or "full".',
);
runtime.exit(1);
return;
}
if (interactive && !opts.yes) {
const ok = await confirm({
message: stylePromptMessage(`Proceed with ${scope} reset?`),
});
if (isCancel(ok) || !ok) {
cancel(stylePromptTitle("Reset cancelled.") ?? "Reset cancelled.");
runtime.exit(0);
return;
}
}
const dryRun = Boolean(opts.dryRun);
const cfg = loadConfig();
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const oauthDir = resolveOAuthDir();
const configInsideState = isPathWithin(configPath, stateDir);
const oauthInsideState = isPathWithin(oauthDir, stateDir);
const workspaceDirs = collectWorkspaceDirs(cfg);
if (scope !== "config") {
if (dryRun) {
runtime.log("[dry-run] stop gateway service");
} else {
await stopGatewayIfRunning(runtime);
}
}
if (scope === "config") {
await removePath(configPath, runtime, { dryRun, label: configPath });
return;
}
if (scope === "config+creds+sessions") {
await removePath(configPath, runtime, { dryRun, label: configPath });
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
const sessionDirs = await listAgentSessionDirs(stateDir);
for (const dir of sessionDirs) {
await removePath(dir, runtime, { dryRun, label: dir });
}
runtime.log("Next: clawdbot onboard --install-daemon");
return;
}
if (scope === "full") {
await removePath(stateDir, runtime, { dryRun, label: stateDir });
if (!configInsideState) {
await removePath(configPath, runtime, { dryRun, label: configPath });
}
if (!oauthInsideState) {
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
}
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, { dryRun, label: workspace });
}
runtime.log("Next: clawdbot onboard --install-daemon");
return;
}
}
+220
View File
@@ -0,0 +1,220 @@
import path from "node:path";
import { cancel, confirm, isCancel, multiselect } from "@clack/prompts";
import {
isNixMode,
loadConfig,
resolveConfigPath,
resolveOAuthDir,
resolveStateDir,
} from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js";
import {
stylePromptHint,
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { resolveHomeDir } from "../utils.js";
import {
collectWorkspaceDirs,
isPathWithin,
removePath,
} from "./cleanup-utils.js";
type UninstallScope = "service" | "state" | "workspace" | "app";
export type UninstallOptions = {
service?: boolean;
state?: boolean;
workspace?: boolean;
app?: boolean;
all?: boolean;
yes?: boolean;
nonInteractive?: boolean;
dryRun?: boolean;
};
const multiselectStyled = <T>(params: Parameters<typeof multiselect<T>>[0]) =>
multiselect({
...params,
message: stylePromptMessage(params.message),
options: params.options.map((opt) =>
opt.hint === undefined
? opt
: { ...opt, hint: stylePromptHint(opt.hint) },
),
});
function buildScopeSelection(opts: UninstallOptions): {
scopes: Set<UninstallScope>;
hadExplicit: boolean;
} {
const hadExplicit = Boolean(
opts.all || opts.service || opts.state || opts.workspace || opts.app,
);
const scopes = new Set<UninstallScope>();
if (opts.all || opts.service) scopes.add("service");
if (opts.all || opts.state) scopes.add("state");
if (opts.all || opts.workspace) scopes.add("workspace");
if (opts.all || opts.app) scopes.add("app");
return { scopes, hadExplicit };
}
async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
if (isNixMode) {
runtime.error("Nix mode detected; daemon uninstall is disabled.");
return false;
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return false;
}
if (!loaded) {
runtime.log(`Gateway service ${service.notLoadedText}.`);
return true;
}
try {
await service.stop({ profile, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}
try {
await service.uninstall({ env: process.env, stdout: process.stdout });
return true;
} catch (err) {
runtime.error(`Gateway uninstall failed: ${String(err)}`);
return false;
}
}
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) {
if (process.platform !== "darwin") return;
await removePath("/Applications/Clawdbot.app", runtime, {
dryRun,
label: "/Applications/Clawdbot.app",
});
}
export async function uninstallCommand(
runtime: RuntimeEnv,
opts: UninstallOptions,
) {
const { scopes, hadExplicit } = buildScopeSelection(opts);
const interactive = !opts.nonInteractive;
if (!interactive && !opts.yes) {
runtime.error("Non-interactive mode requires --yes.");
runtime.exit(1);
return;
}
if (!hadExplicit) {
if (!interactive) {
runtime.error(
"Non-interactive mode requires explicit scopes (use --all).",
);
runtime.exit(1);
return;
}
const selection = await multiselectStyled<UninstallScope>({
message: "Uninstall which components?",
options: [
{
value: "service",
label: "Gateway service",
hint: "launchd / systemd / schtasks",
},
{ value: "state", label: "State + config", hint: "~/.clawdbot" },
{ value: "workspace", label: "Workspace", hint: "agent files" },
{
value: "app",
label: "macOS app",
hint: "/Applications/Clawdbot.app",
},
],
initialValues: ["service", "state", "workspace"],
});
if (isCancel(selection)) {
cancel(
stylePromptTitle("Uninstall cancelled.") ?? "Uninstall cancelled.",
);
runtime.exit(0);
return;
}
for (const value of selection) scopes.add(value);
}
if (scopes.size === 0) {
runtime.log("Nothing selected.");
return;
}
if (interactive && !opts.yes) {
const ok = await confirm({
message: stylePromptMessage("Proceed with uninstall?"),
});
if (isCancel(ok) || !ok) {
cancel(
stylePromptTitle("Uninstall cancelled.") ?? "Uninstall cancelled.",
);
runtime.exit(0);
return;
}
}
const dryRun = Boolean(opts.dryRun);
const cfg = loadConfig();
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const oauthDir = resolveOAuthDir();
const configInsideState = isPathWithin(configPath, stateDir);
const oauthInsideState = isPathWithin(oauthDir, stateDir);
const workspaceDirs = collectWorkspaceDirs(cfg);
if (scopes.has("service")) {
if (dryRun) {
runtime.log("[dry-run] remove gateway service");
} else {
await stopAndUninstallService(runtime);
}
}
if (scopes.has("state")) {
await removePath(stateDir, runtime, { dryRun, label: stateDir });
if (!configInsideState) {
await removePath(configPath, runtime, { dryRun, label: configPath });
}
if (!oauthInsideState) {
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
}
}
if (scopes.has("workspace")) {
for (const workspace of workspaceDirs) {
await removePath(workspace, runtime, { dryRun, label: workspace });
}
}
if (scopes.has("app")) {
await removeMacApp(runtime, dryRun);
}
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
if (scopes.has("state") && !scopes.has("workspace")) {
const home = resolveHomeDir();
if (
home &&
workspaceDirs.some((dir) => dir.startsWith(path.resolve(home)))
) {
runtime.log(
"Tip: workspaces were preserved. Re-run with --workspace to remove them.",
);
}
}
}