refactor(commands): split CLI commands
This commit is contained in:
parent
2b60ee96f2
commit
a58ff1ac63
156
src/commands/agents.bindings.ts
Normal file
156
src/commands/agents.bindings.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import type { ChatChannelId } from "../channels/registry.js";
|
||||
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAgentId,
|
||||
} from "../routing/session-key.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
|
||||
function bindingMatchKey(match: AgentBinding["match"]) {
|
||||
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
return [
|
||||
match.channel,
|
||||
accountId,
|
||||
match.peer?.kind ?? "",
|
||||
match.peer?.id ?? "",
|
||||
match.guildId ?? "",
|
||||
match.teamId ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function describeBinding(binding: AgentBinding) {
|
||||
const match = binding.match;
|
||||
const parts = [match.channel];
|
||||
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(" ");
|
||||
}
|
||||
|
||||
export function applyAgentBindings(
|
||||
cfg: ClawdbotConfig,
|
||||
bindings: AgentBinding[],
|
||||
): {
|
||||
config: ClawdbotConfig;
|
||||
added: AgentBinding[];
|
||||
skipped: AgentBinding[];
|
||||
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
||||
} {
|
||||
const existing = cfg.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,
|
||||
bindings: [...existing, ...added],
|
||||
},
|
||||
added,
|
||||
skipped,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultAccountId(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: ChatChannelId,
|
||||
): string {
|
||||
const plugin = getChannelPlugin(provider);
|
||||
if (!plugin) return DEFAULT_ACCOUNT_ID;
|
||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
}
|
||||
|
||||
export function buildChannelBindings(params: {
|
||||
agentId: string;
|
||||
selection: ChannelChoice[];
|
||||
config: ClawdbotConfig;
|
||||
accountIds?: Partial<Record<ChannelChoice, string>>;
|
||||
}): AgentBinding[] {
|
||||
const bindings: AgentBinding[] = [];
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
for (const channel of params.selection) {
|
||||
const match: AgentBinding["match"] = { channel };
|
||||
const accountId = params.accountIds?.[channel]?.trim();
|
||||
if (accountId) {
|
||||
match.accountId = accountId;
|
||||
} else {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (plugin?.meta.forceAccountBinding) {
|
||||
match.accountId = resolveDefaultAccountId(params.config, channel);
|
||||
}
|
||||
}
|
||||
bindings.push({ agentId, match });
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function parseBindingSpecs(params: {
|
||||
agentId: string;
|
||||
specs?: string[];
|
||||
config: ClawdbotConfig;
|
||||
}): { bindings: AgentBinding[]; errors: string[] } {
|
||||
const bindings: AgentBinding[] = [];
|
||||
const errors: string[] = [];
|
||||
const specs = params.specs ?? [];
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
for (const raw of specs) {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) continue;
|
||||
const [channelRaw, accountRaw] = trimmed.split(":", 2);
|
||||
const channel = normalizeChatChannelId(channelRaw);
|
||||
if (!channel) {
|
||||
errors.push(`Unknown channel "${channelRaw}".`);
|
||||
continue;
|
||||
}
|
||||
let accountId = accountRaw?.trim();
|
||||
if (accountRaw !== undefined && !accountId) {
|
||||
errors.push(`Invalid binding "${trimmed}" (empty account id).`);
|
||||
continue;
|
||||
}
|
||||
if (!accountId) {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (plugin?.meta.forceAccountBinding) {
|
||||
accountId = resolveDefaultAccountId(params.config, channel);
|
||||
}
|
||||
}
|
||||
const match: AgentBinding["match"] = { channel };
|
||||
if (accountId) match.accountId = accountId;
|
||||
bindings.push({ agentId, match });
|
||||
}
|
||||
return { bindings, errors };
|
||||
}
|
||||
26
src/commands/agents.command-shared.ts
Normal file
26
src/commands/agents.command-shared.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv {
|
||||
return { ...runtime, log: () => {} };
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
335
src/commands/agents.commands.add.ts
Normal file
335
src/commands/agents.commands.add.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import {
|
||||
applyAgentBindings,
|
||||
buildChannelBindings,
|
||||
describeBinding,
|
||||
parseBindingSpecs,
|
||||
} from "./agents.bindings.js";
|
||||
import {
|
||||
createQuietRuntime,
|
||||
requireValidConfig,
|
||||
} from "./agents.command-shared.js";
|
||||
import {
|
||||
applyAgentConfig,
|
||||
findAgentEntryIndex,
|
||||
listAgentEntries,
|
||||
} from "./agents.config.js";
|
||||
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
|
||||
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import { ensureWorkspaceAndSessions } from "./onboard-helpers.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
|
||||
type AgentsAddOptions = {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
model?: string;
|
||||
agentDir?: string;
|
||||
bind?: string[];
|
||||
nonInteractive?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function agentsAddCommand(
|
||||
opts: AgentsAddOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
params?: { hasFlags?: boolean },
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
|
||||
const workspaceFlag = opts.workspace?.trim();
|
||||
const nameInput = opts.name?.trim();
|
||||
const hasFlags = params?.hasFlags === true;
|
||||
const nonInteractive = Boolean(opts.nonInteractive || hasFlags);
|
||||
|
||||
if (nonInteractive && !workspaceFlag) {
|
||||
runtime.error(
|
||||
"Non-interactive mode requires --workspace. Re-run without flags to use the wizard.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
if (!nameInput) {
|
||||
runtime.error("Agent name is required in non-interactive mode.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!workspaceFlag) {
|
||||
runtime.error(
|
||||
"Non-interactive mode requires --workspace. Re-run without flags to use the wizard.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const agentId = normalizeAgentId(nameInput);
|
||||
if (agentId === DEFAULT_AGENT_ID) {
|
||||
runtime.error(`"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (agentId !== nameInput) {
|
||||
runtime.log(`Normalized agent id to "${agentId}".`);
|
||||
}
|
||||
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) {
|
||||
runtime.error(`Agent "${agentId}" already exists.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceDir = resolveUserPath(workspaceFlag);
|
||||
const agentDir = opts.agentDir?.trim()
|
||||
? resolveUserPath(opts.agentDir.trim())
|
||||
: resolveAgentDir(cfg, agentId);
|
||||
const model = opts.model?.trim();
|
||||
const nextConfig = applyAgentConfig(cfg, {
|
||||
agentId,
|
||||
name: nameInput,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
|
||||
const bindingParse = parseBindingSpecs({
|
||||
agentId,
|
||||
specs: opts.bind,
|
||||
config: nextConfig,
|
||||
});
|
||||
if (bindingParse.errors.length > 0) {
|
||||
runtime.error(bindingParse.errors.join("\n"));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const bindingResult =
|
||||
bindingParse.bindings.length > 0
|
||||
? applyAgentBindings(nextConfig, bindingParse.bindings)
|
||||
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
|
||||
|
||||
await writeConfigFile(bindingResult.config);
|
||||
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
|
||||
skipBootstrap: Boolean(
|
||||
bindingResult.config.agents?.defaults?.skipBootstrap,
|
||||
),
|
||||
agentId,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
agentId,
|
||||
name: nameInput,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
model,
|
||||
bindings: {
|
||||
added: bindingResult.added.map(describeBinding),
|
||||
skipped: bindingResult.skipped.map(describeBinding),
|
||||
conflicts: bindingResult.conflicts.map(
|
||||
(conflict) =>
|
||||
`${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||
),
|
||||
},
|
||||
};
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
runtime.log(`Agent: ${agentId}`);
|
||||
runtime.log(`Workspace: ${workspaceDir}`);
|
||||
runtime.log(`Agent dir: ${agentDir}`);
|
||||
if (model) runtime.log(`Model: ${model}`);
|
||||
if (bindingResult.conflicts.length > 0) {
|
||||
runtime.error(
|
||||
[
|
||||
"Skipped bindings already claimed by another agent:",
|
||||
...bindingResult.conflicts.map(
|
||||
(conflict) =>
|
||||
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||
),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prompter = createClackPrompter();
|
||||
try {
|
||||
await prompter.intro("Add Clawdbot agent");
|
||||
const name =
|
||||
nameInput ??
|
||||
(await prompter.text({
|
||||
message: "Agent name",
|
||||
validate: (value) => {
|
||||
if (!value?.trim()) return "Required";
|
||||
const normalized = normalizeAgentId(value);
|
||||
if (normalized === DEFAULT_AGENT_ID) {
|
||||
return `"${DEFAULT_AGENT_ID}" is reserved. Choose another name.`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
const agentName = String(name).trim();
|
||||
const agentId = normalizeAgentId(agentName);
|
||||
if (agentName !== agentId) {
|
||||
await prompter.note(`Normalized id to "${agentId}".`, "Agent id");
|
||||
}
|
||||
|
||||
const existingAgent = listAgentEntries(cfg).find(
|
||||
(agent) => normalizeAgentId(agent.id) === agentId,
|
||||
);
|
||||
if (existingAgent) {
|
||||
const shouldUpdate = await prompter.confirm({
|
||||
message: `Agent "${agentId}" already exists. Update it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!shouldUpdate) {
|
||||
await prompter.outro("No changes made.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDefault = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const workspaceInput = await prompter.text({
|
||||
message: "Workspace directory",
|
||||
initialValue: workspaceDefault,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const workspaceDir = resolveUserPath(
|
||||
String(workspaceInput).trim() || workspaceDefault,
|
||||
);
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
|
||||
let nextConfig = applyAgentConfig(cfg, {
|
||||
agentId,
|
||||
name: agentName,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
const wantsAuth = await prompter.confirm({
|
||||
message: "Configure model/auth for this agent now?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsAuth) {
|
||||
const authStore = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const authChoice = await promptAuthChoiceGrouped({
|
||||
prompter,
|
||||
store: authStore,
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
});
|
||||
|
||||
const authResult = await applyAuthChoice({
|
||||
authChoice,
|
||||
config: nextConfig,
|
||||
prompter,
|
||||
runtime,
|
||||
agentDir,
|
||||
setDefaultModel: false,
|
||||
agentId,
|
||||
});
|
||||
nextConfig = authResult.config;
|
||||
if (authResult.agentModelOverride) {
|
||||
nextConfig = applyAgentConfig(nextConfig, {
|
||||
agentId,
|
||||
model: authResult.agentModelOverride,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await warnIfModelConfigLooksOff(nextConfig, prompter, {
|
||||
agentId,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
let selection: ChannelChoice[] = [];
|
||||
const channelAccountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
onSelection: (value) => {
|
||||
selection = value;
|
||||
},
|
||||
promptAccountIds: true,
|
||||
onAccountId: (channel, accountId) => {
|
||||
channelAccountIds[channel] = accountId;
|
||||
},
|
||||
});
|
||||
|
||||
if (selection.length > 0) {
|
||||
const wantsBindings = await prompter.confirm({
|
||||
message: "Route selected channels to this agent now? (bindings)",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsBindings) {
|
||||
const desiredBindings = buildChannelBindings({
|
||||
agentId,
|
||||
selection,
|
||||
config: nextConfig,
|
||||
accountIds: channelAccountIds,
|
||||
});
|
||||
const result = applyAgentBindings(nextConfig, desiredBindings);
|
||||
nextConfig = result.config;
|
||||
if (result.conflicts.length > 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Skipped bindings already claimed by another agent:",
|
||||
...result.conflicts.map(
|
||||
(conflict) =>
|
||||
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||
),
|
||||
].join("\n"),
|
||||
"Routing bindings",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await prompter.note(
|
||||
[
|
||||
"Routing unchanged. Add bindings when you're ready.",
|
||||
"Docs: https://docs.clawd.bot/concepts/multi-agent",
|
||||
].join("\n"),
|
||||
"Routing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
agentId,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
agentId,
|
||||
name: agentName,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
};
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
await prompter.outro(`Agent "${agentId}" ready.`);
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
107
src/commands/agents.commands.delete.ts
Normal file
107
src/commands/agents.commands.delete.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
|
||||
import {
|
||||
createQuietRuntime,
|
||||
requireValidConfig,
|
||||
} from "./agents.command-shared.js";
|
||||
import {
|
||||
findAgentEntryIndex,
|
||||
listAgentEntries,
|
||||
pruneAgentConfig,
|
||||
} from "./agents.config.js";
|
||||
import { moveToTrash } from "./onboard-helpers.js";
|
||||
|
||||
type AgentsDeleteOptions = {
|
||||
id: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
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 (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
|
||||
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);
|
||||
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
|
||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||
await moveToTrash(workspaceDir, quietRuntime);
|
||||
await moveToTrash(agentDir, quietRuntime);
|
||||
await moveToTrash(sessionsDir, quietRuntime);
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
129
src/commands/agents.commands.list.ts
Normal file
129
src/commands/agents.commands.list.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { describeBinding } from "./agents.bindings.js";
|
||||
import { requireValidConfig } from "./agents.command-shared.js";
|
||||
import type { AgentSummary } from "./agents.config.js";
|
||||
import { buildAgentSummaries } from "./agents.config.js";
|
||||
import {
|
||||
buildProviderStatusIndex,
|
||||
listProvidersForAgent,
|
||||
summarizeBindings,
|
||||
} from "./agents.providers.js";
|
||||
|
||||
type AgentsListOptions = {
|
||||
json?: boolean;
|
||||
bindings?: boolean;
|
||||
};
|
||||
|
||||
function formatSummary(summary: AgentSummary) {
|
||||
const defaultTag = summary.isDefault ? " (default)" : "";
|
||||
const header =
|
||||
summary.name && summary.name !== summary.id
|
||||
? `${summary.id}${defaultTag} (${summary.name})`
|
||||
: `${summary.id}${defaultTag}`;
|
||||
|
||||
const identityParts = [];
|
||||
if (summary.identityEmoji) identityParts.push(summary.identityEmoji);
|
||||
if (summary.identityName) identityParts.push(summary.identityName);
|
||||
const identityLine =
|
||||
identityParts.length > 0 ? identityParts.join(" ") : null;
|
||||
const identitySource =
|
||||
summary.identitySource === "identity"
|
||||
? "IDENTITY.md"
|
||||
: summary.identitySource === "config"
|
||||
? "config"
|
||||
: null;
|
||||
|
||||
const lines = [`- ${header}`];
|
||||
if (identityLine) {
|
||||
lines.push(
|
||||
` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push(` Workspace: ${summary.workspace}`);
|
||||
lines.push(` Agent dir: ${summary.agentDir}`);
|
||||
if (summary.model) lines.push(` Model: ${summary.model}`);
|
||||
lines.push(` Routing rules: ${summary.bindings}`);
|
||||
|
||||
if (summary.routes?.length) {
|
||||
lines.push(` Routing: ${summary.routes.join(", ")}`);
|
||||
}
|
||||
if (summary.providers?.length) {
|
||||
lines.push(" Providers:");
|
||||
for (const provider of summary.providers) {
|
||||
lines.push(` - ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.bindingDetails?.length) {
|
||||
lines.push(" Routing rules:");
|
||||
for (const binding of summary.bindingDetails) {
|
||||
lines.push(` - ${binding}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function agentsListCommand(
|
||||
opts: AgentsListOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
|
||||
const summaries = buildAgentSummaries(cfg);
|
||||
const bindingMap = new Map<string, AgentBinding[]>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const agentId = normalizeAgentId(binding.agentId);
|
||||
const list = bindingMap.get(agentId) ?? [];
|
||||
list.push(binding as AgentBinding);
|
||||
bindingMap.set(agentId, list);
|
||||
}
|
||||
|
||||
if (opts.bindings) {
|
||||
for (const summary of summaries) {
|
||||
const bindings = bindingMap.get(summary.id) ?? [];
|
||||
if (bindings.length > 0) {
|
||||
summary.bindingDetails = bindings.map((binding) =>
|
||||
describeBinding(binding),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const providerStatus = await buildProviderStatusIndex(cfg);
|
||||
|
||||
for (const summary of summaries) {
|
||||
const bindings = bindingMap.get(summary.id) ?? [];
|
||||
const routes = summarizeBindings(cfg, bindings);
|
||||
if (routes.length > 0) {
|
||||
summary.routes = routes;
|
||||
} else if (summary.isDefault) {
|
||||
summary.routes = ["default (no explicit rules)"];
|
||||
}
|
||||
|
||||
const providerLines = listProvidersForAgent({
|
||||
summaryIsDefault: summary.isDefault,
|
||||
cfg,
|
||||
bindings,
|
||||
providerStatus,
|
||||
});
|
||||
if (providerLines.length > 0) summary.providers = providerLines;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summaries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = ["Agents:", ...summaries.map(formatSummary)];
|
||||
lines.push(
|
||||
"Routing rules map channel/account/peer to an agent. Use --bindings for full rules.",
|
||||
);
|
||||
lines.push(
|
||||
"Channel status reflects local config/creds. For live health: clawdbot channels status --probe.",
|
||||
);
|
||||
runtime.log(lines.join("\n"));
|
||||
}
|
||||
246
src/commands/agents.config.ts
Normal file
246
src/commands/agents.config.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
export type AgentSummary = {
|
||||
id: string;
|
||||
name?: string;
|
||||
identityName?: string;
|
||||
identityEmoji?: string;
|
||||
identitySource?: "identity" | "config";
|
||||
workspace: string;
|
||||
agentDir: string;
|
||||
model?: string;
|
||||
bindings: number;
|
||||
bindingDetails?: string[];
|
||||
routes?: string[];
|
||||
providers?: string[];
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
type AgentEntry = NonNullable<
|
||||
NonNullable<ClawdbotConfig["agents"]>["list"]
|
||||
>[number];
|
||||
|
||||
type AgentIdentity = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
};
|
||||
|
||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((entry): entry is AgentEntry =>
|
||||
Boolean(entry && typeof entry === "object"),
|
||||
);
|
||||
}
|
||||
|
||||
export function findAgentEntryIndex(
|
||||
list: AgentEntry[],
|
||||
agentId: string,
|
||||
): number {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
function resolveAgentName(cfg: ClawdbotConfig, agentId: string) {
|
||||
const entry = listAgentEntries(cfg).find(
|
||||
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
|
||||
);
|
||||
return entry?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
|
||||
const entry = listAgentEntries(cfg).find(
|
||||
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
|
||||
);
|
||||
if (entry?.model) {
|
||||
if (typeof entry.model === "string" && entry.model.trim()) {
|
||||
return entry.model.trim();
|
||||
}
|
||||
if (typeof entry.model === "object") {
|
||||
const primary = entry.model.primary?.trim();
|
||||
if (primary) return primary;
|
||||
}
|
||||
}
|
||||
const raw = cfg.agents?.defaults?.model;
|
||||
if (typeof raw === "string") return raw;
|
||||
return raw?.primary?.trim() || undefined;
|
||||
}
|
||||
|
||||
function parseIdentityMarkdown(content: string): AgentIdentity {
|
||||
const identity: AgentIdentity = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(?:-\s*)?([A-Za-z ]+):\s*(.+?)\s*$/);
|
||||
if (!match) continue;
|
||||
const label = match[1]?.trim().toLowerCase();
|
||||
const value = match[2]?.trim();
|
||||
if (!value) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!parsed.name && !parsed.emoji) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
|
||||
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const configuredAgents = listAgentEntries(cfg);
|
||||
const orderedIds =
|
||||
configuredAgents.length > 0
|
||||
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
|
||||
: [defaultAgentId];
|
||||
const bindingCounts = new Map<string, number>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const agentId = normalizeAgentId(binding.agentId);
|
||||
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const ordered = orderedIds.filter(
|
||||
(id, index) => orderedIds.indexOf(id) === index,
|
||||
);
|
||||
|
||||
return ordered.map((id) => {
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, id);
|
||||
const identity = loadAgentIdentity(workspace);
|
||||
const configIdentity = configuredAgents.find(
|
||||
(agent) => normalizeAgentId(agent.id) === id,
|
||||
)?.identity;
|
||||
const identityName = identity?.name ?? configIdentity?.name?.trim();
|
||||
const identityEmoji = identity?.emoji ?? configIdentity?.emoji?.trim();
|
||||
const identitySource = identity
|
||||
? "identity"
|
||||
: configIdentity && (identityName || identityEmoji)
|
||||
? "config"
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
name: resolveAgentName(cfg, id),
|
||||
identityName,
|
||||
identityEmoji,
|
||||
identitySource,
|
||||
workspace,
|
||||
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 name = params.name?.trim();
|
||||
const list = listAgentEntries(cfg);
|
||||
const index = findAgentEntryIndex(list, agentId);
|
||||
const base = index >= 0 ? list[index] : { id: agentId };
|
||||
const nextEntry: AgentEntry = {
|
||||
...base,
|
||||
...(name ? { name } : {}),
|
||||
...(params.workspace ? { workspace: params.workspace } : {}),
|
||||
...(params.agentDir ? { agentDir: params.agentDir } : {}),
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
};
|
||||
const nextList = [...list];
|
||||
if (index >= 0) {
|
||||
nextList[index] = nextEntry;
|
||||
} else {
|
||||
if (
|
||||
nextList.length === 0 &&
|
||||
agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))
|
||||
) {
|
||||
nextList.push({ id: resolveDefaultAgentId(cfg) });
|
||||
}
|
||||
nextList.push(nextEntry);
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
list: nextList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneAgentConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): {
|
||||
config: ClawdbotConfig;
|
||||
removedBindings: number;
|
||||
removedAllow: number;
|
||||
} {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const agents = listAgentEntries(cfg);
|
||||
const nextAgentsList = agents.filter(
|
||||
(entry) => normalizeAgentId(entry.id) !== id,
|
||||
);
|
||||
const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined;
|
||||
|
||||
const bindings = cfg.bindings ?? [];
|
||||
const filteredBindings = bindings.filter(
|
||||
(binding) => normalizeAgentId(binding.agentId) !== id,
|
||||
);
|
||||
|
||||
const allow = cfg.tools?.agentToAgent?.allow ?? [];
|
||||
const filteredAllow = allow.filter((entry) => entry !== id);
|
||||
|
||||
const nextAgentsConfig = cfg.agents
|
||||
? { ...cfg.agents, list: nextAgents }
|
||||
: nextAgents
|
||||
? { list: nextAgents }
|
||||
: undefined;
|
||||
const nextTools = cfg.tools?.agentToAgent
|
||||
? {
|
||||
...cfg.tools,
|
||||
agentToAgent: {
|
||||
...cfg.tools.agentToAgent,
|
||||
allow: filteredAllow.length > 0 ? filteredAllow : undefined,
|
||||
},
|
||||
}
|
||||
: cfg.tools;
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
agents: nextAgentsConfig,
|
||||
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
|
||||
tools: nextTools,
|
||||
},
|
||||
removedBindings: bindings.length - filteredBindings.length,
|
||||
removedAllow: allow.length - filteredAllow.length,
|
||||
};
|
||||
}
|
||||
197
src/commands/agents.providers.ts
Normal file
197
src/commands/agents.providers.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
} from "../channels/plugins/index.js";
|
||||
import type { ChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
getChatChannelMeta,
|
||||
normalizeChatChannelId,
|
||||
} from "../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentBinding } from "../config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
|
||||
type ProviderAccountStatus = {
|
||||
provider: ChatChannelId;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
state:
|
||||
| "linked"
|
||||
| "not linked"
|
||||
| "configured"
|
||||
| "not configured"
|
||||
| "enabled"
|
||||
| "disabled";
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
};
|
||||
|
||||
function providerAccountKey(provider: ChatChannelId, accountId?: string) {
|
||||
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
|
||||
}
|
||||
|
||||
function formatChannelAccountLabel(params: {
|
||||
provider: ChatChannelId;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}): string {
|
||||
const label = getChatChannelMeta(params.provider).label;
|
||||
const account = params.name?.trim()
|
||||
? `${params.accountId} (${params.name.trim()})`
|
||||
: params.accountId;
|
||||
return `${label} ${account}`;
|
||||
}
|
||||
|
||||
function formatProviderState(entry: ProviderAccountStatus): string {
|
||||
const parts = [entry.state];
|
||||
if (entry.enabled === false && entry.state !== "disabled") {
|
||||
parts.push("disabled");
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export async function buildProviderStatusIndex(
|
||||
cfg: ClawdbotConfig,
|
||||
): Promise<Map<string, ProviderAccountStatus>> {
|
||||
const map = new Map<string, ProviderAccountStatus>();
|
||||
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
for (const accountId of accountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
const snapshot = plugin.config.describeAccount?.(account, cfg);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
: typeof snapshot?.enabled === "boolean"
|
||||
? snapshot.enabled
|
||||
: (account as { enabled?: boolean }).enabled;
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: snapshot?.configured;
|
||||
const resolvedEnabled = typeof enabled === "boolean" ? enabled : true;
|
||||
const resolvedConfigured =
|
||||
typeof configured === "boolean" ? configured : true;
|
||||
const state =
|
||||
plugin.status?.resolveAccountState?.({
|
||||
account,
|
||||
cfg,
|
||||
configured: resolvedConfigured,
|
||||
enabled: resolvedEnabled,
|
||||
}) ??
|
||||
(typeof snapshot?.linked === "boolean"
|
||||
? snapshot.linked
|
||||
? "linked"
|
||||
: "not linked"
|
||||
: resolvedConfigured
|
||||
? "configured"
|
||||
: "not configured");
|
||||
const name = snapshot?.name ?? (account as { name?: string }).name;
|
||||
map.set(providerAccountKey(plugin.id, accountId), {
|
||||
provider: plugin.id,
|
||||
accountId,
|
||||
name,
|
||||
state,
|
||||
enabled,
|
||||
configured,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveDefaultAccountId(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: ChatChannelId,
|
||||
): string {
|
||||
const plugin = getChannelPlugin(provider);
|
||||
if (!plugin) return DEFAULT_ACCOUNT_ID;
|
||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
}
|
||||
|
||||
function shouldShowProviderEntry(
|
||||
entry: ProviderAccountStatus,
|
||||
cfg: ClawdbotConfig,
|
||||
): boolean {
|
||||
const plugin = getChannelPlugin(entry.provider);
|
||||
if (!plugin) return Boolean(entry.configured);
|
||||
if (plugin.meta.showConfigured === false) {
|
||||
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
|
||||
return Boolean(entry.configured) || Boolean(providerConfig);
|
||||
}
|
||||
return Boolean(entry.configured);
|
||||
}
|
||||
|
||||
function formatProviderEntry(entry: ProviderAccountStatus): string {
|
||||
const label = formatChannelAccountLabel({
|
||||
provider: entry.provider,
|
||||
accountId: entry.accountId,
|
||||
name: entry.name,
|
||||
});
|
||||
return `${label}: ${formatProviderState(entry)}`;
|
||||
}
|
||||
|
||||
export function summarizeBindings(
|
||||
cfg: ClawdbotConfig,
|
||||
bindings: AgentBinding[],
|
||||
): string[] {
|
||||
if (bindings.length === 0) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const binding of bindings) {
|
||||
const channel = normalizeChatChannelId(binding.match.channel);
|
||||
if (!channel) continue;
|
||||
const accountId =
|
||||
binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
if (!seen.has(key)) {
|
||||
const label = formatChannelAccountLabel({
|
||||
provider: channel,
|
||||
accountId,
|
||||
});
|
||||
seen.set(key, label);
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
export function listProvidersForAgent(params: {
|
||||
summaryIsDefault: boolean;
|
||||
cfg: ClawdbotConfig;
|
||||
bindings: AgentBinding[];
|
||||
providerStatus: Map<string, ProviderAccountStatus>;
|
||||
}): string[] {
|
||||
const allProviderEntries = [...params.providerStatus.values()];
|
||||
const providerLines: string[] = [];
|
||||
if (params.bindings.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const binding of params.bindings) {
|
||||
const channel = normalizeChatChannelId(binding.match.channel);
|
||||
if (!channel) continue;
|
||||
const accountId =
|
||||
binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
|
||||
const key = providerAccountKey(channel, accountId);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const status = params.providerStatus.get(key);
|
||||
if (status) {
|
||||
providerLines.push(formatProviderEntry(status));
|
||||
} else {
|
||||
providerLines.push(
|
||||
`${formatChannelAccountLabel({ provider: channel, accountId })}: unknown`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return providerLines;
|
||||
}
|
||||
|
||||
if (params.summaryIsDefault) {
|
||||
for (const entry of allProviderEntries) {
|
||||
if (shouldShowProviderEntry(entry, params.cfg)) {
|
||||
providerLines.push(formatProviderEntry(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return providerLines;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
48
src/commands/auth-choice.api-key.ts
Normal file
48
src/commands/auth-choice.api-key.ts
Normal file
@ -0,0 +1,48 @@
|
||||
const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 };
|
||||
|
||||
export function normalizeApiKeyInput(raw: string): string {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
// Handle shell-style assignments: export KEY="value" or KEY=value
|
||||
const assignmentMatch = trimmed.match(
|
||||
/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/,
|
||||
);
|
||||
const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed;
|
||||
|
||||
const unquoted =
|
||||
valuePart.length >= 2 &&
|
||||
((valuePart.startsWith('"') && valuePart.endsWith('"')) ||
|
||||
(valuePart.startsWith("'") && valuePart.endsWith("'")) ||
|
||||
(valuePart.startsWith("`") && valuePart.endsWith("`")))
|
||||
? valuePart.slice(1, -1)
|
||||
: valuePart;
|
||||
|
||||
const withoutSemicolon = unquoted.endsWith(";")
|
||||
? unquoted.slice(0, -1)
|
||||
: unquoted;
|
||||
|
||||
return withoutSemicolon.trim();
|
||||
}
|
||||
|
||||
export const validateApiKeyInput = (value: string) =>
|
||||
normalizeApiKeyInput(value).length > 0 ? undefined : "Required";
|
||||
|
||||
export function formatApiKeyPreview(
|
||||
raw: string,
|
||||
opts: { head?: number; tail?: number } = {},
|
||||
): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "…";
|
||||
const head = opts.head ?? DEFAULT_KEY_PREVIEW.head;
|
||||
const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail;
|
||||
if (trimmed.length <= head + tail) {
|
||||
const shortHead = Math.min(2, trimmed.length);
|
||||
const shortTail = Math.min(2, trimmed.length - shortHead);
|
||||
if (shortTail <= 0) {
|
||||
return `${trimmed.slice(0, shortHead)}…`;
|
||||
}
|
||||
return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`;
|
||||
}
|
||||
240
src/commands/auth-choice.apply.anthropic.ts
Normal file
240
src/commands/auth-choice.apply.anthropic.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
upsertAuthProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import {
|
||||
buildTokenProfileId,
|
||||
validateAnthropicSetupToken,
|
||||
} from "./auth-token.js";
|
||||
import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js";
|
||||
|
||||
export async function applyAuthChoiceAnthropic(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice === "claude-cli") {
|
||||
let nextConfig = params.config;
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
|
||||
if (!hasClaudeCli && process.platform === "darwin") {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"macOS will show a Keychain prompt next.",
|
||||
'Choose "Always Allow" so the launchd gateway can start without prompts.',
|
||||
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
|
||||
].join("\n"),
|
||||
"Claude CLI Keychain",
|
||||
);
|
||||
const proceed = await params.prompter.confirm({
|
||||
message: "Check Keychain for Claude CLI credentials now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!proceed) return { config: nextConfig };
|
||||
}
|
||||
|
||||
const storeWithKeychain = hasClaudeCli
|
||||
? store
|
||||
: ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
|
||||
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
if (process.stdin.isTTY) {
|
||||
const runNow = await params.prompter.confirm({
|
||||
message: "Run `claude setup-token` now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (runNow) {
|
||||
const res = await (async () => {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
|
||||
})();
|
||||
if (res.error) {
|
||||
await params.prompter.note(
|
||||
`Failed to run claude: ${String(res.error)}`,
|
||||
"Claude setup-token",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await params.prompter.note(
|
||||
"`claude setup-token` requires an interactive TTY.",
|
||||
"Claude setup-token",
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
"Claude CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
if (params.authChoice === "setup-token" || params.authChoice === "oauth") {
|
||||
let nextConfig = params.config;
|
||||
await params.prompter.note(
|
||||
[
|
||||
"This will run `claude setup-token` to create a long-lived Anthropic token.",
|
||||
"Requires an interactive TTY and a Claude Pro/Max subscription.",
|
||||
].join("\n"),
|
||||
"Anthropic setup-token",
|
||||
);
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
await params.prompter.note(
|
||||
"`claude setup-token` requires an interactive TTY.",
|
||||
"Anthropic setup-token",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
const proceed = await params.prompter.confirm({
|
||||
message: "Run `claude setup-token` now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!proceed) return { config: nextConfig };
|
||||
|
||||
const res = await (async () => {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
|
||||
})();
|
||||
if (res.error) {
|
||||
await params.prompter.note(
|
||||
`Failed to run claude: ${String(res.error)}`,
|
||||
"Anthropic setup-token",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
await params.prompter.note(
|
||||
`claude setup-token failed (exit ${res.status})`,
|
||||
"Anthropic setup-token",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
|
||||
"Anthropic setup-token",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
if (params.authChoice === "token") {
|
||||
let nextConfig = params.config;
|
||||
const provider = (await params.prompter.select({
|
||||
message: "Token provider",
|
||||
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
|
||||
})) as "anthropic";
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Run `claude setup-token` in your terminal.",
|
||||
"Then paste the generated token below.",
|
||||
].join("\n"),
|
||||
"Anthropic token",
|
||||
);
|
||||
|
||||
const tokenRaw = await params.prompter.text({
|
||||
message: "Paste Anthropic setup-token",
|
||||
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
|
||||
});
|
||||
const token = String(tokenRaw).trim();
|
||||
|
||||
const profileNameRaw = await params.prompter.text({
|
||||
message: "Token name (blank = default)",
|
||||
placeholder: "default",
|
||||
});
|
||||
const namedProfileId = buildTokenProfileId({
|
||||
provider,
|
||||
name: String(profileNameRaw ?? ""),
|
||||
});
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId: namedProfileId,
|
||||
agentDir: params.agentDir,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider,
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: namedProfileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
if (params.authChoice === "apiKey") {
|
||||
let nextConfig = params.config;
|
||||
let hasCredential = false;
|
||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setAnthropicApiKey(envKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Anthropic API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setAnthropicApiKey(
|
||||
normalizeApiKeyInput(String(key)),
|
||||
params.agentDir,
|
||||
);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
363
src/commands/auth-choice.apply.api-providers.ts
Normal file
363
src/commands/auth-choice.apply.api-providers.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
import {
|
||||
applyGoogleGeminiModelDefault,
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
} from "./google-gemini-model-default.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMoonshotConfig,
|
||||
applyMoonshotProviderConfig,
|
||||
applyOpencodeZenConfig,
|
||||
applyOpencodeZenProviderConfig,
|
||||
applyOpenrouterConfig,
|
||||
applyOpenrouterProviderConfig,
|
||||
applySyntheticConfig,
|
||||
applySyntheticProviderConfig,
|
||||
applyZaiConfig,
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
setGeminiApiKey,
|
||||
setMoonshotApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setZaiApiKey,
|
||||
ZAI_DEFAULT_MODEL_REF,
|
||||
} from "./onboard-auth.js";
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
|
||||
|
||||
export async function applyAuthChoiceApiProviders(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
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 === "openrouter-api-key") {
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: nextConfig,
|
||||
store,
|
||||
provider: "openrouter",
|
||||
});
|
||||
const existingProfileId = profileOrder.find((profileId) =>
|
||||
Boolean(store.profiles[profileId]),
|
||||
);
|
||||
const existingCred = existingProfileId
|
||||
? store.profiles[existingProfileId]
|
||||
: undefined;
|
||||
let profileId = "openrouter:default";
|
||||
let mode: "api_key" | "oauth" | "token" = "api_key";
|
||||
let hasCredential = false;
|
||||
|
||||
if (existingProfileId && existingCred?.type) {
|
||||
profileId = existingProfileId;
|
||||
mode =
|
||||
existingCred.type === "oauth"
|
||||
? "oauth"
|
||||
: existingCred.type === "token"
|
||||
? "token"
|
||||
: "api_key";
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const envKey = resolveEnvApiKey("openrouter");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setOpenrouterApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenRouter API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setOpenrouterApiKey(
|
||||
normalizeApiKeyInput(String(key)),
|
||||
params.agentDir,
|
||||
);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (hasCredential) {
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider: "openrouter",
|
||||
mode,
|
||||
});
|
||||
}
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
applyDefaultConfig: applyOpenrouterConfig,
|
||||
applyProviderConfig: applyOpenrouterProviderConfig,
|
||||
noteDefault: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "moonshot-api-key") {
|
||||
let hasCredential = false;
|
||||
const envKey = resolveEnvApiKey("moonshot");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setMoonshotApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Moonshot API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setMoonshotApiKey(
|
||||
normalizeApiKeyInput(String(key)),
|
||||
params.agentDir,
|
||||
);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "moonshot:default",
|
||||
provider: "moonshot",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: MOONSHOT_DEFAULT_MODEL_REF,
|
||||
applyDefaultConfig: applyMoonshotConfig,
|
||||
applyProviderConfig: applyMoonshotProviderConfig,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "gemini-api-key") {
|
||||
let hasCredential = false;
|
||||
const envKey = resolveEnvApiKey("google");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setGeminiApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Gemini API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setGeminiApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "google:default",
|
||||
provider: "google",
|
||||
mode: "api_key",
|
||||
});
|
||||
if (params.setDefaultModel) {
|
||||
const applied = applyGoogleGeminiModelDefault(nextConfig);
|
||||
nextConfig = applied.next;
|
||||
if (applied.changed) {
|
||||
await params.prompter.note(
|
||||
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL;
|
||||
await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL);
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "zai-api-key") {
|
||||
let hasCredential = false;
|
||||
const envKey = resolveEnvApiKey("zai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setZaiApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Z.AI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setZaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "zai:default",
|
||||
provider: "zai",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: ZAI_DEFAULT_MODEL_REF,
|
||||
applyDefaultConfig: applyZaiConfig,
|
||||
applyProviderConfig: (config) => ({
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
models: {
|
||||
...config.agents?.defaults?.models,
|
||||
[ZAI_DEFAULT_MODEL_REF]: {
|
||||
...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF],
|
||||
alias:
|
||||
config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]
|
||||
?.alias ?? "GLM",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
noteDefault: ZAI_DEFAULT_MODEL_REF,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "synthetic-api-key") {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Synthetic API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "synthetic:default",
|
||||
provider: "synthetic",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
applyDefaultConfig: applySyntheticConfig,
|
||||
applyProviderConfig: applySyntheticProviderConfig,
|
||||
noteDefault: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "opencode-zen") {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Requires an active OpenCode Zen subscription.",
|
||||
].join("\n"),
|
||||
"OpenCode Zen",
|
||||
);
|
||||
let hasCredential = false;
|
||||
const envKey = resolveEnvApiKey("opencode");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setOpencodeZenApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenCode Zen API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setOpencodeZenApiKey(
|
||||
normalizeApiKeyInput(String(key)),
|
||||
params.agentDir,
|
||||
);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "opencode:default",
|
||||
provider: "opencode",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
applyDefaultConfig: applyOpencodeZenConfig,
|
||||
applyProviderConfig: applyOpencodeZenProviderConfig,
|
||||
noteDefault: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
71
src/commands/auth-choice.apply.github-copilot.ts
Normal file
71
src/commands/auth-choice.apply.github-copilot.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import { applyAuthProfileConfig } from "./onboard-auth.js";
|
||||
|
||||
export async function applyAuthChoiceGitHubCopilot(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice !== "github-copilot") return null;
|
||||
|
||||
let nextConfig = params.config;
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"This will open a GitHub device login to authorize Copilot.",
|
||||
"Requires an active GitHub Copilot subscription.",
|
||||
].join("\n"),
|
||||
"GitHub Copilot",
|
||||
);
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
await params.prompter.note(
|
||||
"GitHub Copilot login requires an interactive TTY.",
|
||||
"GitHub Copilot",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
try {
|
||||
await githubCopilotLoginCommand({ yes: true }, params.runtime);
|
||||
} catch (err) {
|
||||
await params.prompter.note(
|
||||
`GitHub Copilot login failed: ${String(err)}`,
|
||||
"GitHub Copilot",
|
||||
);
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "github-copilot:github",
|
||||
provider: "github-copilot",
|
||||
mode: "token",
|
||||
});
|
||||
|
||||
if (params.setDefaultModel) {
|
||||
const model = "github-copilot/gpt-4o";
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
model: {
|
||||
...(typeof nextConfig.agents?.defaults?.model === "object"
|
||||
? nextConfig.agents.defaults.model
|
||||
: undefined),
|
||||
primary: model,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await params.prompter.note(
|
||||
`Default model set to ${model}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
|
||||
return { config: nextConfig };
|
||||
}
|
||||
104
src/commands/auth-choice.apply.minimax.ts
Normal file
104
src/commands/auth-choice.apply.minimax.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxProviderConfig,
|
||||
setMinimaxApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
|
||||
export async function applyAuthChoiceMiniMax(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
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 === "minimax-cloud" ||
|
||||
params.authChoice === "minimax-api" ||
|
||||
params.authChoice === "minimax-api-lightning"
|
||||
) {
|
||||
const modelId =
|
||||
params.authChoice === "minimax-api-lightning"
|
||||
? "MiniMax-M2.1-lightning"
|
||||
: "MiniMax-M2.1";
|
||||
let hasCredential = false;
|
||||
const envKey = resolveEnvApiKey("minimax");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setMinimaxApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter MiniMax API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setMinimaxApiKey(
|
||||
normalizeApiKeyInput(String(key)),
|
||||
params.agentDir,
|
||||
);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "minimax:default",
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const modelRef = `minimax/${modelId}`;
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: modelRef,
|
||||
applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId),
|
||||
applyProviderConfig: (config) =>
|
||||
applyMinimaxApiProviderConfig(config, modelId),
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "minimax") {
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: "lmstudio/minimax-m2.1-gs32",
|
||||
applyDefaultConfig: applyMinimaxConfig,
|
||||
applyProviderConfig: applyMinimaxProviderConfig,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
221
src/commands/auth-choice.apply.oauth.ts
Normal file
221
src/commands/auth-choice.apply.oauth.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import { loginChutes } from "./chutes-oauth.js";
|
||||
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import { openUrl } from "./onboard-helpers.js";
|
||||
|
||||
export async function applyAuthChoiceOAuth(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice === "chutes") {
|
||||
let nextConfig = params.config;
|
||||
const isRemote = isRemoteEnvironment();
|
||||
const redirectUri =
|
||||
process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() ||
|
||||
"http://127.0.0.1:1456/oauth-callback";
|
||||
const scopes =
|
||||
process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke";
|
||||
const clientId =
|
||||
process.env.CHUTES_CLIENT_ID?.trim() ||
|
||||
String(
|
||||
await params.prompter.text({
|
||||
message: "Enter Chutes OAuth client id",
|
||||
placeholder: "cid_xxx",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
|
||||
|
||||
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.",
|
||||
"",
|
||||
`Redirect URI: ${redirectUri}`,
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Chutes authentication.",
|
||||
"If the callback doesn't auto-complete, paste the redirect URL.",
|
||||
"",
|
||||
`Redirect URI: ${redirectUri}`,
|
||||
].join("\n"),
|
||||
"Chutes OAuth",
|
||||
);
|
||||
|
||||
const spin = params.prompter.progress("Starting OAuth flow…");
|
||||
try {
|
||||
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
|
||||
isRemote,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
spin,
|
||||
openUrl,
|
||||
localBrowserMessage: "Complete sign-in in browser…",
|
||||
});
|
||||
|
||||
const creds = await loginChutes({
|
||||
app: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
scopes: scopes.split(/\s+/).filter(Boolean),
|
||||
},
|
||||
manual: isRemote,
|
||||
onAuth,
|
||||
onPrompt,
|
||||
onProgress: (msg) => spin.update(msg),
|
||||
});
|
||||
|
||||
spin.stop("Chutes OAuth complete");
|
||||
const email = creds.email?.trim() || "default";
|
||||
const profileId = `chutes:${email}`;
|
||||
|
||||
await writeOAuthCredentials("chutes", creds, params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider: "chutes",
|
||||
mode: "oauth",
|
||||
});
|
||||
} catch (err) {
|
||||
spin.stop("Chutes OAuth failed");
|
||||
params.runtime.error(String(err));
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Trouble with OAuth?",
|
||||
"Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).",
|
||||
`Verify the OAuth app redirect URI includes: ${redirectUri}`,
|
||||
"Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview",
|
||||
].join("\n"),
|
||||
"OAuth help",
|
||||
);
|
||||
}
|
||||
return { config: nextConfig };
|
||||
}
|
||||
|
||||
if (params.authChoice === "antigravity") {
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
agents: {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
models: {
|
||||
...nextConfig.agents?.defaults?.models,
|
||||
[modelKey]:
|
||||
nextConfig.agents?.defaults?.models?.[modelKey] ?? {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (params.setDefaultModel) {
|
||||
const existingModel = nextConfig.agents?.defaults?.model;
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel 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",
|
||||
);
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
188
src/commands/auth-choice.apply.openai.ts
Normal file
188
src/commands/auth-choice.apply.openai.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { loginOpenAICodex } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||
import { isRemoteEnvironment } from "./antigravity-oauth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import type {
|
||||
ApplyAuthChoiceParams,
|
||||
ApplyAuthChoiceResult,
|
||||
} from "./auth-choice.apply.js";
|
||||
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import { openUrl } from "./onboard-helpers.js";
|
||||
import {
|
||||
applyOpenAICodexModelDefault,
|
||||
OPENAI_CODEX_DEFAULT_MODEL,
|
||||
} from "./openai-codex-model-default.js";
|
||||
|
||||
export async function applyAuthChoiceOpenAI(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult | null> {
|
||||
if (params.authChoice === "openai-api-key") {
|
||||
const envKey = resolveEnvApiKey("openai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: envKey.apiKey,
|
||||
});
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
process.env.OPENAI_API_KEY = envKey.apiKey;
|
||||
}
|
||||
await params.prompter.note(
|
||||
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
return { config: params.config };
|
||||
}
|
||||
}
|
||||
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: trimmed,
|
||||
});
|
||||
process.env.OPENAI_API_KEY = trimmed;
|
||||
await params.prompter.note(
|
||||
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
|
||||
"OpenAI API key",
|
||||
);
|
||||
return { config: params.config };
|
||||
}
|
||||
|
||||
if (params.authChoice === "openai-codex") {
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
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…");
|
||||
try {
|
||||
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
|
||||
isRemote,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
spin,
|
||||
openUrl,
|
||||
localBrowserMessage: "Complete sign-in in browser…",
|
||||
});
|
||||
|
||||
const creds = await loginOpenAICodex({
|
||||
onAuth,
|
||||
onPrompt,
|
||||
onProgress: (msg) => spin.update(msg),
|
||||
});
|
||||
spin.stop("OpenAI OAuth complete");
|
||||
if (creds) {
|
||||
await writeOAuthCredentials("openai-codex", 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",
|
||||
);
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (params.authChoice === "codex-cli") {
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
"No Codex CLI credentials found at ~/.codex/auth.json.",
|
||||
"Codex CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
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);
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
47
src/commands/auth-choice.apply.ts
Normal file
47
src/commands/auth-choice.apply.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
|
||||
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
|
||||
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
|
||||
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
|
||||
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
|
||||
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
|
||||
import type { AuthChoice } from "./onboard-types.js";
|
||||
|
||||
export type ApplyAuthChoiceParams = {
|
||||
authChoice: AuthChoice;
|
||||
config: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
agentDir?: string;
|
||||
setDefaultModel: boolean;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export type ApplyAuthChoiceResult = {
|
||||
config: ClawdbotConfig;
|
||||
agentModelOverride?: string;
|
||||
};
|
||||
|
||||
export async function applyAuthChoice(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): Promise<ApplyAuthChoiceResult> {
|
||||
const handlers: Array<
|
||||
(p: ApplyAuthChoiceParams) => Promise<ApplyAuthChoiceResult | null>
|
||||
> = [
|
||||
applyAuthChoiceAnthropic,
|
||||
applyAuthChoiceOpenAI,
|
||||
applyAuthChoiceOAuth,
|
||||
applyAuthChoiceApiProviders,
|
||||
applyAuthChoiceMiniMax,
|
||||
applyAuthChoiceGitHubCopilot,
|
||||
];
|
||||
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(params);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return { config: params.config };
|
||||
}
|
||||
28
src/commands/auth-choice.default-model.ts
Normal file
28
src/commands/auth-choice.default-model.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
|
||||
export async function applyDefaultModelChoice(params: {
|
||||
config: ClawdbotConfig;
|
||||
setDefaultModel: boolean;
|
||||
defaultModel: string;
|
||||
applyDefaultConfig: (config: ClawdbotConfig) => ClawdbotConfig;
|
||||
applyProviderConfig: (config: ClawdbotConfig) => ClawdbotConfig;
|
||||
noteDefault?: string;
|
||||
noteAgentModel: (model: string) => Promise<void>;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<{ config: ClawdbotConfig; agentModelOverride?: string }> {
|
||||
if (params.setDefaultModel) {
|
||||
const next = params.applyDefaultConfig(params.config);
|
||||
if (params.noteDefault) {
|
||||
await params.prompter.note(
|
||||
`Default model set to ${params.noteDefault}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
return { config: next };
|
||||
}
|
||||
|
||||
const next = params.applyProviderConfig(params.config);
|
||||
await params.noteAgentModel(params.defaultModel);
|
||||
return { config: next, agentModelOverride: params.defaultModel };
|
||||
}
|
||||
86
src/commands/auth-choice.model-check.ts
Normal file
86
src/commands/auth-choice.model-check.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { resolveAgentModelPrimary } 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 { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { 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
|
||||
? resolveAgentModelPrimary(config, options.agentId)
|
||||
: undefined;
|
||||
const configWithModel =
|
||||
agentModelOverride && agentModelOverride.length > 0
|
||||
? {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
model: {
|
||||
...(typeof config.agents?.defaults?.model === "object"
|
||||
? config.agents.defaults.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 agents.defaults.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 agents.defaults.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
await prompter.note(warnings.join("\n"), "Model check");
|
||||
}
|
||||
}
|
||||
31
src/commands/auth-choice.preferred-provider.ts
Normal file
31
src/commands/auth-choice.preferred-provider.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { AuthChoice } from "./onboard-types.js";
|
||||
|
||||
const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
||||
oauth: "anthropic",
|
||||
"setup-token": "anthropic",
|
||||
"claude-cli": "anthropic",
|
||||
token: "anthropic",
|
||||
apiKey: "anthropic",
|
||||
"openai-codex": "openai-codex",
|
||||
"codex-cli": "openai-codex",
|
||||
chutes: "chutes",
|
||||
"openai-api-key": "openai",
|
||||
"openrouter-api-key": "openrouter",
|
||||
"moonshot-api-key": "moonshot",
|
||||
"gemini-api-key": "google",
|
||||
"zai-api-key": "zai",
|
||||
antigravity: "google-antigravity",
|
||||
"synthetic-api-key": "synthetic",
|
||||
"github-copilot": "github-copilot",
|
||||
"minimax-cloud": "minimax",
|
||||
"minimax-api": "minimax",
|
||||
"minimax-api-lightning": "minimax",
|
||||
minimax: "lmstudio",
|
||||
"opencode-zen": "opencode",
|
||||
};
|
||||
|
||||
export function resolvePreferredProviderForAuthChoice(
|
||||
choice: AuthChoice,
|
||||
): string | undefined {
|
||||
return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
76
src/commands/configure.channels.ts
Normal file
76
src/commands/configure.channels.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { listChatChannels } from "../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { confirm, select } from "./configure.shared.js";
|
||||
import { guardCancel } from "./onboard-helpers.js";
|
||||
|
||||
export async function removeChannelConfigWizard(
|
||||
cfg: ClawdbotConfig,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<ClawdbotConfig> {
|
||||
let next = { ...cfg };
|
||||
|
||||
const listConfiguredChannels = () =>
|
||||
listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined);
|
||||
|
||||
while (true) {
|
||||
const configured = listConfiguredChannels();
|
||||
if (configured.length === 0) {
|
||||
note(
|
||||
[
|
||||
"No channel config found in clawdbot.json.",
|
||||
"Tip: `clawdbot channels status` shows what is configured and enabled.",
|
||||
].join("\n"),
|
||||
"Remove channel",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
const channel = guardCancel(
|
||||
await select({
|
||||
message: "Remove which channel config?",
|
||||
options: [
|
||||
...configured.map((meta) => ({
|
||||
value: meta.id,
|
||||
label: meta.label,
|
||||
hint: "Deletes tokens + settings from config (credentials stay on disk)",
|
||||
})),
|
||||
{ value: "done", label: "Done" },
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as string;
|
||||
|
||||
if (channel === "done") return next;
|
||||
|
||||
const label =
|
||||
listChatChannels().find((meta) => meta.id === channel)?.label ?? channel;
|
||||
const confirmed = guardCancel(
|
||||
await confirm({
|
||||
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (!confirmed) continue;
|
||||
|
||||
const nextChannels: Record<string, unknown> = { ...next.channels };
|
||||
delete nextChannels[channel];
|
||||
next = {
|
||||
...next,
|
||||
channels: Object.keys(nextChannels).length
|
||||
? (nextChannels as ClawdbotConfig["channels"])
|
||||
: undefined,
|
||||
};
|
||||
|
||||
note(
|
||||
[
|
||||
`${label} removed from config.`,
|
||||
"Note: credentials/sessions on disk are unchanged.",
|
||||
].join("\n"),
|
||||
"Channel removed",
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/commands/configure.commands.ts
Normal file
15
src/commands/configure.commands.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { WizardSection } from "./configure.shared.js";
|
||||
import { runConfigureWizard } from "./configure.wizard.js";
|
||||
|
||||
export async function configureCommand(runtime: RuntimeEnv = defaultRuntime) {
|
||||
await runConfigureWizard({ command: "configure" }, runtime);
|
||||
}
|
||||
|
||||
export async function configureCommandWithSections(
|
||||
sections: WizardSection[],
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
await runConfigureWizard({ command: "configure", sections }, runtime);
|
||||
}
|
||||
125
src/commands/configure.daemon.ts
Normal file
125
src/commands/configure.daemon.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import path from "node:path";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { confirm, select } from "./configure.shared.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import { guardCancel } from "./onboard-helpers.js";
|
||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||
|
||||
export async function maybeInstallDaemon(params: {
|
||||
runtime: RuntimeEnv;
|
||||
port: number;
|
||||
gatewayToken?: string;
|
||||
daemonRuntime?: GatewayDaemonRuntime;
|
||||
}) {
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
let shouldCheckLinger = false;
|
||||
let shouldInstall = true;
|
||||
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
if (loaded) {
|
||||
const action = guardCancel(
|
||||
await select({
|
||||
message: "Gateway service already installed",
|
||||
options: [
|
||||
{ value: "restart", label: "Restart" },
|
||||
{ value: "reinstall", label: "Reinstall" },
|
||||
{ value: "skip", label: "Skip" },
|
||||
],
|
||||
}),
|
||||
params.runtime,
|
||||
);
|
||||
if (action === "restart") {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
shouldCheckLinger = true;
|
||||
shouldInstall = false;
|
||||
}
|
||||
if (action === "skip") return;
|
||||
if (action === "reinstall") {
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInstall) {
|
||||
if (!params.daemonRuntime) {
|
||||
daemonRuntime = guardCancel(
|
||||
await select({
|
||||
message: "Gateway daemon runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
}),
|
||||
params.runtime,
|
||||
) as GatewayDaemonRuntime;
|
||||
}
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port: params.port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) note(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port: params.port,
|
||||
token: params.gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
shouldCheckLinger = true;
|
||||
}
|
||||
|
||||
if (shouldCheckLinger) {
|
||||
await ensureSystemdUserLingerInteractive({
|
||||
runtime: params.runtime,
|
||||
prompter: {
|
||||
confirm: async (p) =>
|
||||
guardCancel(await confirm(p), params.runtime) === true,
|
||||
note,
|
||||
},
|
||||
reason:
|
||||
"Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||
requireConfirm: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
73
src/commands/configure.gateway-auth.ts
Normal file
73
src/commands/configure.gateway-auth.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import type { ClawdbotConfig, GatewayAuthConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import {
|
||||
applyAuthChoice,
|
||||
resolvePreferredProviderForAuthChoice,
|
||||
} from "./auth-choice.js";
|
||||
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
|
||||
import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
|
||||
export function buildGatewayAuthConfig(params: {
|
||||
existing?: GatewayAuthConfig;
|
||||
mode: GatewayAuthChoice;
|
||||
token?: string;
|
||||
password?: string;
|
||||
}): GatewayAuthConfig | undefined {
|
||||
const allowTailscale = params.existing?.allowTailscale;
|
||||
const base: GatewayAuthConfig = {};
|
||||
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
|
||||
|
||||
if (params.mode === "off") {
|
||||
return Object.keys(base).length > 0 ? base : undefined;
|
||||
}
|
||||
if (params.mode === "token") {
|
||||
return { ...base, mode: "token", token: params.token };
|
||||
}
|
||||
return { ...base, mode: "password", password: params.password };
|
||||
}
|
||||
|
||||
export async function promptAuthConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
runtime: RuntimeEnv,
|
||||
prompter: WizardPrompter,
|
||||
): Promise<ClawdbotConfig> {
|
||||
const authChoice = await promptAuthChoiceGrouped({
|
||||
prompter,
|
||||
store: ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
}),
|
||||
includeSkip: true,
|
||||
includeClaudeCliIfMissing: true,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
if (authChoice !== "skip") {
|
||||
const applied = await applyAuthChoice({
|
||||
authChoice,
|
||||
config: next,
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: true,
|
||||
});
|
||||
next = applied.config;
|
||||
// Auth choice already set a sensible default model; skip the model picker.
|
||||
return next;
|
||||
}
|
||||
|
||||
const modelSelection = await promptDefaultModel({
|
||||
config: next,
|
||||
prompter,
|
||||
allowKeep: true,
|
||||
ignoreAllowlist: true,
|
||||
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
|
||||
});
|
||||
if (modelSelection.model) {
|
||||
next = applyPrimaryModel(next, modelSelection.model);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
233
src/commands/configure.gateway.ts
Normal file
233
src/commands/configure.gateway.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { findTailscaleBinary } from "../infra/tailscale.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
|
||||
import { confirm, select, text } from "./configure.shared.js";
|
||||
import { guardCancel, randomToken } from "./onboard-helpers.js";
|
||||
|
||||
type GatewayAuthChoice = "off" | "token" | "password";
|
||||
|
||||
export async function promptGatewayConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<{
|
||||
config: ClawdbotConfig;
|
||||
port: number;
|
||||
token?: string;
|
||||
}> {
|
||||
const portRaw = guardCancel(
|
||||
await text({
|
||||
message: "Gateway port",
|
||||
initialValue: String(resolveGatewayPort(cfg)),
|
||||
validate: (value) =>
|
||||
Number.isFinite(Number(value)) ? undefined : "Invalid port",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const port = Number.parseInt(String(portRaw), 10);
|
||||
|
||||
let bind = guardCancel(
|
||||
await select({
|
||||
message: "Gateway bind mode",
|
||||
options: [
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto (Tailnet → LAN)",
|
||||
hint: "Prefer Tailnet IP, fall back to all interfaces if unavailable",
|
||||
},
|
||||
{
|
||||
value: "lan",
|
||||
label: "LAN (All interfaces)",
|
||||
hint: "Bind to 0.0.0.0 - accessible from anywhere on your network",
|
||||
},
|
||||
{
|
||||
value: "loopback",
|
||||
label: "Loopback (Local only)",
|
||||
hint: "Bind to 127.0.0.1 - secure, local-only access",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom IP",
|
||||
hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable",
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "auto" | "lan" | "loopback" | "custom";
|
||||
|
||||
let customBindHost: string | undefined;
|
||||
if (bind === "custom") {
|
||||
const input = guardCancel(
|
||||
await text({
|
||||
message: "Custom IP address",
|
||||
placeholder: "192.168.1.100",
|
||||
validate: (value) => {
|
||||
if (!value) return "IP address is required for custom bind mode";
|
||||
const trimmed = value.trim();
|
||||
const parts = trimmed.split(".");
|
||||
if (parts.length !== 4)
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
if (
|
||||
parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return (
|
||||
!Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n)
|
||||
);
|
||||
})
|
||||
)
|
||||
return undefined;
|
||||
return "Invalid IPv4 address (each octet must be 0-255)";
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
customBindHost = typeof input === "string" ? input : undefined;
|
||||
}
|
||||
|
||||
let authMode = guardCancel(
|
||||
await select({
|
||||
message: "Gateway auth",
|
||||
options: [
|
||||
{
|
||||
value: "off",
|
||||
label: "Off (loopback only)",
|
||||
hint: "Not recommended unless you fully trust local processes",
|
||||
},
|
||||
{ value: "token", label: "Token", hint: "Recommended default" },
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
initialValue: "token",
|
||||
}),
|
||||
runtime,
|
||||
) as GatewayAuthChoice;
|
||||
|
||||
const tailscaleMode = guardCancel(
|
||||
await select({
|
||||
message: "Tailscale exposure",
|
||||
options: [
|
||||
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
|
||||
{
|
||||
value: "serve",
|
||||
label: "Serve",
|
||||
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
|
||||
},
|
||||
{
|
||||
value: "funnel",
|
||||
label: "Funnel",
|
||||
hint: "Public HTTPS via Tailscale Funnel (internet)",
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "off" | "serve" | "funnel";
|
||||
|
||||
// Detect Tailscale binary before proceeding with serve/funnel setup.
|
||||
if (tailscaleMode !== "off") {
|
||||
const tailscaleBin = await findTailscaleBinary();
|
||||
if (!tailscaleBin) {
|
||||
note(
|
||||
[
|
||||
"Tailscale binary not found in PATH or /Applications.",
|
||||
"Ensure Tailscale is installed from:",
|
||||
" https://tailscale.com/download/mac",
|
||||
"",
|
||||
"You can continue setup, but serve/funnel will fail at runtime.",
|
||||
].join("\n"),
|
||||
"Tailscale Warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tailscaleResetOnExit = false;
|
||||
if (tailscaleMode !== "off") {
|
||||
note(
|
||||
[
|
||||
"Docs:",
|
||||
"https://docs.clawd.bot/gateway/tailscale",
|
||||
"https://docs.clawd.bot/web",
|
||||
].join("\n"),
|
||||
"Tailscale",
|
||||
);
|
||||
tailscaleResetOnExit = Boolean(
|
||||
guardCancel(
|
||||
await confirm({
|
||||
message: "Reset Tailscale serve/funnel on exit?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") {
|
||||
note(
|
||||
"Tailscale requires bind=loopback. Adjusting bind to loopback.",
|
||||
"Note",
|
||||
);
|
||||
bind = "loopback";
|
||||
}
|
||||
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
|
||||
authMode = "token";
|
||||
}
|
||||
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
note("Tailscale funnel requires password auth.", "Note");
|
||||
authMode = "password";
|
||||
}
|
||||
|
||||
let gatewayToken: string | undefined;
|
||||
let gatewayPassword: string | undefined;
|
||||
let next = cfg;
|
||||
|
||||
if (authMode === "token") {
|
||||
const tokenInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway token (blank to generate)",
|
||||
initialValue: randomToken(),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
gatewayToken = String(tokenInput).trim() || randomToken();
|
||||
}
|
||||
|
||||
if (authMode === "password") {
|
||||
const password = guardCancel(
|
||||
await text({
|
||||
message: "Gateway password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
gatewayPassword = String(password).trim();
|
||||
}
|
||||
|
||||
const authConfig = buildGatewayAuthConfig({
|
||||
existing: next.gateway?.auth,
|
||||
mode: authMode,
|
||||
token: gatewayToken,
|
||||
password: gatewayPassword,
|
||||
});
|
||||
|
||||
next = {
|
||||
...next,
|
||||
gateway: {
|
||||
...next.gateway,
|
||||
mode: "local",
|
||||
port,
|
||||
bind,
|
||||
auth: authConfig,
|
||||
...(customBindHost && { customBindHost }),
|
||||
tailscale: {
|
||||
...next.gateway?.tailscale,
|
||||
mode: tailscaleMode,
|
||||
resetOnExit: tailscaleResetOnExit,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { config: next, port, token: gatewayToken };
|
||||
}
|
||||
83
src/commands/configure.shared.ts
Normal file
83
src/commands/configure.shared.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {
|
||||
confirm as clackConfirm,
|
||||
intro as clackIntro,
|
||||
outro as clackOutro,
|
||||
select as clackSelect,
|
||||
text as clackText,
|
||||
} from "@clack/prompts";
|
||||
|
||||
import {
|
||||
stylePromptHint,
|
||||
stylePromptMessage,
|
||||
stylePromptTitle,
|
||||
} from "../terminal/prompt-style.js";
|
||||
|
||||
export const CONFIGURE_WIZARD_SECTIONS = [
|
||||
"workspace",
|
||||
"model",
|
||||
"gateway",
|
||||
"daemon",
|
||||
"channels",
|
||||
"skills",
|
||||
"health",
|
||||
] as const;
|
||||
|
||||
export type WizardSection = (typeof CONFIGURE_WIZARD_SECTIONS)[number];
|
||||
|
||||
export type ChannelsWizardMode = "configure" | "remove";
|
||||
|
||||
export type ConfigureWizardParams = {
|
||||
command: "configure" | "update";
|
||||
sections?: WizardSection[];
|
||||
};
|
||||
|
||||
export const CONFIGURE_SECTION_OPTIONS: Array<{
|
||||
value: WizardSection;
|
||||
label: string;
|
||||
hint: string;
|
||||
}> = [
|
||||
{ value: "workspace", label: "Workspace", hint: "Set workspace + sessions" },
|
||||
{ value: "model", label: "Model", hint: "Pick provider + credentials" },
|
||||
{ value: "gateway", label: "Gateway", hint: "Port, bind, auth, tailscale" },
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
hint: "Install/manage the background service",
|
||||
},
|
||||
{
|
||||
value: "channels",
|
||||
label: "Channels",
|
||||
hint: "Link WhatsApp/Telegram/etc and defaults",
|
||||
},
|
||||
{ value: "skills", label: "Skills", hint: "Install/enable workspace skills" },
|
||||
{
|
||||
value: "health",
|
||||
label: "Health check",
|
||||
hint: "Run gateway + channel checks",
|
||||
},
|
||||
];
|
||||
|
||||
export const intro = (message: string) =>
|
||||
clackIntro(stylePromptTitle(message) ?? message);
|
||||
export const outro = (message: string) =>
|
||||
clackOutro(stylePromptTitle(message) ?? message);
|
||||
export const text = (params: Parameters<typeof clackText>[0]) =>
|
||||
clackText({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
});
|
||||
export const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
|
||||
clackConfirm({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
});
|
||||
export const select = <T>(params: Parameters<typeof clackSelect<T>>[0]) =>
|
||||
clackSelect({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) =>
|
||||
opt.hint === undefined
|
||||
? opt
|
||||
: { ...opt, hint: stylePromptHint(opt.hint) },
|
||||
),
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
470
src/commands/configure.wizard.ts
Normal file
470
src/commands/configure.wizard.ts
Normal file
@ -0,0 +1,470 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import { removeChannelConfigWizard } from "./configure.channels.js";
|
||||
import { maybeInstallDaemon } from "./configure.daemon.js";
|
||||
import { promptGatewayConfig } from "./configure.gateway.js";
|
||||
import { promptAuthConfig } from "./configure.gateway-auth.js";
|
||||
import type {
|
||||
ChannelsWizardMode,
|
||||
ConfigureWizardParams,
|
||||
WizardSection,
|
||||
} from "./configure.shared.js";
|
||||
import {
|
||||
CONFIGURE_SECTION_OPTIONS,
|
||||
intro,
|
||||
outro,
|
||||
select,
|
||||
text,
|
||||
} from "./configure.shared.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensureWorkspaceAndSessions,
|
||||
guardCancel,
|
||||
printWizardHeader,
|
||||
probeGatewayReachable,
|
||||
resolveControlUiLinks,
|
||||
summarizeExistingConfig,
|
||||
} from "./onboard-helpers.js";
|
||||
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
|
||||
import { setupSkills } from "./onboard-skills.js";
|
||||
|
||||
type ConfigureSectionChoice = WizardSection | "__continue";
|
||||
|
||||
async function promptConfigureSection(
|
||||
runtime: RuntimeEnv,
|
||||
hasSelection: boolean,
|
||||
): Promise<ConfigureSectionChoice> {
|
||||
return guardCancel(
|
||||
await select<ConfigureSectionChoice>({
|
||||
message: "Select sections to configure",
|
||||
options: [
|
||||
...CONFIGURE_SECTION_OPTIONS,
|
||||
{
|
||||
value: "__continue",
|
||||
label: "Continue",
|
||||
hint: hasSelection ? "Done" : "Skip for now",
|
||||
},
|
||||
],
|
||||
initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
async function promptChannelMode(
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<ChannelsWizardMode> {
|
||||
return guardCancel(
|
||||
await select({
|
||||
message: "Channels",
|
||||
options: [
|
||||
{
|
||||
value: "configure",
|
||||
label: "Configure/link",
|
||||
hint: "Add/update channels; disable unselected accounts",
|
||||
},
|
||||
{
|
||||
value: "remove",
|
||||
label: "Remove channel config",
|
||||
hint: "Delete channel tokens/settings from clawdbot.json",
|
||||
},
|
||||
],
|
||||
initialValue: "configure",
|
||||
}),
|
||||
runtime,
|
||||
) as ChannelsWizardMode;
|
||||
}
|
||||
|
||||
export async function runConfigureWizard(
|
||||
opts: ConfigureWizardParams,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
try {
|
||||
printWizardHeader(runtime);
|
||||
intro(
|
||||
opts.command === "update"
|
||||
? "Clawdbot update wizard"
|
||||
: "Clawdbot configure",
|
||||
);
|
||||
const prompter = createClackPrompter();
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||
|
||||
if (snapshot.exists) {
|
||||
const title = snapshot.valid
|
||||
? "Existing config detected"
|
||||
: "Invalid config";
|
||||
note(summarizeExistingConfig(baseConfig), title);
|
||||
if (!snapshot.valid && snapshot.issues.length > 0) {
|
||||
note(
|
||||
[
|
||||
...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`),
|
||||
"",
|
||||
"Docs: https://docs.clawd.bot/gateway/configuration",
|
||||
].join("\n"),
|
||||
"Config issues",
|
||||
);
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
outro(
|
||||
"Config invalid. Run `clawdbot doctor` to repair it, then re-run configure.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const localUrl = "ws://127.0.0.1:18789";
|
||||
const localProbe = await probeGatewayReachable({
|
||||
url: localUrl,
|
||||
token:
|
||||
baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
password:
|
||||
baseConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||
});
|
||||
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
|
||||
const remoteProbe = remoteUrl
|
||||
? await probeGatewayReachable({
|
||||
url: remoteUrl,
|
||||
token: baseConfig.gateway?.remote?.token,
|
||||
})
|
||||
: null;
|
||||
|
||||
const mode = guardCancel(
|
||||
await select({
|
||||
message: "Where will the Gateway run?",
|
||||
options: [
|
||||
{
|
||||
value: "local",
|
||||
label: "Local (this machine)",
|
||||
hint: localProbe.ok
|
||||
? `Gateway reachable (${localUrl})`
|
||||
: `No gateway detected (${localUrl})`,
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: "Remote (info-only)",
|
||||
hint: !remoteUrl
|
||||
? "No remote URL configured yet"
|
||||
: remoteProbe?.ok
|
||||
? `Gateway reachable (${remoteUrl})`
|
||||
: `Configured but unreachable (${remoteUrl})`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "local" | "remote";
|
||||
|
||||
if (mode === "remote") {
|
||||
let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
|
||||
remoteConfig = applyWizardMetadata(remoteConfig, {
|
||||
command: opts.command,
|
||||
mode,
|
||||
});
|
||||
await writeConfigFile(remoteConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
outro("Remote gateway configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
let nextConfig = { ...baseConfig };
|
||||
let workspaceDir =
|
||||
nextConfig.agents?.defaults?.workspace ??
|
||||
baseConfig.agents?.defaults?.workspace ??
|
||||
DEFAULT_WORKSPACE;
|
||||
let gatewayPort = resolveGatewayPort(baseConfig);
|
||||
let gatewayToken: string | undefined =
|
||||
nextConfig.gateway?.auth?.token ??
|
||||
baseConfig.gateway?.auth?.token ??
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
const persistConfig = async () => {
|
||||
nextConfig = applyWizardMetadata(nextConfig, {
|
||||
command: opts.command,
|
||||
mode,
|
||||
});
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
};
|
||||
|
||||
if (opts.sections) {
|
||||
const selected = opts.sections;
|
||||
if (!selected || selected.length === 0) {
|
||||
outro("No changes selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.includes("workspace")) {
|
||||
const workspaceInput = guardCancel(
|
||||
await text({
|
||||
message: "Workspace directory",
|
||||
initialValue: workspaceDir,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
workspaceDir = resolveUserPath(
|
||||
String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE,
|
||||
);
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime);
|
||||
}
|
||||
|
||||
if (selected.includes("model")) {
|
||||
nextConfig = await promptAuthConfig(nextConfig, runtime, prompter);
|
||||
}
|
||||
|
||||
if (selected.includes("gateway")) {
|
||||
const gateway = await promptGatewayConfig(nextConfig, runtime);
|
||||
nextConfig = gateway.config;
|
||||
gatewayPort = gateway.port;
|
||||
gatewayToken = gateway.token;
|
||||
}
|
||||
|
||||
if (selected.includes("channels")) {
|
||||
const channelMode = await promptChannelMode(runtime);
|
||||
if (channelMode === "configure") {
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
});
|
||||
} else {
|
||||
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.includes("skills")) {
|
||||
const wsDir = resolveUserPath(workspaceDir);
|
||||
nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter);
|
||||
}
|
||||
|
||||
await persistConfig();
|
||||
|
||||
if (selected.includes("daemon")) {
|
||||
if (!selected.includes("gateway")) {
|
||||
const portInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway port for daemon install",
|
||||
initialValue: String(gatewayPort),
|
||||
validate: (value) =>
|
||||
Number.isFinite(Number(value)) ? undefined : "Invalid port",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
gatewayPort = Number.parseInt(String(portInput), 10);
|
||||
}
|
||||
|
||||
await maybeInstallDaemon({ runtime, port: gatewayPort, gatewayToken });
|
||||
}
|
||||
|
||||
if (selected.includes("health")) {
|
||||
await sleep(1000);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
note(
|
||||
[
|
||||
"Docs:",
|
||||
"https://docs.clawd.bot/gateway/health",
|
||||
"https://docs.clawd.bot/gateway/troubleshooting",
|
||||
].join("\n"),
|
||||
"Health check help",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ranSection = false;
|
||||
let didConfigureGateway = false;
|
||||
|
||||
while (true) {
|
||||
const choice = await promptConfigureSection(runtime, ranSection);
|
||||
if (choice === "__continue") break;
|
||||
ranSection = true;
|
||||
|
||||
if (choice === "workspace") {
|
||||
const workspaceInput = guardCancel(
|
||||
await text({
|
||||
message: "Workspace directory",
|
||||
initialValue: workspaceDir,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
workspaceDir = resolveUserPath(
|
||||
String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE,
|
||||
);
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime);
|
||||
await persistConfig();
|
||||
}
|
||||
|
||||
if (choice === "model") {
|
||||
nextConfig = await promptAuthConfig(nextConfig, runtime, prompter);
|
||||
await persistConfig();
|
||||
}
|
||||
|
||||
if (choice === "gateway") {
|
||||
const gateway = await promptGatewayConfig(nextConfig, runtime);
|
||||
nextConfig = gateway.config;
|
||||
gatewayPort = gateway.port;
|
||||
gatewayToken = gateway.token;
|
||||
didConfigureGateway = true;
|
||||
await persistConfig();
|
||||
}
|
||||
|
||||
if (choice === "channels") {
|
||||
const channelMode = await promptChannelMode(runtime);
|
||||
if (channelMode === "configure") {
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
});
|
||||
} else {
|
||||
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
|
||||
}
|
||||
await persistConfig();
|
||||
}
|
||||
|
||||
if (choice === "skills") {
|
||||
const wsDir = resolveUserPath(workspaceDir);
|
||||
nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter);
|
||||
await persistConfig();
|
||||
}
|
||||
|
||||
if (choice === "daemon") {
|
||||
if (!didConfigureGateway) {
|
||||
const portInput = guardCancel(
|
||||
await text({
|
||||
message: "Gateway port for daemon install",
|
||||
initialValue: String(gatewayPort),
|
||||
validate: (value) =>
|
||||
Number.isFinite(Number(value)) ? undefined : "Invalid port",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
gatewayPort = Number.parseInt(String(portInput), 10);
|
||||
}
|
||||
await maybeInstallDaemon({
|
||||
runtime,
|
||||
port: gatewayPort,
|
||||
gatewayToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (choice === "health") {
|
||||
await sleep(1000);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
note(
|
||||
[
|
||||
"Docs:",
|
||||
"https://docs.clawd.bot/gateway/health",
|
||||
"https://docs.clawd.bot/gateway/troubleshooting",
|
||||
].join("\n"),
|
||||
"Health check help",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ranSection) {
|
||||
outro("No changes selected.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
}
|
||||
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
customBindHost: nextConfig.gateway?.customBindHost,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
// Try both new and old passwords since gateway may still have old config.
|
||||
const newPassword =
|
||||
nextConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
const oldPassword =
|
||||
baseConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
const token =
|
||||
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
let gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token,
|
||||
password: newPassword,
|
||||
});
|
||||
// If new password failed and it's different from old password, try old too.
|
||||
if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) {
|
||||
gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token,
|
||||
password: oldPassword,
|
||||
});
|
||||
}
|
||||
const gatewayStatusLine = gatewayProbe.ok
|
||||
? "Gateway: reachable"
|
||||
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;
|
||||
|
||||
note(
|
||||
[
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
"Docs: https://docs.clawd.bot/web/control-ui",
|
||||
].join("\n"),
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
outro("Configure complete.");
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
91
src/commands/doctor-config-flow.ts
Normal file
91
src/commands/doctor-config-flow.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
} from "../config/config.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) {
|
||||
const providers = cfg.models?.providers;
|
||||
if (!providers) return;
|
||||
|
||||
// 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae).
|
||||
const overrides: string[] = [];
|
||||
if (providers.opencode) overrides.push("opencode");
|
||||
if (providers["opencode-zen"]) overrides.push("opencode-zen");
|
||||
if (overrides.length === 0) return;
|
||||
|
||||
const lines = overrides.flatMap((id) => {
|
||||
const providerEntry = providers[id];
|
||||
const api =
|
||||
isRecord(providerEntry) && typeof providerEntry.api === "string"
|
||||
? providerEntry.api
|
||||
: undefined;
|
||||
return [
|
||||
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
|
||||
api ? `- models.providers.${id}.api=${api}` : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
});
|
||||
|
||||
lines.push(
|
||||
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
|
||||
);
|
||||
|
||||
note(lines.join("\n"), "OpenCode Zen");
|
||||
}
|
||||
|
||||
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
options: DoctorOptions;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
}) {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||
if (
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
snapshot.legacyIssues.length === 0
|
||||
) {
|
||||
note("Config invalid; doctor will run with defaults.", "Config");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
const migrate =
|
||||
params.options.nonInteractive === true
|
||||
? true
|
||||
: await params.confirm({
|
||||
message: "Migrate legacy config entries now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (migrate) {
|
||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
snapshot.parsed,
|
||||
);
|
||||
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
|
||||
if (migrated) cfg = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeLegacyConfigValues(cfg);
|
||||
if (normalized.changes.length > 0) {
|
||||
note(normalized.changes.join("\n"), "Doctor changes");
|
||||
cfg = normalized.config;
|
||||
}
|
||||
|
||||
noteOpencodeProviderOverrides(cfg);
|
||||
|
||||
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
|
||||
}
|
||||
190
src/commands/doctor-gateway-daemon-flow.ts
Normal file
190
src/commands/doctor-gateway-daemon-flow.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { sleep } from "../utils.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import {
|
||||
buildGatewayRuntimeHints,
|
||||
formatGatewayRuntimeSummary,
|
||||
} from "./doctor-format.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
|
||||
export async function maybeRepairGatewayDaemon(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: DoctorPrompter;
|
||||
options: DoctorOptions;
|
||||
gatewayDetailsMessage: string;
|
||||
healthOk: boolean;
|
||||
}) {
|
||||
if (params.healthOk) return;
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
let serviceRuntime:
|
||||
| Awaited<ReturnType<typeof service.readRuntime>>
|
||||
| undefined;
|
||||
if (loaded) {
|
||||
serviceRuntime = await service
|
||||
.readRuntime(process.env)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
const port = resolveGatewayPort(params.cfg, process.env);
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
if (diagnostics.status === "busy") {
|
||||
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
|
||||
} else if (loaded && serviceRuntime?.status === "running") {
|
||||
const lastError = await readLastGatewayErrorLine(process.env);
|
||||
if (lastError) note(`Last gateway error: ${lastError}`, "Gateway");
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
note("Gateway daemon not installed.", "Gateway");
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
const install = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Install gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (install) {
|
||||
const daemonRuntime =
|
||||
await params.prompter.select<GatewayDaemonRuntime>(
|
||||
{
|
||||
message: "Gateway daemon runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
},
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
);
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(params.cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(
|
||||
systemNode,
|
||||
programArguments[0],
|
||||
);
|
||||
if (warning) note(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token:
|
||||
params.cfg.gateway?.auth?.token ??
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
});
|
||||
if (summary || hints.length > 0) {
|
||||
const lines: string[] = [];
|
||||
if (summary) lines.push(`Runtime: ${summary}`);
|
||||
lines.push(...hints);
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
|
||||
if (serviceRuntime?.status !== "running") {
|
||||
const start = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Start gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (start) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const label = resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
|
||||
note(
|
||||
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${label}.`,
|
||||
"Gateway",
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceRuntime?.status === "running") {
|
||||
const restart = await params.prompter.confirmSkipInNonInteractive({
|
||||
message: "Restart gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (restart) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await sleep(1500);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(params.gatewayDetailsMessage, "Gateway connection");
|
||||
} else {
|
||||
params.runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/commands/doctor-gateway-health.ts
Normal file
55
src/commands/doctor-gateway-health.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
|
||||
export async function checkGatewayHealth(params: {
|
||||
runtime: RuntimeEnv;
|
||||
cfg: ClawdbotConfig;
|
||||
}) {
|
||||
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
|
||||
let healthOk = false;
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime);
|
||||
healthOk = true;
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
params.runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (healthOk) {
|
||||
try {
|
||||
const status = await callGateway<Record<string, unknown>>({
|
||||
method: "channels.status",
|
||||
params: { probe: true, timeoutMs: 5000 },
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
const issues = collectChannelStatusIssues(status);
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
issues
|
||||
.map(
|
||||
(issue) =>
|
||||
`- ${issue.channel} ${issue.accountId}: ${issue.message}${
|
||||
issue.fix ? ` (${issue.fix})` : ""
|
||||
}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Channel warnings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore: doctor already reported gateway health
|
||||
}
|
||||
}
|
||||
|
||||
return { healthOk };
|
||||
}
|
||||
27
src/commands/doctor-platform-notes.ts
Normal file
27
src/commands/doctor-platform-notes.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return process.env.HOME ?? os.homedir();
|
||||
}
|
||||
|
||||
export async function noteMacLaunchAgentOverrides() {
|
||||
if (process.platform !== "darwin") return;
|
||||
const markerPath = path.join(
|
||||
resolveHomeDir(),
|
||||
".clawdbot",
|
||||
"disable-launchagent",
|
||||
);
|
||||
const hasMarker = fs.existsSync(markerPath);
|
||||
if (!hasMarker) return;
|
||||
|
||||
const lines = [
|
||||
`- LaunchAgent writes are disabled via ${markerPath}.`,
|
||||
"- To restore default behavior:",
|
||||
` rm ${markerPath}`,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
note(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
88
src/commands/doctor-update.ts
Normal file
88
src/commands/doctor-update.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { runGatewayUpdate } from "../infra/update-runner.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
|
||||
async function detectClawdbotGitCheckout(
|
||||
root: string,
|
||||
): Promise<"git" | "not-git" | "unknown"> {
|
||||
const res = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||
{ timeoutMs: 5000 },
|
||||
).catch(() => null);
|
||||
if (!res) return "unknown";
|
||||
if (res.code !== 0) {
|
||||
// Avoid noisy "Update via package manager" notes when git is missing/broken,
|
||||
// but do show it when this is clearly not a git checkout.
|
||||
if (res.stderr.toLowerCase().includes("not a git repository")) {
|
||||
return "not-git";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
return res.stdout.trim() === root ? "git" : "not-git";
|
||||
}
|
||||
|
||||
export async function maybeOfferUpdateBeforeDoctor(params: {
|
||||
runtime: RuntimeEnv;
|
||||
options: DoctorOptions;
|
||||
root: string | null;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
outro: (message: string) => void;
|
||||
}) {
|
||||
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
|
||||
const canOfferUpdate =
|
||||
!updateInProgress &&
|
||||
params.options.nonInteractive !== true &&
|
||||
params.options.yes !== true &&
|
||||
params.options.repair !== true &&
|
||||
Boolean(process.stdin.isTTY);
|
||||
if (!canOfferUpdate || !params.root) return { updated: false };
|
||||
|
||||
const git = await detectClawdbotGitCheckout(params.root);
|
||||
if (git === "git") {
|
||||
const shouldUpdate = await params.confirm({
|
||||
message: "Update Clawdbot from git before running doctor?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldUpdate) return { updated: false };
|
||||
note("Running update (fetch/rebase/build/ui:build/doctor)…", "Update");
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: params.root,
|
||||
argv1: process.argv[1],
|
||||
});
|
||||
note(
|
||||
[
|
||||
`Status: ${result.status}`,
|
||||
`Mode: ${result.mode}`,
|
||||
result.root ? `Root: ${result.root}` : null,
|
||||
result.reason ? `Reason: ${result.reason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Update result",
|
||||
);
|
||||
if (result.status === "ok") {
|
||||
params.outro(
|
||||
"Update completed (doctor already ran as part of the update).",
|
||||
);
|
||||
return { updated: true, handled: true };
|
||||
}
|
||||
return { updated: true, handled: false };
|
||||
}
|
||||
|
||||
if (git === "not-git") {
|
||||
note(
|
||||
[
|
||||
"This install is not a git checkout.",
|
||||
"Update via your package manager, then rerun doctor:",
|
||||
"- npm i -g clawdbot@latest",
|
||||
"- pnpm add -g clawdbot@latest",
|
||||
"- bun add -g clawdbot@latest",
|
||||
].join("\n"),
|
||||
"Update",
|
||||
);
|
||||
}
|
||||
|
||||
return { updated: false };
|
||||
}
|
||||
82
src/commands/doctor-workspace-status.ts
Normal file
82
src/commands/doctor-workspace-status.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import {
|
||||
detectLegacyWorkspaceDirs,
|
||||
formatLegacyWorkspaceWarning,
|
||||
} from "./doctor-workspace.js";
|
||||
|
||||
export function noteWorkspaceStatus(cfg: ClawdbotConfig) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
cfg,
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
|
||||
if (legacyWorkspace.legacyDirs.length > 0) {
|
||||
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
|
||||
}
|
||||
|
||||
const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
|
||||
note(
|
||||
[
|
||||
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
|
||||
`Missing requirements: ${
|
||||
skillsReport.skills.filter(
|
||||
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist,
|
||||
).length
|
||||
}`,
|
||||
`Blocked by allowlist: ${
|
||||
skillsReport.skills.filter((s) => s.blockedByAllowlist).length
|
||||
}`,
|
||||
].join("\n"),
|
||||
"Skills status",
|
||||
);
|
||||
|
||||
const pluginRegistry = loadClawdbotPlugins({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
});
|
||||
if (pluginRegistry.plugins.length > 0) {
|
||||
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
|
||||
const disabled = pluginRegistry.plugins.filter(
|
||||
(p) => p.status === "disabled",
|
||||
);
|
||||
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
|
||||
|
||||
const lines = [
|
||||
`Loaded: ${loaded.length}`,
|
||||
`Disabled: ${disabled.length}`,
|
||||
`Errors: ${errored.length}`,
|
||||
errored.length > 0
|
||||
? `- ${errored
|
||||
.slice(0, 10)
|
||||
.map((p) => p.id)
|
||||
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
|
||||
: null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
|
||||
note(lines.join("\n"), "Plugins");
|
||||
}
|
||||
if (pluginRegistry.diagnostics.length > 0) {
|
||||
const lines = pluginRegistry.diagnostics.map((diag) => {
|
||||
const prefix = diag.level.toUpperCase();
|
||||
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
|
||||
const source = diag.source ? ` (${diag.source})` : "";
|
||||
return `- ${prefix}${plugin}: ${diag.message}${source}`;
|
||||
});
|
||||
note(lines.join("\n"), "Plugin diagnostics");
|
||||
}
|
||||
|
||||
return { workspaceDir };
|
||||
}
|
||||
@ -1,6 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
||||
import {
|
||||
resolveAgentWorkspaceDir,
|
||||
@ -13,60 +10,30 @@ import {
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
} from "../agents/model-selection.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { runGatewayUpdate } from "../infra/update-runner.js";
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { sleep } from "../utils.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import {
|
||||
maybeRepairAnthropicOAuthProfileId,
|
||||
noteAuthProfileHealth,
|
||||
} from "./doctor-auth.js";
|
||||
import {
|
||||
buildGatewayRuntimeHints,
|
||||
formatGatewayRuntimeSummary,
|
||||
} from "./doctor-format.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
|
||||
import { checkGatewayHealth } from "./doctor-gateway-health.js";
|
||||
import {
|
||||
maybeMigrateLegacyGatewayService,
|
||||
maybeRepairGatewayServiceConfig,
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "./doctor-gateway-services.js";
|
||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||
import {
|
||||
maybeMigrateLegacyConfigFile,
|
||||
normalizeLegacyConfigValues,
|
||||
} from "./doctor-legacy-config.js";
|
||||
import { maybeMigrateLegacyConfigFile } from "./doctor-legacy-config.js";
|
||||
import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||
import {
|
||||
maybeRepairSandboxImages,
|
||||
@ -82,14 +49,12 @@ import {
|
||||
runLegacyStateMigrations,
|
||||
} from "./doctor-state-migrations.js";
|
||||
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
|
||||
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
|
||||
import {
|
||||
detectLegacyWorkspaceDirs,
|
||||
formatLegacyWorkspaceWarning,
|
||||
MEMORY_SYSTEM_PROMPT,
|
||||
shouldSuggestMemorySystem,
|
||||
} from "./doctor-workspace.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
printWizardHeader,
|
||||
@ -106,80 +71,6 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
|
||||
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) {
|
||||
const providers = cfg.models?.providers;
|
||||
if (!providers) return;
|
||||
|
||||
// 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae).
|
||||
const overrides: string[] = [];
|
||||
if (providers.opencode) overrides.push("opencode");
|
||||
if (providers["opencode-zen"]) overrides.push("opencode-zen");
|
||||
if (overrides.length === 0) return;
|
||||
|
||||
const lines = overrides.flatMap((id) => {
|
||||
const providerEntry = providers[id];
|
||||
const api =
|
||||
isRecord(providerEntry) && typeof providerEntry.api === "string"
|
||||
? providerEntry.api
|
||||
: undefined;
|
||||
return [
|
||||
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
|
||||
api ? `- models.providers.${id}.api=${api}` : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
});
|
||||
|
||||
lines.push(
|
||||
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
|
||||
);
|
||||
|
||||
note(lines.join("\n"), "OpenCode Zen");
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return process.env.HOME ?? os.homedir();
|
||||
}
|
||||
|
||||
async function noteMacLaunchAgentOverrides() {
|
||||
if (process.platform !== "darwin") return;
|
||||
const markerPath = path.join(
|
||||
resolveHomeDir(),
|
||||
".clawdbot",
|
||||
"disable-launchagent",
|
||||
);
|
||||
const hasMarker = fs.existsSync(markerPath);
|
||||
if (!hasMarker) return;
|
||||
|
||||
const lines = [
|
||||
`- LaunchAgent writes are disabled via ${markerPath}.`,
|
||||
"- To restore default behavior:",
|
||||
` rm ${markerPath}`,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
note(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
|
||||
async function detectClawdbotGitCheckout(
|
||||
root: string,
|
||||
): Promise<"git" | "not-git" | "unknown"> {
|
||||
const res = await runCommandWithTimeout(
|
||||
["git", "-C", root, "rev-parse", "--show-toplevel"],
|
||||
{ timeoutMs: 5000 },
|
||||
).catch(() => null);
|
||||
if (!res) return "unknown";
|
||||
if (res.code !== 0) {
|
||||
// Avoid noisy "Update via package manager" notes when git is missing/broken,
|
||||
// but do show it when this is clearly not a git checkout.
|
||||
if (res.stderr.toLowerCase().includes("not a git repository")) {
|
||||
return "not-git";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
return res.stdout.trim() === root ? "git" : "not-git";
|
||||
}
|
||||
|
||||
export async function doctorCommand(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
options: DoctorOptions = {},
|
||||
@ -194,113 +85,25 @@ export async function doctorCommand(
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
|
||||
const canOfferUpdate =
|
||||
!updateInProgress &&
|
||||
options.nonInteractive !== true &&
|
||||
options.yes !== true &&
|
||||
options.repair !== true &&
|
||||
Boolean(process.stdin.isTTY);
|
||||
if (canOfferUpdate) {
|
||||
if (root) {
|
||||
const git = await detectClawdbotGitCheckout(root);
|
||||
if (git === "git") {
|
||||
const shouldUpdate = await prompter.confirm({
|
||||
message: "Update Clawdbot from git before running doctor?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (shouldUpdate) {
|
||||
note(
|
||||
"Running update (fetch/rebase/build/ui:build/doctor)…",
|
||||
"Update",
|
||||
);
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
});
|
||||
note(
|
||||
[
|
||||
`Status: ${result.status}`,
|
||||
`Mode: ${result.mode}`,
|
||||
result.root ? `Root: ${result.root}` : null,
|
||||
result.reason ? `Reason: ${result.reason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Update result",
|
||||
);
|
||||
if (result.status === "ok") {
|
||||
outro(
|
||||
"Update completed (doctor already ran as part of the update).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (git === "not-git") {
|
||||
note(
|
||||
[
|
||||
"This install is not a git checkout.",
|
||||
"Update via your package manager, then rerun doctor:",
|
||||
"- npm i -g clawdbot@latest",
|
||||
"- pnpm add -g clawdbot@latest",
|
||||
"- bun add -g clawdbot@latest",
|
||||
].join("\n"),
|
||||
"Update",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const updateResult = await maybeOfferUpdateBeforeDoctor({
|
||||
runtime,
|
||||
options,
|
||||
root,
|
||||
confirm: (p) => prompter.confirm(p),
|
||||
outro,
|
||||
});
|
||||
if (updateResult.handled) return;
|
||||
|
||||
await maybeRepairUiProtocolFreshness(runtime, prompter);
|
||||
noteSourceInstallIssues(root);
|
||||
|
||||
await maybeMigrateLegacyConfigFile(runtime);
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||
if (
|
||||
snapshot.exists &&
|
||||
!snapshot.valid &&
|
||||
snapshot.legacyIssues.length === 0
|
||||
) {
|
||||
note("Config invalid; doctor will run with defaults.", "Config");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
const migrate =
|
||||
options.nonInteractive === true
|
||||
? true
|
||||
: await prompter.confirm({
|
||||
message: "Migrate legacy config entries now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (migrate) {
|
||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
|
||||
const { config: migrated, changes } = migrateLegacyConfig(
|
||||
snapshot.parsed,
|
||||
);
|
||||
if (changes.length > 0) {
|
||||
note(changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (migrated) {
|
||||
cfg = migrated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeLegacyConfigValues(cfg);
|
||||
if (normalized.changes.length > 0) {
|
||||
note(normalized.changes.join("\n"), "Doctor changes");
|
||||
cfg = normalized.config;
|
||||
}
|
||||
|
||||
noteOpencodeProviderOverrides(cfg);
|
||||
const configResult = await loadAndMaybeMigrateDoctorConfig({
|
||||
options,
|
||||
confirm: (p) => prompter.confirm(p),
|
||||
});
|
||||
let cfg: ClawdbotConfig = configResult.cfg;
|
||||
|
||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||
await noteAuthProfileHealth({
|
||||
@ -379,7 +182,7 @@ export async function doctorCommand(
|
||||
await noteStateIntegrity(
|
||||
cfg,
|
||||
prompter,
|
||||
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
|
||||
configResult.path ?? CONFIG_PATH_CLAWDBOT,
|
||||
);
|
||||
|
||||
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
|
||||
@ -473,257 +276,17 @@ export async function doctorCommand(
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
noteWorkspaceStatus(cfg);
|
||||
|
||||
const { healthOk } = await checkGatewayHealth({ runtime, cfg });
|
||||
await maybeRepairGatewayDaemon({
|
||||
cfg,
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
|
||||
if (legacyWorkspace.legacyDirs.length > 0) {
|
||||
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
|
||||
}
|
||||
const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
|
||||
note(
|
||||
[
|
||||
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
|
||||
`Missing requirements: ${
|
||||
skillsReport.skills.filter(
|
||||
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist,
|
||||
).length
|
||||
}`,
|
||||
`Blocked by allowlist: ${
|
||||
skillsReport.skills.filter((s) => s.blockedByAllowlist).length
|
||||
}`,
|
||||
].join("\n"),
|
||||
"Skills status",
|
||||
);
|
||||
|
||||
const pluginRegistry = loadClawdbotPlugins({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
runtime,
|
||||
prompter,
|
||||
options,
|
||||
gatewayDetailsMessage: gatewayDetails.message,
|
||||
healthOk,
|
||||
});
|
||||
if (pluginRegistry.plugins.length > 0) {
|
||||
const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded");
|
||||
const disabled = pluginRegistry.plugins.filter(
|
||||
(p) => p.status === "disabled",
|
||||
);
|
||||
const errored = pluginRegistry.plugins.filter((p) => p.status === "error");
|
||||
|
||||
const lines = [
|
||||
`Loaded: ${loaded.length}`,
|
||||
`Disabled: ${disabled.length}`,
|
||||
`Errors: ${errored.length}`,
|
||||
errored.length > 0
|
||||
? `- ${errored
|
||||
.slice(0, 10)
|
||||
.map((p) => p.id)
|
||||
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
|
||||
: null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
|
||||
note(lines.join("\n"), "Plugins");
|
||||
}
|
||||
if (pluginRegistry.diagnostics.length > 0) {
|
||||
const lines = pluginRegistry.diagnostics.map((diag) => {
|
||||
const prefix = diag.level.toUpperCase();
|
||||
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
|
||||
const source = diag.source ? ` (${diag.source})` : "";
|
||||
return `- ${prefix}${plugin}: ${diag.message}${source}`;
|
||||
});
|
||||
note(lines.join("\n"), "Plugin diagnostics");
|
||||
}
|
||||
|
||||
let healthOk = false;
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
healthOk = true;
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (healthOk) {
|
||||
try {
|
||||
const status = await callGateway<Record<string, unknown>>({
|
||||
method: "channels.status",
|
||||
params: { probe: true, timeoutMs: 5000 },
|
||||
timeoutMs: 6000,
|
||||
});
|
||||
const issues = collectChannelStatusIssues(status);
|
||||
if (issues.length > 0) {
|
||||
note(
|
||||
issues
|
||||
.map(
|
||||
(issue) =>
|
||||
`- ${issue.channel} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Channel warnings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore: doctor already reported gateway health
|
||||
}
|
||||
}
|
||||
|
||||
if (!healthOk) {
|
||||
const service = resolveGatewayService();
|
||||
const loaded = await service.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
});
|
||||
let serviceRuntime:
|
||||
| Awaited<ReturnType<typeof service.readRuntime>>
|
||||
| undefined;
|
||||
if (loaded) {
|
||||
serviceRuntime = await service
|
||||
.readRuntime(process.env)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (resolveMode(cfg) === "local") {
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const diagnostics = await inspectPortUsage(port);
|
||||
if (diagnostics.status === "busy") {
|
||||
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
|
||||
} else if (loaded && serviceRuntime?.status === "running") {
|
||||
const lastError = await readLastGatewayErrorLine(process.env);
|
||||
if (lastError) {
|
||||
note(`Last gateway error: ${lastError}`, "Gateway");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!loaded) {
|
||||
note("Gateway daemon not installed.", "Gateway");
|
||||
if (resolveMode(cfg) === "local") {
|
||||
const install = await prompter.confirmSkipInNonInteractive({
|
||||
message: "Install gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (install) {
|
||||
const daemonRuntime = await prompter.select<GatewayDaemonRuntime>(
|
||||
{
|
||||
message: "Gateway daemon runtime",
|
||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
},
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
);
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({
|
||||
env: process.env,
|
||||
});
|
||||
const warning = renderSystemNodeWarning(
|
||||
systemNode,
|
||||
programArguments[0],
|
||||
);
|
||||
if (warning) note(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token:
|
||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
});
|
||||
if (summary || hints.length > 0) {
|
||||
const lines = [];
|
||||
if (summary) lines.push(`Runtime: ${summary}`);
|
||||
lines.push(...hints);
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
if (serviceRuntime?.status !== "running") {
|
||||
const start = await prompter.confirmSkipInNonInteractive({
|
||||
message: "Start gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (start) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await sleep(1500);
|
||||
}
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
const label = resolveGatewayLaunchAgentLabel(
|
||||
process.env.CLAWDBOT_PROFILE,
|
||||
);
|
||||
note(
|
||||
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${label}.`,
|
||||
"Gateway",
|
||||
);
|
||||
}
|
||||
if (serviceRuntime?.status === "running") {
|
||||
const restart = await prompter.confirmSkipInNonInteractive({
|
||||
message: "Restart gateway daemon now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (restart) {
|
||||
await service.restart({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
stdout: process.stdout,
|
||||
});
|
||||
await sleep(1500);
|
||||
try {
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||
await writeConfigFile(cfg);
|
||||
|
||||
132
src/commands/models/list.auth-overview.ts
Normal file
132
src/commands/models/list.auth-overview.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { formatRemainingShort } from "../../agents/auth-health.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay,
|
||||
resolveProfileUnusableUntilForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { maskApiKey } from "./list.format.js";
|
||||
import type { ProviderAuthOverview } from "./list.types.js";
|
||||
|
||||
export function resolveProviderAuthOverview(params: {
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
modelsPath: string;
|
||||
}): ProviderAuthOverview {
|
||||
const { provider, cfg, store } = params;
|
||||
const now = Date.now();
|
||||
const profiles = listProfilesForProvider(store, provider);
|
||||
const withUnusableSuffix = (base: string, profileId: string) => {
|
||||
const unusableUntil = resolveProfileUnusableUntilForDisplay(
|
||||
store,
|
||||
profileId,
|
||||
);
|
||||
if (!unusableUntil || now >= unusableUntil) return base;
|
||||
const stats = store.usageStats?.[profileId];
|
||||
const kind =
|
||||
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
|
||||
? `disabled${stats.disabledReason ? `:${stats.disabledReason}` : ""}`
|
||||
: "cooldown";
|
||||
const remaining = formatRemainingShort(unusableUntil - now);
|
||||
return `${base} [${kind} ${remaining}]`;
|
||||
};
|
||||
const labels = profiles.map((profileId) => {
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile) return `${profileId}=missing`;
|
||||
if (profile.type === "api_key") {
|
||||
return withUnusableSuffix(
|
||||
`${profileId}=${maskApiKey(profile.key)}`,
|
||||
profileId,
|
||||
);
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
return withUnusableSuffix(
|
||||
`${profileId}=token:${maskApiKey(profile.token)}`,
|
||||
profileId,
|
||||
);
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const suffix =
|
||||
display === profileId
|
||||
? ""
|
||||
: display.startsWith(profileId)
|
||||
? display.slice(profileId.length).trim()
|
||||
: `(${display})`;
|
||||
const base = `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
|
||||
return withUnusableSuffix(base, profileId);
|
||||
});
|
||||
const oauthCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "oauth",
|
||||
).length;
|
||||
const tokenCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "token",
|
||||
).length;
|
||||
const apiKeyCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "api_key",
|
||||
).length;
|
||||
|
||||
const envKey = resolveEnvApiKey(provider);
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
|
||||
const effective: ProviderAuthOverview["effective"] = (() => {
|
||||
if (profiles.length > 0) {
|
||||
return {
|
||||
kind: "profiles",
|
||||
detail: shortenHomePath(resolveAuthStorePathForDisplay()),
|
||||
};
|
||||
}
|
||||
if (envKey) {
|
||||
const isOAuthEnv =
|
||||
envKey.source.includes("OAUTH_TOKEN") ||
|
||||
envKey.source.toLowerCase().includes("oauth");
|
||||
return {
|
||||
kind: "env",
|
||||
detail: isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey),
|
||||
};
|
||||
}
|
||||
if (customKey) {
|
||||
return { kind: "models.json", detail: maskApiKey(customKey) };
|
||||
}
|
||||
return { kind: "missing", detail: "missing" };
|
||||
})();
|
||||
|
||||
return {
|
||||
provider,
|
||||
effective,
|
||||
profiles: {
|
||||
count: profiles.length,
|
||||
oauth: oauthCount,
|
||||
token: tokenCount,
|
||||
apiKey: apiKeyCount,
|
||||
labels,
|
||||
},
|
||||
...(envKey
|
||||
? {
|
||||
env: {
|
||||
value:
|
||||
envKey.source.includes("OAUTH_TOKEN") ||
|
||||
envKey.source.toLowerCase().includes("oauth")
|
||||
? "OAuth (env)"
|
||||
: maskApiKey(envKey.apiKey),
|
||||
source: envKey.source,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(customKey
|
||||
? {
|
||||
modelsJson: {
|
||||
value: maskApiKey(customKey),
|
||||
source: `models.json: ${shortenHomePath(params.modelsPath)}`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
102
src/commands/models/list.configured.ts
Normal file
102
src/commands/models/list.configured.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
parseModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ConfiguredEntry } from "./list.types.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, modelKey } from "./shared.js";
|
||||
|
||||
export function resolveConfiguredEntries(cfg: ClawdbotConfig) {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const order: string[] = [];
|
||||
const tagsByKey = new Map<string, Set<string>>();
|
||||
const aliasesByKey = new Map<string, string[]>();
|
||||
|
||||
for (const [key, aliases] of aliasIndex.byKey.entries()) {
|
||||
aliasesByKey.set(key, aliases);
|
||||
}
|
||||
|
||||
const addEntry = (ref: { provider: string; model: string }, tag: string) => {
|
||||
const key = modelKey(ref.provider, ref.model);
|
||||
if (!tagsByKey.has(key)) {
|
||||
tagsByKey.set(key, new Set());
|
||||
order.push(key);
|
||||
}
|
||||
tagsByKey.get(key)?.add(tag);
|
||||
};
|
||||
|
||||
addEntry(resolvedDefault, "default");
|
||||
|
||||
const modelConfig = cfg.agents?.defaults?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const imageModelConfig = cfg.agents?.defaults?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const modelFallbacks =
|
||||
typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
|
||||
const imageFallbacks =
|
||||
typeof imageModelConfig === "object"
|
||||
? (imageModelConfig?.fallbacks ?? [])
|
||||
: [];
|
||||
const imagePrimary = imageModelConfig?.primary?.trim() ?? "";
|
||||
|
||||
modelFallbacks.forEach((raw, idx) => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
addEntry(resolved.ref, `fallback#${idx + 1}`);
|
||||
});
|
||||
|
||||
if (imagePrimary) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: imagePrimary,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) addEntry(resolved.ref, "image");
|
||||
}
|
||||
|
||||
imageFallbacks.forEach((raw, idx) => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
addEntry(resolved.ref, `img-fallback#${idx + 1}`);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(cfg.agents?.defaults?.models ?? {})) {
|
||||
const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER);
|
||||
if (!parsed) continue;
|
||||
addEntry(parsed, "configured");
|
||||
}
|
||||
|
||||
const entries: ConfiguredEntry[] = order.map((key) => {
|
||||
const slash = key.indexOf("/");
|
||||
const provider = slash === -1 ? key : key.slice(0, slash);
|
||||
const model = slash === -1 ? "" : key.slice(slash + 1);
|
||||
return {
|
||||
key,
|
||||
ref: { provider, model },
|
||||
tags: tagsByKey.get(key) ?? new Set(),
|
||||
aliases: aliasesByKey.get(key) ?? [],
|
||||
} satisfies ConfiguredEntry;
|
||||
});
|
||||
|
||||
return { entries };
|
||||
}
|
||||
51
src/commands/models/list.format.ts
Normal file
51
src/commands/models/list.format.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
colorize,
|
||||
isRich as isRichTerminal,
|
||||
theme,
|
||||
} from "../../terminal/theme.js";
|
||||
|
||||
export const isRich = (opts?: { json?: boolean; plain?: boolean }) =>
|
||||
Boolean(isRichTerminal() && !opts?.json && !opts?.plain);
|
||||
|
||||
export const pad = (value: string, size: number) => value.padEnd(size);
|
||||
|
||||
export const formatKey = (key: string, rich: boolean) =>
|
||||
colorize(rich, theme.warn, key);
|
||||
|
||||
export const formatValue = (value: string, rich: boolean) =>
|
||||
colorize(rich, theme.info, value);
|
||||
|
||||
export const formatKeyValue = (
|
||||
key: string,
|
||||
value: string,
|
||||
rich: boolean,
|
||||
valueColor: (value: string) => string = theme.info,
|
||||
) => `${formatKey(key, rich)}=${colorize(rich, valueColor, value)}`;
|
||||
|
||||
export const formatSeparator = (rich: boolean) =>
|
||||
colorize(rich, theme.muted, " | ");
|
||||
|
||||
export const formatTag = (tag: string, rich: boolean) => {
|
||||
if (!rich) return tag;
|
||||
if (tag === "default") return theme.success(tag);
|
||||
if (tag === "image") return theme.accentBright(tag);
|
||||
if (tag === "configured") return theme.accent(tag);
|
||||
if (tag === "missing") return theme.error(tag);
|
||||
if (tag.startsWith("fallback#")) return theme.warn(tag);
|
||||
if (tag.startsWith("img-fallback#")) return theme.warn(tag);
|
||||
if (tag.startsWith("alias:")) return theme.accentDim(tag);
|
||||
return theme.muted(tag);
|
||||
};
|
||||
|
||||
export const truncate = (value: string, max: number) => {
|
||||
if (value.length <= max) return value;
|
||||
if (max <= 3) return value.slice(0, max);
|
||||
return `${value.slice(0, max - 3)}...`;
|
||||
};
|
||||
|
||||
export const maskApiKey = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "missing";
|
||||
if (trimmed.length <= 16) return trimmed;
|
||||
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
|
||||
};
|
||||
129
src/commands/models/list.list-command.ts
Normal file
129
src/commands/models/list.list-command.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { parseModelRef } from "../../agents/model-selection.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveConfiguredEntries } from "./list.configured.js";
|
||||
import { loadModelRegistry, toModelRow } from "./list.registry.js";
|
||||
import { printModelTable } from "./list.table.js";
|
||||
import type { ModelRow } from "./list.types.js";
|
||||
import {
|
||||
DEFAULT_PROVIDER,
|
||||
ensureFlagCompatibility,
|
||||
modelKey,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsListCommand(
|
||||
opts: {
|
||||
all?: boolean;
|
||||
local?: boolean;
|
||||
provider?: string;
|
||||
json?: boolean;
|
||||
plain?: boolean;
|
||||
},
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const providerFilter = (() => {
|
||||
const raw = opts.provider?.trim();
|
||||
if (!raw) return undefined;
|
||||
const parsed = parseModelRef(`${raw}/_`, DEFAULT_PROVIDER);
|
||||
return parsed?.provider ?? raw.toLowerCase();
|
||||
})();
|
||||
|
||||
let models: Model<Api>[] = [];
|
||||
let availableKeys: Set<string> | undefined;
|
||||
try {
|
||||
const loaded = await loadModelRegistry(cfg);
|
||||
models = loaded.models;
|
||||
availableKeys = loaded.availableKeys;
|
||||
} catch (err) {
|
||||
runtime.error(`Model registry unavailable: ${String(err)}`);
|
||||
}
|
||||
|
||||
const modelByKey = new Map(
|
||||
models.map((model) => [modelKey(model.provider, model.id), model]),
|
||||
);
|
||||
|
||||
const { entries } = resolveConfiguredEntries(cfg);
|
||||
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
|
||||
|
||||
const rows: ModelRow[] = [];
|
||||
|
||||
const isLocalBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const host = url.hostname.toLowerCase();
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::1" ||
|
||||
host.endsWith(".local")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (opts.all) {
|
||||
const sorted = [...models].sort((a, b) => {
|
||||
const p = a.provider.localeCompare(b.provider);
|
||||
if (p !== 0) return p;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const model of sorted) {
|
||||
if (providerFilter && model.provider.toLowerCase() !== providerFilter) {
|
||||
continue;
|
||||
}
|
||||
if (opts.local && !isLocalBaseUrl(model.baseUrl)) continue;
|
||||
const key = modelKey(model.provider, model.id);
|
||||
const configured = configuredByKey.get(key);
|
||||
rows.push(
|
||||
toModelRow({
|
||||
model,
|
||||
key,
|
||||
tags: configured ? Array.from(configured.tags) : [],
|
||||
aliases: configured?.aliases ?? [],
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
providerFilter &&
|
||||
entry.ref.provider.toLowerCase() !== providerFilter
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = modelByKey.get(entry.key);
|
||||
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) continue;
|
||||
if (opts.local && !model) continue;
|
||||
rows.push(
|
||||
toModelRow({
|
||||
model,
|
||||
key: entry.key,
|
||||
tags: Array.from(entry.tags),
|
||||
aliases: entry.aliases,
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
runtime.log("No models found.");
|
||||
return;
|
||||
}
|
||||
|
||||
printModelTable(rows, runtime, opts);
|
||||
}
|
||||
115
src/commands/models/list.registry.ts
Normal file
115
src/commands/models/list.registry.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { listProfilesForProvider } from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "../../agents/models-config.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ModelRow } from "./list.types.js";
|
||||
import { modelKey } from "./shared.js";
|
||||
|
||||
const isLocalBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const host = url.hostname.toLowerCase();
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::1" ||
|
||||
host.endsWith(".local")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasAuthForProvider = (
|
||||
provider: string,
|
||||
cfg: ClawdbotConfig,
|
||||
authStore: AuthProfileStore,
|
||||
) => {
|
||||
if (listProfilesForProvider(authStore, provider).length > 0) return true;
|
||||
if (resolveEnvApiKey(provider)) return true;
|
||||
if (getCustomProviderApiKey(cfg, provider)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loadModelRegistry(cfg: ClawdbotConfig) {
|
||||
await ensureClawdbotModelsJson(cfg);
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const registry = discoverModels(authStorage, agentDir);
|
||||
const models = registry.getAll() as Model<Api>[];
|
||||
const availableModels = registry.getAvailable() as Model<Api>[];
|
||||
const availableKeys = new Set(
|
||||
availableModels.map((model) => modelKey(model.provider, model.id)),
|
||||
);
|
||||
return { registry, models, availableKeys };
|
||||
}
|
||||
|
||||
export function toModelRow(params: {
|
||||
model?: Model<Api>;
|
||||
key: string;
|
||||
tags: string[];
|
||||
aliases?: string[];
|
||||
availableKeys?: Set<string>;
|
||||
cfg?: ClawdbotConfig;
|
||||
authStore?: AuthProfileStore;
|
||||
}): ModelRow {
|
||||
const {
|
||||
model,
|
||||
key,
|
||||
tags,
|
||||
aliases = [],
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
} = params;
|
||||
if (!model) {
|
||||
return {
|
||||
key,
|
||||
name: key,
|
||||
input: "-",
|
||||
contextWindow: null,
|
||||
local: null,
|
||||
available: null,
|
||||
tags: [...tags, "missing"],
|
||||
missing: true,
|
||||
};
|
||||
}
|
||||
|
||||
const input = model.input.join("+") || "text";
|
||||
const local = isLocalBaseUrl(model.baseUrl);
|
||||
const available =
|
||||
cfg && authStore
|
||||
? hasAuthForProvider(model.provider, cfg, authStore)
|
||||
: (availableKeys?.has(modelKey(model.provider, model.id)) ?? false);
|
||||
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
|
||||
const mergedTags = new Set(tags);
|
||||
if (aliasTags.length > 0) {
|
||||
for (const tag of mergedTags) {
|
||||
if (tag === "alias" || tag.startsWith("alias:")) mergedTags.delete(tag);
|
||||
}
|
||||
for (const tag of aliasTags) mergedTags.add(tag);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
name: model.name || model.id,
|
||||
input,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
local,
|
||||
available,
|
||||
tags: Array.from(mergedTags),
|
||||
missing: false,
|
||||
};
|
||||
}
|
||||
482
src/commands/models/list.status-command.ts
Normal file
482
src/commands/models/list.status-command.ts
Normal file
@ -0,0 +1,482 @@
|
||||
import path from "node:path";
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
buildAuthHealthSummary,
|
||||
DEFAULT_OAUTH_WARN_MS,
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthStorePathForDisplay,
|
||||
resolveProfileUnusableUntilForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import {
|
||||
parseModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
getShellEnvAppliedKeys,
|
||||
shouldEnableShellEnvFallback,
|
||||
} from "../../infra/shell-env.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
||||
import { isRich } from "./list.format.js";
|
||||
import {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
ensureFlagCompatibility,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsStatusCommand(
|
||||
opts: { json?: boolean; plain?: boolean; check?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
const modelConfig = cfg.agents?.defaults?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const imageConfig = cfg.agents?.defaults?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const rawModel =
|
||||
typeof modelConfig === "string"
|
||||
? modelConfig.trim()
|
||||
: (modelConfig?.primary?.trim() ?? "");
|
||||
const resolvedLabel = `${resolved.provider}/${resolved.model}`;
|
||||
const defaultLabel = rawModel || resolvedLabel;
|
||||
const fallbacks =
|
||||
typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
|
||||
const imageModel =
|
||||
typeof imageConfig === "string"
|
||||
? imageConfig.trim()
|
||||
: (imageConfig?.primary?.trim() ?? "");
|
||||
const imageFallbacks =
|
||||
typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
|
||||
const aliases = Object.entries(cfg.agents?.defaults?.models ?? {}).reduce<
|
||||
Record<string, string>
|
||||
>((acc, [key, entry]) => {
|
||||
const alias = entry?.alias?.trim();
|
||||
if (alias) acc[alias] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
const allowed = Object.keys(cfg.agents?.defaults?.models ?? {});
|
||||
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const store = ensureAuthProfileStore();
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
|
||||
const providersFromStore = new Set(
|
||||
Object.values(store.profiles)
|
||||
.map((profile) => profile.provider)
|
||||
.filter((p): p is string => Boolean(p)),
|
||||
);
|
||||
const providersFromConfig = new Set(
|
||||
Object.keys(cfg.models?.providers ?? {})
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const providersFromModels = new Set<string>();
|
||||
const providersInUse = new Set<string>();
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
imageModel,
|
||||
...imageFallbacks,
|
||||
...allowed,
|
||||
]) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersFromModels.add(parsed.provider);
|
||||
}
|
||||
for (const raw of [
|
||||
defaultLabel,
|
||||
...fallbacks,
|
||||
imageModel,
|
||||
...imageFallbacks,
|
||||
]) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (parsed?.provider) providersInUse.add(parsed.provider);
|
||||
}
|
||||
|
||||
const providersFromEnv = new Set<string>();
|
||||
// Keep in sync with resolveEnvApiKey() mappings (we want visibility even when
|
||||
// a provider isn't currently selected in config/models).
|
||||
const envProbeProviders = [
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"google-vertex",
|
||||
"openai",
|
||||
"google",
|
||||
"groq",
|
||||
"cerebras",
|
||||
"xai",
|
||||
"openrouter",
|
||||
"zai",
|
||||
"mistral",
|
||||
"synthetic",
|
||||
];
|
||||
for (const provider of envProbeProviders) {
|
||||
if (resolveEnvApiKey(provider)) providersFromEnv.add(provider);
|
||||
}
|
||||
|
||||
const providers = Array.from(
|
||||
new Set([
|
||||
...providersFromStore,
|
||||
...providersFromConfig,
|
||||
...providersFromModels,
|
||||
...providersFromEnv,
|
||||
]),
|
||||
)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const applied = getShellEnvAppliedKeys();
|
||||
const shellFallbackEnabled =
|
||||
shouldEnableShellEnvFallback(process.env) ||
|
||||
cfg.env?.shellEnv?.enabled === true;
|
||||
|
||||
const providerAuth = providers
|
||||
.map((provider) =>
|
||||
resolveProviderAuthOverview({ provider, cfg, store, modelsPath }),
|
||||
)
|
||||
.filter((entry) => {
|
||||
const hasAny =
|
||||
entry.profiles.count > 0 ||
|
||||
Boolean(entry.env) ||
|
||||
Boolean(entry.modelsJson);
|
||||
return hasAny;
|
||||
});
|
||||
const providerAuthMap = new Map(
|
||||
providerAuth.map((entry) => [entry.provider, entry]),
|
||||
);
|
||||
const missingProvidersInUse = Array.from(providersInUse)
|
||||
.filter((provider) => !providerAuthMap.has(provider))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const providersWithOauth = providerAuth
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.profiles.oauth > 0 ||
|
||||
entry.profiles.token > 0 ||
|
||||
entry.env?.value === "OAuth (env)",
|
||||
)
|
||||
.map((entry) => {
|
||||
const count =
|
||||
entry.profiles.oauth +
|
||||
entry.profiles.token +
|
||||
(entry.env?.value === "OAuth (env)" ? 1 : 0);
|
||||
return `${entry.provider} (${count})`;
|
||||
});
|
||||
|
||||
const authHealth = buildAuthHealthSummary({
|
||||
store,
|
||||
cfg,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
providers,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||
);
|
||||
|
||||
const unusableProfiles = (() => {
|
||||
const now = Date.now();
|
||||
const out: Array<{
|
||||
profileId: string;
|
||||
provider?: string;
|
||||
kind: "cooldown" | "disabled";
|
||||
reason?: string;
|
||||
until: number;
|
||||
remainingMs: number;
|
||||
}> = [];
|
||||
for (const profileId of Object.keys(store.usageStats ?? {})) {
|
||||
const unusableUntil = resolveProfileUnusableUntilForDisplay(
|
||||
store,
|
||||
profileId,
|
||||
);
|
||||
if (!unusableUntil || now >= unusableUntil) continue;
|
||||
const stats = store.usageStats?.[profileId];
|
||||
const kind =
|
||||
typeof stats?.disabledUntil === "number" && now < stats.disabledUntil
|
||||
? "disabled"
|
||||
: "cooldown";
|
||||
out.push({
|
||||
profileId,
|
||||
provider: store.profiles[profileId]?.provider,
|
||||
kind,
|
||||
reason: stats?.disabledReason,
|
||||
until: unusableUntil,
|
||||
remainingMs: unusableUntil - now,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.remainingMs - b.remainingMs);
|
||||
})();
|
||||
|
||||
const checkStatus = (() => {
|
||||
const hasExpiredOrMissing =
|
||||
oauthProfiles.some((profile) =>
|
||||
["expired", "missing"].includes(profile.status),
|
||||
) || missingProvidersInUse.length > 0;
|
||||
const hasExpiring = oauthProfiles.some(
|
||||
(profile) => profile.status === "expiring",
|
||||
);
|
||||
if (hasExpiredOrMissing) return 1;
|
||||
if (hasExpiring) return 2;
|
||||
return 0;
|
||||
})();
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
configPath: CONFIG_PATH_CLAWDBOT,
|
||||
agentDir,
|
||||
defaultModel: defaultLabel,
|
||||
resolvedDefault: resolvedLabel,
|
||||
fallbacks,
|
||||
imageModel: imageModel || null,
|
||||
imageFallbacks,
|
||||
aliases,
|
||||
allowed,
|
||||
auth: {
|
||||
storePath: resolveAuthStorePathForDisplay(),
|
||||
shellEnvFallback: {
|
||||
enabled: shellFallbackEnabled,
|
||||
appliedKeys: applied,
|
||||
},
|
||||
providersWithOAuth: providersWithOauth,
|
||||
missingProvidersInUse,
|
||||
providers: providerAuth,
|
||||
unusableProfiles,
|
||||
oauth: {
|
||||
warnAfterMs: authHealth.warnAfterMs,
|
||||
profiles: authHealth.profiles,
|
||||
providers: authHealth.providers,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (opts.check) runtime.exit(checkStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plain) {
|
||||
runtime.log(resolvedLabel);
|
||||
if (opts.check) runtime.exit(checkStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich(opts);
|
||||
const label = (value: string) =>
|
||||
colorize(rich, theme.accent, value.padEnd(14));
|
||||
const displayDefault =
|
||||
rawModel && rawModel !== resolvedLabel
|
||||
? `${resolvedLabel} (from ${rawModel})`
|
||||
: resolvedLabel;
|
||||
|
||||
runtime.log(
|
||||
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
theme.info,
|
||||
shortenHomePath(agentDir),
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label("Default")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
theme.success,
|
||||
displayDefault,
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
fallbacks.length ? theme.warn : theme.muted,
|
||||
fallbacks.length ? fallbacks.join(", ") : "-",
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label("Image model")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
imageModel ? theme.accentBright : theme.muted,
|
||||
imageModel || "-",
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
":",
|
||||
)} ${colorize(
|
||||
rich,
|
||||
imageFallbacks.length ? theme.accentBright : theme.muted,
|
||||
imageFallbacks.length ? imageFallbacks.join(", ") : "-",
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
Object.keys(aliases).length ? theme.accent : theme.muted,
|
||||
Object.keys(aliases).length
|
||||
? Object.entries(aliases)
|
||||
.map(([alias, target]) =>
|
||||
rich
|
||||
? `${theme.accentDim(alias)} ${theme.muted("->")} ${theme.info(target)}`
|
||||
: `${alias} -> ${target}`,
|
||||
)
|
||||
.join(", ")
|
||||
: "-",
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label(`Configured models (${allowed.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
allowed.length ? theme.info : theme.muted,
|
||||
allowed.length ? allowed.join(", ") : "all",
|
||||
)}`,
|
||||
);
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "Auth overview"));
|
||||
runtime.log(
|
||||
`${label("Auth store")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
theme.info,
|
||||
shortenHomePath(resolveAuthStorePathForDisplay()),
|
||||
)}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label("Shell env")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
shellFallbackEnabled ? theme.success : theme.muted,
|
||||
shellFallbackEnabled ? "on" : "off",
|
||||
)}${
|
||||
applied.length
|
||||
? colorize(rich, theme.muted, ` (applied: ${applied.join(", ")})`)
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
runtime.log(
|
||||
`${label(`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`)}${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
":",
|
||||
)} ${colorize(
|
||||
rich,
|
||||
providersWithOauth.length ? theme.info : theme.muted,
|
||||
providersWithOauth.length ? providersWithOauth.join(", ") : "-",
|
||||
)}`,
|
||||
);
|
||||
|
||||
const formatKey = (key: string) => colorize(rich, theme.warn, key);
|
||||
const formatKeyValue = (key: string, value: string) =>
|
||||
`${formatKey(key)}=${colorize(rich, theme.info, value)}`;
|
||||
const formatSeparator = () => colorize(rich, theme.muted, " | ");
|
||||
|
||||
for (const entry of providerAuth) {
|
||||
const separator = formatSeparator();
|
||||
const bits: string[] = [];
|
||||
bits.push(
|
||||
formatKeyValue(
|
||||
"effective",
|
||||
`${colorize(rich, theme.accentBright, entry.effective.kind)}:${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
entry.effective.detail,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
if (entry.profiles.count > 0) {
|
||||
bits.push(
|
||||
formatKeyValue(
|
||||
"profiles",
|
||||
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
|
||||
),
|
||||
);
|
||||
if (entry.profiles.labels.length > 0) {
|
||||
bits.push(colorize(rich, theme.info, entry.profiles.labels.join(", ")));
|
||||
}
|
||||
}
|
||||
if (entry.env) {
|
||||
bits.push(
|
||||
formatKeyValue(
|
||||
"env",
|
||||
`${entry.env.value}${separator}${formatKeyValue("source", entry.env.source)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entry.modelsJson) {
|
||||
bits.push(
|
||||
formatKeyValue(
|
||||
"models.json",
|
||||
`${entry.modelsJson.value}${separator}${formatKeyValue("source", entry.modelsJson.source)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`);
|
||||
}
|
||||
|
||||
if (missingProvidersInUse.length > 0) {
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "Missing auth"));
|
||||
for (const provider of missingProvidersInUse) {
|
||||
const hint =
|
||||
provider === "anthropic"
|
||||
? "Run `claude setup-token` or `clawdbot configure`."
|
||||
: "Run `clawdbot configure` or set an API key env var.";
|
||||
runtime.log(`- ${theme.heading(provider)} ${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
|
||||
if (oauthProfiles.length === 0) {
|
||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||
return;
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||
if (status === "static") return colorize(rich, theme.muted, "static");
|
||||
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
||||
return colorize(rich, theme.error, "expired");
|
||||
};
|
||||
|
||||
for (const profile of oauthProfiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store"
|
||||
? colorize(rich, theme.muted, ` (${profile.source})`)
|
||||
: "";
|
||||
runtime.log(`- ${label} ${status}${expiry}${source}`);
|
||||
}
|
||||
|
||||
if (opts.check) runtime.exit(checkStatus);
|
||||
}
|
||||
98
src/commands/models/list.table.ts
Normal file
98
src/commands/models/list.table.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { formatTag, isRich, pad, truncate } from "./list.format.js";
|
||||
import type { ModelRow } from "./list.types.js";
|
||||
import { formatTokenK } from "./shared.js";
|
||||
|
||||
const MODEL_PAD = 42;
|
||||
const INPUT_PAD = 10;
|
||||
const CTX_PAD = 8;
|
||||
const LOCAL_PAD = 5;
|
||||
const AUTH_PAD = 5;
|
||||
|
||||
export function printModelTable(
|
||||
rows: ModelRow[],
|
||||
runtime: RuntimeEnv,
|
||||
opts: { json?: boolean; plain?: boolean } = {},
|
||||
) {
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
count: rows.length,
|
||||
models: rows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plain) {
|
||||
for (const row of rows) runtime.log(row.key);
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = isRich(opts);
|
||||
const header = [
|
||||
pad("Model", MODEL_PAD),
|
||||
pad("Input", INPUT_PAD),
|
||||
pad("Ctx", CTX_PAD),
|
||||
pad("Local", LOCAL_PAD),
|
||||
pad("Auth", AUTH_PAD),
|
||||
"Tags",
|
||||
].join(" ");
|
||||
runtime.log(rich ? theme.heading(header) : header);
|
||||
|
||||
for (const row of rows) {
|
||||
const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD);
|
||||
const inputLabel = pad(row.input || "-", INPUT_PAD);
|
||||
const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD);
|
||||
const localText = row.local === null ? "-" : row.local ? "yes" : "no";
|
||||
const localLabel = pad(localText, LOCAL_PAD);
|
||||
const authText =
|
||||
row.available === null ? "-" : row.available ? "yes" : "no";
|
||||
const authLabel = pad(authText, AUTH_PAD);
|
||||
const tagsLabel =
|
||||
row.tags.length > 0
|
||||
? rich
|
||||
? row.tags.map((tag) => formatTag(tag, rich)).join(",")
|
||||
: row.tags.join(",")
|
||||
: "";
|
||||
|
||||
const coloredInput = colorize(
|
||||
rich,
|
||||
row.input.includes("image") ? theme.accentBright : theme.info,
|
||||
inputLabel,
|
||||
);
|
||||
const coloredLocal = colorize(
|
||||
rich,
|
||||
row.local === null
|
||||
? theme.muted
|
||||
: row.local
|
||||
? theme.success
|
||||
: theme.muted,
|
||||
localLabel,
|
||||
);
|
||||
const coloredAuth = colorize(
|
||||
rich,
|
||||
row.available === null
|
||||
? theme.muted
|
||||
: row.available
|
||||
? theme.success
|
||||
: theme.error,
|
||||
authLabel,
|
||||
);
|
||||
|
||||
const line = [
|
||||
rich ? theme.accent(keyLabel) : keyLabel,
|
||||
coloredInput,
|
||||
ctxLabel,
|
||||
coloredLocal,
|
||||
coloredAuth,
|
||||
tagsLabel,
|
||||
].join(" ");
|
||||
runtime.log(line);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
34
src/commands/models/list.types.ts
Normal file
34
src/commands/models/list.types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type ConfiguredEntry = {
|
||||
key: string;
|
||||
ref: { provider: string; model: string };
|
||||
tags: Set<string>;
|
||||
aliases: string[];
|
||||
};
|
||||
|
||||
export type ModelRow = {
|
||||
key: string;
|
||||
name: string;
|
||||
input: string;
|
||||
contextWindow: number | null;
|
||||
local: boolean | null;
|
||||
available: boolean | null;
|
||||
tags: string[];
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
export type ProviderAuthOverview = {
|
||||
provider: string;
|
||||
effective: {
|
||||
kind: "profiles" | "env" | "models.json" | "missing";
|
||||
detail: string;
|
||||
};
|
||||
profiles: {
|
||||
count: number;
|
||||
oauth: number;
|
||||
token: number;
|
||||
apiKey: number;
|
||||
labels: string[];
|
||||
};
|
||||
env?: { value: string; source: string };
|
||||
modelsJson?: { value: string; source: string };
|
||||
};
|
||||
293
src/commands/onboard-auth.config-core.ts
Normal file
293
src/commands/onboard-auth.config-core.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "../agents/synthetic-models.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
ZAI_DEFAULT_MODEL_REF,
|
||||
} from "./onboard-auth.credentials.js";
|
||||
import {
|
||||
buildMoonshotModelDefinition,
|
||||
MOONSHOT_BASE_URL,
|
||||
MOONSHOT_DEFAULT_MODEL_ID,
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
} from "./onboard-auth.models.js";
|
||||
|
||||
export function applyZaiConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[ZAI_DEFAULT_MODEL_REF] = {
|
||||
...models[ZAI_DEFAULT_MODEL_REF],
|
||||
alias: models[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM",
|
||||
};
|
||||
|
||||
const existingModel = cfg.agents?.defaults?.model;
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: ZAI_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenrouterProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[OPENROUTER_DEFAULT_MODEL_REF] = {
|
||||
...models[OPENROUTER_DEFAULT_MODEL_REF],
|
||||
alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenrouterConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyOpenrouterProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMoonshotProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[MOONSHOT_DEFAULT_MODEL_REF] = {
|
||||
...models[MOONSHOT_DEFAULT_MODEL_REF],
|
||||
alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.moonshot;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const defaultModel = buildMoonshotModelDefinition();
|
||||
const hasDefaultModel = existingModels.some(
|
||||
(model) => model.id === MOONSHOT_DEFAULT_MODEL_ID,
|
||||
);
|
||||
const mergedModels = hasDefaultModel
|
||||
? existingModels
|
||||
: [...existingModels, defaultModel];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim();
|
||||
providers.moonshot = {
|
||||
...existingProviderRest,
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
api: "openai-completions",
|
||||
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMoonshotConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyMoonshotProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: MOONSHOT_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applySyntheticProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[SYNTHETIC_DEFAULT_MODEL_REF] = {
|
||||
...models[SYNTHETIC_DEFAULT_MODEL_REF],
|
||||
alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.1",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.synthetic;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const syntheticModels = SYNTHETIC_MODEL_CATALOG.map(
|
||||
buildSyntheticModelDefinition,
|
||||
);
|
||||
const mergedModels = [
|
||||
...existingModels,
|
||||
...syntheticModels.filter(
|
||||
(model) => !existingModels.some((existing) => existing.id === model.id),
|
||||
),
|
||||
];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim();
|
||||
providers.synthetic = {
|
||||
...existingProviderRest,
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : syntheticModels,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applySyntheticConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applySyntheticProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAuthProfileConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth" | "token";
|
||||
email?: string;
|
||||
preferProfileFirst?: boolean;
|
||||
},
|
||||
): ClawdbotConfig {
|
||||
const profiles = {
|
||||
...cfg.auth?.profiles,
|
||||
[params.profileId]: {
|
||||
provider: params.provider,
|
||||
mode: params.mode,
|
||||
...(params.email ? { email: params.email } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Only maintain `auth.order` when the user explicitly configured it.
|
||||
// Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed.
|
||||
const existingProviderOrder = cfg.auth?.order?.[params.provider];
|
||||
const preferProfileFirst = params.preferProfileFirst ?? true;
|
||||
const reorderedProviderOrder =
|
||||
existingProviderOrder && preferProfileFirst
|
||||
? [
|
||||
params.profileId,
|
||||
...existingProviderOrder.filter(
|
||||
(profileId) => profileId !== params.profileId,
|
||||
),
|
||||
]
|
||||
: existingProviderOrder;
|
||||
const order =
|
||||
existingProviderOrder !== undefined
|
||||
? {
|
||||
...cfg.auth?.order,
|
||||
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
|
||||
? reorderedProviderOrder
|
||||
: [...(reorderedProviderOrder ?? []), params.profileId],
|
||||
}
|
||||
: cfg.auth?.order;
|
||||
return {
|
||||
...cfg,
|
||||
auth: {
|
||||
...cfg.auth,
|
||||
profiles,
|
||||
...(order ? { order } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
231
src/commands/onboard-auth.config-minimax.ts
Normal file
231
src/commands/onboard-auth.config-minimax.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
buildMinimaxApiModelDefinition,
|
||||
buildMinimaxModelDefinition,
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
MINIMAX_API_BASE_URL,
|
||||
MINIMAX_HOSTED_COST,
|
||||
MINIMAX_HOSTED_MODEL_ID,
|
||||
MINIMAX_HOSTED_MODEL_REF,
|
||||
MINIMAX_LM_STUDIO_COST,
|
||||
} from "./onboard-auth.models.js";
|
||||
|
||||
export function applyMinimaxProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models["anthropic/claude-opus-4-5"] = {
|
||||
...models["anthropic/claude-opus-4-5"],
|
||||
alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus",
|
||||
};
|
||||
models["lmstudio/minimax-m2.1-gs32"] = {
|
||||
...models["lmstudio/minimax-m2.1-gs32"],
|
||||
alias: models["lmstudio/minimax-m2.1-gs32"]?.alias ?? "Minimax",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
if (!providers.lmstudio) {
|
||||
providers.lmstudio = {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
buildMinimaxModelDefinition({
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
reasoning: false,
|
||||
cost: MINIMAX_LM_STUDIO_COST,
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxHostedProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { baseUrl?: string },
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[MINIMAX_HOSTED_MODEL_REF] = {
|
||||
...models[MINIMAX_HOSTED_MODEL_REF],
|
||||
alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const hostedModel = buildMinimaxModelDefinition({
|
||||
id: MINIMAX_HOSTED_MODEL_ID,
|
||||
cost: MINIMAX_HOSTED_COST,
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
const existingProvider = providers.minimax;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const hasHostedModel = existingModels.some(
|
||||
(model) => model.id === MINIMAX_HOSTED_MODEL_ID,
|
||||
);
|
||||
const mergedModels = hasHostedModel
|
||||
? existingModels
|
||||
: [...existingModels, hostedModel];
|
||||
providers.minimax = {
|
||||
...existingProvider,
|
||||
baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL,
|
||||
apiKey: "minimax",
|
||||
api: "openai-completions",
|
||||
models: mergedModels.length > 0 ? mergedModels : [hostedModel],
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyMinimaxProviderConfig(cfg);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: "lmstudio/minimax-m2.1-gs32",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxHostedConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { baseUrl?: string },
|
||||
): ClawdbotConfig {
|
||||
const next = applyMinimaxHostedProviderConfig(cfg, params);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...next.agents?.defaults?.model,
|
||||
primary: MINIMAX_HOSTED_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
|
||||
export function applyMinimaxApiProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.minimax;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const apiModel = buildMinimaxApiModelDefinition(modelId);
|
||||
const hasApiModel = existingModels.some((model) => model.id === modelId);
|
||||
const mergedModels = hasApiModel
|
||||
? existingModels
|
||||
: [...existingModels, apiModel];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey =
|
||||
resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
|
||||
providers.minimax = {
|
||||
...existingProviderRest,
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : [apiModel],
|
||||
};
|
||||
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[`minimax/${modelId}`] = {
|
||||
...models[`minimax/${modelId}`],
|
||||
alias: "Minimax",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: { mode: cfg.models?.mode ?? "merge", providers },
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxApiConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const next = applyMinimaxApiProviderConfig(cfg, modelId);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: `minimax/${modelId}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
48
src/commands/onboard-auth.config-opencode.ts
Normal file
48
src/commands/onboard-auth.config-opencode.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
export function applyOpencodeZenProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
// Use the built-in opencode provider from pi-ai; only seed the allowlist alias.
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = {
|
||||
...models[OPENCODE_ZEN_DEFAULT_MODEL_REF],
|
||||
alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpencodeZenConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyOpencodeZenProviderConfig(cfg);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
128
src/commands/onboard-auth.credentials.ts
Normal file
128
src/commands/onboard-auth.credentials.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
|
||||
const resolveAuthAgentDir = (agentDir?: string) =>
|
||||
agentDir ?? resolveClawdbotAgentDir();
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: string,
|
||||
creds: OAuthCredentials,
|
||||
agentDir?: string,
|
||||
): Promise<void> {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: `${provider}:${creds.email ?? "default"}`,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider,
|
||||
...creds,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setAnthropicApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "anthropic:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setGeminiApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "google:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "google",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setMinimaxApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "minimax:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setMoonshotApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "moonshot:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "moonshot",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSyntheticApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "synthetic:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "synthetic",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
|
||||
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
|
||||
|
||||
export async function setZaiApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "zai:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "zai",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setOpenrouterApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "openrouter:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "opencode:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "opencode",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
93
src/commands/onboard-auth.models.ts
Normal file
93
src/commands/onboard-auth.models.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
|
||||
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
|
||||
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
|
||||
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
|
||||
export const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
|
||||
|
||||
export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
|
||||
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
|
||||
export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
export const MINIMAX_API_COST = {
|
||||
input: 15,
|
||||
output: 60,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 10,
|
||||
};
|
||||
export const MINIMAX_HOSTED_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
export const MINIMAX_LM_STUDIO_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
export const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const MINIMAX_MODEL_CATALOG = {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false },
|
||||
"MiniMax-M2.1-lightning": {
|
||||
name: "MiniMax M2.1 Lightning",
|
||||
reasoning: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG;
|
||||
|
||||
export function buildMinimaxModelDefinition(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
}): ModelDefinitionConfig {
|
||||
const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId];
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`,
|
||||
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: params.cost,
|
||||
contextWindow: params.contextWindow,
|
||||
maxTokens: params.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxApiModelDefinition(
|
||||
modelId: string,
|
||||
): ModelDefinitionConfig {
|
||||
return buildMinimaxModelDefinition({
|
||||
id: modelId,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMoonshotModelDefinition(): ModelDefinitionConfig {
|
||||
return {
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2 0905 Preview",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
@ -1,768 +1,52 @@
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
export {
|
||||
SYNTHETIC_DEFAULT_MODEL_ID,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "../agents/synthetic-models.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
||||
export {
|
||||
applyAuthProfileConfig,
|
||||
applyMoonshotConfig,
|
||||
applyMoonshotProviderConfig,
|
||||
applyOpenrouterConfig,
|
||||
applyOpenrouterProviderConfig,
|
||||
applySyntheticConfig,
|
||||
applySyntheticProviderConfig,
|
||||
applyZaiConfig,
|
||||
} from "./onboard-auth.config-core.js";
|
||||
export {
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxHostedConfig,
|
||||
applyMinimaxHostedProviderConfig,
|
||||
applyMinimaxProviderConfig,
|
||||
} from "./onboard-auth.config-minimax.js";
|
||||
|
||||
const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
|
||||
const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
|
||||
const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
|
||||
const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
|
||||
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
|
||||
export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF };
|
||||
|
||||
const resolveAuthAgentDir = (agentDir?: string) =>
|
||||
agentDir ?? resolveClawdbotAgentDir();
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
const MINIMAX_API_COST = {
|
||||
input: 15,
|
||||
output: 60,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 10,
|
||||
};
|
||||
const MINIMAX_HOSTED_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
const MINIMAX_LM_STUDIO_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
const MINIMAX_MODEL_CATALOG = {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false },
|
||||
"MiniMax-M2.1-lightning": {
|
||||
name: "MiniMax M2.1 Lightning",
|
||||
reasoning: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG;
|
||||
|
||||
function buildMinimaxModelDefinition(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
}): ModelDefinitionConfig {
|
||||
const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId];
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`,
|
||||
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: params.cost,
|
||||
contextWindow: params.contextWindow,
|
||||
maxTokens: params.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxApiModelDefinition(
|
||||
modelId: string,
|
||||
): ModelDefinitionConfig {
|
||||
return buildMinimaxModelDefinition({
|
||||
id: modelId,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
}
|
||||
|
||||
function buildMoonshotModelDefinition(): ModelDefinitionConfig {
|
||||
return {
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2 0905 Preview",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: string,
|
||||
creds: OAuthCredentials,
|
||||
agentDir?: string,
|
||||
): Promise<void> {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: `${provider}:${creds.email ?? "default"}`,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider,
|
||||
...creds,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setAnthropicApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "anthropic:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setGeminiApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "google:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "google",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setMinimaxApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "minimax:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "minimax",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setMoonshotApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "moonshot:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "moonshot",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSyntheticApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "synthetic:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "synthetic",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
|
||||
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
|
||||
|
||||
export async function setZaiApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "zai:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "zai",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setOpenrouterApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "openrouter:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "openrouter",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyZaiConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[ZAI_DEFAULT_MODEL_REF] = {
|
||||
...models[ZAI_DEFAULT_MODEL_REF],
|
||||
alias: models[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM",
|
||||
};
|
||||
|
||||
const existingModel = cfg.agents?.defaults?.model;
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: ZAI_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenrouterProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[OPENROUTER_DEFAULT_MODEL_REF] = {
|
||||
...models[OPENROUTER_DEFAULT_MODEL_REF],
|
||||
alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenrouterConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyOpenrouterProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMoonshotProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[MOONSHOT_DEFAULT_MODEL_REF] = {
|
||||
...models[MOONSHOT_DEFAULT_MODEL_REF],
|
||||
alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.moonshot;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const defaultModel = buildMoonshotModelDefinition();
|
||||
const hasDefaultModel = existingModels.some(
|
||||
(model) => model.id === MOONSHOT_DEFAULT_MODEL_ID,
|
||||
);
|
||||
const mergedModels = hasDefaultModel
|
||||
? existingModels
|
||||
: [...existingModels, defaultModel];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim();
|
||||
providers.moonshot = {
|
||||
...existingProviderRest,
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
api: "openai-completions",
|
||||
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMoonshotConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyMoonshotProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: MOONSHOT_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applySyntheticProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[SYNTHETIC_DEFAULT_MODEL_REF] = {
|
||||
...models[SYNTHETIC_DEFAULT_MODEL_REF],
|
||||
alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.1",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.synthetic;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const syntheticModels = SYNTHETIC_MODEL_CATALOG.map(
|
||||
buildSyntheticModelDefinition,
|
||||
);
|
||||
const mergedModels = [
|
||||
...existingModels,
|
||||
...syntheticModels.filter(
|
||||
(model) => !existingModels.some((existing) => existing.id === model.id),
|
||||
),
|
||||
];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim();
|
||||
providers.synthetic = {
|
||||
...existingProviderRest,
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : syntheticModels,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applySyntheticConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applySyntheticProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel &&
|
||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAuthProfileConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth" | "token";
|
||||
email?: string;
|
||||
preferProfileFirst?: boolean;
|
||||
},
|
||||
): ClawdbotConfig {
|
||||
const profiles = {
|
||||
...cfg.auth?.profiles,
|
||||
[params.profileId]: {
|
||||
provider: params.provider,
|
||||
mode: params.mode,
|
||||
...(params.email ? { email: params.email } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Only maintain `auth.order` when the user explicitly configured it.
|
||||
// Default behavior: no explicit order -> resolveAuthProfileOrder can round-robin by lastUsed.
|
||||
const existingProviderOrder = cfg.auth?.order?.[params.provider];
|
||||
const preferProfileFirst = params.preferProfileFirst ?? true;
|
||||
const reorderedProviderOrder =
|
||||
existingProviderOrder && preferProfileFirst
|
||||
? [
|
||||
params.profileId,
|
||||
...existingProviderOrder.filter(
|
||||
(profileId) => profileId !== params.profileId,
|
||||
),
|
||||
]
|
||||
: existingProviderOrder;
|
||||
const order =
|
||||
existingProviderOrder !== undefined
|
||||
? {
|
||||
...cfg.auth?.order,
|
||||
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
|
||||
? reorderedProviderOrder
|
||||
: [...(reorderedProviderOrder ?? []), params.profileId],
|
||||
}
|
||||
: cfg.auth?.order;
|
||||
return {
|
||||
...cfg,
|
||||
auth: {
|
||||
...cfg.auth,
|
||||
profiles,
|
||||
...(order ? { order } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models["anthropic/claude-opus-4-5"] = {
|
||||
...models["anthropic/claude-opus-4-5"],
|
||||
alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus",
|
||||
};
|
||||
models["lmstudio/minimax-m2.1-gs32"] = {
|
||||
...models["lmstudio/minimax-m2.1-gs32"],
|
||||
alias: models["lmstudio/minimax-m2.1-gs32"]?.alias ?? "Minimax",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
if (!providers.lmstudio) {
|
||||
providers.lmstudio = {
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
apiKey: "lmstudio",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
buildMinimaxModelDefinition({
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
reasoning: false,
|
||||
cost: MINIMAX_LM_STUDIO_COST,
|
||||
contextWindow: 196608,
|
||||
maxTokens: 8192,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxHostedProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { baseUrl?: string },
|
||||
): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[MINIMAX_HOSTED_MODEL_REF] = {
|
||||
...models[MINIMAX_HOSTED_MODEL_REF],
|
||||
alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const hostedModel = buildMinimaxModelDefinition({
|
||||
id: MINIMAX_HOSTED_MODEL_ID,
|
||||
cost: MINIMAX_HOSTED_COST,
|
||||
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
|
||||
});
|
||||
const existingProvider = providers.minimax;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const hasHostedModel = existingModels.some(
|
||||
(model) => model.id === MINIMAX_HOSTED_MODEL_ID,
|
||||
);
|
||||
const mergedModels = hasHostedModel
|
||||
? existingModels
|
||||
: [...existingModels, hostedModel];
|
||||
providers.minimax = {
|
||||
...existingProvider,
|
||||
baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL,
|
||||
apiKey: "minimax",
|
||||
api: "openai-completions",
|
||||
models: mergedModels.length > 0 ? mergedModels : [hostedModel],
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyMinimaxProviderConfig(cfg);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: "lmstudio/minimax-m2.1-gs32",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxHostedConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { baseUrl?: string },
|
||||
): ClawdbotConfig {
|
||||
const next = applyMinimaxHostedProviderConfig(cfg, params);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...next.agents?.defaults?.model,
|
||||
primary: MINIMAX_HOSTED_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
|
||||
export function applyMinimaxApiProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.minimax;
|
||||
const existingModels = Array.isArray(existingProvider?.models)
|
||||
? existingProvider.models
|
||||
: [];
|
||||
const apiModel = buildMinimaxApiModelDefinition(modelId);
|
||||
const hasApiModel = existingModels.some((model) => model.id === modelId);
|
||||
const mergedModels = hasApiModel
|
||||
? existingModels
|
||||
: [...existingModels, apiModel];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } =
|
||||
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
|
||||
const resolvedApiKey =
|
||||
typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey =
|
||||
resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
|
||||
providers.minimax = {
|
||||
...existingProviderRest,
|
||||
baseUrl: MINIMAX_API_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : [apiModel],
|
||||
};
|
||||
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[`minimax/${modelId}`] = {
|
||||
...models[`minimax/${modelId}`],
|
||||
alias: "Minimax",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: { mode: cfg.models?.mode ?? "merge", providers },
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMinimaxApiConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
modelId: string = "MiniMax-M2.1",
|
||||
): ClawdbotConfig {
|
||||
const next = applyMinimaxApiProviderConfig(cfg, modelId);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: `minimax/${modelId}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "opencode:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "opencode",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyOpencodeZenProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
// Use the built-in opencode provider from pi-ai; only seed the allowlist alias.
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = {
|
||||
...models[OPENCODE_ZEN_DEFAULT_MODEL_REF],
|
||||
alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpencodeZenConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyOpencodeZenProviderConfig(cfg);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
applyOpencodeZenConfig,
|
||||
applyOpencodeZenProviderConfig,
|
||||
} from "./onboard-auth.config-opencode.js";
|
||||
export {
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setMinimaxApiKey,
|
||||
setMoonshotApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setZaiApiKey,
|
||||
writeOAuthCredentials,
|
||||
ZAI_DEFAULT_MODEL_REF,
|
||||
} from "./onboard-auth.credentials.js";
|
||||
export {
|
||||
buildMinimaxApiModelDefinition,
|
||||
buildMinimaxModelDefinition,
|
||||
buildMoonshotModelDefinition,
|
||||
DEFAULT_MINIMAX_BASE_URL,
|
||||
MINIMAX_API_BASE_URL,
|
||||
MINIMAX_HOSTED_MODEL_ID,
|
||||
MINIMAX_HOSTED_MODEL_REF,
|
||||
MOONSHOT_BASE_URL,
|
||||
MOONSHOT_DEFAULT_MODEL_ID,
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
} from "./onboard-auth.models.js";
|
||||
|
||||
@ -1,136 +1,10 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
upsertAuthProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import {
|
||||
type ClawdbotConfig,
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { upsertSharedEnvVar } from "../infra/env-file.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
buildTokenProfileId,
|
||||
validateAnthropicSetupToken,
|
||||
} from "./auth-token.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
isGatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMoonshotConfig,
|
||||
applyOpencodeZenConfig,
|
||||
applyOpenrouterConfig,
|
||||
applySyntheticConfig,
|
||||
applyZaiConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setMinimaxApiKey,
|
||||
setMoonshotApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setZaiApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensureWorkspaceAndSessions,
|
||||
randomToken,
|
||||
} from "./onboard-helpers.js";
|
||||
import type { AuthChoice, OnboardOptions } from "./onboard-types.js";
|
||||
import { applyOpenAICodexModelDefault } from "./openai-codex-model-default.js";
|
||||
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
||||
|
||||
type NonInteractiveApiKeySource = "flag" | "env" | "profile";
|
||||
|
||||
async function resolveApiKeyFromProfiles(params: {
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
}): Promise<string | null> {
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: params.cfg,
|
||||
store,
|
||||
provider: params.provider,
|
||||
});
|
||||
for (const profileId of order) {
|
||||
const cred = store.profiles[profileId];
|
||||
if (cred?.type !== "api_key") continue;
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg: params.cfg,
|
||||
store,
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (resolved?.apiKey) return resolved.apiKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveNonInteractiveApiKey(params: {
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
flagValue?: string;
|
||||
flagName: string;
|
||||
envVar: string;
|
||||
runtime: RuntimeEnv;
|
||||
agentDir?: string;
|
||||
allowProfile?: boolean;
|
||||
}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> {
|
||||
const flagKey = params.flagValue?.trim();
|
||||
if (flagKey) return { key: flagKey, source: "flag" };
|
||||
|
||||
const envResolved = resolveEnvApiKey(params.provider);
|
||||
if (envResolved?.apiKey) return { key: envResolved.apiKey, source: "env" };
|
||||
|
||||
if (params.allowProfile ?? true) {
|
||||
const profileKey = await resolveApiKeyFromProfiles({
|
||||
provider: params.provider,
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (profileKey) return { key: profileKey, source: "profile" };
|
||||
}
|
||||
|
||||
const profileHint =
|
||||
params.allowProfile === false
|
||||
? ""
|
||||
: `, or existing ${params.provider} API-key profile`;
|
||||
params.runtime.error(
|
||||
`Missing ${params.flagName} (or ${params.envVar} in env${profileHint}).`,
|
||||
);
|
||||
params.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
import { runNonInteractiveOnboardingLocal } from "./onboard-non-interactive/local.js";
|
||||
import { runNonInteractiveOnboardingRemote } from "./onboard-non-interactive/remote.js";
|
||||
import type { OnboardOptions } from "./onboard-types.js";
|
||||
|
||||
export async function runNonInteractiveOnboarding(
|
||||
opts: OnboardOptions,
|
||||
@ -144,6 +18,7 @@ export async function runNonInteractiveOnboarding(
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||
const mode = opts.mode ?? "local";
|
||||
if (mode !== "local" && mode !== "remote") {
|
||||
@ -153,529 +28,9 @@ export async function runNonInteractiveOnboarding(
|
||||
}
|
||||
|
||||
if (mode === "remote") {
|
||||
const remoteUrl = opts.remoteUrl?.trim();
|
||||
if (!remoteUrl) {
|
||||
runtime.error("Missing --remote-url for remote mode.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let nextConfig: ClawdbotConfig = {
|
||||
...baseConfig,
|
||||
gateway: {
|
||||
...baseConfig.gateway,
|
||||
mode: "remote",
|
||||
remote: {
|
||||
url: remoteUrl,
|
||||
token: opts.remoteToken?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
|
||||
const payload = {
|
||||
mode,
|
||||
remoteUrl,
|
||||
auth: opts.remoteToken ? "token" : "none",
|
||||
};
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
runtime.log(`Remote gateway: ${remoteUrl}`);
|
||||
runtime.log(`Auth: ${payload.auth}`);
|
||||
}
|
||||
await runNonInteractiveOnboardingRemote({ opts, runtime, baseConfig });
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceDir = resolveUserPath(
|
||||
(
|
||||
opts.workspace ??
|
||||
baseConfig.agents?.defaults?.workspace ??
|
||||
DEFAULT_WORKSPACE
|
||||
).trim(),
|
||||
);
|
||||
|
||||
let nextConfig: ClawdbotConfig = {
|
||||
...baseConfig,
|
||||
agents: {
|
||||
...baseConfig.agents,
|
||||
defaults: {
|
||||
...baseConfig.agents?.defaults,
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
...baseConfig.gateway,
|
||||
mode: "local",
|
||||
},
|
||||
};
|
||||
|
||||
const authChoice: AuthChoice = opts.authChoice ?? "skip";
|
||||
if (authChoice === "token") {
|
||||
const providerRaw = opts.tokenProvider?.trim();
|
||||
if (!providerRaw) {
|
||||
runtime.error("Missing --token-provider for --auth-choice token.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const provider = normalizeProviderId(providerRaw);
|
||||
if (provider !== "anthropic") {
|
||||
runtime.error(
|
||||
"Only --token-provider anthropic is supported for --auth-choice token.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tokenRaw = opts.token?.trim();
|
||||
if (!tokenRaw) {
|
||||
runtime.error("Missing --token for --auth-choice token.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tokenError = validateAnthropicSetupToken(tokenRaw);
|
||||
if (tokenError) {
|
||||
runtime.error(tokenError);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let expires: number | undefined;
|
||||
const expiresInRaw = opts.tokenExpiresIn?.trim();
|
||||
if (expiresInRaw) {
|
||||
try {
|
||||
expires =
|
||||
Date.now() + parseDurationMs(expiresInRaw, { defaultUnit: "d" });
|
||||
} catch (err) {
|
||||
runtime.error(`Invalid --token-expires-in: ${String(err)}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const profileId =
|
||||
opts.tokenProfileId?.trim() ||
|
||||
buildTokenProfileId({ provider, name: "" });
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider,
|
||||
token: tokenRaw.trim(),
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
});
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
} else if (authChoice === "apiKey") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "anthropic",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.anthropicApiKey,
|
||||
flagName: "--anthropic-api-key",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setAnthropicApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "gemini-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "google",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.geminiApiKey,
|
||||
flagName: "--gemini-api-key",
|
||||
envVar: "GEMINI_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setGeminiApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "google:default",
|
||||
provider: "google",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyGoogleGeminiModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "zai-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "zai",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.zaiApiKey,
|
||||
flagName: "--zai-api-key",
|
||||
envVar: "ZAI_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setZaiApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "zai:default",
|
||||
provider: "zai",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyZaiConfig(nextConfig);
|
||||
} else if (authChoice === "openai-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "openai",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.openaiApiKey,
|
||||
flagName: "--openai-api-key",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
runtime,
|
||||
allowProfile: false,
|
||||
});
|
||||
if (!resolved) return;
|
||||
const key = resolved.key;
|
||||
const result = upsertSharedEnvVar({
|
||||
key: "OPENAI_API_KEY",
|
||||
value: key,
|
||||
});
|
||||
process.env.OPENAI_API_KEY = key;
|
||||
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
|
||||
} else if (authChoice === "openrouter-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "openrouter",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.openrouterApiKey,
|
||||
flagName: "--openrouter-api-key",
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpenrouterApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openrouter:default",
|
||||
provider: "openrouter",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyOpenrouterConfig(nextConfig);
|
||||
} else if (authChoice === "moonshot-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "moonshot",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.moonshotApiKey,
|
||||
flagName: "--moonshot-api-key",
|
||||
envVar: "MOONSHOT_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setMoonshotApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "moonshot:default",
|
||||
provider: "moonshot",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyMoonshotConfig(nextConfig);
|
||||
} else if (authChoice === "synthetic-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "synthetic",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.syntheticApiKey,
|
||||
flagName: "--synthetic-api-key",
|
||||
envVar: "SYNTHETIC_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setSyntheticApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "synthetic:default",
|
||||
provider: "synthetic",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applySyntheticConfig(nextConfig);
|
||||
} else if (
|
||||
authChoice === "minimax-cloud" ||
|
||||
authChoice === "minimax-api" ||
|
||||
authChoice === "minimax-api-lightning"
|
||||
) {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "minimax",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.minimaxApiKey,
|
||||
flagName: "--minimax-api-key",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setMinimaxApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "minimax:default",
|
||||
provider: "minimax",
|
||||
mode: "api_key",
|
||||
});
|
||||
const modelId =
|
||||
authChoice === "minimax-api-lightning"
|
||||
? "MiniMax-M2.1-lightning"
|
||||
: "MiniMax-M2.1";
|
||||
nextConfig = applyMinimaxApiConfig(nextConfig, modelId);
|
||||
} else if (authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(undefined, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
runtime.error(
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (authChoice === "codex-cli") {
|
||||
const store = ensureAuthProfileStore();
|
||||
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
|
||||
runtime.error("No Codex CLI credentials found at ~/.codex/auth.json");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CODEX_CLI_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
});
|
||||
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (authChoice === "opencode-zen") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "opencode",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.opencodeZenApiKey,
|
||||
flagName: "--opencode-zen-api-key",
|
||||
envVar: "OPENCODE_API_KEY (or OPENCODE_ZEN_API_KEY)",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpencodeZenApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "opencode:default",
|
||||
provider: "opencode",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyOpencodeZenConfig(nextConfig);
|
||||
} else if (
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "chutes" ||
|
||||
authChoice === "openai-codex" ||
|
||||
authChoice === "antigravity"
|
||||
) {
|
||||
const label = authChoice === "antigravity" ? "Antigravity" : "OAuth";
|
||||
runtime.error(`${label} requires interactive mode.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasGatewayPort = opts.gatewayPort !== undefined;
|
||||
if (
|
||||
hasGatewayPort &&
|
||||
(!Number.isFinite(opts.gatewayPort) || (opts.gatewayPort ?? 0) <= 0)
|
||||
) {
|
||||
runtime.error("Invalid --gateway-port");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const port = hasGatewayPort
|
||||
? (opts.gatewayPort as number)
|
||||
: resolveGatewayPort(baseConfig);
|
||||
let bind = opts.gatewayBind ?? "loopback";
|
||||
let authMode = opts.gatewayAuth ?? "token";
|
||||
const tailscaleMode = opts.tailscale ?? "off";
|
||||
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
|
||||
|
||||
if (tailscaleMode !== "off" && bind !== "loopback") {
|
||||
bind = "loopback";
|
||||
}
|
||||
if (authMode === "off" && bind !== "loopback") {
|
||||
authMode = "token";
|
||||
}
|
||||
if (tailscaleMode === "funnel" && authMode !== "password") {
|
||||
authMode = "password";
|
||||
}
|
||||
|
||||
let gatewayToken = opts.gatewayToken?.trim() || undefined;
|
||||
if (authMode === "token") {
|
||||
if (!gatewayToken) gatewayToken = randomToken();
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
auth: {
|
||||
...nextConfig.gateway?.auth,
|
||||
mode: "token",
|
||||
token: gatewayToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (authMode === "password") {
|
||||
const password = opts.gatewayPassword?.trim();
|
||||
if (!password) {
|
||||
runtime.error("Missing --gateway-password for password auth.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
auth: {
|
||||
...nextConfig.gateway?.auth,
|
||||
mode: "password",
|
||||
password,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
port,
|
||||
bind,
|
||||
tailscale: {
|
||||
...nextConfig.gateway?.tailscale,
|
||||
mode: tailscaleMode,
|
||||
resetOnExit: tailscaleResetOnExit,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!opts.skipSkills) {
|
||||
const nodeManager = opts.nodeManager ?? "npm";
|
||||
if (!["npm", "pnpm", "bun"].includes(nodeManager)) {
|
||||
runtime.error("Invalid --node-manager (use npm, pnpm, or bun)");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
skills: {
|
||||
...nextConfig.skills,
|
||||
install: {
|
||||
...nextConfig.skills?.install,
|
||||
nodeManager,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
});
|
||||
|
||||
const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
|
||||
if (opts.installDaemon) {
|
||||
const systemdAvailable =
|
||||
process.platform === "linux"
|
||||
? await isSystemdUserServiceAvailable()
|
||||
: true;
|
||||
if (process.platform === "linux" && !systemdAvailable) {
|
||||
runtime.log(
|
||||
"Systemd user services are unavailable; skipping daemon install.",
|
||||
);
|
||||
} else {
|
||||
if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) {
|
||||
runtime.error("Invalid --daemon-runtime (use node or bun)");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } =
|
||||
await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntimeRaw === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(
|
||||
systemNode,
|
||||
programArguments[0],
|
||||
);
|
||||
if (warning) runtime.log(warning);
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
await ensureSystemdUserLingerNonInteractive({ runtime });
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.skipHealth) {
|
||||
await sleep(1000);
|
||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode,
|
||||
workspace: workspaceDir,
|
||||
authChoice,
|
||||
gateway: { port, bind, authMode, tailscaleMode },
|
||||
installDaemon: Boolean(opts.installDaemon),
|
||||
daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined,
|
||||
skipSkills: Boolean(opts.skipSkills),
|
||||
skipHealth: Boolean(opts.skipHealth),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
await runNonInteractiveOnboardingLocal({ opts, runtime, baseConfig });
|
||||
}
|
||||
|
||||
@ -2,10 +2,17 @@ import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
upsertAuthProfile,
|
||||
} from "../../../agents/auth-profiles.js";
|
||||
import { normalizeProviderId } from "../../../agents/model-selection.js";
|
||||
import { parseDurationMs } from "../../../cli/parse-duration.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { upsertSharedEnvVar } from "../../../infra/env-file.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import {
|
||||
buildTokenProfileId,
|
||||
validateAnthropicSetupToken,
|
||||
} from "../../auth-token.js";
|
||||
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
@ -27,7 +34,6 @@ import {
|
||||
} from "../../onboard-auth.js";
|
||||
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
||||
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
|
||||
|
||||
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
||||
|
||||
export async function applyNonInteractiveAuthChoice(params: {
|
||||
@ -58,6 +64,66 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (authChoice === "token") {
|
||||
const providerRaw = opts.tokenProvider?.trim();
|
||||
if (!providerRaw) {
|
||||
runtime.error("Missing --token-provider for --auth-choice token.");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const provider = normalizeProviderId(providerRaw);
|
||||
if (provider !== "anthropic") {
|
||||
runtime.error(
|
||||
"Only --token-provider anthropic is supported for --auth-choice token.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const tokenRaw = opts.token?.trim();
|
||||
if (!tokenRaw) {
|
||||
runtime.error("Missing --token for --auth-choice token.");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const tokenError = validateAnthropicSetupToken(tokenRaw);
|
||||
if (tokenError) {
|
||||
runtime.error(tokenError);
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
let expires: number | undefined;
|
||||
const expiresInRaw = opts.tokenExpiresIn?.trim();
|
||||
if (expiresInRaw) {
|
||||
try {
|
||||
expires =
|
||||
Date.now() + parseDurationMs(expiresInRaw, { defaultUnit: "d" });
|
||||
} catch (err) {
|
||||
runtime.error(`Invalid --token-expires-in: ${String(err)}`);
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const profileId =
|
||||
opts.tokenProfileId?.trim() ||
|
||||
buildTokenProfileId({ provider, name: "" });
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider,
|
||||
token: tokenRaw.trim(),
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
});
|
||||
return applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
}
|
||||
|
||||
if (authChoice === "gemini-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "google",
|
||||
@ -255,18 +321,12 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "chutes" ||
|
||||
authChoice === "openai-codex" ||
|
||||
authChoice === "antigravity"
|
||||
) {
|
||||
const label =
|
||||
authChoice === "antigravity"
|
||||
? "Antigravity"
|
||||
: authChoice === "token"
|
||||
? "Token"
|
||||
: "OAuth";
|
||||
const label = authChoice === "antigravity" ? "Antigravity" : "OAuth";
|
||||
runtime.error(`${label} requires interactive mode.`);
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
resolveGatewayPort,
|
||||
} from "../config/config.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
@ -14,11 +13,8 @@ import { probeGateway } from "../gateway/probe.js";
|
||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import {
|
||||
readRestartSentinel,
|
||||
summarizeRestartSentinel,
|
||||
} from "../infra/restart-sentinel.js";
|
||||
import { inspectPortUsage } from "../infra/ports.js";
|
||||
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
||||
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
||||
import {
|
||||
checkUpdateStatus,
|
||||
@ -26,23 +22,13 @@ import {
|
||||
} from "../infra/update-check.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
||||
import { buildChannelsTable } from "./status-all/channels.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
formatGatewayAuthUsed,
|
||||
redactSecrets,
|
||||
} from "./status-all/format.js";
|
||||
import {
|
||||
pickGatewaySelfPresence,
|
||||
readFileTailLines,
|
||||
summarizeLogTail,
|
||||
} from "./status-all/gateway.js";
|
||||
import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
|
||||
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
|
||||
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
|
||||
|
||||
export async function statusAllCommand(
|
||||
runtime: RuntimeEnv,
|
||||
@ -410,331 +396,34 @@ export async function statusAllCommand(
|
||||
},
|
||||
];
|
||||
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||
const ok = (text: string) => (rich ? theme.success(text) : text);
|
||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||
const fail = (text: string) => (rich ? theme.error(text) : text);
|
||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
const overview = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
const lines = await buildStatusAllReportLines({
|
||||
progress,
|
||||
overviewRows,
|
||||
channels,
|
||||
channelIssues: channelIssues.map((issue) => ({
|
||||
channel: issue.channel,
|
||||
message: issue.message,
|
||||
})),
|
||||
agentStatus,
|
||||
connectionDetailsForReport,
|
||||
diagnosis: {
|
||||
snap,
|
||||
remoteUrlMissing,
|
||||
sentinel,
|
||||
lastErr,
|
||||
port,
|
||||
portUsage,
|
||||
tailscaleMode,
|
||||
tailscale,
|
||||
tailscaleHttpsUrl,
|
||||
skillStatus,
|
||||
channelsStatus,
|
||||
channelIssues,
|
||||
gatewayReachable,
|
||||
health,
|
||||
},
|
||||
});
|
||||
|
||||
const channelRows = channels.rows.map((row) => ({
|
||||
channelId: row.id,
|
||||
Channel: row.label,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
State:
|
||||
row.state === "ok"
|
||||
? ok("OK")
|
||||
: row.state === "warn"
|
||||
? warn("WARN")
|
||||
: row.state === "off"
|
||||
? muted("OFF")
|
||||
: theme.accentDim("SETUP"),
|
||||
Detail: row.detail,
|
||||
}));
|
||||
const channelIssuesByChannel = (() => {
|
||||
const map = new Map<string, typeof channelIssues>();
|
||||
for (const issue of channelIssues) {
|
||||
const key = issue.channel;
|
||||
const list = map.get(key);
|
||||
if (list) list.push(issue);
|
||||
else map.set(key, [issue]);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
const channelRowsWithIssues = channelRows.map((row) => {
|
||||
const issues = channelIssuesByChannel.get(row.channelId) ?? [];
|
||||
if (issues.length === 0) return row;
|
||||
const issue = issues[0];
|
||||
const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`;
|
||||
return {
|
||||
...row,
|
||||
State: warn("WARN"),
|
||||
Detail: `${row.Detail}${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
const channelsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Channel", header: "Channel", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "State", header: "State", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows: channelRowsWithIssues,
|
||||
});
|
||||
|
||||
const agentRows = agentStatus.agents.map((a) => ({
|
||||
Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id,
|
||||
Bootstrap:
|
||||
a.bootstrapPending === true
|
||||
? warn("PENDING")
|
||||
: a.bootstrapPending === false
|
||||
? ok("OK")
|
||||
: "unknown",
|
||||
Sessions: String(a.sessionsCount),
|
||||
Active:
|
||||
a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
|
||||
Store: a.sessionsPath,
|
||||
}));
|
||||
|
||||
const agentsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Agent", header: "Agent", minWidth: 12 },
|
||||
{ key: "Bootstrap", header: "Bootstrap", minWidth: 10 },
|
||||
{ key: "Sessions", header: "Sessions", align: "right", minWidth: 8 },
|
||||
{ key: "Active", header: "Active", minWidth: 10 },
|
||||
{ key: "Store", header: "Store", flex: true, minWidth: 34 },
|
||||
],
|
||||
rows: agentRows,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(heading("Clawdbot status --all"));
|
||||
lines.push("");
|
||||
lines.push(heading("Overview"));
|
||||
lines.push(overview.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Channels"));
|
||||
lines.push(channelsTable.trimEnd());
|
||||
for (const detail of channels.details) {
|
||||
lines.push("");
|
||||
lines.push(heading(detail.title));
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: detail.columns.map((c) => ({
|
||||
key: c,
|
||||
header: c,
|
||||
flex: c === "Notes",
|
||||
minWidth: c === "Notes" ? 28 : 10,
|
||||
})),
|
||||
rows: detail.rows.map((r) => ({
|
||||
...r,
|
||||
...(r.Status === "OK"
|
||||
? { Status: ok("OK") }
|
||||
: r.Status === "WARN"
|
||||
? { Status: warn("WARN") }
|
||||
: {}),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(heading("Agents"));
|
||||
lines.push(agentsTable.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Diagnosis (read-only)"));
|
||||
|
||||
const emitCheck = (label: string, status: "ok" | "warn" | "fail") => {
|
||||
const icon =
|
||||
status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗");
|
||||
const colored =
|
||||
status === "ok"
|
||||
? ok(label)
|
||||
: status === "warn"
|
||||
? warn(label)
|
||||
: fail(label);
|
||||
lines.push(`${icon} ${colored}`);
|
||||
};
|
||||
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway connection details:")}`);
|
||||
for (const line of redactSecrets(connectionDetailsForReport)
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (snap) {
|
||||
const status = !snap.exists ? "fail" : snap.valid ? "ok" : "warn";
|
||||
emitCheck(`Config: ${snap.path ?? "(unknown)"}`, status);
|
||||
const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])];
|
||||
const uniqueIssues = issues.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex(
|
||||
(x) => x.path === issue.path && x.message === issue.message,
|
||||
) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
lines.push(` - ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck("Config: read failed", "warn");
|
||||
}
|
||||
|
||||
if (remoteUrlMissing) {
|
||||
lines.push("");
|
||||
emitCheck(
|
||||
"Gateway remote mode misconfigured (gateway.remote.url missing)",
|
||||
"warn",
|
||||
);
|
||||
lines.push(
|
||||
` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (sentinel?.payload) {
|
||||
emitCheck("Restart sentinel present", "warn");
|
||||
lines.push(
|
||||
` ${muted(`${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`)}`,
|
||||
);
|
||||
} else {
|
||||
emitCheck("Restart sentinel: none", "ok");
|
||||
}
|
||||
|
||||
const lastErrClean = lastErr?.trim() ?? "";
|
||||
const isTrivialLastErr =
|
||||
lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{";
|
||||
if (lastErrClean && !isTrivialLastErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway last log line:")}`);
|
||||
lines.push(` ${muted(redactSecrets(lastErrClean))}`);
|
||||
}
|
||||
|
||||
if (portUsage) {
|
||||
const portOk = portUsage.listeners.length === 0;
|
||||
emitCheck(`Port ${port}`, portOk ? "ok" : "warn");
|
||||
if (!portOk) {
|
||||
for (const line of formatPortDiagnostics(portUsage)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const backend = tailscale.backendState ?? "unknown";
|
||||
const okBackend = backend === "Running";
|
||||
const hasDns = Boolean(tailscale.dnsName);
|
||||
const label =
|
||||
tailscaleMode === "off"
|
||||
? `Tailscale: off · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`
|
||||
: `Tailscale: ${tailscaleMode} · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`;
|
||||
emitCheck(
|
||||
label,
|
||||
okBackend && (tailscaleMode === "off" || hasDns) ? "ok" : "warn",
|
||||
);
|
||||
if (tailscale.error) {
|
||||
lines.push(` ${muted(`error: ${tailscale.error}`)}`);
|
||||
}
|
||||
if (tailscale.ips.length > 0) {
|
||||
lines.push(
|
||||
` ${muted(`ips: ${tailscale.ips.slice(0, 3).join(", ")}${tailscale.ips.length > 3 ? "…" : ""}`)}`,
|
||||
);
|
||||
}
|
||||
if (tailscaleHttpsUrl) {
|
||||
lines.push(` ${muted(`https: ${tailscaleHttpsUrl}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (skillStatus) {
|
||||
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
|
||||
const missing = skillStatus.skills.filter(
|
||||
(s) =>
|
||||
s.eligible && Object.values(s.missing).some((arr) => arr.length),
|
||||
).length;
|
||||
emitCheck(
|
||||
`Skills: ${eligible} eligible · ${missing} missing · ${skillStatus.workspaceDir}`,
|
||||
missing === 0 ? "ok" : "warn",
|
||||
);
|
||||
}
|
||||
|
||||
progress.setLabel("Reading logs…");
|
||||
const logPaths = (() => {
|
||||
try {
|
||||
return resolveGatewayLogPaths(process.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (logPaths) {
|
||||
progress.setLabel("Reading logs…");
|
||||
const [stderrTail, stdoutTail] = await Promise.all([
|
||||
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
|
||||
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
|
||||
]);
|
||||
if (stderrTail.length > 0 || stdoutTail.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${muted(`Gateway logs (tail, summarized): ${logPaths.logDir}`)}`,
|
||||
);
|
||||
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
|
||||
for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
|
||||
for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
progress.tick();
|
||||
|
||||
if (channelsStatus) {
|
||||
emitCheck(
|
||||
`Channel issues (${channelIssues.length || "none"})`,
|
||||
channelIssues.length === 0 ? "ok" : "warn",
|
||||
);
|
||||
for (const issue of channelIssues.slice(0, 12)) {
|
||||
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
|
||||
lines.push(
|
||||
` - ${issue.channel}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
|
||||
);
|
||||
}
|
||||
if (channelIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${channelIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck(
|
||||
`Channel issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`,
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
const healthErr = (() => {
|
||||
if (!health || typeof health !== "object") return "";
|
||||
const record = health as Record<string, unknown>;
|
||||
if (!("error" in record)) return "";
|
||||
const value = record.error;
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
})();
|
||||
if (healthErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway health:")}`);
|
||||
lines.push(` ${muted(redactSecrets(healthErr))}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(muted("Pasteable debug report. Auth tokens redacted."));
|
||||
lines.push("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
lines.push("");
|
||||
|
||||
progress.setLabel("Rendering…");
|
||||
runtime.log(lines.join("\n"));
|
||||
progress.tick();
|
||||
|
||||
269
src/commands/status-all/diagnosis.ts
Normal file
269
src/commands/status-all/diagnosis.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import { formatPortDiagnostics } from "../../infra/ports.js";
|
||||
import {
|
||||
type RestartSentinelPayload,
|
||||
summarizeRestartSentinel,
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { formatAge, redactSecrets } from "./format.js";
|
||||
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
|
||||
|
||||
type ConfigIssueLike = { path: string; message: string };
|
||||
type ConfigSnapshotLike = {
|
||||
exists: boolean;
|
||||
valid: boolean;
|
||||
path?: string | null;
|
||||
legacyIssues?: ConfigIssueLike[] | null;
|
||||
issues?: ConfigIssueLike[] | null;
|
||||
};
|
||||
|
||||
type PortUsageLike = { listeners: unknown[] };
|
||||
|
||||
type TailscaleStatusLike = {
|
||||
backendState: string | null;
|
||||
dnsName: string | null;
|
||||
ips: string[];
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type SkillStatusLike = {
|
||||
workspaceDir: string;
|
||||
skills: Array<{ eligible: boolean; missing: Record<string, unknown[]> }>;
|
||||
};
|
||||
|
||||
type ChannelIssueLike = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
fix?: string;
|
||||
};
|
||||
|
||||
export async function appendStatusAllDiagnosis(params: {
|
||||
lines: string[];
|
||||
progress: ProgressReporter;
|
||||
muted: (text: string) => string;
|
||||
ok: (text: string) => string;
|
||||
warn: (text: string) => string;
|
||||
fail: (text: string) => string;
|
||||
connectionDetailsForReport: string;
|
||||
snap: ConfigSnapshotLike | null;
|
||||
remoteUrlMissing: boolean;
|
||||
sentinel: { payload?: RestartSentinelPayload | null } | null;
|
||||
lastErr: string | null;
|
||||
port: number;
|
||||
portUsage: PortUsageLike | null;
|
||||
tailscaleMode: string;
|
||||
tailscale: TailscaleStatusLike;
|
||||
tailscaleHttpsUrl: string | null;
|
||||
skillStatus: SkillStatusLike | null;
|
||||
channelsStatus: unknown;
|
||||
channelIssues: ChannelIssueLike[];
|
||||
gatewayReachable: boolean;
|
||||
health: unknown;
|
||||
}) {
|
||||
const { lines, muted, ok, warn, fail } = params;
|
||||
|
||||
const emitCheck = (label: string, status: "ok" | "warn" | "fail") => {
|
||||
const icon =
|
||||
status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗");
|
||||
const colored =
|
||||
status === "ok"
|
||||
? ok(label)
|
||||
: status === "warn"
|
||||
? warn(label)
|
||||
: fail(label);
|
||||
lines.push(`${icon} ${colored}`);
|
||||
};
|
||||
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway connection details:")}`);
|
||||
for (const line of redactSecrets(params.connectionDetailsForReport)
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (params.snap) {
|
||||
const status = !params.snap.exists
|
||||
? "fail"
|
||||
: params.snap.valid
|
||||
? "ok"
|
||||
: "warn";
|
||||
emitCheck(`Config: ${params.snap.path ?? "(unknown)"}`, status);
|
||||
const issues = [
|
||||
...(params.snap.legacyIssues ?? []),
|
||||
...(params.snap.issues ?? []),
|
||||
];
|
||||
const uniqueIssues = issues.filter(
|
||||
(issue, index) =>
|
||||
issues.findIndex(
|
||||
(x) => x.path === issue.path && x.message === issue.message,
|
||||
) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
lines.push(` - ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck("Config: read failed", "warn");
|
||||
}
|
||||
|
||||
if (params.remoteUrlMissing) {
|
||||
lines.push("");
|
||||
emitCheck(
|
||||
"Gateway remote mode misconfigured (gateway.remote.url missing)",
|
||||
"warn",
|
||||
);
|
||||
lines.push(
|
||||
` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sentinel?.payload) {
|
||||
emitCheck("Restart sentinel present", "warn");
|
||||
lines.push(
|
||||
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`,
|
||||
);
|
||||
} else {
|
||||
emitCheck("Restart sentinel: none", "ok");
|
||||
}
|
||||
|
||||
const lastErrClean = params.lastErr?.trim() ?? "";
|
||||
const isTrivialLastErr =
|
||||
lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{";
|
||||
if (lastErrClean && !isTrivialLastErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway last log line:")}`);
|
||||
lines.push(` ${muted(redactSecrets(lastErrClean))}`);
|
||||
}
|
||||
|
||||
if (params.portUsage) {
|
||||
const portOk = params.portUsage.listeners.length === 0;
|
||||
emitCheck(`Port ${params.port}`, portOk ? "ok" : "warn");
|
||||
if (!portOk) {
|
||||
for (const line of formatPortDiagnostics(params.portUsage as never)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const backend = params.tailscale.backendState ?? "unknown";
|
||||
const okBackend = backend === "Running";
|
||||
const hasDns = Boolean(params.tailscale.dnsName);
|
||||
const label =
|
||||
params.tailscaleMode === "off"
|
||||
? `Tailscale: off · ${backend}${params.tailscale.dnsName ? ` · ${params.tailscale.dnsName}` : ""}`
|
||||
: `Tailscale: ${params.tailscaleMode} · ${backend}${params.tailscale.dnsName ? ` · ${params.tailscale.dnsName}` : ""}`;
|
||||
emitCheck(
|
||||
label,
|
||||
okBackend && (params.tailscaleMode === "off" || hasDns) ? "ok" : "warn",
|
||||
);
|
||||
if (params.tailscale.error) {
|
||||
lines.push(` ${muted(`error: ${params.tailscale.error}`)}`);
|
||||
}
|
||||
if (params.tailscale.ips.length > 0) {
|
||||
lines.push(
|
||||
` ${muted(`ips: ${params.tailscale.ips.slice(0, 3).join(", ")}${params.tailscale.ips.length > 3 ? "…" : ""}`)}`,
|
||||
);
|
||||
}
|
||||
if (params.tailscaleHttpsUrl) {
|
||||
lines.push(` ${muted(`https: ${params.tailscaleHttpsUrl}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.skillStatus) {
|
||||
const eligible = params.skillStatus.skills.filter((s) => s.eligible).length;
|
||||
const missing = params.skillStatus.skills.filter(
|
||||
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
|
||||
).length;
|
||||
emitCheck(
|
||||
`Skills: ${eligible} eligible · ${missing} missing · ${params.skillStatus.workspaceDir}`,
|
||||
missing === 0 ? "ok" : "warn",
|
||||
);
|
||||
}
|
||||
|
||||
params.progress.setLabel("Reading logs…");
|
||||
const logPaths = (() => {
|
||||
try {
|
||||
return resolveGatewayLogPaths(process.env);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (logPaths) {
|
||||
params.progress.setLabel("Reading logs…");
|
||||
const [stderrTail, stdoutTail] = await Promise.all([
|
||||
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
|
||||
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
|
||||
]);
|
||||
if (stderrTail.length > 0 || stdoutTail.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${muted(`Gateway logs (tail, summarized): ${logPaths.logDir}`)}`,
|
||||
);
|
||||
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
|
||||
for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
|
||||
for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).map(
|
||||
redactSecrets,
|
||||
)) {
|
||||
lines.push(` ${muted(line)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
params.progress.tick();
|
||||
|
||||
if (params.channelsStatus) {
|
||||
emitCheck(
|
||||
`Channel issues (${params.channelIssues.length || "none"})`,
|
||||
params.channelIssues.length === 0 ? "ok" : "warn",
|
||||
);
|
||||
for (const issue of params.channelIssues.slice(0, 12)) {
|
||||
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
|
||||
lines.push(
|
||||
` - ${issue.channel}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
|
||||
);
|
||||
}
|
||||
if (params.channelIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${params.channelIssues.length - 12} more`)}`);
|
||||
}
|
||||
} else {
|
||||
emitCheck(
|
||||
`Channel issues skipped (gateway ${params.gatewayReachable ? "query failed" : "unreachable"})`,
|
||||
"warn",
|
||||
);
|
||||
}
|
||||
|
||||
const healthErr = (() => {
|
||||
if (!params.health || typeof params.health !== "object") return "";
|
||||
const record = params.health as Record<string, unknown>;
|
||||
if (!("error" in record)) return "";
|
||||
const value = record.error;
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
})();
|
||||
if (healthErr) {
|
||||
lines.push("");
|
||||
lines.push(`${muted("Gateway health:")}`);
|
||||
lines.push(` ${muted(redactSecrets(healthErr))}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(muted("Pasteable debug report. Auth tokens redacted."));
|
||||
lines.push("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
lines.push("");
|
||||
}
|
||||
198
src/commands/status-all/report-lines.ts
Normal file
198
src/commands/status-all/report-lines.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { isRich, theme } from "../../terminal/theme.js";
|
||||
import { appendStatusAllDiagnosis } from "./diagnosis.js";
|
||||
import { formatAge } from "./format.js";
|
||||
|
||||
type OverviewRow = { Item: string; Value: string };
|
||||
|
||||
type ChannelsTable = {
|
||||
rows: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
state: "ok" | "warn" | "off" | "setup";
|
||||
detail: string;
|
||||
}>;
|
||||
details: Array<{
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: Array<Record<string, string>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ChannelIssueLike = {
|
||||
channel: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type AgentStatusLike = {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string | null;
|
||||
bootstrapPending?: boolean | null;
|
||||
sessionsCount: number;
|
||||
lastActiveAgeMs?: number | null;
|
||||
sessionsPath: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function buildStatusAllReportLines(params: {
|
||||
progress: ProgressReporter;
|
||||
overviewRows: OverviewRow[];
|
||||
channels: ChannelsTable;
|
||||
channelIssues: ChannelIssueLike[];
|
||||
agentStatus: AgentStatusLike;
|
||||
connectionDetailsForReport: string;
|
||||
diagnosis: Omit<
|
||||
Parameters<typeof appendStatusAllDiagnosis>[0],
|
||||
| "lines"
|
||||
| "progress"
|
||||
| "muted"
|
||||
| "ok"
|
||||
| "warn"
|
||||
| "fail"
|
||||
| "connectionDetailsForReport"
|
||||
>;
|
||||
}) {
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
||||
const ok = (text: string) => (rich ? theme.success(text) : text);
|
||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
||||
const fail = (text: string) => (rich ? theme.error(text) : text);
|
||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
const overview = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: params.overviewRows,
|
||||
});
|
||||
|
||||
const channelRows = params.channels.rows.map((row) => ({
|
||||
channelId: row.id,
|
||||
Channel: row.label,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
State:
|
||||
row.state === "ok"
|
||||
? ok("OK")
|
||||
: row.state === "warn"
|
||||
? warn("WARN")
|
||||
: row.state === "off"
|
||||
? muted("OFF")
|
||||
: theme.accentDim("SETUP"),
|
||||
Detail: row.detail,
|
||||
}));
|
||||
const channelIssuesByChannel = (() => {
|
||||
const map = new Map<string, ChannelIssueLike[]>();
|
||||
for (const issue of params.channelIssues) {
|
||||
const key = issue.channel;
|
||||
const list = map.get(key);
|
||||
if (list) list.push(issue);
|
||||
else map.set(key, [issue]);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
const channelRowsWithIssues = channelRows.map((row) => {
|
||||
const issues = channelIssuesByChannel.get(row.channelId) ?? [];
|
||||
if (issues.length === 0) return row;
|
||||
const issue = issues[0];
|
||||
const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`;
|
||||
return {
|
||||
...row,
|
||||
State: warn("WARN"),
|
||||
Detail: `${row.Detail}${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
const channelsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Channel", header: "Channel", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "State", header: "State", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows: channelRowsWithIssues,
|
||||
});
|
||||
|
||||
const agentRows = params.agentStatus.agents.map((a) => ({
|
||||
Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id,
|
||||
Bootstrap:
|
||||
a.bootstrapPending === true
|
||||
? warn("PENDING")
|
||||
: a.bootstrapPending === false
|
||||
? ok("OK")
|
||||
: "unknown",
|
||||
Sessions: String(a.sessionsCount),
|
||||
Active:
|
||||
a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
|
||||
Store: a.sessionsPath,
|
||||
}));
|
||||
|
||||
const agentsTable = renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Agent", header: "Agent", minWidth: 12 },
|
||||
{ key: "Bootstrap", header: "Bootstrap", minWidth: 10 },
|
||||
{ key: "Sessions", header: "Sessions", align: "right", minWidth: 8 },
|
||||
{ key: "Active", header: "Active", minWidth: 10 },
|
||||
{ key: "Store", header: "Store", flex: true, minWidth: 34 },
|
||||
],
|
||||
rows: agentRows,
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(heading("Clawdbot status --all"));
|
||||
lines.push("");
|
||||
lines.push(heading("Overview"));
|
||||
lines.push(overview.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Channels"));
|
||||
lines.push(channelsTable.trimEnd());
|
||||
for (const detail of params.channels.details) {
|
||||
lines.push("");
|
||||
lines.push(heading(detail.title));
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: detail.columns.map((c) => ({
|
||||
key: c,
|
||||
header: c,
|
||||
flex: c === "Notes",
|
||||
minWidth: c === "Notes" ? 28 : 10,
|
||||
})),
|
||||
rows: detail.rows.map((r) => ({
|
||||
...r,
|
||||
...(r.Status === "OK"
|
||||
? { Status: ok("OK") }
|
||||
: r.Status === "WARN"
|
||||
? { Status: warn("WARN") }
|
||||
: {}),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(heading("Agents"));
|
||||
lines.push(agentsTable.trimEnd());
|
||||
lines.push("");
|
||||
lines.push(heading("Diagnosis (read-only)"));
|
||||
|
||||
await appendStatusAllDiagnosis({
|
||||
lines,
|
||||
progress: params.progress,
|
||||
muted,
|
||||
ok,
|
||||
warn,
|
||||
fail,
|
||||
connectionDetailsForReport: params.connectionDetailsForReport,
|
||||
...params.diagnosis,
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
99
src/commands/status.agent-local.ts
Normal file
99
src/commands/status.agent-local.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { listAgentsForGateway } from "../gateway/session-utils.js";
|
||||
|
||||
export type AgentLocalStatus = {
|
||||
id: string;
|
||||
name?: string;
|
||||
workspaceDir: string | null;
|
||||
bootstrapPending: boolean | null;
|
||||
sessionsPath: string;
|
||||
sessionsCount: number;
|
||||
lastUpdatedAt: number | null;
|
||||
lastActiveAgeMs: number | null;
|
||||
};
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentLocalStatuses(): Promise<{
|
||||
defaultId: string;
|
||||
agents: AgentLocalStatus[];
|
||||
totalSessions: number;
|
||||
bootstrapPendingCount: number;
|
||||
}> {
|
||||
const cfg = loadConfig();
|
||||
const agentList = listAgentsForGateway(cfg);
|
||||
const now = Date.now();
|
||||
|
||||
const statuses: AgentLocalStatus[] = [];
|
||||
for (const agent of agentList.agents) {
|
||||
const agentId = agent.id;
|
||||
const workspaceDir = (() => {
|
||||
try {
|
||||
return resolveAgentWorkspaceDir(cfg, agentId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const bootstrapPath =
|
||||
workspaceDir != null ? path.join(workspaceDir, "BOOTSTRAP.md") : null;
|
||||
const bootstrapPending =
|
||||
bootstrapPath != null ? await fileExists(bootstrapPath) : null;
|
||||
|
||||
const sessionsPath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = (() => {
|
||||
try {
|
||||
return loadSessionStore(sessionsPath);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const sessions = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([, entry]) => entry);
|
||||
const sessionsCount = sessions.length;
|
||||
const lastUpdatedAt = sessions.reduce(
|
||||
(max, e) => Math.max(max, e?.updatedAt ?? 0),
|
||||
0,
|
||||
);
|
||||
const resolvedLastUpdatedAt = lastUpdatedAt > 0 ? lastUpdatedAt : null;
|
||||
const lastActiveAgeMs = resolvedLastUpdatedAt
|
||||
? now - resolvedLastUpdatedAt
|
||||
: null;
|
||||
|
||||
statuses.push({
|
||||
id: agentId,
|
||||
name: agent.name,
|
||||
workspaceDir,
|
||||
bootstrapPending,
|
||||
sessionsPath,
|
||||
sessionsCount,
|
||||
lastUpdatedAt: resolvedLastUpdatedAt,
|
||||
lastActiveAgeMs,
|
||||
});
|
||||
}
|
||||
|
||||
const totalSessions = statuses.reduce((sum, s) => sum + s.sessionsCount, 0);
|
||||
const bootstrapPendingCount = statuses.reduce(
|
||||
(sum, s) => sum + (s.bootstrapPending ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
defaultId: agentList.defaultId,
|
||||
agents: statuses,
|
||||
totalSessions,
|
||||
bootstrapPendingCount,
|
||||
};
|
||||
}
|
||||
407
src/commands/status.command.ts
Normal file
407
src/commands/status.command.ts
Normal file
@ -0,0 +1,407 @@
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { info } from "../globals.js";
|
||||
import {
|
||||
formatUsageReportLines,
|
||||
loadProviderUsageSummary,
|
||||
} from "../infra/provider-usage.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
formatKTokens,
|
||||
formatTokensCompact,
|
||||
shortenText,
|
||||
} from "./status.format.js";
|
||||
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
|
||||
import { scanStatus } from "./status.scan.js";
|
||||
import { formatUpdateOneLiner } from "./status.update.js";
|
||||
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
||||
import { statusAllCommand } from "./status-all.js";
|
||||
|
||||
export async function statusCommand(
|
||||
opts: {
|
||||
json?: boolean;
|
||||
deep?: boolean;
|
||||
usage?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
all?: boolean;
|
||||
},
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
if (opts.all && !opts.json) {
|
||||
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const scan = await scanStatus(
|
||||
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
|
||||
runtime,
|
||||
);
|
||||
const {
|
||||
cfg,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
tailscaleHttpsUrl,
|
||||
update,
|
||||
gatewayConnection,
|
||||
remoteUrlMissing,
|
||||
gatewayMode,
|
||||
gatewayProbe,
|
||||
gatewayReachable,
|
||||
gatewaySelf,
|
||||
channelIssues,
|
||||
agentStatus,
|
||||
channels,
|
||||
summary,
|
||||
} = scan;
|
||||
|
||||
const usage = opts.usage
|
||||
? await withProgress(
|
||||
{
|
||||
label: "Fetching usage snapshot…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
|
||||
)
|
||||
: undefined;
|
||||
const health: HealthSummary | undefined = opts.deep
|
||||
? await withProgress(
|
||||
{
|
||||
label: "Checking gateway health…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway<HealthSummary>({
|
||||
method: "health",
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...summary,
|
||||
os: osSummary,
|
||||
update,
|
||||
gateway: {
|
||||
mode: gatewayMode,
|
||||
url: gatewayConnection.url,
|
||||
urlSource: gatewayConnection.urlSource,
|
||||
misconfigured: remoteUrlMissing,
|
||||
reachable: gatewayReachable,
|
||||
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
|
||||
self: gatewaySelf,
|
||||
error: gatewayProbe?.error ?? null,
|
||||
},
|
||||
agents: agentStatus,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = true;
|
||||
const muted = (value: string) => (rich ? theme.muted(value) : value);
|
||||
const ok = (value: string) => (rich ? theme.success(value) : value);
|
||||
const warn = (value: string) => (rich ? theme.warn(value) : value);
|
||||
|
||||
if (opts.verbose) {
|
||||
const details = buildGatewayConnectionDetails();
|
||||
runtime.log(info("Gateway connection:"));
|
||||
for (const line of details.message.split("\n")) runtime.log(` ${line}`);
|
||||
runtime.log("");
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
const dashboard = (() => {
|
||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
||||
if (!controlUiEnabled) return "disabled";
|
||||
const links = resolveControlUiLinks({
|
||||
port: resolveGatewayPort(cfg),
|
||||
bind: cfg.gateway?.bind,
|
||||
customBindHost: cfg.gateway?.customBindHost,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
});
|
||||
return links.httpUrl;
|
||||
})();
|
||||
|
||||
const gatewayValue = (() => {
|
||||
const target = remoteUrlMissing
|
||||
? `fallback ${gatewayConnection.url}`
|
||||
: `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`;
|
||||
const reach = remoteUrlMissing
|
||||
? warn("misconfigured (remote.url missing)")
|
||||
: gatewayReachable
|
||||
? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`)
|
||||
: warn(
|
||||
gatewayProbe?.error
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable",
|
||||
);
|
||||
const auth =
|
||||
gatewayReachable && !remoteUrlMissing
|
||||
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
|
||||
: "";
|
||||
const self =
|
||||
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
|
||||
? [
|
||||
gatewaySelf?.host ? gatewaySelf.host : null,
|
||||
gatewaySelf?.ip ? `(${gatewaySelf.ip})` : null,
|
||||
gatewaySelf?.version ? `app ${gatewaySelf.version}` : null,
|
||||
gatewaySelf?.platform ? gatewaySelf.platform : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: null;
|
||||
const suffix = self ? ` · ${self}` : "";
|
||||
return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`;
|
||||
})();
|
||||
|
||||
const agentsValue = (() => {
|
||||
const pending =
|
||||
agentStatus.bootstrapPendingCount > 0
|
||||
? `${agentStatus.bootstrapPendingCount} bootstrapping`
|
||||
: "no bootstraps";
|
||||
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
|
||||
const defActive =
|
||||
def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
|
||||
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
|
||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||
})();
|
||||
|
||||
const daemon = await getDaemonStatusSummary();
|
||||
const daemonValue = (() => {
|
||||
if (daemon.installed === false) return `${daemon.label} not installed`;
|
||||
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
||||
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
|
||||
const defaults = summary.sessions.defaults;
|
||||
const defaultCtx = defaults.contextTokens
|
||||
? ` (${formatKTokens(defaults.contextTokens)} ctx)`
|
||||
: "";
|
||||
const eventsValue =
|
||||
summary.queuedSystemEvents.length > 0
|
||||
? `${summary.queuedSystemEvents.length} queued`
|
||||
: "none";
|
||||
|
||||
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
||||
|
||||
const overviewRows = [
|
||||
{ Item: "Dashboard", Value: dashboard },
|
||||
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
||||
{
|
||||
Item: "Tailscale",
|
||||
Value:
|
||||
tailscaleMode === "off"
|
||||
? muted("off")
|
||||
: tailscaleDns && tailscaleHttpsUrl
|
||||
? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}`
|
||||
: warn(`${tailscaleMode} · magicdns unknown`),
|
||||
},
|
||||
{
|
||||
Item: "Update",
|
||||
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
||||
},
|
||||
{ Item: "Gateway", Value: gatewayValue },
|
||||
{ Item: "Daemon", Value: daemonValue },
|
||||
{ Item: "Agents", Value: agentsValue },
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
{ Item: "Events", Value: eventsValue },
|
||||
{ Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` },
|
||||
{
|
||||
Item: "Sessions",
|
||||
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`,
|
||||
},
|
||||
];
|
||||
|
||||
runtime.log(theme.heading("Clawdbot status"));
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Overview"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 12 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 32 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Channels"));
|
||||
const channelIssuesByChannel = (() => {
|
||||
const map = new Map<string, typeof channelIssues>();
|
||||
for (const issue of channelIssues) {
|
||||
const key = issue.channel;
|
||||
const list = map.get(key);
|
||||
if (list) list.push(issue);
|
||||
else map.set(key, [issue]);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Channel", header: "Channel", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "State", header: "State", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: channels.rows.map((row) => {
|
||||
const issues = channelIssuesByChannel.get(row.id) ?? [];
|
||||
const effectiveState =
|
||||
row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
|
||||
const issueSuffix =
|
||||
issues.length > 0
|
||||
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
|
||||
: "";
|
||||
return {
|
||||
Channel: row.label,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
State:
|
||||
effectiveState === "ok"
|
||||
? ok("OK")
|
||||
: effectiveState === "warn"
|
||||
? warn("WARN")
|
||||
: effectiveState === "off"
|
||||
? muted("OFF")
|
||||
: theme.accentDim("SETUP"),
|
||||
Detail: `${row.detail}${issueSuffix}`,
|
||||
};
|
||||
}),
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Sessions"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Key", header: "Key", minWidth: 20, flex: true },
|
||||
{ key: "Kind", header: "Kind", minWidth: 6 },
|
||||
{ key: "Age", header: "Age", minWidth: 9 },
|
||||
{ key: "Model", header: "Model", minWidth: 14 },
|
||||
{ key: "Tokens", header: "Tokens", minWidth: 16 },
|
||||
],
|
||||
rows:
|
||||
summary.sessions.recent.length > 0
|
||||
? summary.sessions.recent.map((sess) => ({
|
||||
Key: shortenText(sess.key, 32),
|
||||
Kind: sess.kind,
|
||||
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
|
||||
Model: sess.model ?? "unknown",
|
||||
Tokens: formatTokensCompact(sess),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
Key: muted("no sessions yet"),
|
||||
Kind: "",
|
||||
Age: "",
|
||||
Model: "",
|
||||
Tokens: "",
|
||||
},
|
||||
],
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
if (summary.queuedSystemEvents.length > 0) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("System events"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }],
|
||||
rows: summary.queuedSystemEvents.slice(0, 5).map((event) => ({
|
||||
Event: event,
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
if (summary.queuedSystemEvents.length > 5) {
|
||||
runtime.log(muted(`… +${summary.queuedSystemEvents.length - 5} more`));
|
||||
}
|
||||
}
|
||||
|
||||
if (health) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Health"));
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
rows.push({
|
||||
Item: "Gateway",
|
||||
Status: ok("reachable"),
|
||||
Detail: `${health.durationMs}ms`,
|
||||
});
|
||||
|
||||
for (const line of formatHealthChannelLines(health)) {
|
||||
const colon = line.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const item = line.slice(0, colon).trim();
|
||||
const detail = line.slice(colon + 1).trim();
|
||||
const normalized = detail.toLowerCase();
|
||||
const status = (() => {
|
||||
if (normalized.startsWith("ok")) return ok("OK");
|
||||
if (normalized.startsWith("failed")) return warn("WARN");
|
||||
if (normalized.startsWith("not configured")) return muted("OFF");
|
||||
if (normalized.startsWith("configured")) return ok("OK");
|
||||
if (normalized.startsWith("linked")) return ok("LINKED");
|
||||
if (normalized.startsWith("not linked")) return warn("UNLINKED");
|
||||
return warn("WARN");
|
||||
})();
|
||||
rows.push({ Item: item, Status: status, Detail: detail });
|
||||
}
|
||||
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Status", header: "Status", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Usage"));
|
||||
for (const line of formatUsageReportLines(usage)) {
|
||||
runtime.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log("FAQ: https://docs.clawd.bot/faq");
|
||||
runtime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
runtime.log("");
|
||||
runtime.log("Next steps:");
|
||||
runtime.log(" Need to share? clawdbot status --all");
|
||||
runtime.log(" Need to debug live? clawdbot logs --follow");
|
||||
if (gatewayReachable) {
|
||||
runtime.log(" Need to test channels? clawdbot status --deep");
|
||||
} else {
|
||||
runtime.log(" Fix reachability first: clawdbot gateway status");
|
||||
}
|
||||
}
|
||||
34
src/commands/status.daemon.ts
Normal file
34
src/commands/status.daemon.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { formatDaemonRuntimeShort } from "./status.format.js";
|
||||
|
||||
export async function getDaemonStatusSummary(): Promise<{
|
||||
label: string;
|
||||
installed: boolean | null;
|
||||
loadedText: string;
|
||||
runtimeShort: string | null;
|
||||
}> {
|
||||
try {
|
||||
const service = resolveGatewayService();
|
||||
const [loaded, runtime, command] = await Promise.all([
|
||||
service
|
||||
.isLoaded({
|
||||
env: process.env,
|
||||
profile: process.env.CLAWDBOT_PROFILE,
|
||||
})
|
||||
.catch(() => false),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
]);
|
||||
const installed = command != null;
|
||||
const loadedText = loaded ? service.loadedText : service.notLoadedText;
|
||||
const runtimeShort = formatDaemonRuntimeShort(runtime);
|
||||
return { label: service.label, installed, loadedText, runtimeShort };
|
||||
} catch {
|
||||
return {
|
||||
label: "Daemon",
|
||||
installed: null,
|
||||
loadedText: "unknown",
|
||||
runtimeShort: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
59
src/commands/status.format.ts
Normal file
59
src/commands/status.format.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { SessionStatus } from "./status.types.js";
|
||||
|
||||
export const formatKTokens = (value: number) =>
|
||||
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||
|
||||
export const formatAge = (ms: number | null | undefined) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
export const formatDuration = (ms: number | null | undefined) => {
|
||||
if (ms == null || !Number.isFinite(ms)) return "unknown";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export const shortenText = (value: string, maxLen: number) => {
|
||||
const chars = Array.from(value);
|
||||
if (chars.length <= maxLen) return value;
|
||||
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`;
|
||||
};
|
||||
|
||||
export const formatTokensCompact = (
|
||||
sess: Pick<SessionStatus, "totalTokens" | "contextTokens" | "percentUsed">,
|
||||
) => {
|
||||
const used = sess.totalTokens ?? 0;
|
||||
const ctx = sess.contextTokens;
|
||||
if (!ctx) return `${formatKTokens(used)} used`;
|
||||
const pctLabel = sess.percentUsed != null ? `${sess.percentUsed}%` : "?%";
|
||||
return `${formatKTokens(used)}/${formatKTokens(ctx)} (${pctLabel})`;
|
||||
};
|
||||
|
||||
export const formatDaemonRuntimeShort = (runtime?: {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
state?: string;
|
||||
detail?: string;
|
||||
missingUnit?: boolean;
|
||||
}) => {
|
||||
if (!runtime) return null;
|
||||
const status = runtime.status ?? "unknown";
|
||||
const details: string[] = [];
|
||||
if (runtime.pid) details.push(`pid ${runtime.pid}`);
|
||||
if (runtime.state && runtime.state.toLowerCase() !== status) {
|
||||
details.push(`state ${runtime.state}`);
|
||||
}
|
||||
const detail = runtime.detail?.replace(/\s+/g, " ").trim() || "";
|
||||
const noisyLaunchctlDetail =
|
||||
runtime.missingUnit === true &&
|
||||
detail.toLowerCase().includes("could not find service");
|
||||
if (detail && !noisyLaunchctlDetail) details.push(detail);
|
||||
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
|
||||
};
|
||||
49
src/commands/status.gateway-probe.ts
Normal file
49
src/commands/status.gateway-probe.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
|
||||
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
|
||||
token?: string;
|
||||
password?: string;
|
||||
} {
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
|
||||
const authToken = cfg.gateway?.auth?.token;
|
||||
const authPassword = cfg.gateway?.auth?.password;
|
||||
const token = isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined);
|
||||
const password =
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.password === "string" &&
|
||||
remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined
|
||||
: typeof authPassword === "string" && authPassword.trim().length > 0
|
||||
? authPassword.trim()
|
||||
: undefined);
|
||||
return { token, password };
|
||||
}
|
||||
|
||||
export function pickGatewaySelfPresence(presence: unknown): {
|
||||
host?: string;
|
||||
ip?: string;
|
||||
version?: string;
|
||||
platform?: string;
|
||||
} | null {
|
||||
if (!Array.isArray(presence)) return null;
|
||||
const entries = presence as Array<Record<string, unknown>>;
|
||||
const self =
|
||||
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
|
||||
if (!self) return null;
|
||||
return {
|
||||
host: typeof self.host === "string" ? self.host : undefined,
|
||||
ip: typeof self.ip === "string" ? self.ip : undefined,
|
||||
version: typeof self.version === "string" ? self.version : undefined,
|
||||
platform: typeof self.platform === "string" ? self.platform : undefined,
|
||||
};
|
||||
}
|
||||
62
src/commands/status.link-channel.ts
Normal file
62
src/commands/status.link-channel.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
export type LinkChannelContext = {
|
||||
linked: boolean;
|
||||
authAgeMs: number | null;
|
||||
account?: unknown;
|
||||
accountId?: string;
|
||||
plugin: ChannelPlugin;
|
||||
};
|
||||
|
||||
export async function resolveLinkChannelContext(
|
||||
cfg: ClawdbotConfig,
|
||||
): Promise<LinkChannelContext | null> {
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
cfg,
|
||||
accountIds,
|
||||
});
|
||||
const account = plugin.config.resolveAccount(cfg, defaultAccountId);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
: true;
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, cfg)
|
||||
: true;
|
||||
const snapshot = plugin.config.describeAccount
|
||||
? plugin.config.describeAccount(account, cfg)
|
||||
: ({
|
||||
accountId: defaultAccountId,
|
||||
enabled,
|
||||
configured,
|
||||
} as ChannelAccountSnapshot);
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
? await plugin.status.buildChannelSummary({
|
||||
account,
|
||||
cfg,
|
||||
defaultAccountId,
|
||||
snapshot,
|
||||
})
|
||||
: undefined;
|
||||
const summaryRecord = summary as Record<string, unknown> | undefined;
|
||||
const linked =
|
||||
summaryRecord && typeof summaryRecord.linked === "boolean"
|
||||
? summaryRecord.linked
|
||||
: null;
|
||||
if (linked === null) continue;
|
||||
const authAgeMs =
|
||||
summaryRecord && typeof summaryRecord.authAgeMs === "number"
|
||||
? summaryRecord.authAgeMs
|
||||
: null;
|
||||
return { linked, authAgeMs, account, accountId: defaultAccountId, plugin };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
165
src/commands/status.scan.ts
Normal file
165
src/commands/status.scan.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||
import { getTailnetHostname } from "../infra/tailscale.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { getAgentLocalStatuses } from "./status.agent-local.js";
|
||||
import {
|
||||
pickGatewaySelfPresence,
|
||||
resolveGatewayProbeAuth,
|
||||
} from "./status.gateway-probe.js";
|
||||
import { getStatusSummary } from "./status.summary.js";
|
||||
import { getUpdateCheckResult } from "./status.update.js";
|
||||
import { buildChannelsTable } from "./status-all/channels.js";
|
||||
|
||||
export type StatusScanResult = {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
osSummary: ReturnType<typeof resolveOsSummary>;
|
||||
tailscaleMode: string;
|
||||
tailscaleDns: string | null;
|
||||
tailscaleHttpsUrl: string | null;
|
||||
update: Awaited<ReturnType<typeof getUpdateCheckResult>>;
|
||||
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
|
||||
remoteUrlMissing: boolean;
|
||||
gatewayMode: "local" | "remote";
|
||||
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
|
||||
gatewayReachable: boolean;
|
||||
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
|
||||
channelIssues: ReturnType<typeof collectChannelStatusIssues>;
|
||||
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatuses>>;
|
||||
channels: Awaited<ReturnType<typeof buildChannelsTable>>;
|
||||
summary: Awaited<ReturnType<typeof getStatusSummary>>;
|
||||
};
|
||||
|
||||
export async function scanStatus(
|
||||
opts: {
|
||||
json?: boolean;
|
||||
timeoutMs?: number;
|
||||
all?: boolean;
|
||||
},
|
||||
_runtime: RuntimeEnv,
|
||||
): Promise<StatusScanResult> {
|
||||
return await withProgress(
|
||||
{
|
||||
label: "Scanning status…",
|
||||
total: 9,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async (progress) => {
|
||||
progress.setLabel("Loading config…");
|
||||
const cfg = loadConfig();
|
||||
const osSummary = resolveOsSummary();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking Tailscale…");
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const tailscaleDns =
|
||||
tailscaleMode === "off"
|
||||
? null
|
||||
: await getTailnetHostname((cmd, args) =>
|
||||
runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }),
|
||||
).catch(() => null);
|
||||
const tailscaleHttpsUrl =
|
||||
tailscaleMode !== "off" && tailscaleDns
|
||||
? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}`
|
||||
: null;
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking for updates…");
|
||||
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
||||
const update = await getUpdateCheckResult({
|
||||
timeoutMs: updateTimeoutMs,
|
||||
fetchGit: true,
|
||||
includeRegistry: true,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Resolving agents…");
|
||||
const agentStatus = await getAgentLocalStatuses();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Probing gateway…");
|
||||
const gatewayConnection = buildGatewayConnectionDetails();
|
||||
const isRemoteMode = cfg.gateway?.mode === "remote";
|
||||
const remoteUrlRaw =
|
||||
typeof cfg.gateway?.remote?.url === "string"
|
||||
? cfg.gateway.remote.url
|
||||
: "";
|
||||
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim();
|
||||
const gatewayMode = isRemoteMode ? "remote" : "local";
|
||||
const gatewayProbe = remoteUrlMissing
|
||||
? null
|
||||
: await probeGateway({
|
||||
url: gatewayConnection.url,
|
||||
auth: resolveGatewayProbeAuth(cfg),
|
||||
timeoutMs: Math.min(
|
||||
opts.all ? 5000 : 2500,
|
||||
opts.timeoutMs ?? 10_000,
|
||||
),
|
||||
}).catch(() => null);
|
||||
const gatewayReachable = gatewayProbe?.ok === true;
|
||||
const gatewaySelf = gatewayProbe?.presence
|
||||
? pickGatewaySelfPresence(gatewayProbe.presence)
|
||||
: null;
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Querying channel status…");
|
||||
const channelsStatus = gatewayReachable
|
||||
? await callGateway<Record<string, unknown>>({
|
||||
method: "channels.status",
|
||||
params: {
|
||||
probe: false,
|
||||
timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000),
|
||||
},
|
||||
timeoutMs: Math.min(
|
||||
opts.all ? 5000 : 2500,
|
||||
opts.timeoutMs ?? 10_000,
|
||||
),
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
const channelIssues = channelsStatus
|
||||
? collectChannelStatusIssues(channelsStatus)
|
||||
: [];
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Summarizing channels…");
|
||||
const channels = await buildChannelsTable(cfg, {
|
||||
// Show token previews in regular status; keep `status --all` redacted.
|
||||
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
|
||||
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Reading sessions…");
|
||||
const summary = await getStatusSummary();
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Rendering…");
|
||||
progress.tick();
|
||||
|
||||
return {
|
||||
cfg,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
tailscaleHttpsUrl,
|
||||
update,
|
||||
gatewayConnection,
|
||||
remoteUrlMissing,
|
||||
gatewayMode,
|
||||
gatewayProbe,
|
||||
gatewayReachable,
|
||||
gatewaySelf,
|
||||
channelIssues,
|
||||
agentStatus,
|
||||
channels,
|
||||
summary,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
153
src/commands/status.summary.ts
Normal file
153
src/commands/status.summary.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { buildChannelSummary } from "../infra/channel-summary.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import { resolveLinkChannelContext } from "./status.link-channel.js";
|
||||
import type { SessionStatus, StatusSummary } from "./status.types.js";
|
||||
|
||||
const classifyKey = (
|
||||
key: string,
|
||||
entry?: SessionEntry,
|
||||
): SessionStatus["kind"] => {
|
||||
if (key === "global") return "global";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||
if (
|
||||
key.startsWith("group:") ||
|
||||
key.includes(":group:") ||
|
||||
key.includes(":channel:")
|
||||
) {
|
||||
return "group";
|
||||
}
|
||||
return "direct";
|
||||
};
|
||||
|
||||
const buildFlags = (entry: SessionEntry): string[] => {
|
||||
const flags: string[] = [];
|
||||
const think = entry?.thinkingLevel;
|
||||
if (typeof think === "string" && think.length > 0)
|
||||
flags.push(`think:${think}`);
|
||||
const verbose = entry?.verboseLevel;
|
||||
if (typeof verbose === "string" && verbose.length > 0)
|
||||
flags.push(`verbose:${verbose}`);
|
||||
const reasoning = entry?.reasoningLevel;
|
||||
if (typeof reasoning === "string" && reasoning.length > 0)
|
||||
flags.push(`reasoning:${reasoning}`);
|
||||
const elevated = entry?.elevatedLevel;
|
||||
if (typeof elevated === "string" && elevated.length > 0)
|
||||
flags.push(`elevated:${elevated}`);
|
||||
if (entry?.systemSent) flags.push("system");
|
||||
if (entry?.abortedLastRun) flags.push("aborted");
|
||||
const sessionId = entry?.sessionId as unknown;
|
||||
if (typeof sessionId === "string" && sessionId.length > 0)
|
||||
flags.push(`id:${sessionId}`);
|
||||
return flags;
|
||||
};
|
||||
|
||||
export async function getStatusSummary(): Promise<StatusSummary> {
|
||||
const cfg = loadConfig();
|
||||
const linkContext = await resolveLinkChannelContext(cfg);
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const channelSummary = await buildChannelSummary(cfg, {
|
||||
colorize: true,
|
||||
includeAllowFrom: true,
|
||||
});
|
||||
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const configModel = resolved.model ?? DEFAULT_MODEL;
|
||||
const configContextTokens =
|
||||
cfg.agents?.defaults?.contextTokens ??
|
||||
lookupContextTokens(configModel) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const now = Date.now();
|
||||
const sessions = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
.map(([key, entry]) => {
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
const age = updatedAt ? now - updatedAt : null;
|
||||
const model = entry?.model ?? configModel ?? null;
|
||||
const contextTokens =
|
||||
entry?.contextTokens ??
|
||||
lookupContextTokens(model) ??
|
||||
configContextTokens ??
|
||||
null;
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
const output = entry?.outputTokens ?? 0;
|
||||
const total = entry?.totalTokens ?? input + output;
|
||||
const remaining =
|
||||
contextTokens != null ? Math.max(0, contextTokens - total) : null;
|
||||
const pct =
|
||||
contextTokens && contextTokens > 0
|
||||
? Math.min(999, Math.round((total / contextTokens) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
key,
|
||||
kind: classifyKey(key, entry),
|
||||
sessionId: entry?.sessionId,
|
||||
updatedAt,
|
||||
age,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
elevatedLevel: entry?.elevatedLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
abortedLastRun: entry?.abortedLastRun,
|
||||
inputTokens: entry?.inputTokens,
|
||||
outputTokens: entry?.outputTokens,
|
||||
totalTokens: total ?? null,
|
||||
remainingTokens: remaining,
|
||||
percentUsed: pct,
|
||||
model,
|
||||
contextTokens,
|
||||
flags: buildFlags(entry),
|
||||
} satisfies SessionStatus;
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
const recent = sessions.slice(0, 5);
|
||||
|
||||
return {
|
||||
linkChannel: linkContext
|
||||
? {
|
||||
id: linkContext.plugin.id,
|
||||
label: linkContext.plugin.meta.label ?? "Channel",
|
||||
linked: linkContext.linked,
|
||||
authAgeMs: linkContext.authAgeMs,
|
||||
}
|
||||
: undefined,
|
||||
heartbeatSeconds,
|
||||
channelSummary,
|
||||
queuedSystemEvents,
|
||||
sessions: {
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
defaults: {
|
||||
model: configModel ?? null,
|
||||
contextTokens: configContextTokens ?? null,
|
||||
},
|
||||
recent,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
41
src/commands/status.types.ts
Normal file
41
src/commands/status.types.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
|
||||
export type SessionStatus = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
sessionId?: string;
|
||||
updatedAt: number | null;
|
||||
age: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens: number | null;
|
||||
remainingTokens: number | null;
|
||||
percentUsed: number | null;
|
||||
model: string | null;
|
||||
contextTokens: number | null;
|
||||
flags: string[];
|
||||
};
|
||||
|
||||
export type StatusSummary = {
|
||||
linkChannel?: {
|
||||
id: ChannelId;
|
||||
label: string;
|
||||
linked: boolean;
|
||||
authAgeMs: number | null;
|
||||
};
|
||||
heartbeatSeconds: number;
|
||||
channelSummary: string[];
|
||||
queuedSystemEvents: string[];
|
||||
sessions: {
|
||||
path: string;
|
||||
count: number;
|
||||
defaults: { model: string | null; contextTokens: number | null };
|
||||
recent: SessionStatus[];
|
||||
};
|
||||
};
|
||||
82
src/commands/status.update.ts
Normal file
82
src/commands/status.update.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import {
|
||||
checkUpdateStatus,
|
||||
compareSemverStrings,
|
||||
type UpdateCheckResult,
|
||||
} from "../infra/update-check.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export async function getUpdateCheckResult(params: {
|
||||
timeoutMs: number;
|
||||
fetchGit: boolean;
|
||||
includeRegistry: boolean;
|
||||
}): Promise<UpdateCheckResult> {
|
||||
const root = await resolveClawdbotPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchGit: params.fetchGit,
|
||||
includeRegistry: params.includeRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUpdateOneLiner(update: UpdateCheckResult): string {
|
||||
const parts: string[] = [];
|
||||
if (update.installKind === "git" && update.git) {
|
||||
const branch = update.git.branch ? `git ${update.git.branch}` : "git";
|
||||
parts.push(branch);
|
||||
if (update.git.upstream) parts.push(`↔ ${update.git.upstream}`);
|
||||
if (update.git.dirty === true) parts.push("dirty");
|
||||
if (update.git.behind != null && update.git.ahead != null) {
|
||||
if (update.git.behind === 0 && update.git.ahead === 0) {
|
||||
parts.push("up to date");
|
||||
} else if (update.git.behind > 0 && update.git.ahead === 0) {
|
||||
parts.push(`behind ${update.git.behind}`);
|
||||
} else if (update.git.behind === 0 && update.git.ahead > 0) {
|
||||
parts.push(`ahead ${update.git.ahead}`);
|
||||
} else if (update.git.behind > 0 && update.git.ahead > 0) {
|
||||
parts.push(
|
||||
`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (update.git.fetchOk === false) parts.push("fetch failed");
|
||||
|
||||
if (update.registry?.latestVersion) {
|
||||
const cmp = compareSemverStrings(VERSION, update.registry.latestVersion);
|
||||
if (cmp === 0) parts.push(`npm latest ${update.registry.latestVersion}`);
|
||||
else if (cmp != null && cmp < 0)
|
||||
parts.push(`npm update ${update.registry.latestVersion}`);
|
||||
else
|
||||
parts.push(`npm latest ${update.registry.latestVersion} (local newer)`);
|
||||
} else if (update.registry?.error) {
|
||||
parts.push("npm latest unknown");
|
||||
}
|
||||
} else {
|
||||
parts.push(
|
||||
update.packageManager !== "unknown" ? update.packageManager : "pkg",
|
||||
);
|
||||
if (update.registry?.latestVersion) {
|
||||
const cmp = compareSemverStrings(VERSION, update.registry.latestVersion);
|
||||
if (cmp === 0) parts.push(`npm latest ${update.registry.latestVersion}`);
|
||||
else if (cmp != null && cmp < 0) {
|
||||
parts.push(`npm update ${update.registry.latestVersion}`);
|
||||
} else {
|
||||
parts.push(`npm latest ${update.registry.latestVersion} (local newer)`);
|
||||
}
|
||||
} else if (update.registry?.error) {
|
||||
parts.push("npm latest unknown");
|
||||
}
|
||||
}
|
||||
|
||||
if (update.deps) {
|
||||
if (update.deps.status === "ok") parts.push("deps ok");
|
||||
if (update.deps.status === "missing") parts.push("deps missing");
|
||||
if (update.deps.status === "stale") parts.push("deps stale");
|
||||
}
|
||||
return `Update: ${parts.join(" · ")}`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user