diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts new file mode 100644 index 000000000..cbffdf929 --- /dev/null +++ b/src/commands/agents.bindings.ts @@ -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(); + 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>; +}): 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 }; +} diff --git a/src/commands/agents.command-shared.ts b/src/commands/agents.command-shared.ts new file mode 100644 index 000000000..352b24fea --- /dev/null +++ b/src/commands/agents.command-shared.ts @@ -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 { + 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; +} diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts new file mode 100644 index 000000000..5490f5fa0 --- /dev/null +++ b/src/commands/agents.commands.add.ts @@ -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> = {}; + 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; + } +} diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts new file mode 100644 index 000000000..21ba5abd7 --- /dev/null +++ b/src/commands/agents.commands.delete.ts @@ -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}`); + } +} diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts new file mode 100644 index 000000000..c12079e43 --- /dev/null +++ b/src/commands/agents.commands.list.ts @@ -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(); + 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")); +} diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts new file mode 100644 index 000000000..d1026664b --- /dev/null +++ b/src/commands/agents.config.ts @@ -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["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(); + 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, + }; +} diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts new file mode 100644 index 000000000..31546cd92 --- /dev/null +++ b/src/commands/agents.providers.ts @@ -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> { + const map = new Map(); + + 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)[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(); + 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[] { + const allProviderEntries = [...params.providerStatus.values()]; + const providerLines: string[] = []; + if (params.bindings.length > 0) { + const seen = new Set(); + 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; +} diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 3405ae94c..46364e1e7 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,1105 +1,5 @@ -import fs from "node:fs"; -import path from "node:path"; -import { - resolveAgentDir, - resolveAgentWorkspaceDir, - resolveDefaultAgentId, -} from "../agents/agent-scope.js"; -import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; -import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { - getChannelPlugin, - listChannelPlugins, -} from "../channels/plugins/index.js"; -import { - type ChatChannelId, - getChatChannelMeta, - normalizeChatChannelId, -} from "../channels/registry.js"; -import type { ClawdbotConfig } from "../config/config.js"; -import { - CONFIG_PATH_CLAWDBOT, - readConfigFileSnapshot, - writeConfigFile, -} from "../config/config.js"; -import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; -import type { AgentBinding } from "../config/types.js"; -import { - DEFAULT_ACCOUNT_ID, - DEFAULT_AGENT_ID, - normalizeAgentId, -} from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; -import { resolveUserPath } from "../utils.js"; -import { createClackPrompter } from "../wizard/clack-prompter.js"; -import { WizardCancelledError } from "../wizard/prompts.js"; -import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; -import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; -import { setupChannels } from "./onboard-channels.js"; -import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js"; -import type { ChannelChoice } from "./onboard-types.js"; - -type AgentsListOptions = { - json?: boolean; - bindings?: boolean; -}; - -type AgentsAddOptions = { - name?: string; - workspace?: string; - model?: string; - agentDir?: string; - bind?: string[]; - nonInteractive?: boolean; - json?: boolean; -}; - -type AgentsDeleteOptions = { - id: string; - force?: boolean; - json?: boolean; -}; - -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["list"] ->[number]; - -type AgentIdentity = { - name?: string; - emoji?: string; - creature?: string; - vibe?: string; -}; - -type ProviderAccountStatus = { - provider: ChatChannelId; - accountId: string; - name?: string; - state: - | "linked" - | "not linked" - | "configured" - | "not configured" - | "enabled" - | "disabled"; - enabled?: boolean; - configured?: boolean; -}; - -function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv { - return { ...runtime, log: () => {} }; -} - -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"), - ); -} - -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(); - 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, - }, - }; -} - -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 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(); - 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, - }; -} - -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, - }; -} - -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"); -} - -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(", "); -} - -async function buildProviderStatusIndex( - cfg: ClawdbotConfig, -): Promise> { - const map = new Map(); - - 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)[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)}`; -} - -function summarizeBindings( - cfg: ClawdbotConfig, - bindings: AgentBinding[], -): string[] { - if (bindings.length === 0) return []; - const seen = new Map(); - 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()]; -} - -async function requireValidConfig( - runtime: RuntimeEnv, -): Promise { - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - const issues = - snapshot.issues.length > 0 - ? snapshot.issues - .map((issue) => `- ${issue.path}: ${issue.message}`) - .join("\n") - : "Unknown validation issue."; - runtime.error(`Config invalid:\n${issues}`); - runtime.error("Fix the config or run clawdbot doctor."); - runtime.exit(1); - return null; - } - return snapshot.config; -} - -export async function agentsListCommand( - opts: AgentsListOptions, - runtime: RuntimeEnv = defaultRuntime, -) { - const cfg = await requireValidConfig(runtime); - if (!cfg) return; - - const summaries = buildAgentSummaries(cfg); - const bindingMap = new Map(); - 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 as AgentBinding), - ); - } - } - } - - const providerStatus = await buildProviderStatusIndex(cfg); - const allProviderEntries = [...providerStatus.values()]; - - 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: string[] = []; - if (bindings.length > 0) { - const seen = new Set(); - 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)) continue; - seen.add(key); - const status = providerStatus.get(key); - if (status) { - providerLines.push(formatProviderEntry(status)); - } else { - providerLines.push( - `${formatChannelAccountLabel({ provider: channel, accountId })}: unknown`, - ); - } - } - } else if (summary.isDefault) { - for (const entry of allProviderEntries) { - if (shouldShowProviderEntry(entry, cfg)) { - providerLines.push(formatProviderEntry(entry)); - } - } - } - 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")); -} - -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(" "); -} - -function buildChannelBindings(params: { - agentId: string; - selection: ChannelChoice[]; - config: ClawdbotConfig; - accountIds?: Partial>; -}): 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; -} - -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 }; -} - -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> = {}; - 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; - } -} - -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}`); - } -} +export * from "./agents.bindings.js"; +export * from "./agents.commands.add.js"; +export * from "./agents.commands.delete.js"; +export * from "./agents.commands.list.js"; +export * from "./agents.config.js"; diff --git a/src/commands/auth-choice.api-key.ts b/src/commands/auth-choice.api-key.ts new file mode 100644 index 000000000..ba11f48c5 --- /dev/null +++ b/src/commands/auth-choice.api-key.ts @@ -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)}`; +} diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts new file mode 100644 index 000000000..f697922f4 --- /dev/null +++ b/src/commands/auth-choice.apply.anthropic.ts @@ -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 { + 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; +} diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts new file mode 100644 index 000000000..f8521abc1 --- /dev/null +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -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 { + 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; +} diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts new file mode 100644 index 000000000..83e66f478 --- /dev/null +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -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 { + 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 }; +} diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts new file mode 100644 index 000000000..4bc434202 --- /dev/null +++ b/src/commands/auth-choice.apply.minimax.ts @@ -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 { + 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; +} diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts new file mode 100644 index 000000000..fd75780d7 --- /dev/null +++ b/src/commands/auth-choice.apply.oauth.ts @@ -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 { + 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) + ? { + 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; +} diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts new file mode 100644 index 000000000..b9f16f00b --- /dev/null +++ b/src/commands/auth-choice.apply.openai.ts @@ -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 { + 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; +} diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts new file mode 100644 index 000000000..0a929f2a6 --- /dev/null +++ b/src/commands/auth-choice.apply.ts @@ -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 { + const handlers: Array< + (p: ApplyAuthChoiceParams) => Promise + > = [ + applyAuthChoiceAnthropic, + applyAuthChoiceOpenAI, + applyAuthChoiceOAuth, + applyAuthChoiceApiProviders, + applyAuthChoiceMiniMax, + applyAuthChoiceGitHubCopilot, + ]; + + for (const handler of handlers) { + const result = await handler(params); + if (result) return result; + } + + return { config: params.config }; +} diff --git a/src/commands/auth-choice.default-model.ts b/src/commands/auth-choice.default-model.ts new file mode 100644 index 000000000..6343932a1 --- /dev/null +++ b/src/commands/auth-choice.default-model.ts @@ -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; + 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 }; +} diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts new file mode 100644 index 000000000..7ae383491 --- /dev/null +++ b/src/commands/auth-choice.model-check.ts @@ -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"); + } +} diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts new file mode 100644 index 000000000..750c69ab7 --- /dev/null +++ b/src/commands/auth-choice.preferred-provider.ts @@ -0,0 +1,31 @@ +import type { AuthChoice } from "./onboard-types.js"; + +const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { + 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]; +} diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index c1edddde9..940d05ed0 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,1192 +1,3 @@ -import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; -import { resolveAgentModelPrimary } from "../agents/agent-scope.js"; -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - ensureAuthProfileStore, - listProfilesForProvider, - resolveAuthProfileOrder, - upsertAuthProfile, -} 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 { upsertSharedEnvVar } from "../infra/env-file.js"; -import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { - isRemoteEnvironment, - loginAntigravityVpsAware, -} from "./antigravity-oauth.js"; -import { - buildTokenProfileId, - validateAnthropicSetupToken, -} from "./auth-token.js"; -import { loginChutes } from "./chutes-oauth.js"; -import { - applyGoogleGeminiModelDefault, - GOOGLE_GEMINI_DEFAULT_MODEL, -} from "./google-gemini-model-default.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { - applyAuthProfileConfig, - applyMinimaxApiConfig, - applyMinimaxApiProviderConfig, - applyMinimaxConfig, - applyMinimaxProviderConfig, - applyMoonshotConfig, - applyMoonshotProviderConfig, - applyOpencodeZenConfig, - applyOpencodeZenProviderConfig, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, - applySyntheticConfig, - applySyntheticProviderConfig, - applyZaiConfig, - MOONSHOT_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, - SYNTHETIC_DEFAULT_MODEL_REF, - setAnthropicApiKey, - setGeminiApiKey, - setMinimaxApiKey, - setMoonshotApiKey, - setOpencodeZenApiKey, - setOpenrouterApiKey, - setSyntheticApiKey, - setZaiApiKey, - writeOAuthCredentials, - ZAI_DEFAULT_MODEL_REF, -} from "./onboard-auth.js"; -import { openUrl } from "./onboard-helpers.js"; -import type { AuthChoice } from "./onboard-types.js"; -import { - applyOpenAICodexModelDefault, - OPENAI_CODEX_DEFAULT_MODEL, -} from "./openai-codex-model-default.js"; -import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; - -const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; - -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(); -} - -const validateApiKeyInput = (value: string) => - normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; - -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)}`; -} - -async function applyDefaultModelChoice(params: { - config: ClawdbotConfig; - setDefaultModel: boolean; - defaultModel: string; - applyDefaultConfig: (config: ClawdbotConfig) => ClawdbotConfig; - applyProviderConfig: (config: ClawdbotConfig) => ClawdbotConfig; - noteDefault?: string; - noteAgentModel: (model: string) => Promise; - 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 }; -} - -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"); - } -} - -export async function applyAuthChoice(params: { - authChoice: AuthChoice; - config: ClawdbotConfig; - prompter: WizardPrompter; - runtime: RuntimeEnv; - agentDir?: string; - setDefaultModel: boolean; - agentId?: string; -}): Promise<{ config: ClawdbotConfig; agentModelOverride?: string }> { - let nextConfig = params.config; - let agentModelOverride: string | undefined; - - const noteAgentModel = async (model: string) => { - if (!params.agentId) return; - await params.prompter.note( - `Default model set to ${model} for agent "${params.agentId}".`, - "Model configured", - ); - }; - - if (params.authChoice === "claude-cli") { - 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, agentModelOverride }; - } - } - - 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, agentModelOverride }; - } - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if ( - params.authChoice === "setup-token" || - params.authChoice === "oauth" - ) { - 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, agentModelOverride }; - } - - const proceed = await params.prompter.confirm({ - message: "Run `claude setup-token` now?", - initialValue: true, - }); - if (!proceed) return { config: nextConfig, agentModelOverride }; - - 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, agentModelOverride }; - } - 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, agentModelOverride }; - } - - 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, agentModelOverride }; - } - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if (params.authChoice === "token") { - 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", - }); - } else 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: nextConfig, agentModelOverride }; - } - } - - 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", - ); - } else 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; - } - } else 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; - } - } else if (params.authChoice === "chutes") { - 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", - ); - } - } else if (params.authChoice === "openai-codex") { - const isRemote = isRemoteEnvironment(); - await params.prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - const spin = params.prompter.progress("Starting OAuth flow…"); - 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", - ); - } - } else if (params.authChoice === "codex-cli") { - 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); - } - } else if (params.authChoice === "antigravity") { - const isRemote = isRemoteEnvironment(); - await params.prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, copy the redirect URL and paste it back here.", - ].join("\n") - : [ - "Browser will open for Google authentication.", - "Sign in with your Google account that has Antigravity access.", - "The callback will be captured automatically on localhost:51121.", - ].join("\n"), - "Google Antigravity OAuth", - ); - const spin = params.prompter.progress("Starting OAuth flow…"); - let oauthCreds: OAuthCredentials | null = null; - try { - oauthCreds = await loginAntigravityVpsAware( - async (url) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, - ); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - params.runtime.log(`Open: ${url}`); - } - }, - (msg) => spin.update(msg), - ); - spin.stop("Antigravity OAuth complete"); - if (oauthCreds) { - await writeOAuthCredentials( - "google-antigravity", - oauthCreds, - params.agentDir, - ); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, - provider: "google-antigravity", - mode: "oauth", - }); - const modelKey = "google-antigravity/claude-opus-4-5-thinking"; - nextConfig = { - ...nextConfig, - 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) - ? { - 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", - ); - } - } else 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); - } - } else 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; - } - } else 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; - } - } else if (params.authChoice === "apiKey") { - 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", - }); - } else 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; - } - } else if (params.authChoice === "github-copilot") { - 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, agentModelOverride }; - } - - try { - await githubCopilotLoginCommand({ yes: true }, params.runtime); - } catch (err) { - await params.prompter.note( - `GitHub Copilot login failed: ${String(err)}`, - "GitHub Copilot", - ); - return { config: nextConfig, agentModelOverride }; - } - - 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", - ); - } - } else 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; - } - } else 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 }; -} - -export function resolvePreferredProviderForAuthChoice( - choice: AuthChoice, -): string | undefined { - return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice]; -} - -const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - 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 { applyAuthChoice } from "./auth-choice.apply.js"; +export { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js"; +export { resolvePreferredProviderForAuthChoice } from "./auth-choice.preferred-provider.js"; diff --git a/src/commands/channels.part-1.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts similarity index 100% rename from src/commands/channels.part-1.test.ts rename to src/commands/channels.adds-non-default-telegram-account.test.ts diff --git a/src/commands/channels.part-2.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts similarity index 100% rename from src/commands/channels.part-2.test.ts rename to src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts new file mode 100644 index 000000000..74155e83a --- /dev/null +++ b/src/commands/configure.channels.ts @@ -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 { + 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 = { ...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", + ); + } +} diff --git a/src/commands/configure.commands.ts b/src/commands/configure.commands.ts new file mode 100644 index 000000000..991c7c117 --- /dev/null +++ b/src/commands/configure.commands.ts @@ -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); +} diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts new file mode 100644 index 000000000..e624b6f3b --- /dev/null +++ b/src/commands/configure.daemon.ts @@ -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, + }); + } +} diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts new file mode 100644 index 000000000..b5d3231e5 --- /dev/null +++ b/src/commands/configure.gateway-auth.ts @@ -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 { + 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; +} diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts new file mode 100644 index 000000000..14c0872ca --- /dev/null +++ b/src/commands/configure.gateway.ts @@ -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 }; +} diff --git a/src/commands/configure.shared.ts b/src/commands/configure.shared.ts new file mode 100644 index 000000000..fd7f4ef46 --- /dev/null +++ b/src/commands/configure.shared.ts @@ -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[0]) => + clackText({ + ...params, + message: stylePromptMessage(params.message), + }); +export const confirm = (params: Parameters[0]) => + clackConfirm({ + ...params, + message: stylePromptMessage(params.message), + }); +export const select = (params: Parameters>[0]) => + clackSelect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 5d3f78c60..0c214f14b 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -1,1072 +1,10 @@ -import path from "node:path"; - -import { - confirm as clackConfirm, - intro as clackIntro, - outro as clackOutro, - select as clackSelect, - text as clackText, -} from "@clack/prompts"; -import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { listChatChannels } from "../channels/registry.js"; -import type { ClawdbotConfig, GatewayAuthConfig } from "../config/config.js"; -import { - 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 { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; -import { findTailscaleBinary } from "../infra/tailscale.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; -import { note } from "../terminal/note.js"; -import { - stylePromptHint, - stylePromptMessage, - stylePromptTitle, -} from "../terminal/prompt-style.js"; -import { resolveUserPath, sleep } from "../utils.js"; -import { createClackPrompter } from "../wizard/clack-prompter.js"; -import { - WizardCancelledError, - type WizardPrompter, -} from "../wizard/prompts.js"; -import { - applyAuthChoice, - resolvePreferredProviderForAuthChoice, -} from "./auth-choice.js"; -import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; -import { - DEFAULT_GATEWAY_DAEMON_RUNTIME, - GATEWAY_DAEMON_RUNTIME_OPTIONS, - type GatewayDaemonRuntime, -} from "./daemon-runtime.js"; -import { healthCommand } from "./health.js"; -import { formatHealthCheckFailure } from "./health-format.js"; -import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js"; -import { setupChannels } from "./onboard-channels.js"; -import { - applyWizardMetadata, - DEFAULT_WORKSPACE, - ensureWorkspaceAndSessions, - guardCancel, - printWizardHeader, - probeGatewayReachable, - randomToken, - resolveControlUiLinks, - summarizeExistingConfig, -} from "./onboard-helpers.js"; -import { promptRemoteGatewayConfig } from "./onboard-remote.js"; -import { setupSkills } from "./onboard-skills.js"; -import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; - -export const CONFIGURE_WIZARD_SECTIONS = [ - "workspace", - "model", - "gateway", - "daemon", - "channels", - "skills", - "health", -] as const; - -export type WizardSection = (typeof CONFIGURE_WIZARD_SECTIONS)[number]; - -type ChannelsWizardMode = "configure" | "remove"; - -type ConfigureWizardParams = { - command: "configure" | "update"; - sections?: WizardSection[]; -}; - -const intro = (message: string) => - clackIntro(stylePromptTitle(message) ?? message); -const outro = (message: string) => - clackOutro(stylePromptTitle(message) ?? message); -const text = (params: Parameters[0]) => - clackText({ - ...params, - message: stylePromptMessage(params.message), - }); -const confirm = (params: Parameters[0]) => - clackConfirm({ - ...params, - message: stylePromptMessage(params.message), - }); -const select = (params: Parameters>[0]) => - clackSelect({ - ...params, - message: stylePromptMessage(params.message), - options: params.options.map((opt) => - opt.hint === undefined - ? opt - : { ...opt, hint: stylePromptHint(opt.hint) }, - ), - }); - -const CONFIGURE_SECTION_OPTIONS: { - value: WizardSection; - label: string; - hint: string; -}[] = [ - { - value: "workspace", - label: "Workspace", - hint: "Set default workspace + ensure sessions", - }, - { - value: "model", - label: "Model/auth", - hint: "Pick model + auth profile sources", - }, - { - value: "gateway", - label: "Gateway config", - hint: "Port/bind/auth/control UI settings", - }, - { - value: "daemon", - label: "Gateway 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", - }, -]; - -type ConfigureSectionChoice = WizardSection | "__continue"; - -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 }; -} - -async function promptConfigureSection( - runtime: RuntimeEnv, - hasSelection: boolean, -): Promise { - return guardCancel( - await select({ - 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 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 }; -} - -async function promptAuthConfig( - cfg: ClawdbotConfig, - runtime: RuntimeEnv, - prompter: WizardPrompter, -): Promise { - 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; -} - -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, - }); - } -} - -async function removeChannelConfigWizard( - cfg: ClawdbotConfig, - runtime: RuntimeEnv, -): Promise { - 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 = { ...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", - ); - } -} - -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 = 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; - - 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 = 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; - - 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; - } -} - -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); -} +export { + configureCommand, + configureCommandWithSections, +} from "./configure.commands.js"; +export { buildGatewayAuthConfig } from "./configure.gateway-auth.js"; +export { + CONFIGURE_WIZARD_SECTIONS, + type WizardSection, +} from "./configure.shared.js"; +export { runConfigureWizard } from "./configure.wizard.js"; diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts new file mode 100644 index 000000000..070a7996d --- /dev/null +++ b/src/commands/configure.wizard.ts @@ -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 { + return guardCancel( + await select({ + 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 { + 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; + } +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts new file mode 100644 index 000000000..c1eec643a --- /dev/null +++ b/src/commands/doctor-config-flow.ts @@ -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 { + 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; +}) { + 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 }; +} diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts new file mode 100644 index 000000000..80e4728c8 --- /dev/null +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -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> + | 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( + { + 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)); + } + } + } + } +} diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts new file mode 100644 index 000000000..6be9a5732 --- /dev/null +++ b/src/commands/doctor-gateway-health.ts @@ -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>({ + 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 }; +} diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts new file mode 100644 index 000000000..7af415ab8 --- /dev/null +++ b/src/commands/doctor-platform-notes.ts @@ -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)"); +} diff --git a/src/commands/doctor-update.ts b/src/commands/doctor-update.ts new file mode 100644 index 000000000..1da0894f1 --- /dev/null +++ b/src/commands/doctor-update.ts @@ -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; + 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 }; +} diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts new file mode 100644 index 000000000..659b83f5c --- /dev/null +++ b/src/commands/doctor-workspace-status.ts @@ -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 }; +} diff --git a/src/commands/doctor.part-4.test.ts b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts similarity index 100% rename from src/commands/doctor.part-4.test.ts rename to src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts diff --git a/src/commands/doctor.part-2.test.ts b/src/commands/doctor.migrates-legacy-config-file.test.ts similarity index 100% rename from src/commands/doctor.part-2.test.ts rename to src/commands/doctor.migrates-legacy-config-file.test.ts diff --git a/src/commands/doctor.part-1.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts similarity index 100% rename from src/commands/doctor.part-1.test.ts rename to src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts diff --git a/src/commands/doctor.part-5.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts similarity index 100% rename from src/commands/doctor.part-5.test.ts rename to src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bd8b0a1dc..d9ac2e427 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { intro as clackIntro, outro as clackOutro } from "@clack/prompts"; import { resolveAgentWorkspaceDir, @@ -13,60 +10,30 @@ import { resolveConfiguredModelRef, resolveHooksGmailModel, } from "../agents/model-selection.js"; -import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { - CONFIG_PATH_CLAWDBOT, - migrateLegacyConfig, - readConfigFileSnapshot, - resolveGatewayPort, - writeConfigFile, -} from "../config/config.js"; -import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; -import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { - renderSystemNodeWarning, - resolvePreferredNodePath, - resolveSystemNodeInfo, -} from "../daemon/runtime-paths.js"; +import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; -import { buildServiceEnvironment } from "../daemon/service-env.js"; -import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; -import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; -import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; -import { runGatewayUpdate } from "../infra/update-runner.js"; -import { loadClawdbotPlugins } from "../plugins/loader.js"; -import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; -import { sleep } from "../utils.js"; -import { - DEFAULT_GATEWAY_DAEMON_RUNTIME, - GATEWAY_DAEMON_RUNTIME_OPTIONS, - type GatewayDaemonRuntime, -} from "./daemon-runtime.js"; import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth, } from "./doctor-auth.js"; -import { - buildGatewayRuntimeHints, - formatGatewayRuntimeSummary, -} from "./doctor-format.js"; +import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; +import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; +import { checkGatewayHealth } from "./doctor-gateway-health.js"; import { maybeMigrateLegacyGatewayService, maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; import { noteSourceInstallIssues } from "./doctor-install.js"; -import { - maybeMigrateLegacyConfigFile, - normalizeLegacyConfigValues, -} from "./doctor-legacy-config.js"; +import { maybeMigrateLegacyConfigFile } from "./doctor-legacy-config.js"; +import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, @@ -82,14 +49,12 @@ import { runLegacyStateMigrations, } from "./doctor-state-migrations.js"; import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; +import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js"; import { - detectLegacyWorkspaceDirs, - formatLegacyWorkspaceWarning, MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem, } from "./doctor-workspace.js"; -import { healthCommand } from "./health.js"; -import { formatHealthCheckFailure } from "./health-format.js"; +import { noteWorkspaceStatus } from "./doctor-workspace-status.js"; import { applyWizardMetadata, printWizardHeader, @@ -106,80 +71,6 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) { - const providers = cfg.models?.providers; - if (!providers) return; - - // 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae). - const overrides: string[] = []; - if (providers.opencode) overrides.push("opencode"); - if (providers["opencode-zen"]) overrides.push("opencode-zen"); - if (overrides.length === 0) return; - - const lines = overrides.flatMap((id) => { - const providerEntry = providers[id]; - const api = - isRecord(providerEntry) && typeof providerEntry.api === "string" - ? providerEntry.api - : undefined; - return [ - `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, - api ? `- models.providers.${id}.api=${api}` : null, - ].filter((line): line is string => Boolean(line)); - }); - - lines.push( - "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", - ); - - note(lines.join("\n"), "OpenCode Zen"); -} - -function resolveHomeDir(): string { - return process.env.HOME ?? os.homedir(); -} - -async function noteMacLaunchAgentOverrides() { - if (process.platform !== "darwin") return; - const markerPath = path.join( - resolveHomeDir(), - ".clawdbot", - "disable-launchagent", - ); - const hasMarker = fs.existsSync(markerPath); - if (!hasMarker) return; - - const lines = [ - `- LaunchAgent writes are disabled via ${markerPath}.`, - "- To restore default behavior:", - ` rm ${markerPath}`, - ].filter((line): line is string => Boolean(line)); - note(lines.join("\n"), "Gateway (macOS)"); -} - -async function detectClawdbotGitCheckout( - root: string, -): Promise<"git" | "not-git" | "unknown"> { - const res = await runCommandWithTimeout( - ["git", "-C", root, "rev-parse", "--show-toplevel"], - { timeoutMs: 5000 }, - ).catch(() => null); - if (!res) return "unknown"; - if (res.code !== 0) { - // Avoid noisy "Update via package manager" notes when git is missing/broken, - // but do show it when this is clearly not a git checkout. - if (res.stderr.toLowerCase().includes("not a git repository")) { - return "not-git"; - } - return "unknown"; - } - return res.stdout.trim() === root ? "git" : "not-git"; -} - export async function doctorCommand( runtime: RuntimeEnv = defaultRuntime, options: DoctorOptions = {}, @@ -194,113 +85,25 @@ export async function doctorCommand( cwd: process.cwd(), }); - const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1"; - const canOfferUpdate = - !updateInProgress && - options.nonInteractive !== true && - options.yes !== true && - options.repair !== true && - Boolean(process.stdin.isTTY); - if (canOfferUpdate) { - if (root) { - const git = await detectClawdbotGitCheckout(root); - if (git === "git") { - const shouldUpdate = await prompter.confirm({ - message: "Update Clawdbot from git before running doctor?", - initialValue: true, - }); - if (shouldUpdate) { - note( - "Running update (fetch/rebase/build/ui:build/doctor)…", - "Update", - ); - const result = await runGatewayUpdate({ - cwd: root, - argv1: process.argv[1], - }); - note( - [ - `Status: ${result.status}`, - `Mode: ${result.mode}`, - result.root ? `Root: ${result.root}` : null, - result.reason ? `Reason: ${result.reason}` : null, - ] - .filter(Boolean) - .join("\n"), - "Update result", - ); - if (result.status === "ok") { - outro( - "Update completed (doctor already ran as part of the update).", - ); - return; - } - } - } else if (git === "not-git") { - note( - [ - "This install is not a git checkout.", - "Update via your package manager, then rerun doctor:", - "- npm i -g clawdbot@latest", - "- pnpm add -g clawdbot@latest", - "- bun add -g clawdbot@latest", - ].join("\n"), - "Update", - ); - } - } - } + const updateResult = await maybeOfferUpdateBeforeDoctor({ + runtime, + options, + root, + confirm: (p) => prompter.confirm(p), + outro, + }); + if (updateResult.handled) return; await maybeRepairUiProtocolFreshness(runtime, prompter); noteSourceInstallIssues(root); await maybeMigrateLegacyConfigFile(runtime); - const snapshot = await readConfigFileSnapshot(); - let cfg: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; - if ( - snapshot.exists && - !snapshot.valid && - snapshot.legacyIssues.length === 0 - ) { - note("Config invalid; doctor will run with defaults.", "Config"); - } - - if (snapshot.legacyIssues.length > 0) { - note( - snapshot.legacyIssues - .map((issue) => `- ${issue.path}: ${issue.message}`) - .join("\n"), - "Legacy config keys detected", - ); - const migrate = - options.nonInteractive === true - ? true - : await prompter.confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }); - if (migrate) { - // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom. - const { config: migrated, changes } = migrateLegacyConfig( - snapshot.parsed, - ); - if (changes.length > 0) { - note(changes.join("\n"), "Doctor changes"); - } - if (migrated) { - cfg = migrated; - } - } - } - - const normalized = normalizeLegacyConfigValues(cfg); - if (normalized.changes.length > 0) { - note(normalized.changes.join("\n"), "Doctor changes"); - cfg = normalized.config; - } - - noteOpencodeProviderOverrides(cfg); + const configResult = await loadAndMaybeMigrateDoctorConfig({ + options, + confirm: (p) => prompter.confirm(p), + }); + let cfg: ClawdbotConfig = configResult.cfg; cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); await noteAuthProfileHealth({ @@ -379,7 +182,7 @@ export async function doctorCommand( await noteStateIntegrity( cfg, prompter, - snapshot.path ?? CONFIG_PATH_CLAWDBOT, + configResult.path ?? CONFIG_PATH_CLAWDBOT, ); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); @@ -473,257 +276,17 @@ export async function doctorCommand( } } - const workspaceDir = resolveAgentWorkspaceDir( + noteWorkspaceStatus(cfg); + + const { healthOk } = await checkGatewayHealth({ runtime, cfg }); + await maybeRepairGatewayDaemon({ cfg, - resolveDefaultAgentId(cfg), - ); - const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir }); - if (legacyWorkspace.legacyDirs.length > 0) { - note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace"); - } - const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); - note( - [ - `Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`, - `Missing requirements: ${ - skillsReport.skills.filter( - (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, - ).length - }`, - `Blocked by allowlist: ${ - skillsReport.skills.filter((s) => s.blockedByAllowlist).length - }`, - ].join("\n"), - "Skills status", - ); - - const pluginRegistry = loadClawdbotPlugins({ - config: cfg, - workspaceDir, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }, + runtime, + prompter, + options, + gatewayDetailsMessage: gatewayDetails.message, + healthOk, }); - if (pluginRegistry.plugins.length > 0) { - const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded"); - const disabled = pluginRegistry.plugins.filter( - (p) => p.status === "disabled", - ); - const errored = pluginRegistry.plugins.filter((p) => p.status === "error"); - - const lines = [ - `Loaded: ${loaded.length}`, - `Disabled: ${disabled.length}`, - `Errors: ${errored.length}`, - errored.length > 0 - ? `- ${errored - .slice(0, 10) - .map((p) => p.id) - .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` - : null, - ].filter((line): line is string => Boolean(line)); - - note(lines.join("\n"), "Plugins"); - } - if (pluginRegistry.diagnostics.length > 0) { - const lines = pluginRegistry.diagnostics.map((diag) => { - const prefix = diag.level.toUpperCase(); - const plugin = diag.pluginId ? ` ${diag.pluginId}` : ""; - const source = diag.source ? ` (${diag.source})` : ""; - return `- ${prefix}${plugin}: ${diag.message}${source}`; - }); - note(lines.join("\n"), "Plugin diagnostics"); - } - - let healthOk = false; - try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - healthOk = true; - } catch (err) { - const message = String(err); - if (message.includes("gateway closed")) { - note("Gateway not running.", "Gateway"); - note(gatewayDetails.message, "Gateway connection"); - } else { - runtime.error(formatHealthCheckFailure(err)); - } - } - - if (healthOk) { - try { - const status = await callGateway>({ - 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> - | 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( - { - message: "Gateway daemon runtime", - options: GATEWAY_DAEMON_RUNTIME_OPTIONS, - initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, - }, - DEFAULT_GATEWAY_DAEMON_RUNTIME, - ); - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const port = resolveGatewayPort(cfg, process.env); - const nodePath = await resolvePreferredNodePath({ - env: process.env, - runtime: daemonRuntime, - }); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ - port, - dev: devMode, - runtime: daemonRuntime, - nodePath, - }); - if (daemonRuntime === "node") { - const systemNode = await resolveSystemNodeInfo({ - env: process.env, - }); - const warning = renderSystemNodeWarning( - systemNode, - programArguments[0], - ); - if (warning) note(warning, "Gateway runtime"); - } - const environment = buildServiceEnvironment({ - env: process.env, - port, - token: - cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - launchdLabel: - process.platform === "darwin" - ? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE) - : undefined, - }); - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - } - } - } else { - const summary = formatGatewayRuntimeSummary(serviceRuntime); - const hints = buildGatewayRuntimeHints(serviceRuntime, { - platform: process.platform, - env: process.env, - }); - if (summary || hints.length > 0) { - const lines = []; - if (summary) lines.push(`Runtime: ${summary}`); - lines.push(...hints); - note(lines.join("\n"), "Gateway"); - } - if (serviceRuntime?.status !== "running") { - const start = await prompter.confirmSkipInNonInteractive({ - message: "Start gateway daemon now?", - initialValue: true, - }); - if (start) { - await service.restart({ - env: process.env, - profile: process.env.CLAWDBOT_PROFILE, - stdout: process.stdout, - }); - await sleep(1500); - } - } - if (process.platform === "darwin") { - const label = resolveGatewayLaunchAgentLabel( - process.env.CLAWDBOT_PROFILE, - ); - note( - `LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${label}.`, - "Gateway", - ); - } - if (serviceRuntime?.status === "running") { - const restart = await prompter.confirmSkipInNonInteractive({ - message: "Restart gateway daemon now?", - initialValue: true, - }); - if (restart) { - await service.restart({ - env: process.env, - profile: process.env.CLAWDBOT_PROFILE, - stdout: process.stdout, - }); - await sleep(1500); - try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } catch (err) { - const message = String(err); - if (message.includes("gateway closed")) { - note("Gateway not running.", "Gateway"); - note(gatewayDetails.message, "Gateway connection"); - } else { - runtime.error(formatHealthCheckFailure(err)); - } - } - } - } - } - } cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); await writeConfigFile(cfg); diff --git a/src/commands/doctor.part-3.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts similarity index 100% rename from src/commands/doctor.part-3.test.ts rename to src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts diff --git a/src/commands/doctor.part-6.test.ts b/src/commands/doctor.warns-state-directory-is-missing.test.ts similarity index 100% rename from src/commands/doctor.part-6.test.ts rename to src/commands/doctor.warns-state-directory-is-missing.test.ts diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts new file mode 100644 index 000000000..a774f061e --- /dev/null +++ b/src/commands/models/list.auth-overview.ts @@ -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)}`, + }, + } + : {}), + }; +} diff --git a/src/commands/models/list.configured.ts b/src/commands/models/list.configured.ts new file mode 100644 index 000000000..fc12a57b5 --- /dev/null +++ b/src/commands/models/list.configured.ts @@ -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>(); + const aliasesByKey = new Map(); + + 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 }; +} diff --git a/src/commands/models/list.format.ts b/src/commands/models/list.format.ts new file mode 100644 index 000000000..e3011b842 --- /dev/null +++ b/src/commands/models/list.format.ts @@ -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)}`; +}; diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts new file mode 100644 index 000000000..68de31a5d --- /dev/null +++ b/src/commands/models/list.list-command.ts @@ -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[] = []; + let availableKeys: Set | 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); +} diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts new file mode 100644 index 000000000..b3b9b75fd --- /dev/null +++ b/src/commands/models/list.registry.ts @@ -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[]; + const availableModels = registry.getAvailable() as Model[]; + const availableKeys = new Set( + availableModels.map((model) => modelKey(model.provider, model.id)), + ); + return { registry, models, availableKeys }; +} + +export function toModelRow(params: { + model?: Model; + key: string; + tags: string[]; + aliases?: string[]; + availableKeys?: Set; + 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, + }; +} diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts new file mode 100644 index 000000000..155cc2009 --- /dev/null +++ b/src/commands/models/list.status-command.ts @@ -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 + >((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(); + const providersInUse = new Set(); + 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(); + // 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); +} diff --git a/src/commands/models/list.table.ts b/src/commands/models/list.table.ts new file mode 100644 index 000000000..fa440c85a --- /dev/null +++ b/src/commands/models/list.table.ts @@ -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); + } +} diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index b784f5126..0473476f8 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -1,1110 +1,2 @@ -import path from "node:path"; - -import type { Api, Model } from "@mariozechner/pi-ai"; -import { - discoverAuthStorage, - discoverModels, -} from "@mariozechner/pi-coding-agent"; - -import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; -import { - buildAuthHealthSummary, - DEFAULT_OAUTH_WARN_MS, - formatRemainingShort, -} from "../../agents/auth-health.js"; -import { - type AuthProfileStore, - ensureAuthProfileStore, - listProfilesForProvider, - resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay, - resolveProfileUnusableUntilForDisplay, -} from "../../agents/auth-profiles.js"; -import { - getCustomProviderApiKey, - resolveEnvApiKey, -} from "../../agents/model-auth.js"; -import { - buildModelAliasIndex, - parseModelRef, - resolveConfiguredModelRef, - resolveModelRefFromString, -} from "../../agents/model-selection.js"; -import { ensureClawdbotModelsJson } from "../../agents/models-config.js"; -import { - type ClawdbotConfig, - CONFIG_PATH_CLAWDBOT, - loadConfig, -} from "../../config/config.js"; -import { - getShellEnvAppliedKeys, - shouldEnableShellEnvFallback, -} from "../../infra/shell-env.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { - colorize, - isRich as isRichTerminal, - theme, -} from "../../terminal/theme.js"; -import { shortenHomePath } from "../../utils.js"; -import { - DEFAULT_MODEL, - DEFAULT_PROVIDER, - ensureFlagCompatibility, - formatTokenK, - modelKey, -} from "./shared.js"; - -const MODEL_PAD = 42; -const INPUT_PAD = 10; -const CTX_PAD = 8; -const LOCAL_PAD = 5; -const AUTH_PAD = 5; - -const isRich = (opts?: { json?: boolean; plain?: boolean }) => - Boolean(isRichTerminal() && !opts?.json && !opts?.plain); - -const pad = (value: string, size: number) => value.padEnd(size); - -const formatKey = (key: string, rich: boolean) => - colorize(rich, theme.warn, key); - -const formatValue = (value: string, rich: boolean) => - colorize(rich, theme.info, value); - -const formatKeyValue = ( - key: string, - value: string, - rich: boolean, - valueColor: (value: string) => string = theme.info, -) => `${formatKey(key, rich)}=${colorize(rich, valueColor, value)}`; - -const formatSeparator = (rich: boolean) => colorize(rich, theme.muted, " | "); - -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); -}; - -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)}...`; -}; - -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)}`; -}; - -type ConfiguredEntry = { - key: string; - ref: { provider: string; model: string }; - tags: Set; - aliases: string[]; -}; - -type ModelRow = { - key: string; - name: string; - input: string; - contextWindow: number | null; - local: boolean | null; - available: boolean | null; - tags: string[]; - missing: boolean; -}; - -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, -): boolean => { - if (listProfilesForProvider(authStore, provider).length > 0) return true; - if (resolveEnvApiKey(provider)) return true; - if (getCustomProviderApiKey(cfg, provider)) return true; - return false; -}; - -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 }; -}; - -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)}`, - }, - } - : {}), - }; -} - -const 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>(); - const aliasesByKey = new Map(); - - 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 }; -}; - -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[]; - const availableModels = registry.getAvailable() as Model[]; - const availableKeys = new Set( - availableModels.map((model) => modelKey(model.provider, model.id)), - ); - return { registry, models, availableKeys }; -} - -function toModelRow(params: { - model?: Model; - key: string; - tags: string[]; - aliases?: string[]; - availableKeys?: Set; - 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, - }; -} - -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); - } -} - -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[] = []; - let availableKeys: Set | 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[] = []; - - 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); -} - -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 - >((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(); - const providersInUse = new Set(); - 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(); - // 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(", ") : "-", - )}`, - ); - - for (const entry of providerAuth) { - const separator = formatSeparator(rich); - const bits: string[] = []; - bits.push( - formatKeyValue( - "effective", - `${colorize(rich, theme.accentBright, entry.effective.kind)}:${colorize( - rich, - theme.muted, - entry.effective.detail, - )}`, - rich, - (value) => value, - ), - ); - 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})`, - rich, - ), - ); - if (entry.profiles.labels.length > 0) { - bits.push(formatValue(entry.profiles.labels.join(", "), rich)); - } - } - if (entry.env) { - bits.push( - formatKeyValue( - "env", - `${entry.env.value}${separator}${formatKeyValue( - "source", - entry.env.source, - rich, - )}`, - rich, - ), - ); - } - if (entry.modelsJson) { - bits.push( - formatKeyValue( - "models.json", - `${entry.modelsJson.value}${separator}${formatKeyValue( - "source", - entry.modelsJson.source, - rich, - )}`, - rich, - ), - ); - } - 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); - } -} +export * from "./list.list-command.js"; +export * from "./list.status-command.js"; diff --git a/src/commands/models/list.types.ts b/src/commands/models/list.types.ts new file mode 100644 index 000000000..2f4157aaa --- /dev/null +++ b/src/commands/models/list.types.ts @@ -0,0 +1,34 @@ +export type ConfiguredEntry = { + key: string; + ref: { provider: string; model: string }; + tags: Set; + 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 }; +}; diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts new file mode 100644 index 000000000..b7997e3c1 --- /dev/null +++ b/src/commands/onboard-auth.config-core.ts @@ -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) + ? { + 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) + ? { + 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 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) + ? { + 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 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) + ? { + 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 } : {}), + }, + }; +} diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts new file mode 100644 index 000000000..7f04f3bd1 --- /dev/null +++ b/src/commands/onboard-auth.config-minimax.ts @@ -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) + ? { + 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 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) + ? { + fallbacks: ( + next.agents.defaults.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: `minimax/${modelId}`, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-auth.config-opencode.ts b/src/commands/onboard-auth.config-opencode.ts new file mode 100644 index 000000000..85bb54526 --- /dev/null +++ b/src/commands/onboard-auth.config-opencode.ts @@ -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) + ? { + fallbacks: ( + next.agents.defaults.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: OPENCODE_ZEN_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts new file mode 100644 index 000000000..e8b22fa9d --- /dev/null +++ b/src/commands/onboard-auth.credentials.ts @@ -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 { + // 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), + }); +} diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts new file mode 100644 index 000000000..378b733e7 --- /dev/null +++ b/src/commands/onboard-auth.models.ts @@ -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, + }; +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 4e7e0f790..8091515ee 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,768 +1,52 @@ -import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; -import { - buildSyntheticModelDefinition, - SYNTHETIC_BASE_URL, +export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF, - SYNTHETIC_MODEL_CATALOG, } from "../agents/synthetic-models.js"; -import type { ClawdbotConfig } from "../config/config.js"; -import type { ModelDefinitionConfig } from "../config/types.js"; +export { + applyAuthProfileConfig, + applyMoonshotConfig, + applyMoonshotProviderConfig, + applyOpenrouterConfig, + applyOpenrouterProviderConfig, + applySyntheticConfig, + applySyntheticProviderConfig, + applyZaiConfig, +} from "./onboard-auth.config-core.js"; +export { + applyMinimaxApiConfig, + applyMinimaxApiProviderConfig, + applyMinimaxConfig, + applyMinimaxHostedConfig, + applyMinimaxHostedProviderConfig, + applyMinimaxProviderConfig, +} from "./onboard-auth.config-minimax.js"; -const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1"; -const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; -const DEFAULT_MINIMAX_MAX_TOKENS = 8192; -export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; -const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; -const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; -const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; -export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; -export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF }; - -const resolveAuthAgentDir = (agentDir?: string) => - agentDir ?? resolveClawdbotAgentDir(); -// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. -const MINIMAX_API_COST = { - input: 15, - output: 60, - cacheRead: 2, - cacheWrite: 10, -}; -const MINIMAX_HOSTED_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -const MINIMAX_LM_STUDIO_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -const MOONSHOT_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -const MINIMAX_MODEL_CATALOG = { - "MiniMax-M2.1": { name: "MiniMax M2.1", reasoning: false }, - "MiniMax-M2.1-lightning": { - name: "MiniMax M2.1 Lightning", - reasoning: false, - }, -} as const; - -type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; - -function buildMinimaxModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - cost: ModelDefinitionConfig["cost"]; - contextWindow: number; - maxTokens: number; -}): ModelDefinitionConfig { - const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: ["text"], - cost: params.cost, - contextWindow: params.contextWindow, - maxTokens: params.maxTokens, - }; -} - -function buildMinimaxApiModelDefinition( - modelId: string, -): ModelDefinitionConfig { - return buildMinimaxModelDefinition({ - id: modelId, - cost: MINIMAX_API_COST, - contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, - maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, - }); -} - -function buildMoonshotModelDefinition(): ModelDefinitionConfig { - return { - id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2 0905 Preview", - reasoning: false, - input: ["text"], - cost: MOONSHOT_DEFAULT_COST, - contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, - maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, - }; -} - -export async function writeOAuthCredentials( - provider: string, - creds: OAuthCredentials, - agentDir?: string, -): Promise { - // 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) - ? { - 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) - ? { - 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 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) - ? { - 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 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) - ? { - 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) - ? { - 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 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) - ? { - 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) - ? { - fallbacks: ( - next.agents.defaults.model as { fallbacks?: string[] } - ).fallbacks, - } - : undefined), - primary: OPENCODE_ZEN_DEFAULT_MODEL_REF, - }, - }, - }, - }; -} +export { + applyOpencodeZenConfig, + applyOpencodeZenProviderConfig, +} from "./onboard-auth.config-opencode.js"; +export { + OPENROUTER_DEFAULT_MODEL_REF, + setAnthropicApiKey, + setGeminiApiKey, + setMinimaxApiKey, + setMoonshotApiKey, + setOpencodeZenApiKey, + setOpenrouterApiKey, + setSyntheticApiKey, + setZaiApiKey, + writeOAuthCredentials, + ZAI_DEFAULT_MODEL_REF, +} from "./onboard-auth.credentials.js"; +export { + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMoonshotModelDefinition, + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, + MOONSHOT_DEFAULT_MODEL_REF, +} from "./onboard-auth.models.js"; diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 9165b50a2..ce276c732 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -1,136 +1,10 @@ -import path from "node:path"; -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - ensureAuthProfileStore, - resolveApiKeyForProfile, - resolveAuthProfileOrder, - upsertAuthProfile, -} from "../agents/auth-profiles.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; -import { - type ClawdbotConfig, - CONFIG_PATH_CLAWDBOT, - readConfigFileSnapshot, - resolveGatewayPort, - writeConfigFile, -} from "../config/config.js"; -import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { - renderSystemNodeWarning, - resolvePreferredNodePath, - resolveSystemNodeInfo, -} from "../daemon/runtime-paths.js"; -import { resolveGatewayService } from "../daemon/service.js"; -import { buildServiceEnvironment } from "../daemon/service-env.js"; -import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; -import { upsertSharedEnvVar } from "../infra/env-file.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { readConfigFileSnapshot } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { resolveUserPath, sleep } from "../utils.js"; -import { - buildTokenProfileId, - validateAnthropicSetupToken, -} from "./auth-token.js"; -import { - DEFAULT_GATEWAY_DAEMON_RUNTIME, - isGatewayDaemonRuntime, -} from "./daemon-runtime.js"; -import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js"; -import { healthCommand } from "./health.js"; -import { - applyAuthProfileConfig, - applyMinimaxApiConfig, - applyMinimaxConfig, - applyMoonshotConfig, - applyOpencodeZenConfig, - applyOpenrouterConfig, - applySyntheticConfig, - applyZaiConfig, - setAnthropicApiKey, - setGeminiApiKey, - setMinimaxApiKey, - setMoonshotApiKey, - setOpencodeZenApiKey, - setOpenrouterApiKey, - setSyntheticApiKey, - setZaiApiKey, -} from "./onboard-auth.js"; -import { - applyWizardMetadata, - DEFAULT_WORKSPACE, - ensureWorkspaceAndSessions, - randomToken, -} from "./onboard-helpers.js"; -import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; -import { applyOpenAICodexModelDefault } from "./openai-codex-model-default.js"; -import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js"; - -type NonInteractiveApiKeySource = "flag" | "env" | "profile"; - -async function resolveApiKeyFromProfiles(params: { - provider: string; - cfg: ClawdbotConfig; - agentDir?: string; -}): Promise { - const store = ensureAuthProfileStore(params.agentDir); - const order = resolveAuthProfileOrder({ - cfg: params.cfg, - store, - provider: params.provider, - }); - for (const profileId of order) { - const cred = store.profiles[profileId]; - if (cred?.type !== "api_key") continue; - const resolved = await resolveApiKeyForProfile({ - cfg: params.cfg, - store, - profileId, - agentDir: params.agentDir, - }); - if (resolved?.apiKey) return resolved.apiKey; - } - return null; -} - -async function resolveNonInteractiveApiKey(params: { - provider: string; - cfg: ClawdbotConfig; - flagValue?: string; - flagName: string; - envVar: string; - runtime: RuntimeEnv; - agentDir?: string; - allowProfile?: boolean; -}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> { - const flagKey = params.flagValue?.trim(); - if (flagKey) return { key: flagKey, source: "flag" }; - - const envResolved = resolveEnvApiKey(params.provider); - if (envResolved?.apiKey) return { key: envResolved.apiKey, source: "env" }; - - if (params.allowProfile ?? true) { - const profileKey = await resolveApiKeyFromProfiles({ - provider: params.provider, - cfg: params.cfg, - agentDir: params.agentDir, - }); - if (profileKey) return { key: profileKey, source: "profile" }; - } - - const profileHint = - params.allowProfile === false - ? "" - : `, or existing ${params.provider} API-key profile`; - params.runtime.error( - `Missing ${params.flagName} (or ${params.envVar} in env${profileHint}).`, - ); - params.runtime.exit(1); - return null; -} +import { runNonInteractiveOnboardingLocal } from "./onboard-non-interactive/local.js"; +import { runNonInteractiveOnboardingRemote } from "./onboard-non-interactive/remote.js"; +import type { OnboardOptions } from "./onboard-types.js"; export async function runNonInteractiveOnboarding( opts: OnboardOptions, @@ -144,6 +18,7 @@ export async function runNonInteractiveOnboarding( runtime.exit(1); return; } + const baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; const mode = opts.mode ?? "local"; if (mode !== "local" && mode !== "remote") { @@ -153,529 +28,9 @@ export async function runNonInteractiveOnboarding( } if (mode === "remote") { - const remoteUrl = opts.remoteUrl?.trim(); - if (!remoteUrl) { - runtime.error("Missing --remote-url for remote mode."); - runtime.exit(1); - return; - } - - let nextConfig: ClawdbotConfig = { - ...baseConfig, - gateway: { - ...baseConfig.gateway, - mode: "remote", - remote: { - url: remoteUrl, - token: opts.remoteToken?.trim() || undefined, - }, - }, - }; - nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); - await writeConfigFile(nextConfig); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - - const payload = { - mode, - remoteUrl, - auth: opts.remoteToken ? "token" : "none", - }; - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(`Remote gateway: ${remoteUrl}`); - runtime.log(`Auth: ${payload.auth}`); - } + await runNonInteractiveOnboardingRemote({ opts, runtime, baseConfig }); return; } - const workspaceDir = resolveUserPath( - ( - opts.workspace ?? - baseConfig.agents?.defaults?.workspace ?? - DEFAULT_WORKSPACE - ).trim(), - ); - - let nextConfig: ClawdbotConfig = { - ...baseConfig, - agents: { - ...baseConfig.agents, - defaults: { - ...baseConfig.agents?.defaults, - workspace: workspaceDir, - }, - }, - gateway: { - ...baseConfig.gateway, - mode: "local", - }, - }; - - const authChoice: AuthChoice = opts.authChoice ?? "skip"; - if (authChoice === "token") { - const providerRaw = opts.tokenProvider?.trim(); - if (!providerRaw) { - runtime.error("Missing --token-provider for --auth-choice token."); - runtime.exit(1); - return; - } - const provider = normalizeProviderId(providerRaw); - if (provider !== "anthropic") { - runtime.error( - "Only --token-provider anthropic is supported for --auth-choice token.", - ); - runtime.exit(1); - return; - } - const tokenRaw = opts.token?.trim(); - if (!tokenRaw) { - runtime.error("Missing --token for --auth-choice token."); - runtime.exit(1); - return; - } - const tokenError = validateAnthropicSetupToken(tokenRaw); - if (tokenError) { - runtime.error(tokenError); - runtime.exit(1); - return; - } - - let expires: number | undefined; - const expiresInRaw = opts.tokenExpiresIn?.trim(); - if (expiresInRaw) { - try { - expires = - Date.now() + parseDurationMs(expiresInRaw, { defaultUnit: "d" }); - } catch (err) { - runtime.error(`Invalid --token-expires-in: ${String(err)}`); - runtime.exit(1); - return; - } - } - - const profileId = - opts.tokenProfileId?.trim() || - buildTokenProfileId({ provider, name: "" }); - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider, - token: tokenRaw.trim(), - ...(expires ? { expires } : {}), - }, - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider, - mode: "token", - }); - } else if (authChoice === "apiKey") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "anthropic", - cfg: baseConfig, - flagValue: opts.anthropicApiKey, - flagName: "--anthropic-api-key", - envVar: "ANTHROPIC_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setAnthropicApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "anthropic:default", - provider: "anthropic", - mode: "api_key", - }); - } else if (authChoice === "gemini-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "google", - cfg: baseConfig, - flagValue: opts.geminiApiKey, - flagName: "--gemini-api-key", - envVar: "GEMINI_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setGeminiApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "google:default", - provider: "google", - mode: "api_key", - }); - nextConfig = applyGoogleGeminiModelDefault(nextConfig).next; - } else if (authChoice === "zai-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "zai", - cfg: baseConfig, - flagValue: opts.zaiApiKey, - flagName: "--zai-api-key", - envVar: "ZAI_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setZaiApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "zai:default", - provider: "zai", - mode: "api_key", - }); - nextConfig = applyZaiConfig(nextConfig); - } else if (authChoice === "openai-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "openai", - cfg: baseConfig, - flagValue: opts.openaiApiKey, - flagName: "--openai-api-key", - envVar: "OPENAI_API_KEY", - runtime, - allowProfile: false, - }); - if (!resolved) return; - const key = resolved.key; - const result = upsertSharedEnvVar({ - key: "OPENAI_API_KEY", - value: key, - }); - process.env.OPENAI_API_KEY = key; - runtime.log(`Saved OPENAI_API_KEY to ${result.path}`); - } else if (authChoice === "openrouter-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "openrouter", - cfg: baseConfig, - flagValue: opts.openrouterApiKey, - flagName: "--openrouter-api-key", - envVar: "OPENROUTER_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setOpenrouterApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "openrouter:default", - provider: "openrouter", - mode: "api_key", - }); - nextConfig = applyOpenrouterConfig(nextConfig); - } else if (authChoice === "moonshot-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "moonshot", - cfg: baseConfig, - flagValue: opts.moonshotApiKey, - flagName: "--moonshot-api-key", - envVar: "MOONSHOT_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setMoonshotApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }); - nextConfig = applyMoonshotConfig(nextConfig); - } else if (authChoice === "synthetic-api-key") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "synthetic", - cfg: baseConfig, - flagValue: opts.syntheticApiKey, - flagName: "--synthetic-api-key", - envVar: "SYNTHETIC_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setSyntheticApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "synthetic:default", - provider: "synthetic", - mode: "api_key", - }); - nextConfig = applySyntheticConfig(nextConfig); - } else if ( - authChoice === "minimax-cloud" || - authChoice === "minimax-api" || - authChoice === "minimax-api-lightning" - ) { - const resolved = await resolveNonInteractiveApiKey({ - provider: "minimax", - cfg: baseConfig, - flagValue: opts.minimaxApiKey, - flagName: "--minimax-api-key", - envVar: "MINIMAX_API_KEY", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setMinimaxApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "minimax:default", - provider: "minimax", - mode: "api_key", - }); - const modelId = - authChoice === "minimax-api-lightning" - ? "MiniMax-M2.1-lightning" - : "MiniMax-M2.1"; - nextConfig = applyMinimaxApiConfig(nextConfig, modelId); - } else if (authChoice === "claude-cli") { - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { - runtime.error( - process.platform === "darwin" - ? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".' - : "No Claude CLI credentials found at ~/.claude/.credentials.json", - ); - runtime.exit(1); - return; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if (authChoice === "codex-cli") { - const store = ensureAuthProfileStore(); - if (!store.profiles[CODEX_CLI_PROFILE_ID]) { - runtime.error("No Codex CLI credentials found at ~/.codex/auth.json"); - runtime.exit(1); - return; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CODEX_CLI_PROFILE_ID, - provider: "openai-codex", - mode: "oauth", - }); - nextConfig = applyOpenAICodexModelDefault(nextConfig).next; - } else if (authChoice === "minimax") { - nextConfig = applyMinimaxConfig(nextConfig); - } else if (authChoice === "opencode-zen") { - const resolved = await resolveNonInteractiveApiKey({ - provider: "opencode", - cfg: baseConfig, - flagValue: opts.opencodeZenApiKey, - flagName: "--opencode-zen-api-key", - envVar: "OPENCODE_API_KEY (or OPENCODE_ZEN_API_KEY)", - runtime, - }); - if (!resolved) return; - if (resolved.source !== "profile") { - await setOpencodeZenApiKey(resolved.key); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "opencode:default", - provider: "opencode", - mode: "api_key", - }); - nextConfig = applyOpencodeZenConfig(nextConfig); - } else if ( - authChoice === "oauth" || - authChoice === "chutes" || - authChoice === "openai-codex" || - authChoice === "antigravity" - ) { - const label = authChoice === "antigravity" ? "Antigravity" : "OAuth"; - runtime.error(`${label} requires interactive mode.`); - runtime.exit(1); - return; - } - - const hasGatewayPort = opts.gatewayPort !== undefined; - if ( - hasGatewayPort && - (!Number.isFinite(opts.gatewayPort) || (opts.gatewayPort ?? 0) <= 0) - ) { - runtime.error("Invalid --gateway-port"); - runtime.exit(1); - return; - } - const port = hasGatewayPort - ? (opts.gatewayPort as number) - : resolveGatewayPort(baseConfig); - let bind = opts.gatewayBind ?? "loopback"; - let authMode = opts.gatewayAuth ?? "token"; - const tailscaleMode = opts.tailscale ?? "off"; - const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); - - if (tailscaleMode !== "off" && bind !== "loopback") { - bind = "loopback"; - } - if (authMode === "off" && bind !== "loopback") { - authMode = "token"; - } - if (tailscaleMode === "funnel" && authMode !== "password") { - authMode = "password"; - } - - let gatewayToken = opts.gatewayToken?.trim() || undefined; - if (authMode === "token") { - if (!gatewayToken) gatewayToken = randomToken(); - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "token", - token: gatewayToken, - }, - }, - }; - } - if (authMode === "password") { - const password = opts.gatewayPassword?.trim(); - if (!password) { - runtime.error("Missing --gateway-password for password auth."); - runtime.exit(1); - return; - } - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - auth: { - ...nextConfig.gateway?.auth, - mode: "password", - password, - }, - }, - }; - } - - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - port, - bind, - tailscale: { - ...nextConfig.gateway?.tailscale, - mode: tailscaleMode, - resetOnExit: tailscaleResetOnExit, - }, - }, - }; - - if (!opts.skipSkills) { - const nodeManager = opts.nodeManager ?? "npm"; - if (!["npm", "pnpm", "bun"].includes(nodeManager)) { - runtime.error("Invalid --node-manager (use npm, pnpm, or bun)"); - runtime.exit(1); - return; - } - nextConfig = { - ...nextConfig, - skills: { - ...nextConfig.skills, - install: { - ...nextConfig.skills?.install, - nodeManager, - }, - }, - }; - } - - nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); - await writeConfigFile(nextConfig); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - await ensureWorkspaceAndSessions(workspaceDir, runtime, { - skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), - }); - - const daemonRuntimeRaw = opts.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; - - if (opts.installDaemon) { - const systemdAvailable = - process.platform === "linux" - ? await isSystemdUserServiceAvailable() - : true; - if (process.platform === "linux" && !systemdAvailable) { - runtime.log( - "Systemd user services are unavailable; skipping daemon install.", - ); - } else { - if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) { - runtime.error("Invalid --daemon-runtime (use node or bun)"); - runtime.exit(1); - return; - } - const service = resolveGatewayService(); - const devMode = - process.argv[1]?.includes(`${path.sep}src${path.sep}`) && - process.argv[1]?.endsWith(".ts"); - const nodePath = await resolvePreferredNodePath({ - env: process.env, - runtime: daemonRuntimeRaw, - }); - const { programArguments, workingDirectory } = - await resolveGatewayProgramArguments({ - port, - dev: devMode, - runtime: daemonRuntimeRaw, - nodePath, - }); - if (daemonRuntimeRaw === "node") { - const systemNode = await resolveSystemNodeInfo({ env: process.env }); - const warning = renderSystemNodeWarning( - systemNode, - programArguments[0], - ); - if (warning) runtime.log(warning); - } - const environment = buildServiceEnvironment({ - env: process.env, - port, - token: gatewayToken, - launchdLabel: - process.platform === "darwin" - ? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE) - : undefined, - }); - await service.install({ - env: process.env, - stdout: process.stdout, - programArguments, - workingDirectory, - environment, - }); - await ensureSystemdUserLingerNonInteractive({ runtime }); - } - } - - if (!opts.skipHealth) { - await sleep(1000); - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } - - if (opts.json) { - runtime.log( - JSON.stringify( - { - mode, - workspace: workspaceDir, - authChoice, - gateway: { port, bind, authMode, tailscaleMode }, - installDaemon: Boolean(opts.installDaemon), - daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined, - skipSkills: Boolean(opts.skipSkills), - skipHealth: Boolean(opts.skipHealth), - }, - null, - 2, - ), - ); - } + await runNonInteractiveOnboardingLocal({ opts, runtime, baseConfig }); } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 713c36e90..2e69e9d8f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -2,10 +2,17 @@ import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, + upsertAuthProfile, } from "../../../agents/auth-profiles.js"; +import { normalizeProviderId } from "../../../agents/model-selection.js"; +import { parseDurationMs } from "../../../cli/parse-duration.js"; import type { ClawdbotConfig } from "../../../config/config.js"; import { upsertSharedEnvVar } from "../../../infra/env-file.js"; import type { RuntimeEnv } from "../../../runtime.js"; +import { + buildTokenProfileId, + validateAnthropicSetupToken, +} from "../../auth-token.js"; import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyAuthProfileConfig, @@ -27,7 +34,6 @@ import { } from "../../onboard-auth.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js"; - import { resolveNonInteractiveApiKey } from "../api-keys.js"; export async function applyNonInteractiveAuthChoice(params: { @@ -58,6 +64,66 @@ export async function applyNonInteractiveAuthChoice(params: { }); } + if (authChoice === "token") { + const providerRaw = opts.tokenProvider?.trim(); + if (!providerRaw) { + runtime.error("Missing --token-provider for --auth-choice token."); + runtime.exit(1); + return null; + } + const provider = normalizeProviderId(providerRaw); + if (provider !== "anthropic") { + runtime.error( + "Only --token-provider anthropic is supported for --auth-choice token.", + ); + runtime.exit(1); + return null; + } + const tokenRaw = opts.token?.trim(); + if (!tokenRaw) { + runtime.error("Missing --token for --auth-choice token."); + runtime.exit(1); + return null; + } + const tokenError = validateAnthropicSetupToken(tokenRaw); + if (tokenError) { + runtime.error(tokenError); + runtime.exit(1); + return null; + } + + let expires: number | undefined; + const expiresInRaw = opts.tokenExpiresIn?.trim(); + if (expiresInRaw) { + try { + expires = + Date.now() + parseDurationMs(expiresInRaw, { defaultUnit: "d" }); + } catch (err) { + runtime.error(`Invalid --token-expires-in: ${String(err)}`); + runtime.exit(1); + return null; + } + } + + const profileId = + opts.tokenProfileId?.trim() || + buildTokenProfileId({ provider, name: "" }); + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider, + token: tokenRaw.trim(), + ...(expires ? { expires } : {}), + }, + }); + return applyAuthProfileConfig(nextConfig, { + profileId, + provider, + mode: "token", + }); + } + if (authChoice === "gemini-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "google", @@ -255,18 +321,12 @@ export async function applyNonInteractiveAuthChoice(params: { } if ( - authChoice === "token" || authChoice === "oauth" || authChoice === "chutes" || authChoice === "openai-codex" || authChoice === "antigravity" ) { - const label = - authChoice === "antigravity" - ? "Antigravity" - : authChoice === "token" - ? "Token" - : "OAuth"; + const label = authChoice === "antigravity" ? "Antigravity" : "OAuth"; runtime.error(`${label} requires interactive mode.`); runtime.exit(1); return null; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index a6c1e7e04..56d8f0385 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -6,7 +6,6 @@ import { resolveGatewayPort, } from "../config/config.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; -import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; @@ -14,11 +13,8 @@ import { probeGateway } from "../gateway/probe.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; -import { - readRestartSentinel, - summarizeRestartSentinel, -} from "../infra/restart-sentinel.js"; +import { inspectPortUsage } from "../infra/ports.js"; +import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { checkUpdateStatus, @@ -26,23 +22,13 @@ import { } from "../infra/update-check.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; -import { renderTable } from "../terminal/table.js"; -import { isRich, theme } from "../terminal/theme.js"; import { VERSION } from "../version.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; import { getAgentLocalStatuses } from "./status-all/agents.js"; import { buildChannelsTable } from "./status-all/channels.js"; -import { - formatAge, - formatDuration, - formatGatewayAuthUsed, - redactSecrets, -} from "./status-all/format.js"; -import { - pickGatewaySelfPresence, - readFileTailLines, - summarizeLogTail, -} from "./status-all/gateway.js"; +import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js"; +import { pickGatewaySelfPresence } from "./status-all/gateway.js"; +import { buildStatusAllReportLines } from "./status-all/report-lines.js"; export async function statusAllCommand( runtime: RuntimeEnv, @@ -410,331 +396,34 @@ export async function statusAllCommand( }, ]; - const rich = isRich(); - const heading = (text: string) => (rich ? theme.heading(text) : text); - const ok = (text: string) => (rich ? theme.success(text) : text); - const warn = (text: string) => (rich ? theme.warn(text) : text); - const fail = (text: string) => (rich ? theme.error(text) : text); - const muted = (text: string) => (rich ? theme.muted(text) : text); - - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); - - const overview = renderTable({ - width: tableWidth, - columns: [ - { key: "Item", header: "Item", minWidth: 10 }, - { key: "Value", header: "Value", flex: true, minWidth: 24 }, - ], - rows: overviewRows, + const lines = await buildStatusAllReportLines({ + progress, + overviewRows, + channels, + channelIssues: channelIssues.map((issue) => ({ + channel: issue.channel, + message: issue.message, + })), + agentStatus, + connectionDetailsForReport, + diagnosis: { + snap, + remoteUrlMissing, + sentinel, + lastErr, + port, + portUsage, + tailscaleMode, + tailscale, + tailscaleHttpsUrl, + skillStatus, + channelsStatus, + channelIssues, + gatewayReachable, + health, + }, }); - const channelRows = channels.rows.map((row) => ({ - channelId: row.id, - Channel: row.label, - Enabled: row.enabled ? ok("ON") : muted("OFF"), - State: - row.state === "ok" - ? ok("OK") - : row.state === "warn" - ? warn("WARN") - : row.state === "off" - ? muted("OFF") - : theme.accentDim("SETUP"), - Detail: row.detail, - })); - const channelIssuesByChannel = (() => { - const map = new Map(); - 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; - if (!("error" in record)) return ""; - const value = record.error; - if (!value) return ""; - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return "[unserializable error]"; - } - })(); - if (healthErr) { - lines.push(""); - lines.push(`${muted("Gateway health:")}`); - lines.push(` ${muted(redactSecrets(healthErr))}`); - } - - lines.push(""); - lines.push(muted("Pasteable debug report. Auth tokens redacted.")); - lines.push("Troubleshooting: https://docs.clawd.bot/troubleshooting"); - lines.push(""); - progress.setLabel("Rendering…"); runtime.log(lines.join("\n")); progress.tick(); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts new file mode 100644 index 000000000..218bb36f7 --- /dev/null +++ b/src/commands/status-all/diagnosis.ts @@ -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 }>; +}; + +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; + 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(""); +} diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts new file mode 100644 index 000000000..f4dddab1a --- /dev/null +++ b/src/commands/status-all/report-lines.ts @@ -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>; + }>; +}; + +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[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(); + 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; +} diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts new file mode 100644 index 000000000..2f1ca1dd8 --- /dev/null +++ b/src/commands/status.agent-local.ts @@ -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 { + 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, + }; +} diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts new file mode 100644 index 000000000..b636cf7a1 --- /dev/null +++ b/src/commands/status.command.ts @@ -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({ + 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(); + 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> = []; + 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"); + } +} diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts new file mode 100644 index 000000000..2fa5159c6 --- /dev/null +++ b/src/commands/status.daemon.ts @@ -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, + }; + } +} diff --git a/src/commands/status.format.ts b/src/commands/status.format.ts new file mode 100644 index 000000000..cd48a0a9a --- /dev/null +++ b/src/commands/status.format.ts @@ -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, +) => { + 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; +}; diff --git a/src/commands/status.gateway-probe.ts b/src/commands/status.gateway-probe.ts new file mode 100644 index 000000000..abf3f0c16 --- /dev/null +++ b/src/commands/status.gateway-probe.ts @@ -0,0 +1,49 @@ +import type { loadConfig } from "../config/config.js"; + +export function resolveGatewayProbeAuth(cfg: ReturnType): { + 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>; + 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, + }; +} diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts new file mode 100644 index 000000000..282ff8744 --- /dev/null +++ b/src/commands/status.link-channel.ts @@ -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 { + 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 | 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; +} diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts new file mode 100644 index 000000000..1d17acd52 --- /dev/null +++ b/src/commands/status.scan.ts @@ -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; + osSummary: ReturnType; + tailscaleMode: string; + tailscaleDns: string | null; + tailscaleHttpsUrl: string | null; + update: Awaited>; + gatewayConnection: ReturnType; + remoteUrlMissing: boolean; + gatewayMode: "local" | "remote"; + gatewayProbe: Awaited> | null; + gatewayReachable: boolean; + gatewaySelf: ReturnType; + channelIssues: ReturnType; + agentStatus: Awaited>; + channels: Awaited>; + summary: Awaited>; +}; + +export async function scanStatus( + opts: { + json?: boolean; + timeoutMs?: number; + all?: boolean; + }, + _runtime: RuntimeEnv, +): Promise { + 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>({ + 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, + }; + }, + ); +} diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts new file mode 100644 index 000000000..7b9d5ab41 --- /dev/null +++ b/src/commands/status.summary.ts @@ -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 { + 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, + }, + }; +} diff --git a/src/commands/status.ts b/src/commands/status.ts index a06917879..5380a98a6 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,1091 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -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 { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { listChannelPlugins } from "../channels/plugins/index.js"; -import type { - ChannelAccountSnapshot, - ChannelId, - ChannelPlugin, -} from "../channels/plugins/types.js"; -import { withProgress } from "../cli/progress.js"; -import { - type ClawdbotConfig, - loadConfig, - resolveGatewayPort, -} from "../config/config.js"; -import { - loadSessionStore, - resolveMainSessionKey, - resolveStorePath, - type SessionEntry, -} from "../config/sessions.js"; -import { resolveGatewayService } from "../daemon/service.js"; -import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; -import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; -import { probeGateway } from "../gateway/probe.js"; -import { listAgentsForGateway } from "../gateway/session-utils.js"; -import { info } from "../globals.js"; -import { buildChannelSummary } from "../infra/channel-summary.js"; -import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; -import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; -import { resolveOsSummary } from "../infra/os-summary.js"; -import { - formatUsageReportLines, - loadProviderUsageSummary, -} from "../infra/provider-usage.js"; -import { peekSystemEvents } from "../infra/system-events.js"; -import { getTailnetHostname } from "../infra/tailscale.js"; -import { - checkUpdateStatus, - compareSemverStrings, - type UpdateCheckResult, -} from "../infra/update-check.js"; -import { runExec } from "../process/exec.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { renderTable } from "../terminal/table.js"; -import { theme } from "../terminal/theme.js"; -import { VERSION } from "../version.js"; -import { resolveHeartbeatSeconds } from "../web/reconnect.js"; -import { formatHealthChannelLines, type HealthSummary } from "./health.js"; -import { resolveControlUiLinks } from "./onboard-helpers.js"; -import { buildChannelsTable } from "./status-all/channels.js"; -import { formatGatewayAuthUsed } from "./status-all/format.js"; -import { statusAllCommand } from "./status-all.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[]; - }; -}; - -type LinkChannelContext = { - linked: boolean; - authAgeMs: number | null; - account?: unknown; - accountId?: string; - plugin: ChannelPlugin; -}; - -async function resolveLinkChannelContext( - cfg: ClawdbotConfig, -): Promise { - 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 | 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; -} - -export async function getStatusSummary(): Promise { - 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, - }, - }; -} - -const formatKTokens = (value: number) => - `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; - -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`; -}; - -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`; -}; - -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("")}…`; -}; - -const formatTokensCompact = ( - sess: Pick, -) => { - 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})`; -}; - -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 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; -}; - -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, - }; - } -} - -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 { - try { - await fs.access(p); - return true; - } catch { - return false; - } -} - -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, - }; -} - -function resolveGatewayProbeAuth(cfg: ReturnType): { - 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 }; -} - -function pickGatewaySelfPresence(presence: unknown): { - host?: string; - ip?: string; - version?: string; - platform?: string; -} | null { - if (!Array.isArray(presence)) return null; - const entries = presence as Array>; - 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, - }; -} - -async function getUpdateCheckResult(params: { - timeoutMs: number; - fetchGit: boolean; - includeRegistry: boolean; -}): Promise { - 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, - }); -} - -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(" · ")}`; -} - -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 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 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>({ - 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, - }; - }, - ); - - 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({ - 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(); - 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> = []; - 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"); - } -} +export { statusCommand } from "./status.command.js"; +export { getStatusSummary } from "./status.summary.js"; +export type { SessionStatus, StatusSummary } from "./status.types.js"; diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts new file mode 100644 index 000000000..f63c5b3e8 --- /dev/null +++ b/src/commands/status.types.ts @@ -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[]; + }; +}; diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts new file mode 100644 index 000000000..e87cf022b --- /dev/null +++ b/src/commands/status.update.ts @@ -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 { + 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(" · ")}`; +}