From a72fdf7c2651f52e652c524eef11fab4f111d575 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 19:14:14 +0100 Subject: [PATCH] feat: expand wizard setup flow --- CHANGELOG.md | 4 +- docs/configuration.md | 16 + docs/test.md | 2 + docs/wizard.md | 47 ++- scripts/e2e/onboard-docker.sh | 118 +++++- src/cli/program.ts | 41 ++ src/commands/configure.ts | 521 ++++++++++++++++++++++++ src/commands/doctor.ts | 93 +++++ src/commands/onboard-helpers.ts | 44 +- src/commands/onboard-interactive.ts | 16 +- src/commands/onboard-non-interactive.ts | 2 + src/commands/onboard-providers.ts | 89 +++- src/commands/signal-install.ts | 199 +++++++++ src/commands/update.ts | 21 + src/config/config.ts | 18 + 15 files changed, 1201 insertions(+), 30 deletions(-) create mode 100644 src/commands/configure.ts create mode 100644 src/commands/doctor.ts create mode 100644 src/commands/signal-install.ts create mode 100644 src/commands/update.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 439ac8fd9..a161df7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ - Tests: add a Z.AI live test gate for smoke validation when keys are present. - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. -- CLI: add ASCII banner header to onboarding wizard start. +- CLI: add ASCII banner header to wizard entry points. +- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization. +- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config. - Skills: allow `bun` as a node manager for skill installs. - Tests: add a Docker-based onboarding E2E harness. diff --git a/docs/configuration.md b/docs/configuration.md index 978d662fa..35f2ae95a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,6 +41,22 @@ If set, CLAWDIS derives defaults (only when you haven’t set them explicitly): } ``` +### `wizard` + +Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). + +```json5 +{ + wizard: { + lastRunAt: "2026-01-01T00:00:00.000Z", + lastRunVersion: "2.0.0-beta5", + lastRunCommit: "abc1234", + lastRunCommand: "configure", + lastRunMode: "local" + } +} +``` + ### `logging` - Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log` diff --git a/docs/test.md b/docs/test.md index 98f6bec7e..e4118e399 100644 --- a/docs/test.md +++ b/docs/test.md @@ -28,3 +28,5 @@ Full cold-start flow in a clean Linux container: ```bash scripts/e2e/onboard-docker.sh ``` + +This script drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `clawdis health`. diff --git a/docs/wizard.md b/docs/wizard.md index e824fd50b..eab869976 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -7,16 +7,22 @@ read_when: # Onboarding Wizard (CLI) -Goal: single interactive flow to set up Clawdis Gateway + workspace + skills on a new machine. +Goal: interactive wizards for first-run onboarding **and** ongoing reconfiguration. Uses `@clack/prompts` for arrow-key selection and step UX. Scope: **Local gateway only**. Remote mode is **info-only** (no config writes). ## Entry points +First run: - `clawdis onboard` (primary) - `clawdis setup --wizard` (alias) +Ongoing: +- `clawdis configure` (models/providers/skills/gateway/workspace) +- `clawdis doctor` (health + quick fixes) +- `clawdis update` (audit + modernize config defaults) + ## Non-interactive mode `--non-interactive` + flags to skip prompts. `--json` outputs a machine summary. @@ -64,7 +70,7 @@ Reset uses `trash` (never `rm`). - WhatsApp: optional `clawdis login` QR flow - Telegram: bot token (config or env) - Discord: bot token (config or env) - - Signal: `signal-cli` detection + account config + - Signal: `signal-cli` detection + account config + install option 6) **Daemon install (local only)** - macOS: LaunchAgent @@ -104,9 +110,46 @@ Wizard writes: - `skills.install.nodeManager` (npm | pnpm | bun) - `skills.entries..env` / `.apiKey` (if set in skills step) - `telegram.botToken`, `discord.token`, `signal.*` (if set in providers step) + - `wizard.lastRunAt`, `wizard.lastRunVersion`, `wizard.lastRunCommit`, `wizard.lastRunCommand`, `wizard.lastRunMode` WhatsApp login writes credentials to `~/.clawdis/credentials/creds.json`. +## Configure / Doctor / Update flows + +`clawdis configure` offers a menu of sections: +- Model/auth +- Providers (incl Signal install) +- Gateway + daemon +- Workspace + bootstrap files +- Skills +- Health check (optional at end) + +`clawdis doctor`: +- Gateway reachability +- Provider probes +- Skills status +- Quick fixes (start gateway, relink WhatsApp, prompt missing tokens) + +`clawdis update`: +- Audit config vs defaults +- Suggest upgrades/changes +- Re-run key steps as needed + +Each wizard run updates `wizard.*` metadata. + +## Signal CLI install (wizard) + +Wizard can install signal-cli from GitHub releases: +- Fetch latest release from `https://github.com/AsamK/signal-cli/releases/latest` +- Download platform asset (Linux native preferred) +- Extract under `~/.clawdis/tools/signal-cli//` +- Set `signal.cliPath` to the detected `signal-cli` binary + +Notes: +- signal-cli requires Java 21 for JVM builds. +- Native builds are available for some platforms; fallback to JVM build if native not found. +- Auto-install is not supported on Windows yet. + ## Minimax M2.1 (LM Studio) config snippet ```json5 diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index ea6e9a3dc..1e6739fc7 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -10,23 +10,115 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running onboarding E2E..." docker run --rm -t "$IMAGE_NAME" bash -lc ' set -euo pipefail + export TERM=xterm-256color - node dist/index.js onboard \ - --non-interactive \ - --mode local \ - --workspace /root/clawd \ - --auth-choice skip \ - --gateway-port 18789 \ - --gateway-bind loopback \ - --gateway-auth off \ - --tailscale off \ - --skip-skills \ - --skip-health \ - --json + input_fifo="$(mktemp -u /tmp/clawdis-onboard-input.XXXXXX)" + mkfifo "$input_fifo" + script -q -c "node dist/index.js onboard" /dev/null < "$input_fifo" & + wizard_pid=$! + exec 3> "$input_fifo" + + send() { + local payload="$1" + local delay="${2:-0.4}" + sleep "$delay" + printf "%b" "$payload" >&3 + } + + send $'"'"'\r'"'"' 0.8 + send $'"'"'\r'"'"' 0.6 + send $'"'"'\e[B\e[B\e[B\r'"'"' 0.6 + send $'"'"'\r'"'"' 0.4 + send $'"'"'\r'"'"' 0.4 + send $'"'"'\r'"'"' 0.4 + send $'"'"'\r'"'"' 0.4 + send $'"'"'n\r'"'"' 0.4 + send $'"'"'n\r'"'"' 0.4 + send $'"'"'n\r'"'"' 0.4 + + exec 3>&- + wait "$wizard_pid" + rm -f "$input_fifo" + + workspace_dir="$HOME/clawd" + config_path="$HOME/.clawdis/clawdis.json" + sessions_dir="$HOME/.clawdis/sessions" + + if [ ! -f "$config_path" ]; then + echo "Missing config: $config_path" + exit 1 + fi + + for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do + if [ ! -f "$workspace_dir/$file" ]; then + echo "Missing workspace file: $workspace_dir/$file" + exit 1 + fi + done + + if [ ! -d "$sessions_dir" ]; then + echo "Missing sessions dir: $sessions_dir" + exit 1 + fi + + CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const expectedWorkspace = process.env.WORKSPACE_DIR; +const errors = []; + +if (cfg?.agent?.workspace !== expectedWorkspace) { + errors.push(`agent.workspace mismatch (got ${cfg?.agent?.workspace ?? "unset"})`); +} +if (cfg?.gateway?.mode !== "local") { + errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); +} +if (cfg?.gateway?.bind !== "loopback") { + errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`); +} +if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") { + errors.push( + `gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`, + ); +} +if (!cfg?.wizard?.lastRunAt) { + errors.push("wizard.lastRunAt missing"); +} +if (!cfg?.wizard?.lastRunVersion) { + errors.push("wizard.lastRunVersion missing"); +} +if (cfg?.wizard?.lastRunCommand !== "onboard") { + errors.push( + `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, + ); +} +if (cfg?.wizard?.lastRunMode !== "local") { + errors.push( + `wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`, + ); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE node dist/index.js gateway-daemon --port 18789 --bind loopback > /tmp/gateway.log 2>&1 & GW_PID=$! - sleep 2 + for _ in $(seq 1 10); do + if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then + break + fi + sleep 1 + done + + if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then + cat /tmp/gateway.log + exit 1 + fi node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1) diff --git a/src/cli/program.ts b/src/cli/program.ts index cb67d9421..c81098ccb 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,12 +1,15 @@ import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; +import { configureCommand } from "../commands/configure.js"; +import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; import { onboardCommand } from "../commands/onboard.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; import { statusCommand } from "../commands/status.js"; +import { updateCommand } from "../commands/update.js"; import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; @@ -201,6 +204,44 @@ export function buildProgram() { } }); + program + .command("configure") + .description( + "Interactive wizard to update models, providers, skills, and gateway", + ) + .action(async () => { + try { + await configureCommand(defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("doctor") + .description("Health checks + quick fixes for the gateway and providers") + .action(async () => { + try { + await doctorCommand(defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("update") + .description("Audit and modernize the local configuration") + .action(async () => { + try { + await updateCommand(defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("login") .description("Link your personal WhatsApp via QR (web provider)") diff --git a/src/commands/configure.ts b/src/commands/configure.ts new file mode 100644 index 000000000..649a46db7 --- /dev/null +++ b/src/commands/configure.ts @@ -0,0 +1,521 @@ +import path from "node:path"; + +import { + confirm, + intro, + multiselect, + note, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; + +import type { ClawdisConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDIS, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; +import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath, sleep } from "../utils.js"; +import { healthCommand } from "./health.js"; +import { + applyMinimaxConfig, + setAnthropicApiKey, + writeOAuthCredentials, +} from "./onboard-auth.js"; +import { + applyWizardMetadata, + DEFAULT_WORKSPACE, + ensureWorkspaceAndSessions, + guardCancel, + openUrl, + printWizardHeader, + randomToken, + summarizeExistingConfig, +} from "./onboard-helpers.js"; +import { setupProviders } from "./onboard-providers.js"; +import { setupSkills } from "./onboard-skills.js"; + +type WizardSection = + | "model" + | "providers" + | "gateway" + | "daemon" + | "workspace" + | "skills" + | "health"; + +type ConfigureWizardParams = { + command: "configure" | "update"; + sections?: WizardSection[]; +}; + +async function promptGatewayConfig( + cfg: ClawdisConfig, + runtime: RuntimeEnv, +): Promise<{ + config: ClawdisConfig; + port: number; + token?: string; +}> { + const portRaw = guardCancel( + await text({ + message: "Gateway port", + initialValue: "18789", + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + runtime, + ); + const port = Number.parseInt(String(portRaw), 10); + + let bind = guardCancel( + await select({ + message: "Gateway bind", + options: [ + { value: "loopback", label: "Loopback (127.0.0.1)" }, + { value: "lan", label: "LAN" }, + { value: "tailnet", label: "Tailnet" }, + { value: "auto", label: "Auto" }, + ], + }), + runtime, + ) as "loopback" | "lan" | "tailnet" | "auto"; + + let authMode = guardCancel( + await select({ + message: "Gateway auth", + options: [ + { value: "off", label: "Off (loopback only)" }, + { value: "token", label: "Token" }, + { value: "password", label: "Password" }, + ], + }), + runtime, + ) as "off" | "token" | "password"; + + const tailscaleMode = guardCancel( + await select({ + message: "Tailscale exposure", + options: [ + { value: "off", label: "Off" }, + { value: "serve", label: "Serve" }, + { value: "funnel", label: "Funnel" }, + ], + }), + runtime, + ) as "off" | "serve" | "funnel"; + + let tailscaleResetOnExit = false; + if (tailscaleMode !== "off") { + tailscaleResetOnExit = Boolean( + guardCancel( + await confirm({ + message: "Reset Tailscale serve/funnel on exit?", + initialValue: false, + }), + runtime, + ), + ); + } + + if (tailscaleMode !== "off" && bind !== "loopback") { + note( + "Tailscale requires bind=loopback. Adjusting bind to loopback.", + "Note", + ); + bind = "loopback"; + } + + if (authMode === "off" && bind !== "loopback") { + note("Non-loopback bind requires auth. Switching to token auth.", "Note"); + authMode = "token"; + } + + if (tailscaleMode === "funnel" && authMode !== "password") { + note("Tailscale funnel requires password auth.", "Note"); + authMode = "password"; + } + + let gatewayToken: string | undefined; + let next = cfg; + + if (authMode === "token") { + const tokenInput = guardCancel( + await text({ + message: "Gateway token (blank to generate)", + initialValue: randomToken(), + }), + runtime, + ); + gatewayToken = String(tokenInput).trim() || randomToken(); + next = { + ...next, + gateway: { + ...next.gateway, + auth: { ...next.gateway?.auth, mode: "token" }, + }, + }; + } + + if (authMode === "password") { + const password = guardCancel( + await text({ + message: "Gateway password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + next = { + ...next, + gateway: { + ...next.gateway, + auth: { + ...next.gateway?.auth, + mode: "password", + password: String(password).trim(), + }, + }, + }; + } + + next = { + ...next, + gateway: { + ...next.gateway, + mode: "local", + bind, + tailscale: { + ...next.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + return { config: next, port, token: gatewayToken }; +} + +async function promptAuthConfig( + cfg: ClawdisConfig, + runtime: RuntimeEnv, +): Promise { + const authChoice = guardCancel( + await select({ + message: "Model/auth choice", + options: [ + { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "apiKey", label: "Anthropic API key" }, + { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, + { value: "skip", label: "Skip for now" }, + ], + }), + runtime, + ) as "oauth" | "apiKey" | "minimax" | "skip"; + + let next = cfg; + + if (authChoice === "oauth") { + note( + "Browser will open. Paste the code shown after login (code#state).", + "Anthropic OAuth", + ); + const spin = spinner(); + spin.start("Waiting for authorization…"); + let oauthCreds: OAuthCredentials | null = null; + try { + oauthCreds = await loginAnthropic( + async (url) => { + await openUrl(url); + runtime.log(`Open: ${url}`); + }, + async () => { + const code = guardCancel( + await text({ + message: "Paste authorization code (code#state)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + return String(code); + }, + ); + spin.stop("OAuth complete"); + if (oauthCreds) { + await writeOAuthCredentials("anthropic", oauthCreds); + } + } catch (err) { + spin.stop("OAuth failed"); + runtime.error(String(err)); + } + } else if (authChoice === "apiKey") { + const key = guardCancel( + await text({ + message: "Enter Anthropic API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + await setAnthropicApiKey(String(key).trim()); + } else if (authChoice === "minimax") { + next = applyMinimaxConfig(next); + } + + const modelInput = guardCancel( + await text({ + message: "Default model (blank to keep)", + initialValue: next.agent?.model ?? "", + }), + runtime, + ); + const model = String(modelInput ?? "").trim(); + if (model) { + next = { + ...next, + agent: { + ...next.agent, + model, + }, + }; + } + + return next; +} + +async function maybeInstallDaemon(params: { + runtime: RuntimeEnv; + port: number; + gatewayToken?: string; +}) { + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + if (loaded) { + const action = guardCancel( + await select({ + message: "Gateway service already installed", + options: [ + { value: "restart", label: "Restart" }, + { value: "reinstall", label: "Reinstall" }, + { value: "skip", label: "Skip" }, + ], + }), + params.runtime, + ); + if (action === "restart") { + await service.restart({ stdout: process.stdout }); + return; + } + if (action === "skip") return; + if (action === "reinstall") { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } + } + + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port: params.port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDIS_GATEWAY_TOKEN: params.gatewayToken, + CLAWDIS_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); +} + +export async function runConfigureWizard( + opts: ConfigureWizardParams, + runtime: RuntimeEnv = defaultRuntime, +) { + printWizardHeader(runtime); + intro( + opts.command === "update" ? "Clawdis update wizard" : "Clawdis configure", + ); + + const snapshot = await readConfigFileSnapshot(); + let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; + + if (snapshot.exists) { + const title = snapshot.valid + ? "Existing config detected" + : "Invalid config"; + note(summarizeExistingConfig(baseConfig), title); + if (!snapshot.valid && snapshot.issues.length > 0) { + note( + snapshot.issues + .map((iss) => `- ${iss.path}: ${iss.message}`) + .join("\n"), + "Config issues", + ); + } + if (!snapshot.valid) { + const reset = guardCancel( + await confirm({ + message: "Config invalid. Start fresh?", + initialValue: true, + }), + runtime, + ); + if (reset) baseConfig = {}; + } + } + + const mode = guardCancel( + await select({ + message: "Where will the Gateway run?", + options: [ + { value: "local", label: "Local (this machine)" }, + { value: "remote", label: "Remote (info-only)" }, + ], + }), + runtime, + ) as "local" | "remote"; + + if (mode === "remote") { + note( + [ + "Run on the gateway host:", + "- clawdis setup", + "- clawdis gateway-daemon --port 18789", + "- OAuth creds: ~/.clawdis/credentials/oauth.json", + "- Workspace: ~/clawd", + ].join("\n"), + "Remote setup", + ); + outro("Done. Local config unchanged."); + return; + } + + const selected = opts.sections + ? opts.sections + : (guardCancel( + await multiselect({ + message: "Select sections to configure", + options: [ + { value: "workspace", label: "Workspace" }, + { value: "model", label: "Model/auth" }, + { value: "gateway", label: "Gateway config" }, + { value: "daemon", label: "Gateway daemon" }, + { value: "providers", label: "Providers" }, + { value: "skills", label: "Skills" }, + { value: "health", label: "Health check" }, + ], + }), + runtime, + ) as WizardSection[]); + + if (!selected || selected.length === 0) { + outro("No changes selected."); + return; + } + + let nextConfig = { ...baseConfig }; + let workspaceDir = + nextConfig.agent?.workspace ?? + baseConfig.agent?.workspace ?? + DEFAULT_WORKSPACE; + let gatewayPort = 18789; + let gatewayToken: string | undefined; + + if (selected.includes("workspace")) { + const workspaceInput = guardCancel( + await text({ + message: "Workspace directory", + initialValue: workspaceDir, + }), + runtime, + ); + workspaceDir = resolveUserPath( + String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE, + ); + nextConfig = { + ...nextConfig, + agent: { + ...nextConfig.agent, + workspace: workspaceDir, + }, + }; + await ensureWorkspaceAndSessions(workspaceDir, runtime); + } + + if (selected.includes("model")) { + nextConfig = await promptAuthConfig(nextConfig, runtime); + } + + if (selected.includes("gateway")) { + const gateway = await promptGatewayConfig(nextConfig, runtime); + nextConfig = gateway.config; + gatewayPort = gateway.port; + gatewayToken = gateway.token; + } + + if (selected.includes("providers")) { + nextConfig = await setupProviders(nextConfig, runtime, { + allowDisable: true, + allowSignalInstall: true, + }); + } + + if (selected.includes("skills")) { + const wsDir = resolveUserPath(workspaceDir); + nextConfig = await setupSkills(nextConfig, wsDir, runtime); + } + + nextConfig = applyWizardMetadata(nextConfig, { + command: opts.command, + mode, + }); + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); + + if (selected.includes("daemon")) { + if (!selected.includes("gateway")) { + const portInput = guardCancel( + await text({ + message: "Gateway port for daemon install", + initialValue: String(gatewayPort), + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + runtime, + ); + gatewayPort = Number.parseInt(String(portInput), 10); + } + + await maybeInstallDaemon({ + runtime, + port: gatewayPort, + gatewayToken, + }); + } + + if (selected.includes("health")) { + await sleep(1000); + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(`Health check failed: ${String(err)}`); + } + } + + outro("Configure complete."); +} + +export async function configureCommand(runtime: RuntimeEnv = defaultRuntime) { + await runConfigureWizard({ command: "configure" }, runtime); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 000000000..8e52b0d24 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,93 @@ +import { confirm, intro, note, outro } from "@clack/prompts"; + +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import type { ClawdisConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDIS, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath, sleep } from "../utils.js"; +import { healthCommand } from "./health.js"; +import { + applyWizardMetadata, + DEFAULT_WORKSPACE, + guardCancel, + printWizardHeader, +} from "./onboard-helpers.js"; + +function resolveMode(cfg: ClawdisConfig): "local" | "remote" { + return cfg.gateway?.mode === "remote" ? "remote" : "local"; +} + +export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { + printWizardHeader(runtime); + intro("Clawdis doctor"); + + const snapshot = await readConfigFileSnapshot(); + let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {}; + if (snapshot.exists && !snapshot.valid) { + note("Config invalid; doctor will run with defaults.", "Config"); + } + + const workspaceDir = resolveUserPath( + cfg.agent?.workspace ?? DEFAULT_WORKSPACE, + ); + const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); + note( + [ + `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`, + `Missing requirements: ${ + skillsReport.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ).length + }`, + `Blocked by allowlist: ${ + skillsReport.skills.filter((s) => s.blockedByAllowlist).length + }`, + ].join("\n"), + "Skills status", + ); + + let healthOk = false; + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + healthOk = true; + } catch (err) { + runtime.error(`Health check failed: ${String(err)}`); + } + + if (!healthOk) { + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + if (!loaded) { + note("Gateway daemon not installed.", "Gateway"); + } else { + const restart = guardCancel( + await confirm({ + message: "Restart gateway daemon now?", + initialValue: true, + }), + runtime, + ); + if (restart) { + await service.restart({ stdout: process.stdout }); + await sleep(1500); + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(`Health check failed: ${String(err)}`); + } + } + } + } + + cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); + await writeConfigFile(cfg); + runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); + + outro("Doctor complete."); +} diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 22894e88a..825ae0d85 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -13,7 +13,8 @@ import { CONFIG_PATH_CLAWDIS } from "../config/config.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; -import { CONFIG_DIR } from "../utils.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { VERSION } from "../version.js"; import type { NodeManagerChoice, ResetScope } from "./onboard-types.js"; export function guardCancel(value: T, runtime: RuntimeEnv): T { @@ -41,6 +42,36 @@ export function randomToken(): string { return crypto.randomBytes(24).toString("hex"); } +export function printWizardHeader(runtime: RuntimeEnv) { + const header = [ + "##### # ### # # #### ##### ####", + "# # # # # # # # # # ", + "# # ##### # # # # # # ### ", + "# # # # ## ## # # # #", + "##### ##### # # # # #### ##### #### ", + ].join("\n"); + runtime.log(header); +} + +export function applyWizardMetadata( + cfg: ClawdisConfig, + params: { command: string; mode: "local" | "remote" }, +): ClawdisConfig { + const commit = + process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim() || undefined; + return { + ...cfg, + wizard: { + ...cfg.wizard, + lastRunAt: new Date().toISOString(), + lastRunVersion: VERSION, + lastRunCommit: commit, + lastRunCommand: params.command, + lastRunMode: params.mode, + }, + }; +} + export async function openUrl(url: string): Promise { const platform = process.platform; const command = @@ -114,6 +145,17 @@ export async function handleReset( } export async function detectBinary(name: string): Promise { + if (!name?.trim()) return false; + const resolved = name.startsWith("~") ? resolveUserPath(name) : name; + if (path.isAbsolute(resolved) || resolved.startsWith(".")) { + try { + await fs.access(resolved); + return true; + } catch { + return false; + } + } + const command = process.platform === "win32" ? ["where", name] diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index a124378ff..720efcbca 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -30,11 +30,13 @@ import { writeOAuthCredentials, } from "./onboard-auth.js"; import { + applyWizardMetadata, DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, handleReset, openUrl, + printWizardHeader, randomToken, summarizeExistingConfig, } from "./onboard-helpers.js"; @@ -52,14 +54,7 @@ export async function runInteractiveOnboarding( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const header = [ - " _____ _ ___ _ _ ____ ___ ____ ", - " / ____| | / _ \\ | | | | | _ \\_ _/ __|", - "| | | | | | | | | | | | | | | | |\\__ \\", - "| |___ | |___| |_| | | |__| |___ | |_| | |___) |", - " \\_____|_____|\\___/ \\____/_____|____/___/____/ ", - ].join("\n"); - runtime.log(header); + printWizardHeader(runtime); intro("Clawdis onboarding"); const snapshot = await readConfigFileSnapshot(); @@ -363,13 +358,16 @@ export async function runInteractiveOnboarding( }, }; - nextConfig = await setupProviders(nextConfig, runtime); + nextConfig = await setupProviders(nextConfig, runtime, { + allowSignalInstall: true, + }); await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); await ensureWorkspaceAndSessions(workspaceDir, runtime); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime); + nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); const installDaemon = guardCancel( diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 4904655c7..d4164755e 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -15,6 +15,7 @@ import { resolveUserPath, sleep } from "../utils.js"; import { healthCommand } from "./health.js"; import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; import { + applyWizardMetadata, DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, randomToken, @@ -168,6 +169,7 @@ export async function runNonInteractiveOnboarding( }; } + nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); await ensureWorkspaceAndSessions(workspaceDir, runtime); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 503007199..e5697ae75 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -9,6 +9,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { resolveWebAuthDir } from "../web/session.js"; import { detectBinary, guardCancel } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; +import { installSignalCli } from "./signal-install.js"; async function pathExists(filePath: string): Promise { try { @@ -27,6 +28,7 @@ async function detectWhatsAppLinked(): Promise { export async function setupProviders( cfg: ClawdisConfig, runtime: RuntimeEnv, + options?: { allowDisable?: boolean; allowSignalInstall?: boolean }, ): Promise { const whatsappLinked = await detectWhatsAppLinked(); const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); @@ -38,7 +40,8 @@ export async function setupProviders( const signalConfigured = Boolean( cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort, ); - const signalCliDetected = await detectBinary("signal-cli"); + const signalCliPath = cfg.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); note( [ @@ -46,7 +49,7 @@ export async function setupProviders( `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, `Discord: ${discordConfigured ? "configured" : "needs token"}`, `Signal: ${signalConfigured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, ].join("\n"), "Provider status", ); @@ -241,7 +244,35 @@ export async function setupProviders( } if (selection.includes("signal")) { - if (!signalCliDetected) { + let resolvedCliPath = signalCliPath; + let cliDetected = signalCliDetected; + if (options?.allowSignalInstall) { + const wantsInstall = guardCancel( + await confirm({ + message: cliDetected + ? "signal-cli detected. Reinstall/update now?" + : "signal-cli not found. Install now?", + initialValue: !cliDetected, + }), + runtime, + ); + if (wantsInstall) { + try { + const result = await installSignalCli(runtime); + if (result.ok && result.cliPath) { + cliDetected = true; + resolvedCliPath = result.cliPath; + note(`Installed signal-cli at ${result.cliPath}`, "Signal"); + } else if (!result.ok) { + note(result.error ?? "signal-cli install failed.", "Signal"); + } + } catch (err) { + note(`signal-cli install failed: ${String(err)}`, "Signal"); + } + } + } + + if (!cliDetected) { note( "signal-cli not found. Install it, then rerun this step or set signal.cliPath.", "Signal", @@ -279,7 +310,7 @@ export async function setupProviders( ...next.signal, enabled: true, account, - cliPath: next.signal?.cliPath ?? "signal-cli", + cliPath: resolvedCliPath ?? "signal-cli", }, }; } @@ -294,5 +325,55 @@ export async function setupProviders( ); } + if (options?.allowDisable) { + if (!selection.includes("telegram") && telegramConfigured) { + const disable = guardCancel( + await confirm({ + message: "Disable Telegram provider?", + initialValue: false, + }), + runtime, + ); + if (disable) { + next = { + ...next, + telegram: { ...next.telegram, enabled: false }, + }; + } + } + + if (!selection.includes("discord") && discordConfigured) { + const disable = guardCancel( + await confirm({ + message: "Disable Discord provider?", + initialValue: false, + }), + runtime, + ); + if (disable) { + next = { + ...next, + discord: { ...next.discord, enabled: false }, + }; + } + } + + if (!selection.includes("signal") && signalConfigured) { + const disable = guardCancel( + await confirm({ + message: "Disable Signal provider?", + initialValue: false, + }), + runtime, + ); + if (disable) { + next = { + ...next, + signal: { ...next.signal, enabled: false }, + }; + } + } + } + return next; } diff --git a/src/commands/signal-install.ts b/src/commands/signal-install.ts new file mode 100644 index 000000000..965125670 --- /dev/null +++ b/src/commands/signal-install.ts @@ -0,0 +1,199 @@ +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import { request } from "node:https"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; + +import { runCommandWithTimeout } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { CONFIG_DIR } from "../utils.js"; + +type ReleaseAsset = { + name?: string; + browser_download_url?: string; +}; + +type NamedAsset = { + name: string; + browser_download_url: string; +}; + +type ReleaseResponse = { + tag_name?: string; + assets?: ReleaseAsset[]; +}; + +export type SignalInstallResult = { + ok: boolean; + cliPath?: string; + version?: string; + error?: string; +}; + +function looksLikeArchive(name: string): boolean { + return ( + name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip") + ); +} + +function pickAsset(assets: ReleaseAsset[], platform: NodeJS.Platform) { + const withName = assets.filter((asset): asset is NamedAsset => + Boolean(asset.name && asset.browser_download_url), + ); + const byName = (pattern: RegExp) => + withName.find((asset) => pattern.test(asset.name.toLowerCase())); + + if (platform === "linux") { + return ( + byName(/linux-native/) || + byName(/linux/) || + withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) + ); + } + + if (platform === "darwin") { + return ( + byName(/macos|osx|darwin/) || + withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) + ); + } + + if (platform === "win32") { + return ( + byName(/windows|win/) || + withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())) + ); + } + + return withName.find((asset) => looksLikeArchive(asset.name.toLowerCase())); +} + +async function downloadToFile( + url: string, + dest: string, + maxRedirects = 5, +): Promise { + await new Promise((resolve, reject) => { + const req = request(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + const location = res.headers.location; + if (!location || maxRedirects <= 0) { + reject(new Error("Redirect loop or missing Location header")); + return; + } + const redirectUrl = new URL(location, url).href; + resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1)); + return; + } + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`)); + return; + } + const out = createWriteStream(dest); + pipeline(res, out).then(resolve).catch(reject); + }); + req.on("error", reject); + req.end(); + }); +} + +async function findSignalCliBinary(root: string): Promise { + const candidates: string[] = []; + const enqueue = async (dir: string, depth: number) => { + if (depth > 3) return; + const entries = await fs + .readdir(dir, { withFileTypes: true }) + .catch(() => []); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await enqueue(full, depth + 1); + } else if (entry.isFile() && entry.name === "signal-cli") { + candidates.push(full); + } + } + }; + await enqueue(root, 0); + return candidates[0] ?? null; +} + +export async function installSignalCli( + runtime: RuntimeEnv, +): Promise { + if (process.platform === "win32") { + return { + ok: false, + error: "Signal CLI auto-install is not supported on Windows yet.", + }; + } + + const apiUrl = + "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; + const response = await fetch(apiUrl, { + headers: { + "User-Agent": "clawdis", + Accept: "application/vnd.github+json", + }, + }); + + if (!response.ok) { + return { + ok: false, + error: `Failed to fetch release info (${response.status})`, + }; + } + + const payload = (await response.json()) as ReleaseResponse; + const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; + const assets = payload.assets ?? []; + const asset = pickAsset(assets, process.platform); + const assetName = asset?.name ?? ""; + const assetUrl = asset?.browser_download_url ?? ""; + + if (!assetName || !assetUrl) { + return { + ok: false, + error: "No compatible release asset found for this platform.", + }; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-signal-")); + const archivePath = path.join(tmpDir, assetName); + + runtime.log(`Downloading signal-cli ${version} (${assetName})…`); + await downloadToFile(assetUrl, archivePath); + + const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); + await fs.mkdir(installRoot, { recursive: true }); + + if (assetName.endsWith(".zip")) { + await runCommandWithTimeout( + ["unzip", "-q", archivePath, "-d", installRoot], + { + timeoutMs: 60_000, + }, + ); + } else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz")) { + await runCommandWithTimeout( + ["tar", "-xzf", archivePath, "-C", installRoot], + { + timeoutMs: 60_000, + }, + ); + } else { + return { ok: false, error: `Unsupported archive type: ${assetName}` }; + } + + const cliPath = await findSignalCliBinary(installRoot); + if (!cliPath) { + return { + ok: false, + error: `signal-cli binary not found after extracting ${assetName}`, + }; + } + + await fs.chmod(cliPath, 0o755).catch(() => {}); + + return { ok: true, cliPath, version }; +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 000000000..39e87b5ee --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,21 @@ +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { runConfigureWizard } from "./configure.js"; + +export async function updateCommand(runtime: RuntimeEnv = defaultRuntime) { + await runConfigureWizard( + { + command: "update", + sections: [ + "workspace", + "model", + "gateway", + "daemon", + "providers", + "skills", + "health", + ], + }, + runtime, + ); +} diff --git a/src/config/config.ts b/src/config/config.ts index 214f9e868..ed0247fc3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -390,6 +390,13 @@ export type ClawdisConfig = { theme?: string; emoji?: string; }; + wizard?: { + lastRunAt?: string; + lastRunVersion?: string; + lastRunCommit?: string; + lastRunCommand?: string; + lastRunMode?: "local" | "remote"; + }; logging?: LoggingConfig; browser?: BrowserConfig; ui?: { @@ -699,6 +706,17 @@ const ClawdisSchema = z.object({ emoji: z.string().optional(), }) .optional(), + wizard: z + .object({ + lastRunAt: z.string().optional(), + lastRunVersion: z.string().optional(), + lastRunCommit: z.string().optional(), + lastRunCommand: z.string().optional(), + lastRunMode: z + .union([z.literal("local"), z.literal("remote")]) + .optional(), + }) + .optional(), logging: z .object({ level: z