diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a464b7d9..9969e98e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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. diff --git a/docs/cli/index.md b/docs/cli/index.md index d5926e42a..42e894c01 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -48,6 +48,8 @@ clawdbot [--dev] [--profile ] onboard configure (alias: config) doctor + reset + uninstall update providers list @@ -442,6 +444,36 @@ Options: - `--store ` - `--active ` +## Reset / Uninstall + +### `reset` +Reset local config/state (keeps the CLI installed). + +Options: +- `--scope ` +- `--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` diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md new file mode 100644 index 000000000..8a51ea6a2 --- /dev/null +++ b/docs/install/uninstall.md @@ -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-`). +- 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.`): + +```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.`. + +### Linux (systemd user unit) + +Default unit name is `clawdbot-gateway.service` (or `clawdbot-gateway-.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 ()`). +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-\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. diff --git a/docs/start/faq.md b/docs/start/faq.md index 36cc415d8..be942b2b3 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -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-`). +- 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. diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index d3e37463b..6f711d9ba 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -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 diff --git a/package.json b/package.json index 5605c92b3..efa7515bf 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "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", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile new file mode 100644 index 000000000..002806130 --- /dev/null +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -0,0 +1,20 @@ +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 +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"] diff --git a/scripts/docker/cleanup-smoke/run.sh b/scripts/docker/cleanup-smoke/run.sh new file mode 100755 index 000000000..457bd9659 --- /dev/null +++ b/scripts/docker/cleanup-smoke/run.sh @@ -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" diff --git a/scripts/test-cleanup-docker.sh b/scripts/test-cleanup-docker.sh new file mode 100755 index 000000000..0c3282574 --- /dev/null +++ b/scripts/test-cleanup-docker.sh @@ -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" diff --git a/src/cli/program.ts b/src/cli/program.ts index e6c0689aa..d57f55a41 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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 ", + "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 }) diff --git a/src/commands/cleanup-utils.ts b/src/commands/cleanup-utils.ts new file mode 100644 index 000000000..6f515c535 --- /dev/null +++ b/src/commands/cleanup-utils.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { ClawdbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.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(); + 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 { + 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 { + 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 []; + } +} diff --git a/src/commands/reset.ts b/src/commands/reset.ts new file mode 100644 index 000000000..c8642a7b1 --- /dev/null +++ b/src/commands/reset.ts @@ -0,0 +1,162 @@ +import { cancel, confirm, isCancel, select } from "@clack/prompts"; + +import { + loadConfig, + resolveConfigPath, + resolveOAuthDir, + resolveStateDir, + isNixMode, +} 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 = (params: Parameters>[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({ + 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; + } +} diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts new file mode 100644 index 000000000..e0ee090e9 --- /dev/null +++ b/src/commands/uninstall.ts @@ -0,0 +1,193 @@ +import { cancel, confirm, isCancel, multiselect } from "@clack/prompts"; +import path from "node:path"; + +import { loadConfig, resolveConfigPath, resolveOAuthDir, resolveStateDir, isNixMode } 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 = ( + params: Parameters>[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; + hadExplicit: boolean; +} { + const hadExplicit = Boolean( + opts.all || opts.service || opts.state || opts.workspace || opts.app, + ); + const scopes = new Set(); + 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 { + 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({ + 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.", + ); + } + } +}