diff --git a/docs/cli/index.md b/docs/cli/index.md index 8e1bf6e22..33ea246ed 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -30,6 +30,10 @@ clawdbot [--dev] [--profile ] send poll agent + agents + list + add + delete status health sessions @@ -147,7 +151,7 @@ Options: - `--workspace ` - `--non-interactive` - `--mode ` -- `--auth-choice ` +- `--auth-choice ` - `--anthropic-api-key ` - `--gateway-port ` - `--gateway-bind ` @@ -272,6 +276,29 @@ Options: - `--json` - `--timeout ` +### `agents` +Manage isolated agents (workspaces + auth + routing). + +#### `agents list` +List configured agents. + +Options: +- `--json` + +#### `agents add [name]` +Add a new isolated agent. If `--workspace` is omitted, runs the guided wizard. + +Options: +- `--workspace ` +- `--json` + +#### `agents delete ` +Delete an agent and prune its workspace + state. + +Options: +- `--force` +- `--json` + ### `status` Show linked session health and recent recipients. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 004fd1bf4..d17a556a8 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -28,6 +28,16 @@ If you do nothing, Clawdbot runs a single agent: - Workspace defaults to `~/clawd` (or `~/clawd-` when `CLAWDBOT_PROFILE` is set). - State defaults to `~/.clawdbot/agents/main/agent`. +## Agent helper + +Use the agent wizard to add a new isolated agent: + +```bash +clawdbot agents add work +``` + +Then add `routing.bindings` (or let the wizard do it) to route inbound messages. + ## Multiple agents = multiple people, multiple personalities With **multiple agents**, each `agentId` becomes a **fully isolated persona**: @@ -73,10 +83,12 @@ multiple phone numbers without mixing sessions. agents: { home: { + name: "Home", workspace: "~/clawd-home", agentDir: "~/.clawdbot/agents/home/agent", }, work: { + name: "Work", workspace: "~/clawd-work", agentDir: "~/.clawdbot/agents/work/agent", }, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index bf8fc4501..42f69c8ff 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -330,8 +330,10 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `routing.defaultAgentId`: fallback when no binding matches (default: `main`). - `routing.agents.`: per-agent overrides. + - `name`: display name for the agent. - `workspace`: default `~/clawd-` (for `main`, falls back to legacy `agent.workspace`). - `agentDir`: default `~/.clawdbot/agents//agent`. + - `model`: per-agent default model (provider/model), overrides `agent.model` for that agent. - `routing.bindings[]`: routes inbound messages to an `agentId`. - `match.provider` (required) - `match.accountId` (optional; `*` = any account; omitted = default account) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index b79c15662..66eb8f190 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -38,6 +38,12 @@ clawdbot configure **Remote mode** only configures the local client to connect to a Gateway elsewhere. It does **not** install or change anything on the remote host. +To add more isolated agents (separate workspace + sessions + auth), use: + +```bash +clawdbot agents add +``` + ## Flow details (local) 1) **Existing config detection** @@ -110,6 +116,20 @@ Notes: - macOS: Bonjour (`dns-sd`) - Linux: Avahi (`avahi-browse`) +## Add another agent + +Use `clawdbot agents add ` to create a separate agent with its own workspace, +sessions, and auth profiles. Running without `--workspace` launches the wizard. + +What it sets: +- `routing.agents..name` +- `routing.agents..workspace` +- `routing.agents..agentDir` + +Notes: +- Default workspaces follow `~/clawd-`. +- Add `routing.bindings` to route inbound messages (the wizard can do this). + ## Non‑interactive mode Use `--non-interactive` to automate or script onboarding: @@ -128,6 +148,12 @@ clawdbot onboard --non-interactive \ Add `--json` for a machine‑readable summary. +Add agent (non‑interactive) example: + +```bash +clawdbot agents add work --workspace ~/clawd-work +``` + ## Gateway wizard RPC The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`). @@ -159,6 +185,8 @@ Typical fields in `~/.clawdbot/clawdbot.json`: - `wizard.lastRunCommand` - `wizard.lastRunMode` +`clawdbot agents add` writes `routing.agents.` and optional `routing.bindings`. + WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp//`. Sessions are stored under `~/.clawdbot/agents//sessions/`. diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index e462abbcf..34feee5d6 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -21,16 +21,25 @@ export function resolveAgentIdFromSessionKey( export function resolveAgentConfig( cfg: ClawdbotConfig, agentId: string, -): { workspace?: string; agentDir?: string } | undefined { +): + | { + name?: string; + workspace?: string; + agentDir?: string; + model?: string; + } + | undefined { const id = normalizeAgentId(agentId); const agents = cfg.routing?.agents; if (!agents || typeof agents !== "object") return undefined; const entry = agents[id]; if (!entry || typeof entry !== "object") return undefined; return { + name: typeof entry.name === "string" ? entry.name : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, + model: typeof entry.model === "string" ? entry.model : undefined, }; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 2f17d9e64..3dbaf85ed 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -199,10 +199,12 @@ export async function getReplyFromConfig( configOverride?: ClawdbotConfig, ): Promise { const cfg = configOverride ?? loadConfig(); + const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const agentCfg = cfg.agent; const sessionCfg = cfg.session; const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ cfg, + agentId, }); let provider = defaultProvider; let model = defaultModel; @@ -221,7 +223,6 @@ export async function getReplyFromConfig( } } - const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index b567c4bc8..618dc5741 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1,4 +1,5 @@ import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { resolveAuthProfileDisplayLabel, resolveAuthStorePathForDisplay, @@ -768,20 +769,41 @@ export async function persistInlineDirectives(params: { }; } -export function resolveDefaultModel(params: { cfg: ClawdbotConfig }): { +export function resolveDefaultModel(params: { + cfg: ClawdbotConfig; + agentId?: string; +}): { defaultProvider: string; defaultModel: string; aliasIndex: ModelAliasIndex; } { + const agentModelOverride = params.agentId + ? resolveAgentConfig(params.cfg, params.agentId)?.model?.trim() + : undefined; + const cfg = + agentModelOverride && agentModelOverride.length > 0 + ? { + ...params.cfg, + agent: { + ...params.cfg.agent, + model: { + ...(typeof params.cfg.agent?.model === "object" + ? params.cfg.agent.model + : undefined), + primary: agentModelOverride, + }, + }, + } + : params.cfg; const mainModel = resolveConfiguredModelRef({ - cfg: params.cfg, + cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); const defaultProvider = mainModel.provider; const defaultModel = mainModel.model; const aliasIndex = buildModelAliasIndex({ - cfg: params.cfg, + cfg, defaultProvider, }); return { defaultProvider, defaultModel, aliasIndex }; diff --git a/src/cli/program.ts b/src/cli/program.ts index bc1bc1efa..46d8eeddf 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,6 +1,11 @@ import chalk from "chalk"; import { Command } from "commander"; import { agentCliCommand } from "../commands/agent-via-gateway.js"; +import { + agentsAddCommand, + agentsDeleteCommand, + agentsListCommand, +} from "../commands/agents.js"; import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; @@ -217,7 +222,10 @@ export function buildProgram() { .option("--workspace ", "Agent workspace directory (default: ~/clawd)") .option("--non-interactive", "Run without prompts", false) .option("--mode ", "Wizard mode: local|remote") - .option("--auth-choice ", "Auth: oauth|apiKey|minimax|skip") + .option( + "--auth-choice ", + "Auth: oauth|openai-codex|antigravity|apiKey|minimax|skip", + ) .option("--anthropic-api-key ", "Anthropic API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto") @@ -243,6 +251,8 @@ export function buildProgram() { mode: opts.mode as "local" | "remote" | undefined, authChoice: opts.authChoice as | "oauth" + | "openai-codex" + | "antigravity" | "apiKey" | "minimax" | "skip" @@ -545,6 +555,74 @@ Examples: } }); + const agents = program + .command("agents") + .description("Manage isolated agents (workspaces + auth + routing)"); + + agents + .command("list") + .description("List configured agents") + .option("--json", "Output JSON instead of text", false) + .action(async (opts) => { + try { + await agentsListCommand({ json: Boolean(opts.json) }, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + agents + .command("add [name]") + .description("Add a new isolated agent") + .option("--workspace ", "Workspace directory for the new agent") + .option("--json", "Output JSON summary", false) + .action(async (name, opts) => { + try { + await agentsAddCommand( + { + name: typeof name === "string" ? name : undefined, + workspace: opts.workspace as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + agents + .command("delete ") + .description("Delete an agent and prune workspace/state") + .option("--force", "Skip confirmation", false) + .option("--json", "Output JSON summary", false) + .action(async (id, opts) => { + try { + await agentsDeleteCommand( + { + id: String(id), + force: Boolean(opts.force), + json: Boolean(opts.json), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + agents.action(async () => { + try { + await agentsListCommand({}, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + registerCanvasCli(program); registerGatewayCli(program); registerModelsCli(program); diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts new file mode 100644 index 000000000..b1e4214dd --- /dev/null +++ b/src/commands/agents.test.ts @@ -0,0 +1,140 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { + applyAgentBindings, + applyAgentConfig, + buildAgentSummaries, + pruneAgentConfig, +} from "./agents.js"; + +describe("agents helpers", () => { + it("buildAgentSummaries includes default + routing agents", () => { + const cfg: ClawdbotConfig = { + agent: { workspace: "/main-ws", model: { primary: "anthropic/claude" } }, + routing: { + defaultAgentId: "work", + agents: { + work: { + name: "Work", + workspace: "/work-ws", + agentDir: "/state/agents/work/agent", + model: "openai/gpt-4.1", + }, + }, + bindings: [ + { + agentId: "work", + match: { provider: "whatsapp", accountId: "biz" }, + }, + { agentId: "main", match: { provider: "telegram" } }, + ], + }, + }; + + const summaries = buildAgentSummaries(cfg); + const main = summaries.find((summary) => summary.id === "main"); + const work = summaries.find((summary) => summary.id === "work"); + + expect(main).toBeTruthy(); + expect(main?.workspace).toBe("/main-ws"); + expect(main?.bindings).toBe(1); + expect(main?.model).toBe("anthropic/claude"); + expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe( + true, + ); + + expect(work).toBeTruthy(); + expect(work?.name).toBe("Work"); + expect(work?.workspace).toBe("/work-ws"); + expect(work?.agentDir).toBe("/state/agents/work/agent"); + expect(work?.bindings).toBe(1); + expect(work?.isDefault).toBe(true); + }); + + it("applyAgentConfig merges updates", () => { + const cfg: ClawdbotConfig = { + routing: { + agents: { + work: { workspace: "/old-ws", model: "anthropic/claude" }, + }, + }, + }; + + const next = applyAgentConfig(cfg, { + agentId: "work", + name: "Work", + workspace: "/new-ws", + agentDir: "/state/work/agent", + }); + + const work = next.routing?.agents?.work; + expect(work?.name).toBe("Work"); + expect(work?.workspace).toBe("/new-ws"); + expect(work?.agentDir).toBe("/state/work/agent"); + expect(work?.model).toBe("anthropic/claude"); + }); + + it("applyAgentBindings skips duplicates and reports conflicts", () => { + const cfg: ClawdbotConfig = { + routing: { + bindings: [ + { + agentId: "main", + match: { provider: "whatsapp", accountId: "default" }, + }, + ], + }, + }; + + const result = applyAgentBindings(cfg, [ + { + agentId: "main", + match: { provider: "whatsapp", accountId: "default" }, + }, + { + agentId: "work", + match: { provider: "whatsapp", accountId: "default" }, + }, + { + agentId: "work", + match: { provider: "telegram" }, + }, + ]); + + expect(result.added).toHaveLength(1); + expect(result.skipped).toHaveLength(1); + expect(result.conflicts).toHaveLength(1); + expect(result.config.routing?.bindings).toHaveLength(2); + }); + + it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => { + const cfg: ClawdbotConfig = { + routing: { + defaultAgentId: "work", + agents: { + work: { workspace: "/work-ws" }, + home: { workspace: "/home-ws" }, + }, + bindings: [ + { agentId: "work", match: { provider: "whatsapp" } }, + { agentId: "home", match: { provider: "telegram" } }, + ], + agentToAgent: { enabled: true, allow: ["work", "home"] }, + }, + }; + + const result = pruneAgentConfig(cfg, "work"); + expect(result.config.routing?.agents?.work).toBeUndefined(); + expect(result.config.routing?.agents?.home).toBeTruthy(); + expect(result.config.routing?.bindings).toHaveLength(1); + expect(result.config.routing?.bindings?.[0]?.agentId).toBe("home"); + expect(result.config.routing?.agentToAgent?.allow).toEqual(["home"]); + expect(result.config.routing?.defaultAgentId).toBe(DEFAULT_AGENT_ID); + expect(result.removedBindings).toBe(1); + expect(result.removedAllow).toBe(1); + }); +}); diff --git a/src/commands/agents.ts b/src/commands/agents.ts new file mode 100644 index 000000000..78ce0ab86 --- /dev/null +++ b/src/commands/agents.ts @@ -0,0 +1,653 @@ +import { + resolveAgentDir, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + CONFIG_PATH_CLAWDBOT, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; +import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { + DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, + normalizeAgentId, +} from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveDefaultWhatsAppAccountId } from "../web/accounts.js"; +import { createClackPrompter } from "../wizard/clack-prompter.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; +import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; +import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js"; +import { setupProviders } from "./onboard-providers.js"; +import type { AuthChoice, ProviderChoice } from "./onboard-types.js"; + +type AgentsListOptions = { + json?: boolean; +}; + +type AgentsAddOptions = { + name?: string; + workspace?: string; + json?: boolean; +}; + +type AgentsDeleteOptions = { + id: string; + force?: boolean; + json?: boolean; +}; + +export type AgentSummary = { + id: string; + name?: string; + workspace: string; + agentDir: string; + model?: string; + bindings: number; + isDefault: boolean; +}; + +type AgentBinding = { + agentId: string; + match: { + provider: string; + accountId?: string; + peer?: { kind: "dm" | "group" | "channel"; id: string }; + guildId?: string; + teamId?: string; + }; +}; + +function resolveAgentName(cfg: ClawdbotConfig, agentId: string) { + return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined; +} + +function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) { + if (agentId !== DEFAULT_AGENT_ID) { + return cfg.routing?.agents?.[agentId]?.model?.trim() || undefined; + } + const raw = cfg.agent?.model; + if (typeof raw === "string") return raw; + return raw?.primary?.trim() || undefined; +} + +export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] { + const defaultAgentId = normalizeAgentId( + cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + const agentIds = new Set([ + DEFAULT_AGENT_ID, + defaultAgentId, + ...Object.keys(cfg.routing?.agents ?? {}), + ]); + + const bindingCounts = new Map(); + for (const binding of cfg.routing?.bindings ?? []) { + const agentId = normalizeAgentId(binding.agentId); + bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1); + } + + const ordered = [ + DEFAULT_AGENT_ID, + ...[...agentIds] + .filter((id) => id !== DEFAULT_AGENT_ID) + .sort((a, b) => a.localeCompare(b)), + ]; + + return ordered.map((id) => ({ + id, + name: resolveAgentName(cfg, id), + workspace: resolveAgentWorkspaceDir(cfg, id), + agentDir: resolveAgentDir(cfg, id), + model: resolveAgentModel(cfg, id), + bindings: bindingCounts.get(id) ?? 0, + isDefault: id === defaultAgentId, + })); +} + +export function applyAgentConfig( + cfg: ClawdbotConfig, + params: { + agentId: string; + name?: string; + workspace?: string; + agentDir?: string; + model?: string; + }, +): ClawdbotConfig { + const agentId = normalizeAgentId(params.agentId); + const existing = cfg.routing?.agents?.[agentId] ?? {}; + const name = params.name?.trim(); + return { + ...cfg, + routing: { + ...cfg.routing, + agents: { + ...cfg.routing?.agents, + [agentId]: { + ...existing, + ...(name ? { name } : {}), + ...(params.workspace ? { workspace: params.workspace } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.model ? { model: params.model } : {}), + }, + }, + }, + }; +} + +function bindingMatchKey(match: AgentBinding["match"]) { + const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID; + return [ + match.provider, + accountId, + match.peer?.kind ?? "", + match.peer?.id ?? "", + match.guildId ?? "", + match.teamId ?? "", + ].join("|"); +} + +export function applyAgentBindings( + cfg: ClawdbotConfig, + bindings: AgentBinding[], +): { + config: ClawdbotConfig; + added: AgentBinding[]; + skipped: AgentBinding[]; + conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; +} { + const existing = cfg.routing?.bindings ?? []; + const existingMatchMap = new Map(); + for (const binding of existing) { + const key = bindingMatchKey(binding.match); + if (!existingMatchMap.has(key)) { + existingMatchMap.set(key, normalizeAgentId(binding.agentId)); + } + } + + const added: AgentBinding[] = []; + const skipped: AgentBinding[] = []; + const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = + []; + + for (const binding of bindings) { + const agentId = normalizeAgentId(binding.agentId); + const key = bindingMatchKey(binding.match); + const existingAgentId = existingMatchMap.get(key); + if (existingAgentId) { + if (existingAgentId === agentId) { + skipped.push(binding); + } else { + conflicts.push({ binding, existingAgentId }); + } + continue; + } + existingMatchMap.set(key, agentId); + added.push({ ...binding, agentId }); + } + + if (added.length === 0) { + return { config: cfg, added, skipped, conflicts }; + } + + return { + config: { + ...cfg, + routing: { + ...cfg.routing, + bindings: [...existing, ...added], + }, + }, + added, + skipped, + conflicts, + }; +} + +export function pruneAgentConfig( + cfg: ClawdbotConfig, + agentId: string, +): { + config: ClawdbotConfig; + removedBindings: number; + removedAllow: number; +} { + const id = normalizeAgentId(agentId); + const agents = { ...(cfg.routing?.agents ?? {}) }; + delete agents[id]; + const nextAgents = Object.keys(agents).length > 0 ? agents : undefined; + + const bindings = cfg.routing?.bindings ?? []; + const filteredBindings = bindings.filter( + (binding) => normalizeAgentId(binding.agentId) !== id, + ); + + const allow = cfg.routing?.agentToAgent?.allow ?? []; + const filteredAllow = allow.filter((entry) => entry !== id); + + const nextRouting = { + ...cfg.routing, + ...(nextAgents ? { agents: nextAgents } : {}), + ...(nextAgents ? {} : { agents: undefined }), + bindings: filteredBindings.length > 0 ? filteredBindings : undefined, + agentToAgent: cfg.routing?.agentToAgent + ? { + ...cfg.routing.agentToAgent, + allow: filteredAllow.length > 0 ? filteredAllow : undefined, + } + : undefined, + defaultAgentId: + normalizeAgentId(cfg.routing?.defaultAgentId ?? DEFAULT_AGENT_ID) === id + ? DEFAULT_AGENT_ID + : cfg.routing?.defaultAgentId, + }; + + return { + config: { + ...cfg, + routing: nextRouting, + }, + removedBindings: bindings.length - filteredBindings.length, + removedAllow: allow.length - filteredAllow.length, + }; +} + +function formatSummary(summary: AgentSummary) { + const name = + summary.name && summary.name !== summary.id ? ` "${summary.name}"` : ""; + const defaultTag = summary.isDefault ? " (default)" : ""; + const parts = [ + `${summary.id}${name}${defaultTag}`, + `workspace: ${summary.workspace}`, + `agentDir: ${summary.agentDir}`, + summary.model ? `model: ${summary.model}` : null, + `bindings: ${summary.bindings}`, + ].filter(Boolean); + return `- ${parts.join(" | ")}`; +} + +async function requireValidConfig( + runtime: RuntimeEnv, +): Promise { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + const issues = + snapshot.issues.length > 0 + ? snapshot.issues + .map((issue) => `- ${issue.path}: ${issue.message}`) + .join("\n") + : "Unknown validation issue."; + runtime.error(`Config invalid:\n${issues}`); + runtime.error("Fix the config or run clawdbot doctor."); + runtime.exit(1); + return null; + } + return snapshot.config; +} + +export async function agentsListCommand( + opts: AgentsListOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const summaries = buildAgentSummaries(cfg); + if (opts.json) { + runtime.log(JSON.stringify(summaries, null, 2)); + return; + } + + runtime.log(["Agents:", ...summaries.map(formatSummary)].join("\n")); +} + +function describeBinding(binding: AgentBinding) { + const match = binding.match; + const parts = [match.provider]; + if (match.accountId) parts.push(`accountId=${match.accountId}`); + if (match.peer) parts.push(`peer=${match.peer.kind}:${match.peer.id}`); + if (match.guildId) parts.push(`guild=${match.guildId}`); + if (match.teamId) parts.push(`team=${match.teamId}`); + return parts.join(" "); +} + +function buildProviderBindings(params: { + agentId: string; + selection: ProviderChoice[]; + config: ClawdbotConfig; + whatsappAccountId?: string; +}): AgentBinding[] { + const bindings: AgentBinding[] = []; + const agentId = normalizeAgentId(params.agentId); + for (const provider of params.selection) { + const match: AgentBinding["match"] = { provider }; + if (provider === "whatsapp") { + const accountId = + params.whatsappAccountId?.trim() || + resolveDefaultWhatsAppAccountId(params.config); + match.accountId = accountId || DEFAULT_ACCOUNT_ID; + } + bindings.push({ agentId, match }); + } + return bindings; +} + +export async function agentsAddCommand( + opts: AgentsAddOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const workspaceFlag = opts.workspace?.trim(); + const nameInput = opts.name?.trim(); + + if (workspaceFlag) { + if (!nameInput) { + runtime.error("Agent name is required when --workspace is provided."); + runtime.exit(1); + return; + } + const agentId = normalizeAgentId(nameInput); + if (agentId === DEFAULT_AGENT_ID) { + runtime.error(`"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`); + runtime.exit(1); + return; + } + if (agentId !== nameInput) { + runtime.log(`Normalized agent id to "${agentId}".`); + } + if (cfg.routing?.agents?.[agentId]) { + runtime.error(`Agent "${agentId}" already exists.`); + runtime.exit(1); + return; + } + + const workspaceDir = resolveUserPath(workspaceFlag); + const agentDir = resolveAgentDir(cfg, agentId); + const nextConfig = applyAgentConfig(cfg, { + agentId, + name: nameInput, + workspace: workspaceDir, + agentDir, + }); + + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + await ensureWorkspaceAndSessions(workspaceDir, runtime, { + skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + agentId, + }); + + const payload = { + agentId, + name: nameInput, + workspace: workspaceDir, + agentDir, + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } else { + runtime.log(`Agent: ${agentId}`); + runtime.log(`Workspace: ${workspaceDir}`); + runtime.log(`Agent dir: ${agentDir}`); + } + return; + } + + const prompter = createClackPrompter(); + try { + await prompter.intro("Add Clawdbot agent"); + const name = + nameInput ?? + (await prompter.text({ + message: "Agent name", + validate: (value) => { + if (!value?.trim()) return "Required"; + const normalized = normalizeAgentId(value); + if (normalized === DEFAULT_AGENT_ID) { + return `"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`; + } + return undefined; + }, + })); + + const agentName = String(name).trim(); + const agentId = normalizeAgentId(agentName); + if (agentName !== agentId) { + await prompter.note(`Normalized id to "${agentId}".`, "Agent id"); + } + + const existingAgent = cfg.routing?.agents?.[agentId]; + if (existingAgent) { + const shouldUpdate = await prompter.confirm({ + message: `Agent "${agentId}" already exists. Update it?`, + initialValue: false, + }); + if (!shouldUpdate) { + await prompter.outro("No changes made."); + return; + } + } + + const workspaceDefault = resolveAgentWorkspaceDir(cfg, agentId); + const workspaceInput = await prompter.text({ + message: "Workspace directory", + initialValue: workspaceDefault, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const workspaceDir = resolveUserPath( + String(workspaceInput).trim() || workspaceDefault, + ); + const agentDir = resolveAgentDir(cfg, agentId); + + let nextConfig = applyAgentConfig(cfg, { + agentId, + name: agentName, + workspace: workspaceDir, + agentDir, + }); + + const wantsAuth = await prompter.confirm({ + message: "Configure model/auth for this agent now?", + initialValue: false, + }); + if (wantsAuth) { + const authChoice = (await prompter.select({ + message: "Model/auth choice", + options: [ + { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, + { + value: "antigravity", + label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", + }, + { value: "apiKey", label: "Anthropic API key" }, + { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, + { value: "skip", label: "Skip for now" }, + ], + })) as AuthChoice; + + const authResult = await applyAuthChoice({ + authChoice, + config: nextConfig, + prompter, + runtime, + agentDir, + setDefaultModel: false, + agentId, + }); + nextConfig = authResult.config; + if (authResult.agentModelOverride) { + nextConfig = applyAgentConfig(nextConfig, { + agentId, + model: authResult.agentModelOverride, + }); + } + } + + await warnIfModelConfigLooksOff(nextConfig, prompter, { + agentId, + agentDir, + }); + + let selection: ProviderChoice[] = []; + let whatsappAccountId: string | undefined; + nextConfig = await setupProviders(nextConfig, runtime, prompter, { + allowSignalInstall: true, + onSelection: (value) => { + selection = value; + }, + promptWhatsAppAccountId: true, + onWhatsAppAccountId: (value) => { + whatsappAccountId = value; + }, + }); + + if (selection.length > 0) { + const wantsBindings = await prompter.confirm({ + message: + "Route selected providers to this agent now? (routing.bindings)", + initialValue: false, + }); + if (wantsBindings) { + const desiredBindings = buildProviderBindings({ + agentId, + selection, + config: nextConfig, + whatsappAccountId, + }); + const result = applyAgentBindings(nextConfig, desiredBindings); + nextConfig = result.config; + if (result.conflicts.length > 0) { + await prompter.note( + [ + "Skipped bindings already claimed by another agent:", + ...result.conflicts.map( + (conflict) => + `- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + ].join("\n"), + "Routing bindings", + ); + } + } else { + await prompter.note( + [ + "Routing unchanged. Add routing.bindings when you're ready.", + "Docs: https://docs.clawd.bot/concepts/multi-agent", + ].join("\n"), + "Routing", + ); + } + } + + await writeConfigFile(nextConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + await ensureWorkspaceAndSessions(workspaceDir, runtime, { + skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + agentId, + }); + + const payload = { + agentId, + name: agentName, + workspace: workspaceDir, + agentDir, + }; + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + } + await prompter.outro(`Agent "${agentId}" ready.`); + } catch (err) { + if (err instanceof WizardCancelledError) { + runtime.exit(0); + return; + } + throw err; + } +} + +export async function agentsDeleteCommand( + opts: AgentsDeleteOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const input = opts.id?.trim(); + if (!input) { + runtime.error("Agent id is required."); + runtime.exit(1); + return; + } + + const agentId = normalizeAgentId(input); + if (agentId !== input) { + runtime.log(`Normalized agent id to "${agentId}".`); + } + if (agentId === DEFAULT_AGENT_ID) { + runtime.error(`"${DEFAULT_AGENT_ID}" cannot be deleted.`); + runtime.exit(1); + return; + } + + if (!cfg.routing?.agents?.[agentId]) { + runtime.error(`Agent "${agentId}" not found.`); + runtime.exit(1); + return; + } + + if (!opts.force) { + if (!process.stdin.isTTY) { + runtime.error("Non-interactive session. Re-run with --force."); + runtime.exit(1); + return; + } + const prompter = createClackPrompter(); + const confirmed = await prompter.confirm({ + message: `Delete agent "${agentId}" and prune workspace/state?`, + initialValue: false, + }); + if (!confirmed) { + runtime.log("Cancelled."); + return; + } + } + + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const agentDir = resolveAgentDir(cfg, agentId); + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + + const result = pruneAgentConfig(cfg, agentId); + await writeConfigFile(result.config); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + + await moveToTrash(workspaceDir, runtime); + await moveToTrash(agentDir, runtime); + await moveToTrash(sessionsDir, runtime); + + if (opts.json) { + runtime.log( + JSON.stringify( + { + agentId, + workspace: workspaceDir, + agentDir, + sessionsDir, + removedBindings: result.removedBindings, + removedAllow: result.removedAllow, + }, + null, + 2, + ), + ); + } else { + runtime.log(`Deleted agent: ${agentId}`); + } +} diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts new file mode 100644 index 000000000..7c3770320 --- /dev/null +++ b/src/commands/auth-choice.ts @@ -0,0 +1,368 @@ +import { + loginAnthropic, + loginOpenAICodex, + type OAuthCredentials, + type OAuthProvider, +} from "@mariozechner/pi-ai"; +import { resolveAgentConfig } from "../agents/agent-scope.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../agents/auth-profiles.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../agents/model-auth.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + isRemoteEnvironment, + loginAntigravityVpsAware, +} from "./antigravity-oauth.js"; +import { + applyAuthProfileConfig, + applyMinimaxConfig, + applyMinimaxProviderConfig, + setAnthropicApiKey, + writeOAuthCredentials, +} from "./onboard-auth.js"; +import { openUrl } from "./onboard-helpers.js"; +import type { AuthChoice } from "./onboard-types.js"; +import { + applyOpenAICodexModelDefault, + OPENAI_CODEX_DEFAULT_MODEL, +} from "./openai-codex-model-default.js"; + +export async function warnIfModelConfigLooksOff( + config: ClawdbotConfig, + prompter: WizardPrompter, + options?: { agentId?: string; agentDir?: string }, +) { + const agentModelOverride = options?.agentId + ? resolveAgentConfig(config, options.agentId)?.model?.trim() + : undefined; + const configWithModel = + agentModelOverride && agentModelOverride.length > 0 + ? { + ...config, + agent: { + ...config.agent, + model: { + ...(typeof config.agent?.model === "object" + ? config.agent.model + : undefined), + primary: agentModelOverride, + }, + }, + } + : config; + const ref = resolveConfiguredModelRef({ + cfg: configWithModel, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const warnings: string[] = []; + const catalog = await loadModelCatalog({ + config: configWithModel, + useCache: false, + }); + if (catalog.length > 0) { + const known = catalog.some( + (entry) => entry.provider === ref.provider && entry.id === ref.model, + ); + if (!known) { + warnings.push( + `Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`, + ); + } + } + + const store = ensureAuthProfileStore(options?.agentDir); + const hasProfile = listProfilesForProvider(store, ref.provider).length > 0; + const envKey = resolveEnvApiKey(ref.provider); + const customKey = getCustomProviderApiKey(config, ref.provider); + if (!hasProfile && !envKey && !customKey) { + warnings.push( + `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, + ); + } + + if (ref.provider === "openai") { + const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; + if (hasCodex) { + warnings.push( + `Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, + ); + } + } + + if (warnings.length > 0) { + await prompter.note(warnings.join("\n"), "Model check"); + } +} + +export async function applyAuthChoice(params: { + authChoice: AuthChoice; + config: ClawdbotConfig; + prompter: WizardPrompter; + runtime: RuntimeEnv; + agentDir?: string; + setDefaultModel: boolean; + agentId?: string; +}): Promise<{ config: ClawdbotConfig; agentModelOverride?: string }> { + let nextConfig = params.config; + let agentModelOverride: string | undefined; + + const noteAgentModel = async (model: string) => { + if (!params.agentId) return; + await params.prompter.note( + `Default model set to ${model} for agent "${params.agentId}".`, + "Model configured", + ); + }; + + if (params.authChoice === "oauth") { + await params.prompter.note( + "Browser will open. Paste the code shown after login (code#state).", + "Anthropic OAuth", + ); + const spin = params.prompter.progress("Waiting for authorization…"); + let oauthCreds: OAuthCredentials | null = null; + try { + oauthCreds = await loginAnthropic( + async (url) => { + await openUrl(url); + params.runtime.log(`Open: ${url}`); + }, + async () => { + const code = await params.prompter.text({ + message: "Paste authorization code (code#state)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return String(code); + }, + ); + spin.stop("OAuth complete"); + if (oauthCreds) { + await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir); + const profileId = `anthropic:${oauthCreds.email ?? "default"}`; + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "anthropic", + mode: "oauth", + email: oauthCreds.email ?? undefined, + }); + } + } catch (err) { + spin.stop("OAuth failed"); + params.runtime.error(String(err)); + await params.prompter.note( + "Trouble with OAuth? See https://docs.clawd.bot/start/faq", + "OAuth help", + ); + } + } else if (params.authChoice === "openai-codex") { + const isRemote = isRemoteEnvironment(); + await params.prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + ].join("\n") + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), + "OpenAI Codex OAuth", + ); + const spin = params.prompter.progress("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; + try { + const creds = await loginOpenAICodex({ + onAuth: async ({ url }) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + params.runtime.log( + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + ); + manualCodePromise = params.prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }) + .then((value) => String(value)); + } else { + spin.update("Complete sign-in in browser…"); + await openUrl(url); + params.runtime.log(`Open: ${url}`); + } + }, + onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return String(code); + }, + onProgress: (msg) => spin.update(msg), + }); + spin.stop("OpenAI OAuth complete"); + if (creds) { + await writeOAuthCredentials( + "openai-codex" as unknown as OAuthProvider, + creds, + params.agentDir, + ); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "openai-codex:default", + provider: "openai-codex", + mode: "oauth", + }); + if (params.setDefaultModel) { + const applied = applyOpenAICodexModelDefault(nextConfig); + nextConfig = applied.next; + if (applied.changed) { + await params.prompter.note( + `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, + "Model configured", + ); + } + } else { + agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL; + await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL); + } + } + } catch (err) { + spin.stop("OpenAI OAuth failed"); + params.runtime.error(String(err)); + await params.prompter.note( + "Trouble with OAuth? See https://docs.clawd.bot/start/faq", + "OAuth help", + ); + } + } else if (params.authChoice === "antigravity") { + const isRemote = isRemoteEnvironment(); + await params.prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, copy the redirect URL and paste it back here.", + ].join("\n") + : [ + "Browser will open for Google authentication.", + "Sign in with your Google account that has Antigravity access.", + "The callback will be captured automatically on localhost:51121.", + ].join("\n"), + "Google Antigravity OAuth", + ); + const spin = params.prompter.progress("Starting OAuth flow…"); + let oauthCreds: OAuthCredentials | null = null; + try { + oauthCreds = await loginAntigravityVpsAware( + async (url) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + params.runtime.log( + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + ); + } else { + spin.update("Complete sign-in in browser…"); + await openUrl(url); + params.runtime.log(`Open: ${url}`); + } + }, + (msg) => spin.update(msg), + ); + spin.stop("Antigravity OAuth complete"); + if (oauthCreds) { + await writeOAuthCredentials( + "google-antigravity", + oauthCreds, + params.agentDir, + ); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, + provider: "google-antigravity", + mode: "oauth", + }); + const modelKey = "google-antigravity/claude-opus-4-5-thinking"; + nextConfig = { + ...nextConfig, + agent: { + ...nextConfig.agent, + models: { + ...nextConfig.agent?.models, + [modelKey]: nextConfig.agent?.models?.[modelKey] ?? {}, + }, + }, + }; + if (params.setDefaultModel) { + nextConfig = { + ...nextConfig, + agent: { + ...nextConfig.agent, + model: { + ...(nextConfig.agent?.model && + "fallbacks" in + (nextConfig.agent.model as Record) + ? { + fallbacks: ( + nextConfig.agent.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: modelKey, + }, + }, + }; + await params.prompter.note( + `Default model set to ${modelKey}`, + "Model configured", + ); + } else { + agentModelOverride = modelKey; + await noteAgentModel(modelKey); + } + } + } catch (err) { + spin.stop("Antigravity OAuth failed"); + params.runtime.error(String(err)); + await params.prompter.note( + "Trouble with OAuth? See https://docs.clawd.bot/start/faq", + "OAuth help", + ); + } + } else if (params.authChoice === "apiKey") { + const key = await params.prompter.text({ + message: "Enter Anthropic API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setAnthropicApiKey(String(key).trim(), params.agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); + } else if (params.authChoice === "minimax") { + if (params.setDefaultModel) { + nextConfig = applyMinimaxConfig(nextConfig); + } else { + nextConfig = applyMinimaxProviderConfig(nextConfig); + agentModelOverride = "lmstudio/minimax-m2.1-gs32"; + await noteAgentModel("lmstudio/minimax-m2.1-gs32"); + } + } + + return { config: nextConfig, agentModelOverride }; +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index f35f5a59c..db51f4b84 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -6,9 +6,9 @@ import type { ClawdbotConfig } from "../config/config.js"; export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, + agentDir?: string, ): Promise { // Write to the multi-agent path so gateway finds credentials on startup - const agentDir = resolveDefaultAgentDir(); upsertAuthProfile({ profileId: `${provider}:${creds.email ?? "default"}`, credential: { @@ -16,13 +16,12 @@ export async function writeOAuthCredentials( provider, ...creds, }, - agentDir, + agentDir: agentDir ?? resolveDefaultAgentDir(), }); } -export async function setAnthropicApiKey(key: string) { +export async function setAnthropicApiKey(key: string, agentDir?: string) { // Write to the multi-agent path so gateway finds credentials on startup - const agentDir = resolveDefaultAgentDir(); upsertAuthProfile({ profileId: "anthropic:default", credential: { @@ -30,7 +29,7 @@ export async function setAnthropicApiKey(key: string) { provider: "anthropic", key, }, - agentDir, + agentDir: agentDir ?? resolveDefaultAgentDir(), }); } @@ -74,7 +73,9 @@ export function applyAuthProfileConfig( }; } -export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { +export function applyMinimaxProviderConfig( + cfg: ClawdbotConfig, +): ClawdbotConfig { const models = { ...cfg.agent?.models }; models["anthropic/claude-opus-4-5"] = { ...models["anthropic/claude-opus-4-5"], @@ -109,16 +110,6 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { ...cfg, agent: { ...cfg.agent, - model: { - ...(cfg.agent?.model && - "fallbacks" in (cfg.agent.model as Record) - ? { - fallbacks: (cfg.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: "lmstudio/minimax-m2.1-gs32", - }, models, }, models: { @@ -127,3 +118,23 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { }, }; } + +export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { + const next = applyMinimaxProviderConfig(cfg); + return { + ...next, + agent: { + ...next.agent, + model: { + ...(next.agent?.model && + "fallbacks" in (next.agent.model as Record) + ? { + fallbacks: (next.agent.model as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: "lmstudio/minimax-m2.1-gs32", + }, + }, + }; +} diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 3cd1a48d7..887d766ec 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -11,7 +11,7 @@ import { } from "../agents/workspace.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; -import { resolveSessionTranscriptsDir } from "../config/sessions.js"; +import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; @@ -19,7 +19,11 @@ import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; -import type { NodeManagerChoice, ResetScope } from "./onboard-types.js"; +import type { + NodeManagerChoice, + OnboardMode, + ResetScope, +} from "./onboard-types.js"; export function guardCancel(value: T, runtime: RuntimeEnv): T { if (isCancel(value)) { @@ -72,7 +76,7 @@ export function printWizardHeader(runtime: RuntimeEnv) { export function applyWizardMetadata( cfg: ClawdbotConfig, - params: { command: string; mode: "local" | "remote" }, + params: { command: string; mode: OnboardMode }, ): ClawdbotConfig { const commit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim() || undefined; @@ -226,14 +230,14 @@ export async function openUrl(url: string): Promise { export async function ensureWorkspaceAndSessions( workspaceDir: string, runtime: RuntimeEnv, - options?: { skipBootstrap?: boolean }, + options?: { skipBootstrap?: boolean; agentId?: string }, ) { const ws = await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !options?.skipBootstrap, }); runtime.log(`Workspace OK: ${ws.dir}`); - const sessionsDir = resolveSessionTranscriptsDir(); + const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId); await fs.mkdir(sessionsDir, { recursive: true }); runtime.log(`Sessions OK: ${sessionsDir}`); } @@ -275,7 +279,7 @@ export async function handleReset( await moveToTrash(CONFIG_PATH_CLAWDBOT, runtime); if (scope === "config") return; await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime); - await moveToTrash(resolveSessionTranscriptsDir(), runtime); + await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime); if (scope === "full") { await moveToTrash(workspaceDir, runtime); } diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index ae96a26cd..68e5b3301 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -29,11 +29,7 @@ import { ensureWorkspaceAndSessions, randomToken, } from "./onboard-helpers.js"; -import type { - AuthChoice, - OnboardMode, - OnboardOptions, -} from "./onboard-types.js"; +import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js"; export async function runNonInteractiveOnboarding( @@ -42,7 +38,12 @@ export async function runNonInteractiveOnboarding( ) { const snapshot = await readConfigFileSnapshot(); const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; - const mode: OnboardMode = opts.mode ?? "local"; + const mode = opts.mode ?? "local"; + if (mode !== "local" && mode !== "remote") { + runtime.error(`Invalid --mode "${String(mode)}" (use local|remote).`); + runtime.exit(1); + return; + } if (mode === "remote") { const remoteUrl = opts.remoteUrl?.trim(); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index d061affd6..5e60f6b4b 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -3,9 +3,17 @@ import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy } from "../config/types.js"; import { loginWeb } from "../provider-web.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; -import { WA_WEB_AUTH_DIR } from "../web/session.js"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAuthDir, +} from "../web/accounts.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; @@ -28,8 +36,12 @@ async function pathExists(filePath: string): Promise { } } -async function detectWhatsAppLinked(): Promise { - const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); +async function detectWhatsAppLinked( + cfg: ClawdbotConfig, + accountId: string, +): Promise { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + const credsPath = path.join(authDir, "creds.json"); return await pathExists(credsPath); } @@ -461,13 +473,24 @@ async function promptWhatsAppAllowFrom( return setWhatsAppAllowFrom(next, unique); } +type SetupProvidersOptions = { + allowDisable?: boolean; + allowSignalInstall?: boolean; + onSelection?: (selection: ProviderChoice[]) => void; + whatsappAccountId?: string; + promptWhatsAppAccountId?: boolean; + onWhatsAppAccountId?: (accountId: string) => void; +}; + export async function setupProviders( cfg: ClawdbotConfig, runtime: RuntimeEnv, prompter: WizardPrompter, - options?: { allowDisable?: boolean; allowSignalInstall?: boolean }, + options?: SetupProvidersOptions, ): Promise { - const whatsappLinked = await detectWhatsAppLinked(); + let whatsappAccountId = + options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); + let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId); const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim()); @@ -491,9 +514,11 @@ export async function setupProviders( const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; const imessageCliDetected = await detectBinary(imessageCliPath); + const waAccountLabel = + whatsappAccountId === DEFAULT_ACCOUNT_ID ? "default" : whatsappAccountId; await prompter.note( [ - `WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`, + `WhatsApp (${waAccountLabel}): ${whatsappLinked ? "linked" : "not linked"}`, `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, `Discord: ${discordConfigured ? "configured" : "needs token"}`, `Slack: ${slackConfigured ? "configured" : "needs tokens"}`, @@ -549,14 +574,71 @@ export async function setupProviders( ], })) as ProviderChoice[]; + options?.onSelection?.(selection); + let next = cfg; if (selection.includes("whatsapp")) { + if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) { + const existingIds = listWhatsAppAccountIds(next); + const choice = (await prompter.select({ + message: "WhatsApp account", + options: [ + ...existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + { value: "__new__", label: "Add a new account" }, + ], + })) as string; + + if (choice === "__new__") { + const entered = await prompter.text({ + message: "New WhatsApp account id", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const normalized = normalizeAccountId(String(entered)); + if (String(entered).trim() !== normalized) { + await prompter.note( + `Normalized account id to "${normalized}".`, + "WhatsApp account", + ); + } + whatsappAccountId = normalized; + } else { + whatsappAccountId = choice; + } + } + + if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) { + next = { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: { + ...next.whatsapp?.accounts, + [whatsappAccountId]: { + ...(next.whatsapp?.accounts?.[whatsappAccountId] ?? {}), + enabled: + next.whatsapp?.accounts?.[whatsappAccountId]?.enabled ?? true, + }, + }, + }, + }; + } + + options?.onWhatsAppAccountId?.(whatsappAccountId); + whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId); + const { authDir } = resolveWhatsAppAuthDir({ + cfg: next, + accountId: whatsappAccountId, + }); + if (!whatsappLinked) { await prompter.note( [ "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${WA_WEB_AUTH_DIR}/ for future runs.`, + `Credentials are stored under ${authDir}/ for future runs.`, "Docs: https://docs.clawd.bot/whatsapp", ].join("\n"), "WhatsApp linking", @@ -570,7 +652,7 @@ export async function setupProviders( }); if (wantsLink) { try { - await loginWeb(false, "web"); + await loginWeb(false, "web", undefined, runtime, whatsappAccountId); } catch (err) { runtime.error(`WhatsApp login failed: ${String(err)}`); await prompter.note( diff --git a/src/config/sessions.ts b/src/config/sessions.ts index e3f499438..e1f986a1d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -115,6 +115,14 @@ export function resolveSessionTranscriptsDir( return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir); } +export function resolveSessionTranscriptsDirForAgent( + agentId?: string, + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + return resolveAgentSessionsDir(agentId, env, homedir); +} + export function resolveDefaultSessionStorePath(agentId?: string): string { return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } diff --git a/src/config/types.ts b/src/config/types.ts index 84019131b..75fd1a99d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -539,6 +539,7 @@ export type RoutingConfig = { agents?: Record< string, { + name?: string; workspace?: string; agentDir?: string; model?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b7550b376..749d46401 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -223,6 +223,7 @@ const RoutingSchema = z z.string(), z .object({ + name: z.string().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), model: z.string().optional(), diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 3a4ecc7a7..52563936f 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -23,6 +23,20 @@ export function normalizeAgentId(value: string | undefined | null): string { ); } +export function normalizeAccountId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return DEFAULT_ACCOUNT_ID; + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || DEFAULT_ACCOUNT_ID + ); +} + export function parseAgentSessionKey( sessionKey: string | undefined | null, ): ParsedAgentSessionKey | null { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 6682e2f35..b01648862 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -1,38 +1,15 @@ import path from "node:path"; import { - loginAnthropic, - loginOpenAICodex, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; -import { - ensureAuthProfileStore, - listProfilesForProvider, -} from "../agents/auth-profiles.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { - getCustomProviderApiKey, - resolveEnvApiKey, -} from "../agents/model-auth.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; -import { - isRemoteEnvironment, - loginAntigravityVpsAware, -} from "../commands/antigravity-oauth.js"; + applyAuthChoice, + warnIfModelConfigLooksOff, +} from "../commands/auth-choice.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "../commands/daemon-runtime.js"; import { healthCommand } from "../commands/health.js"; -import { - applyAuthProfileConfig, - applyMinimaxConfig, - setAnthropicApiKey, - writeOAuthCredentials, -} from "../commands/onboard-auth.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -57,10 +34,6 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { - applyOpenAICodexModelDefault, - OPENAI_CODEX_DEFAULT_MODEL, -} from "../commands/openai-codex-model-default.js"; import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -78,52 +51,6 @@ import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import type { WizardPrompter } from "./prompts.js"; -async function warnIfModelConfigLooksOff( - config: ClawdbotConfig, - prompter: WizardPrompter, -) { - const ref = resolveConfiguredModelRef({ - cfg: config, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const warnings: string[] = []; - const catalog = await loadModelCatalog({ config, useCache: false }); - if (catalog.length > 0) { - const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, - ); - if (!known) { - warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`, - ); - } - } - - const store = ensureAuthProfileStore(); - const hasProfile = listProfilesForProvider(store, ref.provider).length > 0; - const envKey = resolveEnvApiKey(ref.provider); - const customKey = getCustomProviderApiKey(config, ref.provider); - if (!hasProfile && !envKey && !customKey) { - warnings.push( - `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, - ); - } - - if (ref.provider === "openai") { - const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; - if (hasCodex) { - warnings.push( - `Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, - ); - } - } - - if (warnings.length > 0) { - await prompter.note(warnings.join("\n"), "Model check"); - } -} - export async function runOnboardingWizard( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, @@ -203,18 +130,18 @@ export async function runOnboardingWizard( const mode = opts.mode ?? ((await prompter.select({ - message: "Where will the Gateway run?", + message: "What do you want to set up?", options: [ { value: "local", - label: "Local (this machine)", + label: "Local gateway (this machine)", hint: localProbe.ok ? `Gateway reachable (${localUrl})` : `No gateway detected (${localUrl})`, }, { value: "remote", - label: "Remote (info-only)", + label: "Remote gateway (info-only)", hint: !remoteUrl ? "No remote URL configured yet" : remoteProbe?.ok @@ -271,214 +198,14 @@ export async function runOnboardingWizard( ], })) as AuthChoice; - if (authChoice === "oauth") { - await prompter.note( - "Browser will open. Paste the code shown after login (code#state).", - "Anthropic OAuth", - ); - const spin = prompter.progress("Waiting for authorization…"); - let oauthCreds: OAuthCredentials | null = null; - try { - oauthCreds = await loginAnthropic( - async (url) => { - await openUrl(url); - runtime.log(`Open: ${url}`); - }, - async () => { - const code = await prompter.text({ - message: "Paste authorization code (code#state)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, - ); - spin.stop("OAuth complete"); - if (oauthCreds) { - await writeOAuthCredentials("anthropic", oauthCreds); - const profileId = `anthropic:${oauthCreds.email ?? "default"}`; - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "anthropic", - mode: "oauth", - email: oauthCreds.email ?? undefined, - }); - } - } catch (err) { - spin.stop("OAuth failed"); - runtime.error(String(err)); - await prompter.note( - "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", - ); - } - } else if (authChoice === "openai-codex") { - const isRemote = isRemoteEnvironment(); - await prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - const spin = prompter.progress("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; - try { - const creds = await loginOpenAICodex({ - onAuth: async ({ url }) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - manualCodePromise = prompter - .text({ - message: "Paste the redirect URL (or authorization code)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }) - .then((value) => String(value)); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); - } - }, - onPrompt: async (prompt) => { - if (manualCodePromise) { - return manualCodePromise; - } - const code = await prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, - onProgress: (msg) => spin.update(msg), - }); - spin.stop("OpenAI OAuth complete"); - if (creds) { - await writeOAuthCredentials( - "openai-codex" as unknown as OAuthProvider, - creds, - ); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "openai-codex:default", - provider: "openai-codex", - mode: "oauth", - }); - const applied = applyOpenAICodexModelDefault(nextConfig); - nextConfig = applied.next; - if (applied.changed) { - await prompter.note( - `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", - ); - } - } - } catch (err) { - spin.stop("OpenAI OAuth failed"); - runtime.error(String(err)); - await prompter.note( - "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", - ); - } - } else if (authChoice === "antigravity") { - const isRemote = isRemoteEnvironment(); - await prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, copy the redirect URL and paste it back here.", - ].join("\n") - : [ - "Browser will open for Google authentication.", - "Sign in with your Google account that has Antigravity access.", - "The callback will be captured automatically on localhost:51121.", - ].join("\n"), - "Google Antigravity OAuth", - ); - const spin = prompter.progress("Starting OAuth flow…"); - let oauthCreds: OAuthCredentials | null = null; - try { - oauthCreds = await loginAntigravityVpsAware( - async (url) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); - } - }, - (msg) => spin.update(msg), - ); - spin.stop("Antigravity OAuth complete"); - if (oauthCreds) { - await writeOAuthCredentials("google-antigravity", oauthCreds); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, - provider: "google-antigravity", - mode: "oauth", - }); - nextConfig = { - ...nextConfig, - agent: { - ...nextConfig.agent, - model: { - ...(nextConfig.agent?.model && - "fallbacks" in (nextConfig.agent.model as Record) - ? { - fallbacks: ( - nextConfig.agent.model as { fallbacks?: string[] } - ).fallbacks, - } - : undefined), - primary: "google-antigravity/claude-opus-4-5-thinking", - }, - models: { - ...nextConfig.agent?.models, - "google-antigravity/claude-opus-4-5-thinking": - nextConfig.agent?.models?.[ - "google-antigravity/claude-opus-4-5-thinking" - ] ?? {}, - }, - }, - }; - await prompter.note( - "Default model set to google-antigravity/claude-opus-4-5-thinking", - "Model configured", - ); - } - } catch (err) { - spin.stop("Antigravity OAuth failed"); - runtime.error(String(err)); - await prompter.note( - "Trouble with OAuth? See https://docs.clawd.bot/start/faq", - "OAuth help", - ); - } - } else if (authChoice === "apiKey") { - const key = await prompter.text({ - message: "Enter Anthropic API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - await setAnthropicApiKey(String(key).trim()); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "anthropic:default", - provider: "anthropic", - mode: "api_key", - }); - } else if (authChoice === "minimax") { - nextConfig = applyMinimaxConfig(nextConfig); - } + const authResult = await applyAuthChoice({ + authChoice, + config: nextConfig, + prompter, + runtime, + setDefaultModel: true, + }); + nextConfig = authResult.config; await warnIfModelConfigLooksOff(nextConfig, prompter);