feat: add agents command
This commit is contained in:
@@ -30,6 +30,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
send
|
send
|
||||||
poll
|
poll
|
||||||
agent
|
agent
|
||||||
|
agents
|
||||||
|
list
|
||||||
|
add
|
||||||
|
delete
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@@ -147,7 +151,7 @@ Options:
|
|||||||
- `--workspace <dir>`
|
- `--workspace <dir>`
|
||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--auth-choice <oauth|apiKey|minimax|skip>`
|
- `--auth-choice <oauth|openai-codex|antigravity|apiKey|minimax|skip>`
|
||||||
- `--anthropic-api-key <key>`
|
- `--anthropic-api-key <key>`
|
||||||
- `--gateway-port <port>`
|
- `--gateway-port <port>`
|
||||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||||
@@ -272,6 +276,29 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--timeout <seconds>`
|
- `--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`
|
### `status`
|
||||||
Show linked session health and recent recipients.
|
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).
|
- Workspace defaults to `~/clawd` (or `~/clawd-<profile>` when `CLAWDBOT_PROFILE` is set).
|
||||||
- State defaults to `~/.clawdbot/agents/main/agent`.
|
- 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
|
## Multiple agents = multiple people, multiple personalities
|
||||||
|
|
||||||
With **multiple agents**, each `agentId` becomes a **fully isolated persona**:
|
With **multiple agents**, each `agentId` becomes a **fully isolated persona**:
|
||||||
@@ -73,10 +83,12 @@ multiple phone numbers without mixing sessions.
|
|||||||
|
|
||||||
agents: {
|
agents: {
|
||||||
home: {
|
home: {
|
||||||
|
name: "Home",
|
||||||
workspace: "~/clawd-home",
|
workspace: "~/clawd-home",
|
||||||
agentDir: "~/.clawdbot/agents/home/agent",
|
agentDir: "~/.clawdbot/agents/home/agent",
|
||||||
},
|
},
|
||||||
work: {
|
work: {
|
||||||
|
name: "Work",
|
||||||
workspace: "~/clawd-work",
|
workspace: "~/clawd-work",
|
||||||
agentDir: "~/.clawdbot/agents/work/agent",
|
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.defaultAgentId`: fallback when no binding matches (default: `main`).
|
||||||
- `routing.agents.<agentId>`: per-agent overrides.
|
- `routing.agents.<agentId>`: per-agent overrides.
|
||||||
|
- `name`: display name for the agent.
|
||||||
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
|
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
|
||||||
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
|
- `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`.
|
- `routing.bindings[]`: routes inbound messages to an `agentId`.
|
||||||
- `match.provider` (required)
|
- `match.provider` (required)
|
||||||
- `match.accountId` (optional; `*` = any account; omitted = default account)
|
- `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.
|
**Remote mode** only configures the local client to connect to a Gateway elsewhere.
|
||||||
It does **not** install or change anything on the remote host.
|
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)
|
## Flow details (local)
|
||||||
|
|
||||||
1) **Existing config detection**
|
1) **Existing config detection**
|
||||||
@@ -110,6 +116,20 @@ Notes:
|
|||||||
- macOS: Bonjour (`dns-sd`)
|
- macOS: Bonjour (`dns-sd`)
|
||||||
- Linux: Avahi (`avahi-browse`)
|
- 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
|
## Non‑interactive mode
|
||||||
|
|
||||||
Use `--non-interactive` to automate or script onboarding:
|
Use `--non-interactive` to automate or script onboarding:
|
||||||
@@ -128,6 +148,12 @@ clawdbot onboard --non-interactive \
|
|||||||
|
|
||||||
Add `--json` for a machine‑readable summary.
|
Add `--json` for a machine‑readable summary.
|
||||||
|
|
||||||
|
Add agent (non‑interactive) example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot agents add work --workspace ~/clawd-work
|
||||||
|
```
|
||||||
|
|
||||||
## Gateway wizard RPC
|
## Gateway wizard RPC
|
||||||
|
|
||||||
The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`).
|
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.lastRunCommand`
|
||||||
- `wizard.lastRunMode`
|
- `wizard.lastRunMode`
|
||||||
|
|
||||||
|
`clawdbot agents add` writes `routing.agents.<agentId>` and optional `routing.bindings`.
|
||||||
|
|
||||||
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
|
||||||
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,25 @@ export function resolveAgentIdFromSessionKey(
|
|||||||
export function resolveAgentConfig(
|
export function resolveAgentConfig(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): { workspace?: string; agentDir?: string } | undefined {
|
):
|
||||||
|
| {
|
||||||
|
name?: string;
|
||||||
|
workspace?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
const id = normalizeAgentId(agentId);
|
const id = normalizeAgentId(agentId);
|
||||||
const agents = cfg.routing?.agents;
|
const agents = cfg.routing?.agents;
|
||||||
if (!agents || typeof agents !== "object") return undefined;
|
if (!agents || typeof agents !== "object") return undefined;
|
||||||
const entry = agents[id];
|
const entry = agents[id];
|
||||||
if (!entry || typeof entry !== "object") return undefined;
|
if (!entry || typeof entry !== "object") return undefined;
|
||||||
return {
|
return {
|
||||||
|
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||||
workspace:
|
workspace:
|
||||||
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : 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,
|
configOverride?: ClawdbotConfig,
|
||||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||||
const cfg = configOverride ?? loadConfig();
|
const cfg = configOverride ?? loadConfig();
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
||||||
const agentCfg = cfg.agent;
|
const agentCfg = cfg.agent;
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
|
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
|
||||||
cfg,
|
cfg,
|
||||||
|
agentId,
|
||||||
});
|
});
|
||||||
let provider = defaultProvider;
|
let provider = defaultProvider;
|
||||||
let model = defaultModel;
|
let model = defaultModel;
|
||||||
@@ -221,7 +223,6 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
|
|
||||||
const workspaceDirRaw =
|
const workspaceDirRaw =
|
||||||
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
const workspace = await ensureAgentWorkspace({
|
const workspace = await ensureAgentWorkspace({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||||
|
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
resolveAuthStorePathForDisplay,
|
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;
|
defaultProvider: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
aliasIndex: ModelAliasIndex;
|
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({
|
const mainModel = resolveConfiguredModelRef({
|
||||||
cfg: params.cfg,
|
cfg,
|
||||||
defaultProvider: DEFAULT_PROVIDER,
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
});
|
});
|
||||||
const defaultProvider = mainModel.provider;
|
const defaultProvider = mainModel.provider;
|
||||||
const defaultModel = mainModel.model;
|
const defaultModel = mainModel.model;
|
||||||
const aliasIndex = buildModelAliasIndex({
|
const aliasIndex = buildModelAliasIndex({
|
||||||
cfg: params.cfg,
|
cfg,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
});
|
});
|
||||||
return { defaultProvider, defaultModel, aliasIndex };
|
return { defaultProvider, defaultModel, aliasIndex };
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { agentCliCommand } from "../commands/agent-via-gateway.js";
|
import { agentCliCommand } from "../commands/agent-via-gateway.js";
|
||||||
|
import {
|
||||||
|
agentsAddCommand,
|
||||||
|
agentsDeleteCommand,
|
||||||
|
agentsListCommand,
|
||||||
|
} from "../commands/agents.js";
|
||||||
import { configureCommand } from "../commands/configure.js";
|
import { configureCommand } from "../commands/configure.js";
|
||||||
import { doctorCommand } from "../commands/doctor.js";
|
import { doctorCommand } from "../commands/doctor.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
@@ -217,7 +222,10 @@ export function buildProgram() {
|
|||||||
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
|
.option("--workspace <dir>", "Agent workspace directory (default: ~/clawd)")
|
||||||
.option("--non-interactive", "Run without prompts", false)
|
.option("--non-interactive", "Run without prompts", false)
|
||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.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("--anthropic-api-key <key>", "Anthropic API key")
|
||||||
.option("--gateway-port <port>", "Gateway port")
|
.option("--gateway-port <port>", "Gateway port")
|
||||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
||||||
@@ -243,6 +251,8 @@ export function buildProgram() {
|
|||||||
mode: opts.mode as "local" | "remote" | undefined,
|
mode: opts.mode as "local" | "remote" | undefined,
|
||||||
authChoice: opts.authChoice as
|
authChoice: opts.authChoice as
|
||||||
| "oauth"
|
| "oauth"
|
||||||
|
| "openai-codex"
|
||||||
|
| "antigravity"
|
||||||
| "apiKey"
|
| "apiKey"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
| "skip"
|
| "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);
|
registerCanvasCli(program);
|
||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
registerModelsCli(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(
|
export async function writeOAuthCredentials(
|
||||||
provider: OAuthProvider,
|
provider: OAuthProvider,
|
||||||
creds: OAuthCredentials,
|
creds: OAuthCredentials,
|
||||||
|
agentDir?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Write to the multi-agent path so gateway finds credentials on startup
|
// Write to the multi-agent path so gateway finds credentials on startup
|
||||||
const agentDir = resolveDefaultAgentDir();
|
|
||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId: `${provider}:${creds.email ?? "default"}`,
|
profileId: `${provider}:${creds.email ?? "default"}`,
|
||||||
credential: {
|
credential: {
|
||||||
@@ -16,13 +16,12 @@ export async function writeOAuthCredentials(
|
|||||||
provider,
|
provider,
|
||||||
...creds,
|
...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
|
// Write to the multi-agent path so gateway finds credentials on startup
|
||||||
const agentDir = resolveDefaultAgentDir();
|
|
||||||
upsertAuthProfile({
|
upsertAuthProfile({
|
||||||
profileId: "anthropic:default",
|
profileId: "anthropic:default",
|
||||||
credential: {
|
credential: {
|
||||||
@@ -30,7 +29,7 @@ export async function setAnthropicApiKey(key: string) {
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
key,
|
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 };
|
const models = { ...cfg.agent?.models };
|
||||||
models["anthropic/claude-opus-4-5"] = {
|
models["anthropic/claude-opus-4-5"] = {
|
||||||
...models["anthropic/claude-opus-4-5"],
|
...models["anthropic/claude-opus-4-5"],
|
||||||
@@ -109,16 +110,6 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
|||||||
...cfg,
|
...cfg,
|
||||||
agent: {
|
agent: {
|
||||||
...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,
|
||||||
},
|
},
|
||||||
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";
|
} from "../agents/workspace.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { CONFIG_PATH_CLAWDBOT } 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 { callGateway } from "../gateway/call.js";
|
||||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
@@ -19,7 +19,11 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||||
import { VERSION } from "../version.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 {
|
export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
|
||||||
if (isCancel(value)) {
|
if (isCancel(value)) {
|
||||||
@@ -72,7 +76,7 @@ export function printWizardHeader(runtime: RuntimeEnv) {
|
|||||||
|
|
||||||
export function applyWizardMetadata(
|
export function applyWizardMetadata(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
params: { command: string; mode: "local" | "remote" },
|
params: { command: string; mode: OnboardMode },
|
||||||
): ClawdbotConfig {
|
): ClawdbotConfig {
|
||||||
const commit =
|
const commit =
|
||||||
process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim() || undefined;
|
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(
|
export async function ensureWorkspaceAndSessions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
options?: { skipBootstrap?: boolean },
|
options?: { skipBootstrap?: boolean; agentId?: string },
|
||||||
) {
|
) {
|
||||||
const ws = await ensureAgentWorkspace({
|
const ws = await ensureAgentWorkspace({
|
||||||
dir: workspaceDir,
|
dir: workspaceDir,
|
||||||
ensureBootstrapFiles: !options?.skipBootstrap,
|
ensureBootstrapFiles: !options?.skipBootstrap,
|
||||||
});
|
});
|
||||||
runtime.log(`Workspace OK: ${ws.dir}`);
|
runtime.log(`Workspace OK: ${ws.dir}`);
|
||||||
const sessionsDir = resolveSessionTranscriptsDir();
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
|
||||||
await fs.mkdir(sessionsDir, { recursive: true });
|
await fs.mkdir(sessionsDir, { recursive: true });
|
||||||
runtime.log(`Sessions OK: ${sessionsDir}`);
|
runtime.log(`Sessions OK: ${sessionsDir}`);
|
||||||
}
|
}
|
||||||
@@ -275,7 +279,7 @@ export async function handleReset(
|
|||||||
await moveToTrash(CONFIG_PATH_CLAWDBOT, runtime);
|
await moveToTrash(CONFIG_PATH_CLAWDBOT, runtime);
|
||||||
if (scope === "config") return;
|
if (scope === "config") return;
|
||||||
await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime);
|
await moveToTrash(path.join(CONFIG_DIR, "credentials"), runtime);
|
||||||
await moveToTrash(resolveSessionTranscriptsDir(), runtime);
|
await moveToTrash(resolveSessionTranscriptsDirForAgent(), runtime);
|
||||||
if (scope === "full") {
|
if (scope === "full") {
|
||||||
await moveToTrash(workspaceDir, runtime);
|
await moveToTrash(workspaceDir, runtime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ import {
|
|||||||
ensureWorkspaceAndSessions,
|
ensureWorkspaceAndSessions,
|
||||||
randomToken,
|
randomToken,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
import type {
|
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
|
||||||
AuthChoice,
|
|
||||||
OnboardMode,
|
|
||||||
OnboardOptions,
|
|
||||||
} from "./onboard-types.js";
|
|
||||||
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
||||||
|
|
||||||
export async function runNonInteractiveOnboarding(
|
export async function runNonInteractiveOnboarding(
|
||||||
@@ -42,7 +38,12 @@ export async function runNonInteractiveOnboarding(
|
|||||||
) {
|
) {
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
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") {
|
if (mode === "remote") {
|
||||||
const remoteUrl = opts.remoteUrl?.trim();
|
const remoteUrl = opts.remoteUrl?.trim();
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ import path from "node:path";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { DmPolicy } from "../config/types.js";
|
import type { DmPolicy } from "../config/types.js";
|
||||||
import { loginWeb } from "../provider-web.js";
|
import { loginWeb } from "../provider-web.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
normalizeAccountId,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeE164 } from "../utils.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 type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { detectBinary } from "./onboard-helpers.js";
|
import { detectBinary } from "./onboard-helpers.js";
|
||||||
import type { ProviderChoice } from "./onboard-types.js";
|
import type { ProviderChoice } from "./onboard-types.js";
|
||||||
@@ -28,8 +36,12 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectWhatsAppLinked(): Promise<boolean> {
|
async function detectWhatsAppLinked(
|
||||||
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
|
||||||
|
const credsPath = path.join(authDir, "creds.json");
|
||||||
return await pathExists(credsPath);
|
return await pathExists(credsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,13 +473,24 @@ async function promptWhatsAppAllowFrom(
|
|||||||
return setWhatsAppAllowFrom(next, unique);
|
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(
|
export async function setupProviders(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
prompter: WizardPrompter,
|
prompter: WizardPrompter,
|
||||||
options?: { allowDisable?: boolean; allowSignalInstall?: boolean },
|
options?: SetupProvidersOptions,
|
||||||
): Promise<ClawdbotConfig> {
|
): 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 telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
|
||||||
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
||||||
const slackBotEnv = Boolean(process.env.SLACK_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 imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
|
||||||
const imessageCliDetected = await detectBinary(imessageCliPath);
|
const imessageCliDetected = await detectBinary(imessageCliPath);
|
||||||
|
|
||||||
|
const waAccountLabel =
|
||||||
|
whatsappAccountId === DEFAULT_ACCOUNT_ID ? "default" : whatsappAccountId;
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
`WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`,
|
`WhatsApp (${waAccountLabel}): ${whatsappLinked ? "linked" : "not linked"}`,
|
||||||
`Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
|
`Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
|
||||||
`Discord: ${discordConfigured ? "configured" : "needs token"}`,
|
`Discord: ${discordConfigured ? "configured" : "needs token"}`,
|
||||||
`Slack: ${slackConfigured ? "configured" : "needs tokens"}`,
|
`Slack: ${slackConfigured ? "configured" : "needs tokens"}`,
|
||||||
@@ -549,14 +574,71 @@ export async function setupProviders(
|
|||||||
],
|
],
|
||||||
})) as ProviderChoice[];
|
})) as ProviderChoice[];
|
||||||
|
|
||||||
|
options?.onSelection?.(selection);
|
||||||
|
|
||||||
let next = cfg;
|
let next = cfg;
|
||||||
|
|
||||||
if (selection.includes("whatsapp")) {
|
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) {
|
if (!whatsappLinked) {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"Scan the QR with WhatsApp on your phone.",
|
"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",
|
"Docs: https://docs.clawd.bot/whatsapp",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"WhatsApp linking",
|
"WhatsApp linking",
|
||||||
@@ -570,7 +652,7 @@ export async function setupProviders(
|
|||||||
});
|
});
|
||||||
if (wantsLink) {
|
if (wantsLink) {
|
||||||
try {
|
try {
|
||||||
await loginWeb(false, "web");
|
await loginWeb(false, "web", undefined, runtime, whatsappAccountId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export function resolveSessionTranscriptsDir(
|
|||||||
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
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 {
|
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ export type RoutingConfig = {
|
|||||||
agents?: Record<
|
agents?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
name?: string;
|
||||||
workspace?: string;
|
workspace?: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ const RoutingSchema = z
|
|||||||
z.string(),
|
z.string(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
workspace: z.string().optional(),
|
workspace: z.string().optional(),
|
||||||
agentDir: z.string().optional(),
|
agentDir: z.string().optional(),
|
||||||
model: 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(
|
export function parseAgentSessionKey(
|
||||||
sessionKey: string | undefined | null,
|
sessionKey: string | undefined | null,
|
||||||
): ParsedAgentSessionKey | null {
|
): ParsedAgentSessionKey | null {
|
||||||
|
|||||||
@@ -1,38 +1,15 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loginAnthropic,
|
applyAuthChoice,
|
||||||
loginOpenAICodex,
|
warnIfModelConfigLooksOff,
|
||||||
type OAuthCredentials,
|
} from "../commands/auth-choice.js";
|
||||||
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";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||||
type GatewayDaemonRuntime,
|
type GatewayDaemonRuntime,
|
||||||
} from "../commands/daemon-runtime.js";
|
} from "../commands/daemon-runtime.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
import {
|
|
||||||
applyAuthProfileConfig,
|
|
||||||
applyMinimaxConfig,
|
|
||||||
setAnthropicApiKey,
|
|
||||||
writeOAuthCredentials,
|
|
||||||
} from "../commands/onboard-auth.js";
|
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
DEFAULT_WORKSPACE,
|
DEFAULT_WORKSPACE,
|
||||||
@@ -57,10 +34,6 @@ import type {
|
|||||||
OnboardOptions,
|
OnboardOptions,
|
||||||
ResetScope,
|
ResetScope,
|
||||||
} from "../commands/onboard-types.js";
|
} 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 { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -78,52 +51,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import type { WizardPrompter } from "./prompts.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(
|
export async function runOnboardingWizard(
|
||||||
opts: OnboardOptions,
|
opts: OnboardOptions,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
@@ -203,18 +130,18 @@ export async function runOnboardingWizard(
|
|||||||
const mode =
|
const mode =
|
||||||
opts.mode ??
|
opts.mode ??
|
||||||
((await prompter.select({
|
((await prompter.select({
|
||||||
message: "Where will the Gateway run?",
|
message: "What do you want to set up?",
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: "local",
|
value: "local",
|
||||||
label: "Local (this machine)",
|
label: "Local gateway (this machine)",
|
||||||
hint: localProbe.ok
|
hint: localProbe.ok
|
||||||
? `Gateway reachable (${localUrl})`
|
? `Gateway reachable (${localUrl})`
|
||||||
: `No gateway detected (${localUrl})`,
|
: `No gateway detected (${localUrl})`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "remote",
|
value: "remote",
|
||||||
label: "Remote (info-only)",
|
label: "Remote gateway (info-only)",
|
||||||
hint: !remoteUrl
|
hint: !remoteUrl
|
||||||
? "No remote URL configured yet"
|
? "No remote URL configured yet"
|
||||||
: remoteProbe?.ok
|
: remoteProbe?.ok
|
||||||
@@ -271,214 +198,14 @@ export async function runOnboardingWizard(
|
|||||||
],
|
],
|
||||||
})) as AuthChoice;
|
})) as AuthChoice;
|
||||||
|
|
||||||
if (authChoice === "oauth") {
|
const authResult = await applyAuthChoice({
|
||||||
await prompter.note(
|
authChoice,
|
||||||
"Browser will open. Paste the code shown after login (code#state).",
|
config: nextConfig,
|
||||||
"Anthropic OAuth",
|
prompter,
|
||||||
);
|
runtime,
|
||||||
const spin = prompter.progress("Waiting for authorization…");
|
setDefaultModel: true,
|
||||||
let oauthCreds: OAuthCredentials | null = null;
|
});
|
||||||
try {
|
nextConfig = authResult.config;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user