Files
clawdbot/src/commands/agents.commands.add.ts
2026-01-15 05:17:19 +00:00

361 lines
11 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.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;
};
async function fileExists(pathname: string): Promise<boolean> {
try {
await fs.stat(pathname);
return true;
} catch {
return false;
}
}
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 defaultAgentId = resolveDefaultAgentId(cfg);
if (defaultAgentId !== agentId) {
const sourceAuthPath = resolveAuthStorePath(resolveAgentDir(cfg, defaultAgentId));
const destAuthPath = resolveAuthStorePath(agentDir);
const sameAuthPath =
path.resolve(sourceAuthPath).toLowerCase() === path.resolve(destAuthPath).toLowerCase();
if (
!sameAuthPath &&
(await fileExists(sourceAuthPath)) &&
!(await fileExists(destAuthPath))
) {
const shouldCopy = await prompter.confirm({
message: `Copy auth profiles from "${defaultAgentId}"?`,
initialValue: false,
});
if (shouldCopy) {
await fs.mkdir(path.dirname(destAuthPath), { recursive: true });
await fs.copyFile(sourceAuthPath, destAuthPath);
await prompter.note(`Copied auth profiles from "${defaultAgentId}".`, "Auth profiles");
}
}
}
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<Record<ChannelChoice, string>> = {};
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;
}
}