diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6f4fc4657 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +.git +.worktrees +.bun-cache +.bun +.tmp +**/.tmp +.DS_Store +**/.DS_Store +*.png +*.jpg +*.jpeg +*.webp +*.gif +*.mp4 +*.mov +*.wav +*.mp3 +node_modules +**/node_modules +.pnpm-store +**/.pnpm-store +.turbo +**/.turbo +.cache +**/.cache +.next +**/.next +coverage +**/coverage +*.log +tmp +**/tmp + +# build artifacts +dist +**/dist +apps/macos/.build +apps/ios/build +**/*.trace + +# large app trees not needed for CLI build +apps/ +ui/ +assets/ +Peekaboo/ +Swabble/ +Core/ +Users/ +vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a12ea802..0c09a83b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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. - Skills: allow `bun` as a node manager for skill installs. - Tests: add a Docker-based onboarding E2E harness. diff --git a/docs/wizard.md b/docs/wizard.md index f0b076005..e824fd50b 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -60,22 +60,28 @@ Reset uses `trash` (never `rm`). - Auth: token | password | off - Tailscale: off | serve | funnel -5) **Daemon install (local only)** +5) **Providers (optional)** + - WhatsApp: optional `clawdis login` QR flow + - Telegram: bot token (config or env) + - Discord: bot token (config or env) + - Signal: `signal-cli` detection + account config + +6) **Daemon install (local only)** - macOS: LaunchAgent - Linux: systemd user unit - Windows: Scheduled Task -6) **Health** +7) **Health** - Start/restart daemon - `clawdis health` summary -7) **Skills (recommended)** +8) **Skills (recommended)** - Read from `buildWorkspaceSkillStatus` - Show eligible vs missing requirements - Offer installs via preferred installer - Allow skip -8) **Finish** +9) **Finish** - Summary + next steps - Reminder: iOS/Android/macOS node apps add canvas/camera/screen/system features. @@ -97,6 +103,9 @@ Wizard writes: - `agent.model` + `models.providers` (if Minimax selected) - `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) + +WhatsApp login writes credentials to `~/.clawdis/credentials/creds.json`. ## Minimax M2.1 (LM Studio) config snippet diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index f706a2374..2d8ab1c46 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -3,7 +3,13 @@ FROM node:22-bookworm RUN corepack enable WORKDIR /app -COPY . . + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./ +COPY patches ./patches +COPY src ./src +COPY scripts ./scripts +COPY docs ./docs +COPY skills ./skills RUN pnpm install --frozen-lockfile RUN pnpm build diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts new file mode 100644 index 000000000..bf3e257ba --- /dev/null +++ b/src/commands/onboard-auth.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; + +import { resolveClawdisAgentDir } from "../agents/agent-paths.js"; +import type { ClawdisConfig } from "../config/config.js"; +import { CONFIG_DIR } from "../utils.js"; + +export async function writeOAuthCredentials( + provider: "anthropic", + creds: OAuthCredentials, +): Promise { + const dir = path.join(CONFIG_DIR, "credentials"); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const filePath = path.join(dir, "oauth.json"); + let storage: Record = {}; + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + if (parsed && typeof parsed === "object") storage = parsed; + } catch { + // ignore + } + storage[provider] = creds; + await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8"); + await fs.chmod(filePath, 0o600); +} + +export async function setAnthropicApiKey(key: string) { + const agentDir = resolveClawdisAgentDir(); + const authStorage = discoverAuthStorage(agentDir); + authStorage.set("anthropic", { type: "api_key", key }); +} + +export function applyMinimaxConfig(cfg: ClawdisConfig): ClawdisConfig { + const allowed = new Set(cfg.agent?.allowedModels ?? []); + allowed.add("anthropic/claude-opus-4-5"); + allowed.add("lmstudio/minimax-m2.1-gs32"); + + const aliases = { ...(cfg.agent?.modelAliases ?? {}) }; + if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; + if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; + + const providers = { ...(cfg.models?.providers ?? {}) }; + if (!providers.lmstudio) { + providers.lmstudio = { + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "lmstudio", + api: "openai-responses", + models: [ + { + id: "minimax-m2.1-gs32", + name: "MiniMax M2.1 GS32", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 196608, + maxTokens: 8192, + }, + ], + }; + } + + return { + ...cfg, + agent: { + ...cfg.agent, + model: "Minimax", + allowedModels: Array.from(allowed), + modelAliases: aliases, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts new file mode 100644 index 000000000..22894e88a --- /dev/null +++ b/src/commands/onboard-helpers.ts @@ -0,0 +1,129 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { cancel, isCancel } from "@clack/prompts"; + +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; +import type { ClawdisConfig } from "../config/config.js"; +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 type { NodeManagerChoice, ResetScope } from "./onboard-types.js"; + +export function guardCancel(value: T, runtime: RuntimeEnv): T { + if (isCancel(value)) { + cancel("Setup cancelled."); + runtime.exit(0); + } + return value; +} + +export function summarizeExistingConfig(config: ClawdisConfig): string { + const rows: string[] = []; + if (config.agent?.workspace) + rows.push(`workspace: ${config.agent.workspace}`); + if (config.agent?.model) rows.push(`model: ${config.agent.model}`); + if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); + if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`); + if (config.skills?.install?.nodeManager) { + rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`); + } + return rows.length ? rows.join("\n") : "No key settings detected."; +} + +export function randomToken(): string { + return crypto.randomBytes(24).toString("hex"); +} + +export async function openUrl(url: string): Promise { + const platform = process.platform; + const command = + platform === "darwin" + ? ["open", url] + : platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; + try { + await runCommandWithTimeout(command, { timeoutMs: 5_000 }); + } catch { + // ignore; we still print the URL for manual open + } +} + +export async function ensureWorkspaceAndSessions( + workspaceDir: string, + runtime: RuntimeEnv, +) { + const ws = await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: true, + }); + runtime.log(`Workspace OK: ${ws.dir}`); + const sessionsDir = resolveSessionTranscriptsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + runtime.log(`Sessions OK: ${sessionsDir}`); +} + +export function resolveNodeManagerOptions(): Array<{ + value: NodeManagerChoice; + label: string; +}> { + return [ + { value: "npm", label: "npm" }, + { value: "pnpm", label: "pnpm" }, + { value: "bun", label: "bun" }, + ]; +} + +export async function moveToTrash( + pathname: string, + runtime: RuntimeEnv, +): Promise { + if (!pathname) return; + try { + await fs.access(pathname); + } catch { + return; + } + try { + await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 }); + runtime.log(`Moved to Trash: ${pathname}`); + } catch { + runtime.log(`Failed to move to Trash (manual delete): ${pathname}`); + } +} + +export async function handleReset( + scope: ResetScope, + workspaceDir: string, + runtime: RuntimeEnv, +) { + await moveToTrash(CONFIG_PATH_CLAWDIS, runtime); + if (scope === "config") return; + await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime); + await moveToTrash(resolveSessionTranscriptsDir(), runtime); + if (scope === "full") { + await moveToTrash(workspaceDir, runtime); + } +} + +export async function detectBinary(name: string): Promise { + const command = + process.platform === "win32" + ? ["where", name] + : ["/usr/bin/env", "sh", "-lc", `command -v ${name}`]; + try { + const result = await runCommandWithTimeout(command, { timeoutMs: 2000 }); + return result.code === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} + +export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts new file mode 100644 index 000000000..90ddd089a --- /dev/null +++ b/src/commands/onboard-interactive.ts @@ -0,0 +1,435 @@ +import path from "node:path"; + +import { + confirm, + intro, + 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 { healthCommand } from "./health.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath, sleep } from "../utils.js"; +import { + applyMinimaxConfig, + setAnthropicApiKey, + writeOAuthCredentials, +} from "./onboard-auth.js"; +import { + DEFAULT_WORKSPACE, + ensureWorkspaceAndSessions, + guardCancel, + handleReset, + openUrl, + randomToken, + summarizeExistingConfig, +} from "./onboard-helpers.js"; +import { setupProviders } from "./onboard-providers.js"; +import { setupSkills } from "./onboard-skills.js"; +import type { + AuthChoice, + GatewayAuthChoice, + OnboardMode, + OnboardOptions, + ResetScope, +} from "./onboard-types.js"; + +export async function runInteractiveOnboarding( + opts: OnboardOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const header = [ + " _____ _ ___ _ _ ____ ___ ____ ", + " / ____| | / _ \\ | | | | | _ \\_ _/ __|", + "| | | | | | | | | | | | | | | | |\\__ \\", + "| |___ | |___| |_| | | |__| |___ | |_| | |___) |", + " \\_____|_____|\\___/ \\____/_____|____/___/____/ ", + ].join("\n"); + runtime.log(header); + intro("Clawdis onboarding"); + + 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", + ); + } + + const action = guardCancel( + await select({ + message: "Config handling", + options: [ + { value: "keep", label: "Use existing values" }, + { value: "modify", label: "Update values" }, + { value: "reset", label: "Reset" }, + ], + }), + runtime, + ); + + if (action === "reset") { + const workspaceDefault = baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE; + const resetScope = guardCancel( + await select({ + message: "Reset scope", + options: [ + { value: "config", label: "Config only" }, + { value: "config+creds+sessions", label: "Config + creds + sessions" }, + { + value: "full", + label: "Full reset (config + creds + sessions + workspace)", + }, + ], + }), + runtime, + ) as ResetScope; + await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); + baseConfig = {}; + } else if (action === "keep" && !snapshot.valid) { + baseConfig = {}; + } + } + + const mode = + opts.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 OnboardMode); + + 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 workspaceInput = + opts.workspace ?? + (guardCancel( + await text({ + message: "Workspace directory", + initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE, + }), + runtime, + ) as string); + + const workspaceDir = resolveUserPath( + workspaceInput.trim() || DEFAULT_WORKSPACE, + ); + + let nextConfig: ClawdisConfig = { + ...baseConfig, + agent: { + ...baseConfig.agent, + workspace: workspaceDir, + }, + gateway: { + ...baseConfig.gateway, + mode: "local", + }, + }; + + 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 AuthChoice; + + 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") { + nextConfig = applyMinimaxConfig(nextConfig); + } + + 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 GatewayAuthChoice; + + 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; + if (authMode === "token") { + const tokenInput = guardCancel( + await text({ + message: "Gateway token (blank to generate)", + initialValue: randomToken(), + }), + runtime, + ); + gatewayToken = String(tokenInput).trim() || randomToken(); + } + + if (authMode === "password") { + const password = guardCancel( + await text({ + message: "Gateway password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "password", + password: String(password).trim(), + }, + }, + }; + } else if (authMode === "token") { + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { ...nextConfig.gateway?.auth, mode: "token" }, + }, + }; + } + + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + bind, + tailscale: { + ...nextConfig.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + nextConfig = await setupProviders(nextConfig, runtime); + + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); + await ensureWorkspaceAndSessions(workspaceDir, runtime); + + nextConfig = await setupSkills(nextConfig, workspaceDir, runtime); + await writeConfigFile(nextConfig); + + const installDaemon = guardCancel( + await confirm({ + message: "Install Gateway daemon (recommended)", + initialValue: true, + }), + runtime, + ); + + if (installDaemon) { + 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" }, + ], + }), + runtime, + ); + if (action === "restart") { + await service.restart({ stdout: process.stdout }); + } else if (action === "reinstall") { + await service.uninstall({ env: process.env, stdout: process.stdout }); + } + } + + if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) { + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDIS_GATEWAY_TOKEN: gatewayToken, + CLAWDIS_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } + } + + await sleep(1500); + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(`Health check failed: ${String(err)}`); + } + + note( + [ + "Add nodes for extra features:", + "- macOS app (system + notifications)", + "- iOS app (camera/canvas)", + "- Android app (camera/canvas)", + ].join("\n"), + "Optional apps", + ); + + outro("Onboarding complete."); +} diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts new file mode 100644 index 000000000..4d744b74f --- /dev/null +++ b/src/commands/onboard-non-interactive.ts @@ -0,0 +1,215 @@ +import path from "node:path"; + +import { + CONFIG_PATH_CLAWDIS, + readConfigFileSnapshot, + writeConfigFile, + type ClawdisConfig, +} 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 { resolveUserPath, sleep } from "../utils.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { healthCommand } from "./health.js"; +import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; +import { + DEFAULT_WORKSPACE, + ensureWorkspaceAndSessions, + randomToken, +} from "./onboard-helpers.js"; +import type { AuthChoice, OnboardMode, OnboardOptions } from "./onboard-types.js"; + +export async function runNonInteractiveOnboarding( + opts: OnboardOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const snapshot = await readConfigFileSnapshot(); + const baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; + const mode: OnboardMode = opts.mode ?? "local"; + + if (mode === "remote") { + const payload = { + mode, + instructions: [ + "clawdis setup", + "clawdis gateway-daemon --port 18789", + "OAuth creds: ~/.clawdis/credentials/oauth.json", + "Workspace: ~/clawd", + ], + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } else { + runtime.log(payload.instructions.join("\n")); + } + return; + } + + const workspaceDir = resolveUserPath( + (opts.workspace ?? baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE).trim(), + ); + + let nextConfig: ClawdisConfig = { + ...baseConfig, + agent: { + ...baseConfig.agent, + workspace: workspaceDir, + }, + gateway: { + ...baseConfig.gateway, + mode: "local", + }, + }; + + const authChoice: AuthChoice = opts.authChoice ?? "skip"; + if (authChoice === "apiKey") { + const key = opts.anthropicApiKey?.trim(); + if (!key) { + runtime.error("Missing --anthropic-api-key"); + runtime.exit(1); + return; + } + await setAnthropicApiKey(key); + } else if (authChoice === "minimax") { + nextConfig = applyMinimaxConfig(nextConfig); + } else if (authChoice === "oauth") { + runtime.error("OAuth requires interactive mode."); + runtime.exit(1); + return; + } + + const port = opts.gatewayPort ?? 18789; + if (!Number.isFinite(port) || port <= 0) { + runtime.error("Invalid --gateway-port"); + runtime.exit(1); + return; + } + let bind = opts.gatewayBind ?? "loopback"; + let authMode = opts.gatewayAuth ?? "off"; + const tailscaleMode = opts.tailscale ?? "off"; + const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); + + if (tailscaleMode !== "off" && bind !== "loopback") { + bind = "loopback"; + } + if (authMode === "off" && bind !== "loopback") { + authMode = "token"; + } + if (tailscaleMode === "funnel" && authMode !== "password") { + authMode = "password"; + } + + let gatewayToken = opts.gatewayToken?.trim() || undefined; + if (authMode === "token") { + if (!gatewayToken) gatewayToken = randomToken(); + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { ...nextConfig.gateway?.auth, mode: "token" }, + }, + }; + } + if (authMode === "password") { + const password = opts.gatewayPassword?.trim(); + if (!password) { + runtime.error("Missing --gateway-password for password auth."); + runtime.exit(1); + return; + } + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "password", + password, + }, + }, + }; + } + + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + bind, + tailscale: { + ...nextConfig.gateway?.tailscale, + mode: tailscaleMode, + resetOnExit: tailscaleResetOnExit, + }, + }, + }; + + if (!opts.skipSkills) { + const nodeManager = opts.nodeManager ?? "npm"; + if (!["npm", "pnpm", "bun"].includes(nodeManager)) { + runtime.error("Invalid --node-manager (use npm, pnpm, or bun)"); + runtime.exit(1); + return; + } + nextConfig = { + ...nextConfig, + skills: { + ...nextConfig.skills, + install: { + ...nextConfig.skills?.install, + nodeManager, + }, + }, + }; + } + + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); + await ensureWorkspaceAndSessions(workspaceDir, runtime); + + if (opts.installDaemon) { + const service = resolveGatewayService(); + const devMode = + process.argv[1]?.includes(`${path.sep}src${path.sep}`) && + process.argv[1]?.endsWith(".ts"); + const { programArguments, workingDirectory } = + await resolveGatewayProgramArguments({ port, dev: devMode }); + const environment: Record = { + PATH: process.env.PATH, + CLAWDIS_GATEWAY_TOKEN: gatewayToken, + CLAWDIS_LAUNCHD_LABEL: + process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, + }; + await service.install({ + env: process.env, + stdout: process.stdout, + programArguments, + workingDirectory, + environment, + }); + } + + if (!opts.skipHealth) { + await sleep(1000); + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } + + if (opts.json) { + runtime.log( + JSON.stringify( + { + mode, + workspace: workspaceDir, + authChoice, + gateway: { port, bind, authMode, tailscaleMode }, + installDaemon: Boolean(opts.installDaemon), + skipSkills: Boolean(opts.skipSkills), + skipHealth: Boolean(opts.skipHealth), + }, + null, + 2, + ), + ); + } +} diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts new file mode 100644 index 000000000..742849ee3 --- /dev/null +++ b/src/commands/onboard-providers.ts @@ -0,0 +1,298 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { confirm, multiselect, note, text } from "@clack/prompts"; + +import type { ClawdisConfig } from "../config/config.js"; +import { loginWeb } from "../provider-web.js"; +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"; + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function detectWhatsAppLinked(): Promise { + const credsPath = path.join(resolveWebAuthDir(), "creds.json"); + return await pathExists(credsPath); +} + +export async function setupProviders( + cfg: ClawdisConfig, + runtime: RuntimeEnv, +): Promise { + const whatsappLinked = await detectWhatsAppLinked(); + const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); + const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); + const telegramConfigured = Boolean( + telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile, + ); + const discordConfigured = Boolean(discordEnv || cfg.discord?.token); + const signalConfigured = Boolean( + cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort, + ); + const signalCliDetected = await detectBinary("signal-cli"); + + note( + [ + `WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`, + `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, + `Discord: ${discordConfigured ? "configured" : "needs token"}`, + `Signal: ${signalConfigured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"}`, + ].join("\n"), + "Provider status", + ); + + const shouldConfigure = guardCancel( + await confirm({ + message: "Configure chat providers now?", + initialValue: true, + }), + runtime, + ); + if (!shouldConfigure) return cfg; + + const selection = guardCancel( + await multiselect({ + message: "Select providers", + options: [ + { + value: "whatsapp", + label: "WhatsApp (QR link)", + hint: whatsappLinked ? "linked" : "not linked", + }, + { + value: "telegram", + label: "Telegram (Bot API)", + hint: telegramConfigured ? "configured" : "needs token", + }, + { + value: "discord", + label: "Discord (Bot API)", + hint: discordConfigured ? "configured" : "needs token", + }, + { + value: "signal", + label: "Signal (signal-cli)", + hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", + }, + ], + }), + runtime, + ) as ProviderChoice[]; + + let next = cfg; + + if (selection.includes("whatsapp")) { + const wantsLink = guardCancel( + await confirm({ + message: whatsappLinked + ? "WhatsApp already linked. Re-link now?" + : "Link WhatsApp now (QR)?", + initialValue: !whatsappLinked, + }), + runtime, + ); + if (wantsLink) { + try { + await loginWeb(false, "web"); + } catch (err) { + runtime.error(`WhatsApp login failed: ${String(err)}`); + } + } else if (!whatsappLinked) { + note("Run `clawdis login` later to link WhatsApp.", "WhatsApp"); + } + } + + if (selection.includes("telegram")) { + let token: string | null = null; + if (telegramEnv && !cfg.telegram?.botToken) { + const keepEnv = guardCancel( + await confirm({ + message: "TELEGRAM_BOT_TOKEN detected. Use env var?", + initialValue: true, + }), + runtime, + ); + if (!keepEnv) { + token = String( + guardCancel( + await text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + } else if (cfg.telegram?.botToken) { + const keep = guardCancel( + await confirm({ + message: "Telegram token already configured. Keep it?", + initialValue: true, + }), + runtime, + ); + if (!keep) { + token = String( + guardCancel( + await text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + } else { + token = String( + guardCancel( + await text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + + if (token) { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + botToken: token, + }, + }; + } + } + + if (selection.includes("discord")) { + let token: string | null = null; + if (discordEnv && !cfg.discord?.token) { + const keepEnv = guardCancel( + await confirm({ + message: "DISCORD_BOT_TOKEN detected. Use env var?", + initialValue: true, + }), + runtime, + ); + if (!keepEnv) { + token = String( + guardCancel( + await text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + } else if (cfg.discord?.token) { + const keep = guardCancel( + await confirm({ + message: "Discord token already configured. Keep it?", + initialValue: true, + }), + runtime, + ); + if (!keep) { + token = String( + guardCancel( + await text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + } else { + token = String( + guardCancel( + await text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + + if (token) { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + token, + }, + }; + } + } + + if (selection.includes("signal")) { + if (!signalCliDetected) { + note( + "signal-cli not found. Install it, then rerun this step or set signal.cliPath.", + "Signal", + ); + } + + let account = cfg.signal?.account ?? ""; + if (account) { + const keep = guardCancel( + await confirm({ + message: `Signal account set (${account}). Keep it?`, + initialValue: true, + }), + runtime, + ); + if (!keep) account = ""; + } + + if (!account) { + account = String( + guardCancel( + await text({ + message: "Signal bot number (E.164)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ).trim(); + } + + if (account) { + next = { + ...next, + signal: { + ...next.signal, + enabled: true, + account, + cliPath: next.signal?.cliPath ?? "signal-cli", + }, + }; + } + + note( + [ + "Link device with: signal-cli link -n \"Clawdis\"", + "Scan QR in Signal → Linked Devices", + "Then run: clawdis gateway call providers.status --params '{\"probe\":true}'", + ].join("\n"), + "Signal next steps", + ); + } + + return next; +} diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts new file mode 100644 index 000000000..c8ff377ed --- /dev/null +++ b/src/commands/onboard-skills.ts @@ -0,0 +1,134 @@ +import { confirm, multiselect, note, select, spinner, text } from "@clack/prompts"; + +import { installSkill } from "../agents/skills-install.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import type { ClawdisConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { guardCancel, resolveNodeManagerOptions } from "./onboard-helpers.js"; + +function upsertSkillEntry( + cfg: ClawdisConfig, + skillKey: string, + patch: { apiKey?: string }, +): ClawdisConfig { + const entries = { ...(cfg.skills?.entries ?? {}) }; + const existing = (entries[skillKey] as { apiKey?: string } | undefined) ?? {}; + entries[skillKey] = { ...existing, ...patch }; + return { + ...cfg, + skills: { + ...cfg.skills, + entries, + }, + }; +} + +export async function setupSkills( + cfg: ClawdisConfig, + workspaceDir: string, + runtime: RuntimeEnv, +): Promise { + const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); + const eligible = report.skills.filter((s) => s.eligible); + const missing = report.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ); + const blocked = report.skills.filter((s) => s.blockedByAllowlist); + + note( + [ + `Eligible: ${eligible.length}`, + `Missing requirements: ${missing.length}`, + `Blocked by allowlist: ${blocked.length}`, + ].join("\n"), + "Skills status", + ); + + const shouldConfigure = guardCancel( + await confirm({ + message: "Configure skills now? (recommended)", + initialValue: true, + }), + runtime, + ); + if (!shouldConfigure) return cfg; + + const nodeManager = guardCancel( + await select({ + message: "Preferred node manager for skill installs", + options: resolveNodeManagerOptions(), + }), + runtime, + ) as "npm" | "pnpm" | "bun"; + + let next: ClawdisConfig = { + ...cfg, + skills: { + ...cfg.skills, + install: { + ...cfg.skills?.install, + nodeManager, + }, + }, + }; + + const installable = missing.filter( + (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, + ); + if (installable.length > 0) { + const toInstall = guardCancel( + await multiselect({ + message: "Install missing skill dependencies", + options: installable.map((skill) => ({ + value: skill.name, + label: `${skill.emoji ?? "🧩"} ${skill.name}`, + hint: skill.install[0]?.label ?? "install", + })), + }), + runtime, + ); + + for (const name of toInstall as string[]) { + const target = installable.find((s) => s.name === name); + if (!target || target.install.length === 0) continue; + const installId = target.install[0]?.id; + if (!installId) continue; + const spin = spinner(); + spin.start(`Installing ${name}…`); + const result = await installSkill({ + workspaceDir, + skillName: target.name, + installId, + config: next, + }); + spin.stop(result.ok ? `Installed ${name}` : `Install failed: ${name}`); + if (!result.ok && result.stderr) { + runtime.log(result.stderr.trim()); + } + } + } + + for (const skill of missing) { + if (!skill.primaryEnv || skill.missing.env.length === 0) continue; + const wantsKey = guardCancel( + await confirm({ + message: `Set ${skill.primaryEnv} for ${skill.name}?`, + initialValue: false, + }), + runtime, + ); + if (!wantsKey) continue; + const apiKey = String( + guardCancel( + await text({ + message: `Enter ${skill.primaryEnv}`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ), + ); + next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); + } + + return next; +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts new file mode 100644 index 000000000..fb7cda1e9 --- /dev/null +++ b/src/commands/onboard-types.ts @@ -0,0 +1,28 @@ +export type OnboardMode = "local" | "remote"; +export type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip"; +export type GatewayAuthChoice = "off" | "token" | "password"; +export type ResetScope = "config" | "config+creds+sessions" | "full"; +export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto"; +export type TailscaleMode = "off" | "serve" | "funnel"; +export type NodeManagerChoice = "npm" | "pnpm" | "bun"; +export type ProviderChoice = "whatsapp" | "telegram" | "discord" | "signal"; + +export type OnboardOptions = { + mode?: OnboardMode; + workspace?: string; + nonInteractive?: boolean; + authChoice?: AuthChoice; + anthropicApiKey?: string; + gatewayPort?: number; + gatewayBind?: GatewayBind; + gatewayAuth?: GatewayAuthChoice; + gatewayToken?: string; + gatewayPassword?: string; + tailscale?: TailscaleMode; + tailscaleResetOnExit?: boolean; + installDaemon?: boolean; + skipSkills?: boolean; + skipHealth?: boolean; + nodeManager?: NodeManagerChoice; + json?: boolean; +}; diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 11f2c4050..4c3fbab1d 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -1,361 +1,9 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; - -import { - cancel, - confirm, - intro, - isCancel, - multiselect, - note, - outro, - select, - spinner, - text, -} from "@clack/prompts"; -import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; -import { resolveClawdisAgentDir } from "../agents/agent-paths.js"; -import { installSkill } from "../agents/skills-install.js"; -import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; -import { - DEFAULT_AGENT_WORKSPACE_DIR, - ensureAgentWorkspace, -} from "../agents/workspace.js"; -import type { BridgeBindMode, ClawdisConfig } from "../config/config.js"; -import { - CONFIG_PATH_CLAWDIS, - readConfigFileSnapshot, - writeConfigFile, -} from "../config/config.js"; -import { resolveSessionTranscriptsDir } from "../config/sessions.js"; -import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolveGatewayService } from "../daemon/service.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; -import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { CONFIG_DIR, resolveUserPath, sleep } from "../utils.js"; -import { healthCommand } from "./health.js"; - -type OnboardMode = "local" | "remote"; -type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip"; -type GatewayAuthChoice = "off" | "token" | "password"; -type ResetScope = "config" | "config+creds+sessions" | "full"; - -type OnboardOptions = { - mode?: OnboardMode; - workspace?: string; - nonInteractive?: boolean; - authChoice?: AuthChoice; - anthropicApiKey?: string; - gatewayPort?: number; - gatewayBind?: "loopback" | "lan" | "tailnet" | "auto"; - gatewayAuth?: GatewayAuthChoice; - gatewayToken?: string; - gatewayPassword?: string; - tailscale?: "off" | "serve" | "funnel"; - tailscaleResetOnExit?: boolean; - installDaemon?: boolean; - skipSkills?: boolean; - skipHealth?: boolean; - nodeManager?: "npm" | "pnpm" | "bun"; - json?: boolean; -}; - -function guardCancel(value: T, runtime: RuntimeEnv): Exclude { - if (isCancel(value)) { - cancel("Setup cancelled."); - runtime.exit(0); - } - return value as Exclude; -} - -function summarizeExistingConfig(config: ClawdisConfig): string { - const rows: string[] = []; - if (config.agent?.workspace) - rows.push(`workspace: ${config.agent.workspace}`); - if (config.agent?.model) rows.push(`model: ${config.agent.model}`); - if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); - if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`); - if (config.skills?.install?.nodeManager) { - rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`); - } - return rows.length ? rows.join("\n") : "No key settings detected."; -} - -function randomToken(): string { - return crypto.randomBytes(24).toString("hex"); -} - -async function openUrl(url: string): Promise { - const platform = process.platform; - const command = - platform === "darwin" - ? ["open", url] - : platform === "win32" - ? ["cmd", "/c", "start", "", url] - : ["xdg-open", url]; - try { - await runCommandWithTimeout(command, { timeoutMs: 5_000 }); - } catch { - // ignore; we still print the URL for manual open - } -} - -async function ensureWorkspaceAndSessions( - workspaceDir: string, - runtime: RuntimeEnv, -) { - const ws = await ensureAgentWorkspace({ - dir: workspaceDir, - ensureBootstrapFiles: true, - }); - runtime.log(`Workspace OK: ${ws.dir}`); - const sessionsDir = resolveSessionTranscriptsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - runtime.log(`Sessions OK: ${sessionsDir}`); -} - -async function writeOAuthCredentials( - provider: "anthropic", - creds: OAuthCredentials, -): Promise { - const dir = path.join(CONFIG_DIR, "credentials"); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const filePath = path.join(dir, "oauth.json"); - let storage: Record = {}; - try { - const raw = await fs.readFile(filePath, "utf8"); - const parsed = JSON.parse(raw) as Record; - if (parsed && typeof parsed === "object") storage = parsed; - } catch { - // ignore - } - storage[provider] = creds; - await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8"); - await fs.chmod(filePath, 0o600); -} - -async function setAnthropicApiKey(key: string) { - const agentDir = resolveClawdisAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - authStorage.set("anthropic", { type: "api_key", key }); -} - -function applyMinimaxConfig(cfg: ClawdisConfig): ClawdisConfig { - const allowed = new Set(cfg.agent?.allowedModels ?? []); - allowed.add("anthropic/claude-opus-4-5"); - allowed.add("lmstudio/minimax-m2.1-gs32"); - - const aliases = { ...(cfg.agent?.modelAliases ?? {}) }; - if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; - if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; - - const providers = { ...(cfg.models?.providers ?? {}) }; - if (!providers.lmstudio) { - providers.lmstudio = { - baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", - api: "openai-responses", - models: [ - { - id: "minimax-m2.1-gs32", - name: "MiniMax M2.1 GS32", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 196608, - maxTokens: 8192, - }, - ], - }; - } - - return { - ...cfg, - agent: { - ...cfg.agent, - model: "Minimax", - allowedModels: Array.from(allowed), - modelAliases: aliases, - }, - models: { - mode: cfg.models?.mode ?? "merge", - providers, - }, - }; -} - -function upsertSkillEntry( - cfg: ClawdisConfig, - skillKey: string, - patch: { apiKey?: string }, -): ClawdisConfig { - const entries = { ...(cfg.skills?.entries ?? {}) }; - const existing = (entries[skillKey] as { apiKey?: string } | undefined) ?? {}; - entries[skillKey] = { ...existing, ...patch }; - return { - ...cfg, - skills: { - ...cfg.skills, - entries, - }, - }; -} - -function resolveNodeManagerOptions(): Array<{ - value: "npm" | "pnpm" | "bun"; - label: string; -}> { - return [ - { value: "npm", label: "npm" }, - { value: "pnpm", label: "pnpm" }, - { value: "bun", label: "bun" }, - ]; -} - -async function moveToTrash( - pathname: string, - runtime: RuntimeEnv, -): Promise { - if (!pathname) return; - try { - await fs.access(pathname); - } catch { - return; - } - try { - await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 }); - runtime.log(`Moved to Trash: ${pathname}`); - } catch { - runtime.log(`Failed to move to Trash (manual delete): ${pathname}`); - } -} - -async function handleReset( - scope: ResetScope, - workspaceDir: string, - runtime: RuntimeEnv, -) { - await moveToTrash(CONFIG_PATH_CLAWDIS, runtime); - if (scope === "config") return; - await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime); - await moveToTrash(resolveSessionTranscriptsDir(), runtime); - if (scope === "full") { - await moveToTrash(workspaceDir, runtime); - } -} - -async function setupSkills( - cfg: ClawdisConfig, - workspaceDir: string, - runtime: RuntimeEnv, -): Promise { - const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); - const eligible = report.skills.filter((s) => s.eligible); - const missing = report.skills.filter( - (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, - ); - const blocked = report.skills.filter((s) => s.blockedByAllowlist); - - note( - [ - `Eligible: ${eligible.length}`, - `Missing requirements: ${missing.length}`, - `Blocked by allowlist: ${blocked.length}`, - ].join("\n"), - "Skills status", - ); - - const shouldConfigure = guardCancel( - await confirm({ - message: "Configure skills now? (recommended)", - initialValue: true, - }), - runtime, - ); - if (!shouldConfigure) return cfg; - - const nodeManager = guardCancel( - await select({ - message: "Preferred node manager for skill installs", - options: resolveNodeManagerOptions(), - }), - runtime, - ); - - let next: ClawdisConfig = { - ...cfg, - skills: { - ...cfg.skills, - install: { - ...cfg.skills?.install, - nodeManager, - }, - }, - }; - - const installable = missing.filter( - (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, - ); - if (installable.length > 0) { - const toInstall = guardCancel( - await multiselect({ - message: "Install missing skill dependencies", - options: installable.map((skill) => ({ - value: skill.name, - label: `${skill.emoji ?? "🧩"} ${skill.name}`, - hint: skill.install[0]?.label ?? "install", - })), - }), - runtime, - ); - - for (const name of toInstall as string[]) { - const target = installable.find((s) => s.name === name); - if (!target || target.install.length === 0) continue; - const installId = target.install[0]?.id; - if (!installId) continue; - const spin = spinner(); - spin.start(`Installing ${name}…`); - const result = await installSkill({ - workspaceDir, - skillName: target.name, - installId, - config: next, - }); - spin.stop(result.ok ? `Installed ${name}` : `Install failed: ${name}`); - if (!result.ok && result.stderr) { - runtime.log(result.stderr.trim()); - } - } - } - - for (const skill of missing) { - if (!skill.primaryEnv || skill.missing.env.length === 0) continue; - const wantsKey = guardCancel( - await confirm({ - message: `Set ${skill.primaryEnv} for ${skill.name}?`, - initialValue: false, - }), - runtime, - ); - if (!wantsKey) continue; - const apiKey = guardCancel( - await text({ - message: `Enter ${skill.primaryEnv}`, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); - } - - return next; -} +import { runInteractiveOnboarding } from "./onboard-interactive.js"; +import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js"; +import type { OnboardOptions } from "./onboard-types.js"; export async function onboardCommand( opts: OnboardOptions, @@ -364,582 +12,11 @@ export async function onboardCommand( assertSupportedRuntime(runtime); if (opts.nonInteractive) { - const snapshot = await readConfigFileSnapshot(); - const baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; - const mode: OnboardMode = opts.mode ?? "local"; - - if (mode === "remote") { - const payload = { - mode, - instructions: [ - "clawdis setup", - "clawdis gateway-daemon --port 18789", - "OAuth creds: ~/.clawdis/credentials/oauth.json", - "Workspace: ~/clawd", - ], - }; - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(payload.instructions.join("\n")); - } - return; - } - - const workspaceDir = resolveUserPath( - ( - opts.workspace ?? - baseConfig.agent?.workspace ?? - DEFAULT_AGENT_WORKSPACE_DIR - ).trim(), - ); - - let nextConfig: ClawdisConfig = { - ...baseConfig, - agent: { - ...baseConfig.agent, - workspace: workspaceDir, - }, - gateway: { - ...baseConfig.gateway, - mode: "local", - }, - }; - - const authChoice: AuthChoice = opts.authChoice ?? "skip"; - if (authChoice === "apiKey") { - const key = opts.anthropicApiKey?.trim(); - if (!key) { - runtime.error("Missing --anthropic-api-key"); - runtime.exit(1); - return; - } - await setAnthropicApiKey(key); - } else if (authChoice === "minimax") { - nextConfig = applyMinimaxConfig(nextConfig); - } else if (authChoice === "oauth") { - runtime.error("OAuth requires interactive mode."); - runtime.exit(1); - return; - } - - const port = opts.gatewayPort ?? 18789; - if (!Number.isFinite(port) || port <= 0) { - runtime.error("Invalid --gateway-port"); - runtime.exit(1); - return; - } - let bind = opts.gatewayBind ?? "loopback"; - let authMode = opts.gatewayAuth ?? "off"; - const tailscaleMode = opts.tailscale ?? "off"; - const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); - - if (tailscaleMode !== "off" && bind !== "loopback") { - bind = "loopback"; - } - if (authMode === "off" && bind !== "loopback") { - authMode = "token"; - } - if (tailscaleMode === "funnel" && authMode !== "password") { - authMode = "password"; - } - - let gatewayToken = opts.gatewayToken?.trim() || undefined; - if (authMode === "token") { - if (!gatewayToken) gatewayToken = randomToken(); - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { ...nextConfig.gateway?.auth, mode: "token" }, - }, - }; - } - if (authMode === "password") { - const password = opts.gatewayPassword?.trim(); - if (!password) { - runtime.error("Missing --gateway-password for password auth."); - runtime.exit(1); - return; - } - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "password", - password, - }, - }, - }; - } - - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - bind, - tailscale: { - ...nextConfig.gateway?.tailscale, - mode: tailscaleMode, - resetOnExit: tailscaleResetOnExit, - }, - }, - }; - - if (!opts.skipSkills) { - const nodeManager = opts.nodeManager ?? "npm"; - if (!["npm", "pnpm", "bun"].includes(nodeManager)) { - runtime.error("Invalid --node-manager (use npm, pnpm, or bun)"); - runtime.exit(1); - return; - } - nextConfig = { - ...nextConfig, - skills: { - ...nextConfig.skills, - install: { - ...nextConfig.skills?.install, - nodeManager, - }, - }, - }; - } - - await writeConfigFile(nextConfig); - await ensureWorkspaceAndSessions(workspaceDir, runtime); - - if (opts.installDaemon) { - const service = resolveGatewayService(); - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ port, dev: devMode }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDIS_GATEWAY_TOKEN: gatewayToken, - CLAWDIS_LAUNCHD_LABEL: - process.platform === "darwin" - ? GATEWAY_LAUNCH_AGENT_LABEL - : undefined, - }; - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - } - - if (!opts.skipHealth) { - await sleep(1000); - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } - - if (opts.json) { - runtime.log( - JSON.stringify( - { - mode, - workspace: workspaceDir, - authChoice, - gateway: { port, bind, authMode, tailscaleMode }, - installDaemon: Boolean(opts.installDaemon), - skipSkills: Boolean(opts.skipSkills), - skipHealth: Boolean(opts.skipHealth), - }, - null, - 2, - ), - ); - } + await runNonInteractiveOnboarding(opts, runtime); return; } - intro("Clawdis onboarding"); - - 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", - ); - } - - const action = guardCancel( - await select({ - message: "Config handling", - options: [ - { value: "keep", label: "Use existing values" }, - { value: "modify", label: "Update values" }, - { value: "reset", label: "Reset" }, - ], - }), - runtime, - ); - - if (action === "reset") { - const workspaceDefault = - baseConfig.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const resetScope = guardCancel( - await select({ - message: "Reset scope", - options: [ - { value: "config", label: "Config only" }, - { - value: "config+creds+sessions", - label: "Config + creds + sessions", - }, - { - value: "full", - label: "Full reset (config + creds + sessions + workspace)", - }, - ], - }), - runtime, - ) as ResetScope; - await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); - baseConfig = {}; - } else if (action === "keep" && !snapshot.valid) { - baseConfig = {}; - } - } - - const mode = - opts.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 OnboardMode); - - 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 workspaceInput = - opts.workspace ?? - (guardCancel( - await text({ - message: "Workspace directory", - initialValue: - baseConfig.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR, - }), - runtime, - ) as string); - - const workspaceDir = resolveUserPath( - workspaceInput.trim() || DEFAULT_AGENT_WORKSPACE_DIR, - ); - - let nextConfig: ClawdisConfig = { - ...baseConfig, - agent: { - ...baseConfig.agent, - workspace: workspaceDir, - }, - gateway: { - ...baseConfig.gateway, - mode: "local", - }, - }; - - 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 AuthChoice; - - 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"); - 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") { - nextConfig = applyMinimaxConfig(nextConfig); - } - - 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 BridgeBindMode; - - 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 GatewayAuthChoice; - - 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 = 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; - if (authMode === "token") { - const tokenInput = guardCancel( - await text({ - message: "Gateway token (blank to generate)", - initialValue: randomToken(), - }), - runtime, - ); - gatewayToken = String(tokenInput).trim() || randomToken(); - } - - if (authMode === "password") { - const password = guardCancel( - await text({ - message: "Gateway password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "password", - password: String(password).trim(), - }, - }, - }; - } else if (authMode === "token") { - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { ...nextConfig.gateway?.auth, mode: "token" }, - }, - }; - } - - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - bind, - tailscale: { - ...nextConfig.gateway?.tailscale, - mode: tailscaleMode, - resetOnExit: tailscaleResetOnExit, - }, - }, - }; - - await writeConfigFile(nextConfig); - runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`); - await ensureWorkspaceAndSessions(workspaceDir, runtime); - - nextConfig = await setupSkills(nextConfig, workspaceDir, runtime); - await writeConfigFile(nextConfig); - - const installDaemon = guardCancel( - await confirm({ - message: "Install Gateway daemon (recommended)", - initialValue: true, - }), - runtime, - ); - - if (installDaemon) { - 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" }, - ], - }), - runtime, - ); - if (action === "restart") { - await service.restart({ stdout: process.stdout }); - } else if (action === "reinstall") { - await service.uninstall({ env: process.env, stdout: process.stdout }); - } - } - - if ( - !loaded || - (loaded && (await service.isLoaded({ env: process.env })) === false) - ) { - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ port, dev: devMode }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDIS_GATEWAY_TOKEN: gatewayToken, - CLAWDIS_LAUNCHD_LABEL: - process.platform === "darwin" - ? GATEWAY_LAUNCH_AGENT_LABEL - : undefined, - }; - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - } - } - - await sleep(1500); - try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } catch (err) { - runtime.error(`Health check failed: ${String(err)}`); - } - - note( - [ - "Add nodes for extra features:", - "- macOS app (system + notifications)", - "- iOS app (camera/canvas)", - "- Android app (camera/canvas)", - ].join("\n"), - "Optional apps", - ); - - outro("Onboarding complete."); + await runInteractiveOnboarding(opts, runtime); } + +export type { OnboardOptions } from "./onboard-types.js";