import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import { 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 { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { applyAgentBindings, buildChannelBindings, describeBinding, parseBindingSpecs, } from "./agents.bindings.js"; import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js"; import { applyAgentConfig, findAgentEntryIndex, listAgentEntries } from "./agents.config.js"; import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; import { setupChannels } from "./onboard-channels.js"; import { ensureWorkspaceAndSessions } from "./onboard-helpers.js"; import type { ChannelChoice } from "./onboard-types.js"; type AgentsAddOptions = { name?: string; workspace?: string; model?: string; agentDir?: string; bind?: string[]; nonInteractive?: boolean; json?: boolean; }; export async function agentsAddCommand( opts: AgentsAddOptions, runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { const cfg = await requireValidConfig(runtime); if (!cfg) return; const workspaceFlag = opts.workspace?.trim(); const nameInput = opts.name?.trim(); const hasFlags = params?.hasFlags === true; const nonInteractive = Boolean(opts.nonInteractive || hasFlags); if (nonInteractive && !workspaceFlag) { runtime.error( "Non-interactive mode requires --workspace. Re-run without flags to use the wizard.", ); runtime.exit(1); return; } if (nonInteractive) { if (!nameInput) { runtime.error("Agent name is required in non-interactive mode."); runtime.exit(1); return; } if (!workspaceFlag) { runtime.error( "Non-interactive mode requires --workspace. Re-run without flags to use the wizard.", ); 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 (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) { runtime.error(`Agent "${agentId}" already exists.`); runtime.exit(1); return; } const workspaceDir = resolveUserPath(workspaceFlag); const agentDir = opts.agentDir?.trim() ? resolveUserPath(opts.agentDir.trim()) : resolveAgentDir(cfg, agentId); const model = opts.model?.trim(); const nextConfig = applyAgentConfig(cfg, { agentId, name: nameInput, workspace: workspaceDir, agentDir, ...(model ? { model } : {}), }); const bindingParse = parseBindingSpecs({ agentId, specs: opts.bind, config: nextConfig, }); if (bindingParse.errors.length > 0) { runtime.error(bindingParse.errors.join("\n")); runtime.exit(1); return; } const bindingResult = bindingParse.bindings.length > 0 ? applyAgentBindings(nextConfig, bindingParse.bindings) : { config: nextConfig, added: [], skipped: [], conflicts: [] }; await writeConfigFile(bindingResult.config); if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, { skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap), agentId, }); const payload = { agentId, name: nameInput, workspace: workspaceDir, agentDir, model, bindings: { added: bindingResult.added.map(describeBinding), skipped: bindingResult.skipped.map(describeBinding), conflicts: bindingResult.conflicts.map( (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, ), }, }; if (opts.json) { runtime.log(JSON.stringify(payload, null, 2)); } else { runtime.log(`Agent: ${agentId}`); runtime.log(`Workspace: ${workspaceDir}`); runtime.log(`Agent dir: ${agentDir}`); if (model) runtime.log(`Model: ${model}`); if (bindingResult.conflicts.length > 0) { runtime.error( [ "Skipped bindings already claimed by another agent:", ...bindingResult.conflicts.map( (conflict) => `- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, ), ].join("\n"), ); } } 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 = listAgentEntries(cfg).find( (agent) => normalizeAgentId(agent.id) === 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 authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); const authChoice = await promptAuthChoiceGrouped({ prompter, store: authStore, includeSkip: true, includeClaudeCliIfMissing: true, }); 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: ChannelChoice[] = []; const channelAccountIds: Partial> = {}; nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowSignalInstall: true, onSelection: (value) => { selection = value; }, promptAccountIds: true, onAccountId: (channel, accountId) => { channelAccountIds[channel] = accountId; }, }); if (selection.length > 0) { const wantsBindings = await prompter.confirm({ message: "Route selected channels to this agent now? (bindings)", initialValue: false, }); if (wantsBindings) { const desiredBindings = buildChannelBindings({ agentId, selection, config: nextConfig, accountIds: channelAccountIds, }); 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 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.agents?.defaults?.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; } }