From eced473e0515eee45cd70741bdfac639fcbb3aba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 07:51:37 +0100 Subject: [PATCH] feat: add models auth commands --- src/cli/models-cli.ts | 62 ++++++++++ src/cli/parse-duration.test.ts | 4 + src/cli/parse-duration.ts | 21 +++- src/commands/models.ts | 5 + src/commands/models/auth.ts | 207 +++++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 src/commands/models/auth.ts diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 85d7d8149..ac3f5342d 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -4,6 +4,9 @@ import { modelsAliasesAddCommand, modelsAliasesListCommand, modelsAliasesRemoveCommand, + modelsAuthAddCommand, + modelsAuthPasteTokenCommand, + modelsAuthSetupTokenCommand, modelsFallbacksAddCommand, modelsFallbacksClearCommand, modelsFallbacksListCommand, @@ -294,4 +297,63 @@ export function registerModelsCli(program: Command) { defaultRuntime.exit(1); } }); + + const auth = models.command("auth").description("Manage model auth profiles"); + + auth + .command("add") + .description("Interactive auth helper (setup-token or paste token)") + .action(async () => { + try { + await modelsAuthAddCommand({}, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + auth + .command("setup-token") + .description("Run a provider CLI to create/sync a token (TTY required)") + .option("--provider ", "Provider id (default: anthropic)") + .option("--yes", "Skip confirmation", false) + .action(async (opts) => { + try { + await modelsAuthSetupTokenCommand( + { + provider: opts.provider as string | undefined, + yes: Boolean(opts.yes), + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + auth + .command("paste-token") + .description("Paste a token into auth-profiles.json and update config") + .requiredOption("--provider ", "Provider id (e.g. anthropic)") + .option("--profile-id ", "Auth profile id (default: :manual)") + .option( + "--expires-in ", + "Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.", + ) + .action(async (opts) => { + try { + await modelsAuthPasteTokenCommand( + { + provider: opts.provider as string | undefined, + profileId: opts.profileId as string | undefined, + expiresIn: opts.expiresIn as string | undefined, + }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); } diff --git a/src/cli/parse-duration.test.ts b/src/cli/parse-duration.test.ts index b72a00626..c26bcc114 100644 --- a/src/cli/parse-duration.test.ts +++ b/src/cli/parse-duration.test.ts @@ -19,6 +19,10 @@ describe("parseDurationMs", () => { expect(parseDurationMs("2h")).toBe(7_200_000); }); + it("parses days suffix", () => { + expect(parseDurationMs("2d")).toBe(172_800_000); + }); + it("supports decimals", () => { expect(parseDurationMs("0.5s")).toBe(500); }); diff --git a/src/cli/parse-duration.ts b/src/cli/parse-duration.ts index efaea9368..81674fe73 100644 --- a/src/cli/parse-duration.ts +++ b/src/cli/parse-duration.ts @@ -1,5 +1,5 @@ export type DurationMsParseOptions = { - defaultUnit?: "ms" | "s" | "m" | "h"; + defaultUnit?: "ms" | "s" | "m" | "h" | "d"; }; export function parseDurationMs( @@ -11,7 +11,7 @@ export function parseDurationMs( .toLowerCase(); if (!trimmed) throw new Error("invalid duration (empty)"); - const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed); + const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed); if (!m) throw new Error(`invalid duration: ${raw}`); const value = Number(m[1]); @@ -19,9 +19,22 @@ export function parseDurationMs( throw new Error(`invalid duration: ${raw}`); } - const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h"; + const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as + | "ms" + | "s" + | "m" + | "h" + | "d"; const multiplier = - unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000; + unit === "ms" + ? 1 + : unit === "s" + ? 1000 + : unit === "m" + ? 60_000 + : unit === "h" + ? 3_600_000 + : 86_400_000; const ms = Math.round(value * multiplier); if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`); return ms; diff --git a/src/commands/models.ts b/src/commands/models.ts index 4622a4479..636a738cb 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -3,6 +3,11 @@ export { modelsAliasesListCommand, modelsAliasesRemoveCommand, } from "./models/aliases.js"; +export { + modelsAuthAddCommand, + modelsAuthPasteTokenCommand, + modelsAuthSetupTokenCommand, +} from "./models/auth.js"; export { modelsFallbacksAddCommand, modelsFallbacksClearCommand, diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts new file mode 100644 index 000000000..e48abeb22 --- /dev/null +++ b/src/commands/models/auth.ts @@ -0,0 +1,207 @@ +import { spawnSync } from "node:child_process"; + +import { confirm, select, text } from "@clack/prompts"; + +import { + CLAUDE_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 { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { applyAuthProfileConfig } from "../onboard-auth.js"; +import { updateConfig } from "./shared.js"; + +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 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: "token", + }), + ); + + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`); +} + +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, + ); +}