feat: add agents command

This commit is contained in:
Peter Steinberger
2026-01-07 09:58:54 +01:00
parent 9df8af855b
commit 7973fd4caf
20 changed files with 1519 additions and 330 deletions

View File

@@ -30,6 +30,10 @@ clawdbot [--dev] [--profile <name>] <command>
send
poll
agent
agents
list
add
delete
status
health
sessions
@@ -147,7 +151,7 @@ Options:
- `--workspace <dir>`
- `--non-interactive`
- `--mode <local|remote>`
- `--auth-choice <oauth|apiKey|minimax|skip>`
- `--auth-choice <oauth|openai-codex|antigravity|apiKey|minimax|skip>`
- `--anthropic-api-key <key>`
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto>`
@@ -272,6 +276,29 @@ Options:
- `--json`
- `--timeout <seconds>`
### `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 <dir>`
- `--json`
#### `agents delete <id>`
Delete an agent and prune its workspace + state.
Options:
- `--force`
- `--json`
### `status`
Show linked session health and recent recipients.

View File

@@ -28,6 +28,16 @@ If you do nothing, Clawdbot runs a single agent:
- Workspace defaults to `~/clawd` (or `~/clawd-<profile>` 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",
},

View File

@@ -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.<agentId>`: per-agent overrides.
- `name`: display name for the agent.
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
- `agentDir`: default `~/.clawdbot/agents/<agentId>/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)

View File

@@ -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 <name>
```
## 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 <name>` to create a separate agent with its own workspace,
sessions, and auth profiles. Running without `--workspace` launches the wizard.
What it sets:
- `routing.agents.<agentId>.name`
- `routing.agents.<agentId>.workspace`
- `routing.agents.<agentId>.agentDir`
Notes:
- Default workspaces follow `~/clawd-<agentId>`.
- Add `routing.bindings` to route inbound messages (the wizard can do this).
## Noninteractive mode
Use `--non-interactive` to automate or script onboarding:
@@ -128,6 +148,12 @@ clawdbot onboard --non-interactive \
Add `--json` for a machinereadable summary.
Add agent (noninteractive) 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.<agentId>` and optional `routing.bindings`.
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.

View File

@@ -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,
};
}

View File

@@ -199,10 +199,12 @@ export async function getReplyFromConfig(
configOverride?: ClawdbotConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
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({

View File

@@ -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 };

View File

@@ -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 <dir>", "Agent workspace directory (default: ~/clawd)")
.option("--non-interactive", "Run without prompts", false)
.option("--mode <mode>", "Wizard mode: local|remote")
.option("--auth-choice <choice>", "Auth: oauth|apiKey|minimax|skip")
.option(
"--auth-choice <choice>",
"Auth: oauth|openai-codex|antigravity|apiKey|minimax|skip",
)
.option("--anthropic-api-key <key>", "Anthropic API key")
.option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "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 <dir>", "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 <id>")
.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);

140
src/commands/agents.test.ts Normal file
View File

@@ -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);
});
});

653
src/commands/agents.ts Normal file
View File

@@ -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<string>([
DEFAULT_AGENT_ID,
defaultAgentId,
...Object.keys(cfg.routing?.agents ?? {}),
]);
const bindingCounts = new Map<string, number>();
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<string, string>();
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<ClawdbotConfig | null> {
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}`);
}
}

368
src/commands/auth-choice.ts Normal file
View File

@@ -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<string> | 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<string, unknown>)
? {
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 };
}

View File

@@ -6,9 +6,9 @@ import type { ClawdbotConfig } from "../config/config.js";
export async function writeOAuthCredentials(
provider: OAuthProvider,
creds: OAuthCredentials,
agentDir?: string,
): Promise<void> {
// 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<string, unknown>)
? {
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<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
},
},
};
}

View File

@@ -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<T>(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<boolean> {
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);
}

View File

@@ -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();

View File

@@ -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<boolean> {
}
}
async function detectWhatsAppLinked(): Promise<boolean> {
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
async function detectWhatsAppLinked(
cfg: ClawdbotConfig,
accountId: string,
): Promise<boolean> {
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<ClawdbotConfig> {
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(

View File

@@ -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");
}

View File

@@ -539,6 +539,7 @@ export type RoutingConfig = {
agents?: Record<
string,
{
name?: string;
workspace?: string;
agentDir?: string;
model?: string;

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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<string> | 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<string, unknown>)
? {
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);