From 7a917602c5f4002cba6d73ebb2cad7a62dd0fe1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 10:47:24 +0100 Subject: [PATCH] feat(auth): sync OAuth from Claude/Codex CLIs Add source profiles anthropic:claude-cli and openai-codex:codex-cli; surface them in onboarding/configure. Co-authored-by: pepicrft --- CHANGELOG.md | 1 + src/agents/auth-profiles.test.ts | 262 ++++++++++++++++++++++++ src/agents/auth-profiles.ts | 193 ++++++++++++++++- src/commands/agents.ts | 18 +- src/commands/auth-choice-options.ts | 49 +++++ src/commands/auth-choice.ts | 43 ++++ src/commands/configure.ts | 51 +++-- src/commands/onboard-non-interactive.ts | 34 ++- src/commands/onboard-types.ts | 2 + src/wizard/onboarding.ts | 16 +- 10 files changed, 629 insertions(+), 40 deletions(-) create mode 100644 src/commands/auth-choice-options.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0fb65a4..494a08cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. - Auth/Doctor: migrate Anthropic OAuth configs from `anthropic:default` → `anthropic:` and surface a doctor hint on refresh failures. Thanks @RandyVentures for PR #361. (#363) - Auth: delete legacy `auth.json` after migration to prevent stale OAuth token overwrites. Thanks @reeltimeapps for PR #368. +- Auth: auto-sync OAuth creds from Claude CLI/Codex CLI into `anthropic:claude-cli`/`openai-codex:codex-cli` and offer them as onboarding/config choices (avoids `refresh_token_reused`). Thanks @pepicrft for PR #374. - Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293. - Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. - Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding). diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index bf52a1931..91513b8c5 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it } from "vitest"; import { type AuthProfileStore, + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, calculateAuthProfileCooldownMs, ensureAuthProfileStore, resolveAuthProfileOrder, @@ -339,3 +341,263 @@ describe("auth profile cooldowns", () => { expect(calculateAuthProfileCooldownMs(5)).toBe(60 * 60_000); }); }); + +describe("external CLI credential sync", () => { + it("syncs Claude CLI credentials into anthropic:claude-cli", () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-cli-sync-"), + ); + const originalHome = process.env.HOME; + + try { + // Create a temp home with Claude CLI credentials + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); + process.env.HOME = tempHome; + + // Create Claude CLI credentials + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeCreds = { + claudeAiOauth: { + accessToken: "fresh-access-token", + refreshToken: "fresh-refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now + }, + }; + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify(claudeCreds), + ); + + // Create empty auth-profiles.json + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + }, + }), + ); + + // Load the store - should sync from CLI + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles["anthropic:default"]).toBeDefined(); + expect((store.profiles["anthropic:default"] as { key: string }).key).toBe( + "sk-default", + ); + expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access, + ).toBe("fresh-access-token"); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires, + ).toBeGreaterThan(Date.now()); + } finally { + process.env.HOME = originalHome; + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("syncs Codex CLI credentials into openai-codex:codex-cli", () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-codex-sync-"), + ); + const originalHome = process.env.HOME; + + try { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); + process.env.HOME = tempHome; + + // Create Codex CLI credentials + const codexDir = path.join(tempHome, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + const codexCreds = { + tokens: { + access_token: "codex-access-token", + refresh_token: "codex-refresh-token", + }, + }; + const codexAuthPath = path.join(codexDir, "auth.json"); + fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds)); + + // Create empty auth-profiles.json + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: {}, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + + expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined(); + expect( + (store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access, + ).toBe("codex-access-token"); + } finally { + process.env.HOME = originalHome; + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite API keys when syncing external CLI creds", () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-no-overwrite-"), + ); + const originalHome = process.env.HOME; + + try { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); + process.env.HOME = tempHome; + + // Create Claude CLI credentials + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + const claudeCreds = { + claudeAiOauth: { + accessToken: "cli-access", + refreshToken: "cli-refresh", + expiresAt: Date.now() + 30 * 60 * 1000, + }, + }; + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify(claudeCreds), + ); + + // Create auth-profiles.json with an API key + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-store", + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + + // Should keep the store's API key and still add the CLI profile. + expect((store.profiles["anthropic:default"] as { key: string }).key).toBe( + "sk-store", + ); + expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); + } finally { + process.env.HOME = originalHome; + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"), + ); + const originalHome = process.env.HOME; + + try { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); + process.env.HOME = tempHome; + + const claudeDir = path.join(tempHome, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "cli-access", + refreshToken: "cli-refresh", + expiresAt: Date.now() + 30 * 60 * 1000, + }, + }), + ); + + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CLAUDE_CLI_PROFILE_ID]: { + type: "oauth", + provider: "anthropic", + access: "store-access", + refresh: "store-refresh", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + expect( + (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access, + ).toBe("store-access"); + } finally { + process.env.HOME = originalHome; + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("updates codex-cli profile when Codex CLI refresh token changes", () => { + const agentDir = fs.mkdtempSync( + path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"), + ); + const originalHome = process.env.HOME; + + try { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-")); + process.env.HOME = tempHome; + + const codexDir = path.join(tempHome, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + const codexAuthPath = path.join(codexDir, "auth.json"); + fs.writeFileSync( + codexAuthPath, + JSON.stringify({ + tokens: { access_token: "same-access", refresh_token: "new-refresh" }, + }), + ); + fs.utimesSync(codexAuthPath, new Date(), new Date()); + + const authPath = path.join(agentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + [CODEX_CLI_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "same-access", + refresh: "old-refresh", + expires: Date.now() - 1000, + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + expect( + (store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh, + ).toBe("new-refresh"); + } finally { + process.env.HOME = originalHome; + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 20400693a..9a4a14b72 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -19,6 +19,14 @@ import { normalizeProviderId } from "./model-selection.js"; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = "auth-profiles.json"; const LEGACY_AUTH_FILENAME = "auth.json"; + +// External CLI credential file locations +const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; +const CODEX_CLI_AUTH_RELATIVE_PATH = ".codex/auth.json"; + +export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; +export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli"; + const AUTH_STORE_LOCK_OPTIONS = { retries: { retries: 10, @@ -267,11 +275,177 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { return mutated; } +/** + * Read Anthropic OAuth credentials from Claude CLI's credential file. + * Claude CLI stores credentials at ~/.claude/.credentials.json + */ +function readClaudeCliCredentials(): OAuthCredential | null { + const credPath = path.join( + resolveUserPath("~"), + CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH, + ); + const raw = loadJsonFile(credPath); + if (!raw || typeof raw !== "object") return null; + + const data = raw as Record; + const claudeOauth = data.claudeAiOauth as Record | undefined; + if (!claudeOauth || typeof claudeOauth !== "object") return null; + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; +} + +/** + * Read OpenAI Codex OAuth credentials from Codex CLI's auth file. + * Codex CLI stores credentials at ~/.codex/auth.json + */ +function readCodexCliCredentials(): OAuthCredential | null { + const authPath = path.join( + resolveUserPath("~"), + CODEX_CLI_AUTH_RELATIVE_PATH, + ); + const raw = loadJsonFile(authPath); + if (!raw || typeof raw !== "object") return null; + + const data = raw as Record; + const tokens = data.tokens as Record | undefined; + if (!tokens || typeof tokens !== "object") return null; + + const accessToken = tokens.access_token; + const refreshToken = tokens.refresh_token; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + // Codex CLI doesn't store expiry, estimate 1 hour from file mtime or now + let expires: number; + try { + const stat = fs.statSync(authPath); + // Assume token is valid for ~1 hour from when the file was last modified + expires = stat.mtimeMs + 60 * 60 * 1000; + } catch { + expires = Date.now() + 60 * 60 * 1000; + } + + return { + type: "oauth", + provider: "openai-codex" as unknown as OAuthProvider, + access: accessToken, + refresh: refreshToken, + expires, + }; +} + +function shallowEqualOAuthCredentials( + a: OAuthCredential | undefined, + b: OAuthCredential, +): boolean { + if (!a) return false; + if (a.type !== "oauth") return false; + return ( + a.provider === b.provider && + a.access === b.access && + a.refresh === b.refresh && + a.expires === b.expires && + a.email === b.email && + a.enterpriseUrl === b.enterpriseUrl && + a.projectId === b.projectId && + a.accountId === b.accountId + ); +} + +/** + * Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store. + * This allows clawdbot to use the same credentials as these tools without requiring + * separate authentication, and keeps credentials in sync when CLI tools refresh tokens. + * + * Returns true if any credentials were updated. + */ +function syncExternalCliCredentials(store: AuthProfileStore): boolean { + let mutated = false; + const now = Date.now(); + + // Sync from Claude CLI + const claudeCreds = readClaudeCliCredentials(); + if (claudeCreds) { + const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + + // Update if: no existing profile, existing is not oauth, or CLI has newer/valid token + const shouldUpdate = + !existingOAuth || + existingOAuth.provider !== "anthropic" || + existingOAuth.expires <= now || + (claudeCreds.expires > now && + claudeCreds.expires > existingOAuth.expires); + + if ( + shouldUpdate && + !shallowEqualOAuthCredentials(existingOAuth, claudeCreds) + ) { + store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds; + mutated = true; + log.info("synced anthropic credentials from claude cli", { + profileId: CLAUDE_CLI_PROFILE_ID, + expires: new Date(claudeCreds.expires).toISOString(), + }); + } + } + + // Sync from Codex CLI + const codexCreds = readCodexCliCredentials(); + if (codexCreds) { + const existing = store.profiles[CODEX_CLI_PROFILE_ID]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + + // Codex creds don't carry expiry; use file mtime heuristic for freshness. + const shouldUpdate = + !existingOAuth || + existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) || + existingOAuth.expires <= now || + codexCreds.expires > existingOAuth.expires; + + if ( + shouldUpdate && + !shallowEqualOAuthCredentials(existingOAuth, codexCreds) + ) { + store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds; + mutated = true; + log.info("synced openai-codex credentials from codex cli", { + profileId: CODEX_CLI_PROFILE_ID, + expires: new Date(codexCreds.expires).toISOString(), + }); + } + } + + return mutated; +} + export function loadAuthProfileStore(): AuthProfileStore { const authPath = resolveAuthStorePath(); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); - if (asStore) return asStore; + if (asStore) { + // Sync from external CLI tools on every load + const synced = syncExternalCliCredentials(asStore); + if (synced) { + saveJsonFile(authPath, asStore); + } + return asStore; + } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); const legacy = coerceLegacyStore(legacyRaw); @@ -303,17 +477,27 @@ export function loadAuthProfileStore(): AuthProfileStore { }; } } + syncExternalCliCredentials(store); return store; } - return { version: AUTH_STORE_VERSION, profiles: {} }; + const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; + syncExternalCliCredentials(store); + return store; } export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); - if (asStore) return asStore; + if (asStore) { + // Sync from external CLI tools on every load + const synced = syncExternalCliCredentials(asStore); + if (synced) { + saveJsonFile(authPath, asStore); + } + return asStore; + } const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacy = coerceLegacyStore(legacyRaw); @@ -348,7 +532,8 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore { } const mergedOAuth = mergeOAuthFileIntoStore(store); - const shouldWrite = legacy !== null || mergedOAuth; + const syncedCli = syncExternalCliCredentials(store); + const shouldWrite = legacy !== null || mergedOAuth || syncedCli; if (shouldWrite) { saveJsonFile(authPath, store); } diff --git a/src/commands/agents.ts b/src/commands/agents.ts index f7afad11e..76cbc0139 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -2,6 +2,7 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -21,6 +22,7 @@ import { resolveDefaultWhatsAppAccountId } from "../web/accounts.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; +import { buildAuthChoiceOptions } from "./auth-choice-options.js"; import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js"; import { setupProviders } from "./onboard-providers.js"; import type { AuthChoice, ProviderChoice } from "./onboard-types.js"; @@ -458,19 +460,13 @@ export async function agentsAddCommand( initialValue: false, }); if (wantsAuth) { + const authStore = ensureAuthProfileStore(agentDir); const authChoice = (await prompter.select({ message: "Model/auth choice", - options: [ - { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, - { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, - { - value: "antigravity", - label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", - }, - { value: "apiKey", label: "Anthropic API key" }, - { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, - { value: "skip", label: "Skip for now" }, - ], + options: buildAuthChoiceOptions({ + store: authStore, + includeSkip: true, + }), })) as AuthChoice; const authResult = await applyAuthChoice({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts new file mode 100644 index 000000000..9c52a9dbe --- /dev/null +++ b/src/commands/auth-choice-options.ts @@ -0,0 +1,49 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, +} from "../agents/auth-profiles.js"; +import type { AuthChoice } from "./onboard-types.js"; + +export type AuthChoiceOption = { value: AuthChoice; label: string }; + +export function buildAuthChoiceOptions(params: { + store: AuthProfileStore; + includeSkip: boolean; +}): AuthChoiceOption[] { + const options: AuthChoiceOption[] = []; + + const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; + if (claudeCli?.type === "oauth") { + options.push({ + value: "claude-cli", + label: "Anthropic OAuth (Claude CLI)", + }); + } + + options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }); + + const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID]; + if (codexCli?.type === "oauth") { + options.push({ + value: "codex-cli", + label: "OpenAI Codex OAuth (Codex CLI)", + }); + } + + options.push({ + value: "openai-codex", + label: "OpenAI Codex (ChatGPT OAuth)", + }); + options.push({ + value: "antigravity", + label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", + }); + options.push({ value: "apiKey", label: "Anthropic API key" }); + options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" }); + if (params.includeSkip) { + options.push({ value: "skip", label: "Skip for now" }); + } + + return options; +} diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 7c3770320..195bcf50b 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -6,6 +6,8 @@ import { } from "@mariozechner/pi-ai"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, } from "../agents/auth-profiles.js"; @@ -165,6 +167,20 @@ export async function applyAuthChoice(params: { "OAuth help", ); } + } else if (params.authChoice === "claude-cli") { + const store = ensureAuthProfileStore(params.agentDir); + if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { + await params.prompter.note( + "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: "oauth", + }); } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( @@ -250,6 +266,33 @@ export async function applyAuthChoice(params: { "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( diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 878731b58..8d2ee4430 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -16,6 +16,11 @@ import { type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai"; +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + ensureAuthProfileStore, +} from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -35,6 +40,7 @@ import { isRemoteEnvironment, loginAntigravityVpsAware, } from "./antigravity-oauth.js"; +import { buildAuthChoiceOptions } from "./auth-choice-options.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, @@ -254,20 +260,21 @@ async function promptAuthConfig( const authChoice = guardCancel( await select({ message: "Model/auth choice", - options: [ - { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, - { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, - { - value: "antigravity", - label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", - }, - { value: "apiKey", label: "Anthropic API key" }, - { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, - { value: "skip", label: "Skip for now" }, - ], + options: buildAuthChoiceOptions({ + store: ensureAuthProfileStore(), + includeSkip: true, + }), }), runtime, - ) as "oauth" | "openai-codex" | "antigravity" | "apiKey" | "minimax" | "skip"; + ) as + | "oauth" + | "claude-cli" + | "openai-codex" + | "codex-cli" + | "antigravity" + | "apiKey" + | "minimax" + | "skip"; let next = cfg; @@ -312,6 +319,12 @@ async function promptAuthConfig( runtime.error(String(err)); note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth"); } + } else if (authChoice === "claude-cli") { + next = applyAuthProfileConfig(next, { + profileId: CLAUDE_CLI_PROFILE_ID, + provider: "anthropic", + mode: "oauth", + }); } else if (authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); note( @@ -386,6 +399,20 @@ async function promptAuthConfig( runtime.error(String(err)); note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth"); } + } else if (authChoice === "codex-cli") { + next = applyAuthProfileConfig(next, { + profileId: CODEX_CLI_PROFILE_ID, + provider: "openai-codex", + mode: "oauth", + }); + const applied = applyOpenAICodexModelDefault(next); + next = applied.next; + if (applied.changed) { + note( + `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, + "Model configured", + ); + } } else if (authChoice === "antigravity") { const isRemote = isRemoteEnvironment(); note( diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 68e5b3301..7e3821fa1 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -1,5 +1,9 @@ import path from "node:path"; - +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + ensureAuthProfileStore, +} from "../agents/auth-profiles.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, @@ -30,6 +34,7 @@ import { 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"; export async function runNonInteractiveOnboarding( @@ -112,6 +117,33 @@ export async function runNonInteractiveOnboarding( provider: "anthropic", mode: "api_key", }); + } else if (authChoice === "claude-cli") { + const store = ensureAuthProfileStore(); + if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { + runtime.error( + "No Claude CLI credentials found at ~/.claude/.credentials.json", + ); + runtime.exit(1); + return; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: CLAUDE_CLI_PROFILE_ID, + provider: "anthropic", + mode: "oauth", + }); + } 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 ( diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 9d334ea55..ef6227f2e 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -3,7 +3,9 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export type OnboardMode = "local" | "remote"; export type AuthChoice = | "oauth" + | "claude-cli" | "openai-codex" + | "codex-cli" | "antigravity" | "apiKey" | "minimax" diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index b01648862..9f5eff39e 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -1,9 +1,10 @@ import path from "node:path"; - +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { applyAuthChoice, warnIfModelConfigLooksOff, } from "../commands/auth-choice.js"; +import { buildAuthChoiceOptions } from "../commands/auth-choice-options.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, @@ -183,19 +184,10 @@ export async function runOnboardingWizard( }, }; + const authStore = ensureAuthProfileStore(); const authChoice = (await prompter.select({ message: "Model/auth choice", - options: [ - { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, - { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, - { - value: "antigravity", - label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", - }, - { value: "apiKey", label: "Anthropic API key" }, - { value: "minimax", label: "Minimax M2.1 (LM Studio)" }, - { value: "skip", label: "Skip for now" }, - ], + options: buildAuthChoiceOptions({ store: authStore, includeSkip: true }), })) as AuthChoice; const authResult = await applyAuthChoice({