mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 09:17:38 +00:00
refactor(commands): split CLI commands
This commit is contained in:
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 { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
||||||
import {
|
import {
|
||||||
resolveAgentWorkspaceDir,
|
resolveAgentWorkspaceDir,
|
||||||
@@ -13,60 +10,30 @@ import {
|
|||||||
resolveConfiguredModelRef,
|
resolveConfiguredModelRef,
|
||||||
resolveHooksGmailModel,
|
resolveHooksGmailModel,
|
||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
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 { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
|
||||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.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 type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.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 {
|
import {
|
||||||
maybeRepairAnthropicOAuthProfileId,
|
maybeRepairAnthropicOAuthProfileId,
|
||||||
noteAuthProfileHealth,
|
noteAuthProfileHealth,
|
||||||
} from "./doctor-auth.js";
|
} from "./doctor-auth.js";
|
||||||
import {
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
buildGatewayRuntimeHints,
|
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
|
||||||
formatGatewayRuntimeSummary,
|
import { checkGatewayHealth } from "./doctor-gateway-health.js";
|
||||||
} from "./doctor-format.js";
|
|
||||||
import {
|
import {
|
||||||
maybeMigrateLegacyGatewayService,
|
maybeMigrateLegacyGatewayService,
|
||||||
maybeRepairGatewayServiceConfig,
|
maybeRepairGatewayServiceConfig,
|
||||||
maybeScanExtraGatewayServices,
|
maybeScanExtraGatewayServices,
|
||||||
} from "./doctor-gateway-services.js";
|
} from "./doctor-gateway-services.js";
|
||||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||||
import {
|
import { maybeMigrateLegacyConfigFile } from "./doctor-legacy-config.js";
|
||||||
maybeMigrateLegacyConfigFile,
|
import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js";
|
||||||
normalizeLegacyConfigValues,
|
|
||||||
} from "./doctor-legacy-config.js";
|
|
||||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||||
import {
|
import {
|
||||||
maybeRepairSandboxImages,
|
maybeRepairSandboxImages,
|
||||||
@@ -82,14 +49,12 @@ import {
|
|||||||
runLegacyStateMigrations,
|
runLegacyStateMigrations,
|
||||||
} from "./doctor-state-migrations.js";
|
} from "./doctor-state-migrations.js";
|
||||||
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
|
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
|
||||||
|
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
|
||||||
import {
|
import {
|
||||||
detectLegacyWorkspaceDirs,
|
|
||||||
formatLegacyWorkspaceWarning,
|
|
||||||
MEMORY_SYSTEM_PROMPT,
|
MEMORY_SYSTEM_PROMPT,
|
||||||
shouldSuggestMemorySystem,
|
shouldSuggestMemorySystem,
|
||||||
} from "./doctor-workspace.js";
|
} from "./doctor-workspace.js";
|
||||||
import { healthCommand } from "./health.js";
|
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
|
||||||
import { formatHealthCheckFailure } from "./health-format.js";
|
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
@@ -106,80 +71,6 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
|
|||||||
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
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(
|
export async function doctorCommand(
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
options: DoctorOptions = {},
|
options: DoctorOptions = {},
|
||||||
@@ -194,113 +85,25 @@ export async function doctorCommand(
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
|
const updateResult = await maybeOfferUpdateBeforeDoctor({
|
||||||
const canOfferUpdate =
|
runtime,
|
||||||
!updateInProgress &&
|
options,
|
||||||
options.nonInteractive !== true &&
|
root,
|
||||||
options.yes !== true &&
|
confirm: (p) => prompter.confirm(p),
|
||||||
options.repair !== true &&
|
outro,
|
||||||
Boolean(process.stdin.isTTY);
|
});
|
||||||
if (canOfferUpdate) {
|
if (updateResult.handled) return;
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await maybeRepairUiProtocolFreshness(runtime, prompter);
|
await maybeRepairUiProtocolFreshness(runtime, prompter);
|
||||||
noteSourceInstallIssues(root);
|
noteSourceInstallIssues(root);
|
||||||
|
|
||||||
await maybeMigrateLegacyConfigFile(runtime);
|
await maybeMigrateLegacyConfigFile(runtime);
|
||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const configResult = await loadAndMaybeMigrateDoctorConfig({
|
||||||
let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
options,
|
||||||
if (
|
confirm: (p) => prompter.confirm(p),
|
||||||
snapshot.exists &&
|
});
|
||||||
!snapshot.valid &&
|
let cfg: ClawdbotConfig = configResult.cfg;
|
||||||
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);
|
|
||||||
|
|
||||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||||
await noteAuthProfileHealth({
|
await noteAuthProfileHealth({
|
||||||
@@ -379,7 +182,7 @@ export async function doctorCommand(
|
|||||||
await noteStateIntegrity(
|
await noteStateIntegrity(
|
||||||
cfg,
|
cfg,
|
||||||
prompter,
|
prompter,
|
||||||
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
|
configResult.path ?? CONFIG_PATH_CLAWDBOT,
|
||||||
);
|
);
|
||||||
|
|
||||||
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
|
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,
|
cfg,
|
||||||
resolveDefaultAgentId(cfg),
|
runtime,
|
||||||
);
|
prompter,
|
||||||
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
|
options,
|
||||||
if (legacyWorkspace.legacyDirs.length > 0) {
|
gatewayDetailsMessage: gatewayDetails.message,
|
||||||
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
|
healthOk,
|
||||||
}
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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) });
|
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||||
await writeConfigFile(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";
|
export {
|
||||||
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,
|
|
||||||
SYNTHETIC_DEFAULT_MODEL_ID,
|
SYNTHETIC_DEFAULT_MODEL_ID,
|
||||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||||
SYNTHETIC_MODEL_CATALOG,
|
|
||||||
} from "../agents/synthetic-models.js";
|
} from "../agents/synthetic-models.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
export {
|
||||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
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";
|
export {
|
||||||
const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
|
applyOpencodeZenConfig,
|
||||||
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
|
applyOpencodeZenProviderConfig,
|
||||||
const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
|
} from "./onboard-auth.config-opencode.js";
|
||||||
const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
|
export {
|
||||||
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
|
OPENROUTER_DEFAULT_MODEL_REF,
|
||||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
setAnthropicApiKey,
|
||||||
export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
|
setGeminiApiKey,
|
||||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
setMinimaxApiKey,
|
||||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
setMoonshotApiKey,
|
||||||
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
|
setOpencodeZenApiKey,
|
||||||
export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF };
|
setOpenrouterApiKey,
|
||||||
|
setSyntheticApiKey,
|
||||||
const resolveAuthAgentDir = (agentDir?: string) =>
|
setZaiApiKey,
|
||||||
agentDir ?? resolveClawdbotAgentDir();
|
writeOAuthCredentials,
|
||||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
ZAI_DEFAULT_MODEL_REF,
|
||||||
const MINIMAX_API_COST = {
|
} from "./onboard-auth.credentials.js";
|
||||||
input: 15,
|
export {
|
||||||
output: 60,
|
buildMinimaxApiModelDefinition,
|
||||||
cacheRead: 2,
|
buildMinimaxModelDefinition,
|
||||||
cacheWrite: 10,
|
buildMoonshotModelDefinition,
|
||||||
};
|
DEFAULT_MINIMAX_BASE_URL,
|
||||||
const MINIMAX_HOSTED_COST = {
|
MINIMAX_API_BASE_URL,
|
||||||
input: 0,
|
MINIMAX_HOSTED_MODEL_ID,
|
||||||
output: 0,
|
MINIMAX_HOSTED_MODEL_REF,
|
||||||
cacheRead: 0,
|
MOONSHOT_BASE_URL,
|
||||||
cacheWrite: 0,
|
MOONSHOT_DEFAULT_MODEL_ID,
|
||||||
};
|
MOONSHOT_DEFAULT_MODEL_REF,
|
||||||
const MINIMAX_LM_STUDIO_COST = {
|
} from "./onboard-auth.models.js";
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,136 +1,10 @@
|
|||||||
import path from "node:path";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import { readConfigFileSnapshot } from "../config/config.js";
|
||||||
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 { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { runNonInteractiveOnboardingLocal } from "./onboard-non-interactive/local.js";
|
||||||
import {
|
import { runNonInteractiveOnboardingRemote } from "./onboard-non-interactive/remote.js";
|
||||||
buildTokenProfileId,
|
import type { OnboardOptions } from "./onboard-types.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runNonInteractiveOnboarding(
|
export async function runNonInteractiveOnboarding(
|
||||||
opts: OnboardOptions,
|
opts: OnboardOptions,
|
||||||
@@ -144,6 +18,7 @@ export async function runNonInteractiveOnboarding(
|
|||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||||
const mode = opts.mode ?? "local";
|
const mode = opts.mode ?? "local";
|
||||||
if (mode !== "local" && mode !== "remote") {
|
if (mode !== "local" && mode !== "remote") {
|
||||||
@@ -153,529 +28,9 @@ export async function runNonInteractiveOnboarding(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "remote") {
|
if (mode === "remote") {
|
||||||
const remoteUrl = opts.remoteUrl?.trim();
|
await runNonInteractiveOnboardingRemote({ opts, runtime, baseConfig });
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceDir = resolveUserPath(
|
await runNonInteractiveOnboardingLocal({ opts, runtime, baseConfig });
|
||||||
(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import {
|
|||||||
CLAUDE_CLI_PROFILE_ID,
|
CLAUDE_CLI_PROFILE_ID,
|
||||||
CODEX_CLI_PROFILE_ID,
|
CODEX_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
|
upsertAuthProfile,
|
||||||
} from "../../../agents/auth-profiles.js";
|
} 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 type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
import { upsertSharedEnvVar } from "../../../infra/env-file.js";
|
import { upsertSharedEnvVar } from "../../../infra/env-file.js";
|
||||||
import type { RuntimeEnv } from "../../../runtime.js";
|
import type { RuntimeEnv } from "../../../runtime.js";
|
||||||
|
import {
|
||||||
|
buildTokenProfileId,
|
||||||
|
validateAnthropicSetupToken,
|
||||||
|
} from "../../auth-token.js";
|
||||||
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
|
import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js";
|
||||||
import {
|
import {
|
||||||
applyAuthProfileConfig,
|
applyAuthProfileConfig,
|
||||||
@@ -27,7 +34,6 @@ import {
|
|||||||
} from "../../onboard-auth.js";
|
} from "../../onboard-auth.js";
|
||||||
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
||||||
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
|
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
|
||||||
|
|
||||||
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
||||||
|
|
||||||
export async function applyNonInteractiveAuthChoice(params: {
|
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") {
|
if (authChoice === "gemini-api-key") {
|
||||||
const resolved = await resolveNonInteractiveApiKey({
|
const resolved = await resolveNonInteractiveApiKey({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
@@ -255,18 +321,12 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
authChoice === "token" ||
|
|
||||||
authChoice === "oauth" ||
|
authChoice === "oauth" ||
|
||||||
authChoice === "chutes" ||
|
authChoice === "chutes" ||
|
||||||
authChoice === "openai-codex" ||
|
authChoice === "openai-codex" ||
|
||||||
authChoice === "antigravity"
|
authChoice === "antigravity"
|
||||||
) {
|
) {
|
||||||
const label =
|
const label = authChoice === "antigravity" ? "Antigravity" : "OAuth";
|
||||||
authChoice === "antigravity"
|
|
||||||
? "Antigravity"
|
|
||||||
: authChoice === "token"
|
|
||||||
? "Token"
|
|
||||||
: "OAuth";
|
|
||||||
runtime.error(`${label} requires interactive mode.`);
|
runtime.error(`${label} requires interactive mode.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.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 { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
import { inspectPortUsage } from "../infra/ports.js";
|
||||||
import {
|
import { readRestartSentinel } from "../infra/restart-sentinel.js";
|
||||||
readRestartSentinel,
|
|
||||||
summarizeRestartSentinel,
|
|
||||||
} from "../infra/restart-sentinel.js";
|
|
||||||
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
||||||
import {
|
import {
|
||||||
checkUpdateStatus,
|
checkUpdateStatus,
|
||||||
@@ -26,23 +22,13 @@ import {
|
|||||||
} from "../infra/update-check.js";
|
} from "../infra/update-check.js";
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.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 { VERSION } from "../version.js";
|
||||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||||
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
||||||
import { buildChannelsTable } from "./status-all/channels.js";
|
import { buildChannelsTable } from "./status-all/channels.js";
|
||||||
import {
|
import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
|
||||||
formatAge,
|
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
|
||||||
formatDuration,
|
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
|
||||||
formatGatewayAuthUsed,
|
|
||||||
redactSecrets,
|
|
||||||
} from "./status-all/format.js";
|
|
||||||
import {
|
|
||||||
pickGatewaySelfPresence,
|
|
||||||
readFileTailLines,
|
|
||||||
summarizeLogTail,
|
|
||||||
} from "./status-all/gateway.js";
|
|
||||||
|
|
||||||
export async function statusAllCommand(
|
export async function statusAllCommand(
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
@@ -410,331 +396,34 @@ export async function statusAllCommand(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rich = isRich();
|
const lines = await buildStatusAllReportLines({
|
||||||
const heading = (text: string) => (rich ? theme.heading(text) : text);
|
progress,
|
||||||
const ok = (text: string) => (rich ? theme.success(text) : text);
|
overviewRows,
|
||||||
const warn = (text: string) => (rich ? theme.warn(text) : text);
|
channels,
|
||||||
const fail = (text: string) => (rich ? theme.error(text) : text);
|
channelIssues: channelIssues.map((issue) => ({
|
||||||
const muted = (text: string) => (rich ? theme.muted(text) : text);
|
channel: issue.channel,
|
||||||
|
message: issue.message,
|
||||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
})),
|
||||||
|
agentStatus,
|
||||||
const overview = renderTable({
|
connectionDetailsForReport,
|
||||||
width: tableWidth,
|
diagnosis: {
|
||||||
columns: [
|
snap,
|
||||||
{ key: "Item", header: "Item", minWidth: 10 },
|
remoteUrlMissing,
|
||||||
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
|
sentinel,
|
||||||
],
|
lastErr,
|
||||||
rows: overviewRows,
|
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…");
|
progress.setLabel("Rendering…");
|
||||||
runtime.log(lines.join("\n"));
|
runtime.log(lines.join("\n"));
|
||||||
progress.tick();
|
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(" · ")}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user