feat: add agents command
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
## 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.<agentId>` and optional `routing.bindings`.
|
||||
|
||||
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
||||
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
140
src/commands/agents.test.ts
Normal 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
653
src/commands/agents.ts
Normal 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
368
src/commands/auth-choice.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -539,6 +539,7 @@ export type RoutingConfig = {
|
||||
agents?: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user