import { spawnSync } from "node:child_process"; import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts"; import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore, upsertAuthProfile, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { applyAuthProfileConfig } from "../onboard-auth.js"; import { isRemoteEnvironment } from "../antigravity-oauth.js"; import { openUrl } from "../onboard-helpers.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; import { updateConfig } from "./shared.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; const confirm = (params: Parameters[0]) => clackConfirm({ ...params, message: stylePromptMessage(params.message), }); const text = (params: Parameters[0]) => clackText({ ...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) }, ), }); type TokenProvider = "anthropic"; function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null { const trimmed = raw?.trim(); if (!trimmed) return null; const normalized = normalizeProviderId(trimmed); if (normalized === "anthropic") return "anthropic"; return "custom"; } function resolveDefaultTokenProfileId(provider: string): string { return `${normalizeProviderId(provider)}:manual`; } export async function modelsAuthSetupTokenCommand( opts: { provider?: string; yes?: boolean }, runtime: RuntimeEnv, ) { const provider = resolveTokenProvider(opts.provider ?? "anthropic"); if (provider !== "anthropic") { throw new Error( "Only --provider anthropic is supported for setup-token (uses `claude setup-token`).", ); } if (!process.stdin.isTTY) { throw new Error("setup-token requires an interactive TTY."); } if (!opts.yes) { const proceed = await confirm({ message: "Run `claude setup-token` now?", initialValue: true, }); if (!proceed) return; } const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" }); if (res.error) throw res.error; if (typeof res.status === "number" && res.status !== 0) { throw new Error(`claude setup-token failed (exit ${res.status})`); } const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: true, }); const synced = store.profiles[CLAUDE_CLI_PROFILE_ID]; if (!synced) { throw new Error( `No Claude Code CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`, ); } await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId: CLAUDE_CLI_PROFILE_ID, provider: "anthropic", mode: "oauth", }), ); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`); } export async function modelsAuthPasteTokenCommand( opts: { provider?: string; profileId?: string; expiresIn?: string; }, runtime: RuntimeEnv, ) { const rawProvider = opts.provider?.trim(); if (!rawProvider) { throw new Error("Missing --provider."); } const provider = normalizeProviderId(rawProvider); const profileId = opts.profileId?.trim() || resolveDefaultTokenProfileId(provider); const tokenInput = await text({ message: `Paste token for ${provider}`, validate: (value) => (value?.trim() ? undefined : "Required"), }); const token = String(tokenInput).trim(); const expires = opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0 ? Date.now() + parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" }) : undefined; upsertAuthProfile({ profileId, credential: { type: "token", provider, token, ...(expires ? { expires } : {}), }, }); await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" })); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Auth profile: ${profileId} (${provider}/token)`); } export async function modelsAuthAddCommand(_opts: Record, runtime: RuntimeEnv) { const provider = (await select({ message: "Token provider", options: [ { value: "anthropic", label: "anthropic" }, { value: "custom", label: "custom (type provider id)" }, ], })) as TokenProvider | "custom"; const providerId = provider === "custom" ? normalizeProviderId( String( await text({ message: "Provider id", validate: (value) => (value?.trim() ? undefined : "Required"), }), ), ) : provider; const method = (await select({ message: "Token method", options: [ ...(providerId === "anthropic" ? [ { value: "setup-token", label: "setup-token (claude)", hint: "Runs `claude setup-token` (recommended)", }, ] : []), { value: "paste", label: "paste token" }, ], })) as "setup-token" | "paste"; if (method === "setup-token") { await modelsAuthSetupTokenCommand({ provider: providerId }, runtime); return; } const profileIdDefault = resolveDefaultTokenProfileId(providerId); const profileId = String( await text({ message: "Profile id", initialValue: profileIdDefault, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); const wantsExpiry = await confirm({ message: "Does this token expire?", initialValue: false, }); const expiresIn = wantsExpiry ? String( await text({ message: "Expires in (duration)", initialValue: "365d", validate: (value) => { try { parseDurationMs(String(value ?? ""), { defaultUnit: "d" }); return undefined; } catch { return "Invalid duration (e.g. 365d, 12h, 30m)"; } }, }), ).trim() : undefined; await modelsAuthPasteTokenCommand({ provider: providerId, profileId, expiresIn }, runtime); } type LoginOptions = { provider?: string; method?: string; setDefault?: boolean; }; function resolveProviderMatch( providers: ProviderPlugin[], rawProvider?: string, ): ProviderPlugin | null { const raw = rawProvider?.trim(); if (!raw) return null; const normalized = normalizeProviderId(raw); return ( providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? providers.find( (provider) => provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, ) ?? null ); } function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { const raw = rawMethod?.trim(); if (!raw) return null; const normalized = raw.toLowerCase(); return ( provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? null ); } function isPlainRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function mergeConfigPatch(base: T, patch: unknown): T { if (!isPlainRecord(base) || !isPlainRecord(patch)) { return patch as T; } const next: Record = { ...base }; for (const [key, value] of Object.entries(patch)) { const existing = next[key]; if (isPlainRecord(existing) && isPlainRecord(value)) { next[key] = mergeConfigPatch(existing, value); } else { next[key] = value; } } return next as T; } function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig { const models = { ...cfg.agents?.defaults?.models }; models[model] = models[model] ?? {}; const existingModel = cfg.agents?.defaults?.model; return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, models, model: { ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } : undefined), primary: model, }, }, }, }; } function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" { if (credential.type === "api_key") return "api_key"; if (credential.type === "token") return "token"; return "oauth"; } export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { if (!process.stdin.isTTY) { throw new Error("models auth login requires an interactive TTY."); } const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); } const config = snapshot.config; const defaultAgentId = resolveDefaultAgentId(config); const agentDir = resolveAgentDir(config, defaultAgentId); const workspaceDir = resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); const providers = resolvePluginProviders({ config, workspaceDir }); if (providers.length === 0) { throw new Error("No provider plugins found. Install one via `clawdbot plugins install`."); } const prompter = createClackPrompter(); const selectedProvider = resolveProviderMatch(providers, opts.provider) ?? (await prompter.select({ message: "Select a provider", options: providers.map((provider) => ({ value: provider.id, label: provider.label, hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined, })), }).then((id) => resolveProviderMatch(providers, String(id)))); if (!selectedProvider) { throw new Error("Unknown provider. Use --provider to pick a provider plugin."); } const chosenMethod = pickAuthMethod(selectedProvider, opts.method) ?? (selectedProvider.auth.length === 1 ? selectedProvider.auth[0] : await prompter.select({ message: `Auth method for ${selectedProvider.label}`, options: selectedProvider.auth.map((method) => ({ value: method.id, label: method.label, hint: method.hint, })), }).then((id) => selectedProvider.auth.find((method) => method.id === String(id)), )); if (!chosenMethod) { throw new Error("Unknown auth method. Use --method to select one."); } const isRemote = isRemoteEnvironment(); const result: ProviderAuthResult = await chosenMethod.run({ config, agentDir, workspaceDir, prompter, runtime, isRemote, openUrl: async (url) => { await openUrl(url); }, oauth: { createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params), }, }); for (const profile of result.profiles) { upsertAuthProfile({ profileId: profile.profileId, credential: profile.credential, agentDir, }); } await updateConfig((cfg) => { let next = cfg; if (result.configPatch) { next = mergeConfigPatch(next, result.configPatch); } for (const profile of result.profiles) { next = applyAuthProfileConfig(next, { profileId: profile.profileId, provider: profile.credential.provider, mode: credentialMode(profile.credential), }); } if (opts.setDefault && result.defaultModel) { next = applyDefaultModel(next, result.defaultModel); } return next; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); for (const profile of result.profiles) { runtime.log( `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, ); } if (result.defaultModel) { runtime.log( opts.setDefault ? `Default model set to ${result.defaultModel}` : `Default model available: ${result.defaultModel} (use --set-default to apply)`, ); } if (result.notes && result.notes.length > 0) { await prompter.note(result.notes.join("\n"), "Provider notes"); } }