diff --git a/extensions/anthropic/cli-migration.test.ts b/extensions/anthropic/cli-migration.test.ts new file mode 100644 index 00000000000..5e0a40faa76 --- /dev/null +++ b/extensions/anthropic/cli-migration.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; + +const readClaudeCliCredentialsCached = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/provider-auth", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + readClaudeCliCredentialsCached, + }; +}); + +const { buildAnthropicCliMigrationResult, hasClaudeCliAuth } = await import("./cli-migration.js"); + +describe("anthropic cli migration", () => { + it("detects local Claude CLI auth", () => { + readClaudeCliCredentialsCached.mockReturnValue({ type: "oauth" }); + + expect(hasClaudeCliAuth()).toBe(true); + }); + + it("rewrites anthropic defaults to claude-cli defaults", () => { + const result = buildAnthropicCliMigrationResult({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-6", + fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], + }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, + "anthropic/claude-opus-4-6": { alias: "Opus" }, + "openai/gpt-5.2": {}, + }, + }, + }, + }); + + expect(result.profiles).toEqual([]); + expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6"); + expect(result.configPatch).toEqual({ + agents: { + defaults: { + model: { + primary: "claude-cli/claude-sonnet-4-6", + fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"], + }, + models: { + "claude-cli/claude-sonnet-4-6": { alias: "Sonnet" }, + "claude-cli/claude-opus-4-6": { alias: "Opus" }, + "openai/gpt-5.2": {}, + }, + }, + }, + }); + }); + + it("adds a Claude CLI default when no anthropic default is present", () => { + const result = buildAnthropicCliMigrationResult({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "openai/gpt-5.2": {}, + }, + }, + }, + }); + + expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6"); + expect(result.configPatch).toEqual({ + agents: { + defaults: { + models: { + "openai/gpt-5.2": {}, + "claude-cli/claude-sonnet-4-6": {}, + }, + }, + }, + }); + }); +}); diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts new file mode 100644 index 00000000000..93cdff12d9f --- /dev/null +++ b/extensions/anthropic/cli-migration.ts @@ -0,0 +1,131 @@ +import type { OpenClawConfig, ProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; +import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth"; + +const DEFAULT_CLAUDE_CLI_MODEL = "claude-cli/claude-sonnet-4-6"; +type AgentDefaultsModel = NonNullable["defaults"]>["model"]; +type AgentDefaultsModels = NonNullable["defaults"]>["models"]; + +function toClaudeCliModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith("anthropic/")) { + return null; + } + const modelId = trimmed.slice("anthropic/".length).trim(); + if (!modelId.toLowerCase().startsWith("claude-")) { + return null; + } + return `claude-cli/${modelId}`; +} + +function rewriteModelSelection(model: AgentDefaultsModel): { + value: AgentDefaultsModel; + primary?: string; + changed: boolean; +} { + if (typeof model === "string") { + const converted = toClaudeCliModelRef(model); + return converted + ? { value: converted, primary: converted, changed: true } + : { value: model, changed: false }; + } + if (!model || typeof model !== "object" || Array.isArray(model)) { + return { value: model, changed: false }; + } + + const current = model as Record; + const next: Record = { ...current }; + let changed = false; + let primary: string | undefined; + + if (typeof current.primary === "string") { + const converted = toClaudeCliModelRef(current.primary); + if (converted) { + next.primary = converted; + primary = converted; + changed = true; + } + } + + const currentFallbacks = current.fallbacks; + if (Array.isArray(currentFallbacks)) { + const nextFallbacks = currentFallbacks.map((entry) => + typeof entry === "string" ? (toClaudeCliModelRef(entry) ?? entry) : entry, + ); + if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) { + next.fallbacks = nextFallbacks; + changed = true; + } + } + + return { + value: changed ? next : model, + ...(primary ? { primary } : {}), + changed, + }; +} + +function rewriteModelEntryMap(models: Record | undefined): { + value: Record | undefined; + migrated: string[]; +} { + if (!models) { + return { value: models, migrated: [] }; + } + + const next = { ...models }; + const migrated: string[] = []; + + for (const [rawKey, value] of Object.entries(models)) { + const converted = toClaudeCliModelRef(rawKey); + if (!converted) { + continue; + } + if (!(converted in next)) { + next[converted] = value; + } + delete next[rawKey]; + migrated.push(converted); + } + + return { + value: migrated.length > 0 ? next : models, + migrated, + }; +} + +export function hasClaudeCliAuth(): boolean { + return Boolean(readClaudeCliCredentialsCached()); +} + +export function buildAnthropicCliMigrationResult(config: OpenClawConfig): ProviderAuthResult { + const defaults = config.agents?.defaults; + const rewrittenModel = rewriteModelSelection(defaults?.model); + const rewrittenModels = rewriteModelEntryMap(defaults?.models); + const existingModels = (rewrittenModels.value ?? + defaults?.models ?? + {}) as NonNullable; + const defaultModel = rewrittenModel.primary ?? DEFAULT_CLAUDE_CLI_MODEL; + + return { + profiles: [], + configPatch: { + agents: { + defaults: { + ...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}), + models: { + ...existingModels, + [defaultModel]: existingModels[defaultModel] ?? {}, + } as NonNullable, + }, + }, + }, + defaultModel, + notes: [ + "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", + "Existing Anthropic auth profiles are kept for rollback.", + ...(rewrittenModels.migrated.length > 0 + ? [`Migrated allowlist entries: ${rewrittenModels.migrated.join(", ")}.`] + : []), + ], + }; +} diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 06724dc8981..c36bf9b8e08 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -28,6 +28,7 @@ import { import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { buildAnthropicCliBackend } from "./cli-backend.js"; +import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js"; import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; const PROVIDER_ID = "anthropic"; @@ -312,6 +313,59 @@ async function runAnthropicSetupTokenNonInteractive(ctx: { }); } +async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise { + if (!hasClaudeCliAuth()) { + throw new Error( + [ + "Claude CLI is not authenticated on this host.", + `Run ${formatCliCommand("claude auth login")} first, then re-run this setup.`, + ].join("\n"), + ); + } + return buildAnthropicCliMigrationResult(ctx.config); +} + +async function runAnthropicCliMigrationNonInteractive(ctx: { + config: ProviderAuthContext["config"]; + runtime: ProviderAuthContext["runtime"]; +}): Promise { + if (!hasClaudeCliAuth()) { + ctx.runtime.error( + [ + 'Auth choice "anthropic-cli" requires Claude CLI auth on this host.', + `Run ${formatCliCommand("claude auth login")} first.`, + ].join("\n"), + ); + ctx.runtime.exit(1); + return null; + } + + const result = buildAnthropicCliMigrationResult(ctx.config); + const currentDefaults = ctx.config.agents?.defaults; + const currentModel = currentDefaults?.model; + const currentFallbacks = + currentModel && typeof currentModel === "object" && "fallbacks" in currentModel + ? currentModel.fallbacks + : undefined; + + return { + ...ctx.config, + ...result.configPatch, + agents: { + ...ctx.config.agents, + ...result.configPatch?.agents, + defaults: { + ...currentDefaults, + ...result.configPatch?.agents?.defaults, + model: { + ...(Array.isArray(currentFallbacks) ? { fallbacks: currentFallbacks } : {}), + primary: result.defaultModel, + }, + }, + }, + }; +} + export default definePluginEntry({ id: PROVIDER_ID, name: "Anthropic Provider", @@ -325,6 +379,33 @@ export default definePluginEntry({ envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID], auth: [ + { + id: "cli", + label: "Claude CLI", + hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*", + kind: "custom", + wizard: { + choiceId: "anthropic-cli", + choiceLabel: "Anthropic Claude CLI", + choiceHint: "Reuse a local Claude CLI login on this host", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + setup-token + API key", + modelAllowlist: { + allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST].map((model) => + model.replace(/^anthropic\//, "claude-cli/"), + ), + initialSelections: ["claude-cli/claude-sonnet-4-6"], + message: "Claude CLI models", + }, + }, + run: async (ctx: ProviderAuthContext) => await runAnthropicCliMigration(ctx), + runNonInteractive: async (ctx) => + await runAnthropicCliMigrationNonInteractive({ + config: ctx.config, + runtime: ctx.runtime, + }), + }, { id: "setup-token", label: "setup-token (claude)", @@ -336,7 +417,7 @@ export default definePluginEntry({ choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", groupId: "anthropic", groupLabel: "Anthropic", - groupHint: "setup-token + API key", + groupHint: "Claude CLI + setup-token + API key", modelAllowlist: { allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST], initialSelections: ["anthropic/claude-sonnet-4-6"], @@ -368,7 +449,7 @@ export default definePluginEntry({ choiceLabel: "Anthropic API key", groupId: "anthropic", groupLabel: "Anthropic", - groupHint: "setup-token + API key", + groupHint: "Claude CLI + setup-token + API key", }, }), ], diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 106f832eef9..4dcae3c5de4 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -7,6 +7,16 @@ "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] }, "providerAuthChoices": [ + { + "provider": "anthropic", + "method": "cli", + "choiceId": "anthropic-cli", + "choiceLabel": "Anthropic Claude CLI", + "choiceHint": "Reuse a local Claude CLI login on this host", + "groupId": "anthropic", + "groupLabel": "Anthropic", + "groupHint": "Claude CLI + setup-token + API key" + }, { "provider": "anthropic", "method": "setup-token", @@ -15,7 +25,7 @@ "choiceHint": "Run `claude setup-token` elsewhere, then paste the token here", "groupId": "anthropic", "groupLabel": "Anthropic", - "groupHint": "setup-token + API key" + "groupHint": "Claude CLI + setup-token + API key" }, { "provider": "anthropic", @@ -24,7 +34,7 @@ "choiceLabel": "Anthropic API key", "groupId": "anthropic", "groupLabel": "Anthropic", - "groupHint": "setup-token + API key", + "groupHint": "Claude CLI + setup-token + API key", "optionKey": "anthropicApiKey", "cliFlag": "--anthropic-api-key", "cliOption": "--anthropic-api-key ", diff --git a/src/commands/auth-choice-legacy.test.ts b/src/commands/auth-choice-legacy.test.ts new file mode 100644 index 00000000000..1b44d3366a0 --- /dev/null +++ b/src/commands/auth-choice-legacy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + formatDeprecatedNonInteractiveAuthChoiceError, + normalizeLegacyOnboardAuthChoice, + resolveDeprecatedAuthChoiceReplacement, +} from "./auth-choice-legacy.js"; + +describe("auth choice legacy aliases", () => { + it("maps claude-cli to the new anthropic cli choice", () => { + expect(normalizeLegacyOnboardAuthChoice("claude-cli")).toBe("anthropic-cli"); + expect(resolveDeprecatedAuthChoiceReplacement("claude-cli")).toEqual({ + normalized: "anthropic-cli", + message: 'Auth choice "claude-cli" is deprecated; using Anthropic Claude CLI setup instead.', + }); + expect(formatDeprecatedNonInteractiveAuthChoiceError("claude-cli")).toBe( + 'Auth choice "claude-cli" is deprecated.\nUse "--auth-choice anthropic-cli".', + ); + }); +}); diff --git a/src/commands/auth-choice-legacy.ts b/src/commands/auth-choice-legacy.ts index 59f25a59650..b62fa0c9f51 100644 --- a/src/commands/auth-choice-legacy.ts +++ b/src/commands/auth-choice-legacy.ts @@ -10,9 +10,12 @@ export const AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI: ReadonlyArray = [ export function normalizeLegacyOnboardAuthChoice( authChoice: AuthChoice | undefined, ): AuthChoice | undefined { - if (authChoice === "oauth" || authChoice === "claude-cli") { + if (authChoice === "oauth") { return "setup-token"; } + if (authChoice === "claude-cli") { + return "anthropic-cli"; + } if (authChoice === "codex-cli") { return "openai-codex"; } @@ -31,8 +34,8 @@ export function resolveDeprecatedAuthChoiceReplacement(authChoice: "claude-cli" } { if (authChoice === "claude-cli") { return { - normalized: "setup-token", - message: 'Auth choice "claude-cli" is deprecated; using setup-token flow instead.', + normalized: "anthropic-cli", + message: 'Auth choice "claude-cli" is deprecated; using Anthropic Claude CLI setup instead.', }; } return { @@ -45,8 +48,6 @@ export function formatDeprecatedNonInteractiveAuthChoiceError( authChoice: "claude-cli" | "codex-cli", ): string { const replacement = - authChoice === "claude-cli" - ? '"--auth-choice token" (Anthropic setup-token)' - : '"--auth-choice openai-codex"'; + authChoice === "claude-cli" ? '"--auth-choice anthropic-cli"' : '"--auth-choice openai-codex"'; return [`Auth choice "${authChoice}" is deprecated.`, `Use ${replacement}.`].join("\n"); } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 5b0fe84c847..5a4c36844e0 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -229,6 +229,52 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4"); }); + it("supports provider-owned Claude CLI migration without writing auth profiles", async () => { + const runtime = createRuntime(); + const runClaudeCliMigration = vi.fn().mockResolvedValue({ + profiles: [], + defaultModel: "claude-cli/claude-sonnet-4-6", + configPatch: { + agents: { + defaults: { + models: { + "claude-cli/claude-sonnet-4-6": {}, + }, + }, + }, + }, + }); + mocks.resolvePluginProviders.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + auth: [ + { + id: "cli", + label: "Claude CLI", + kind: "custom", + run: runClaudeCliMigration, + }, + ], + }, + ]); + + await modelsAuthLoginCommand( + { provider: "anthropic", method: "cli", setDefault: true }, + runtime, + ); + + expect(runClaudeCliMigration).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).not.toHaveBeenCalled(); + expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ + primary: "claude-cli/claude-sonnet-4-6", + }); + expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ + "claude-cli/claude-sonnet-4-6": {}, + }); + expect(runtime.log).toHaveBeenCalledWith("Default model set to claude-cli/claude-sonnet-4-6"); + }); + it("clears stale auth lockouts before attempting openai-codex login", async () => { const runtime = createRuntime(); const fakeStore = { diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index a03ad036dcf..3378e7b71a6 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -9,6 +9,7 @@ export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/ export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js"; export { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; export { listProfilesForProvider, upsertAuthProfile } from "../agents/auth-profiles/profiles.js"; +export { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js"; export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js"; export { MINIMAX_OAUTH_MARKER, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 251ddd4450f..ecea3c2b3bc 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -510,6 +510,7 @@ describe("plugin-sdk subpath exports", () => { expectSourceMentions("provider-auth", [ "buildOauthProviderAuthResult", "generatePkceVerifierChallenge", + "readClaudeCliCredentialsCached", "toFormUrlEncoded", ]); expectSourceOmits("core", ["buildOauthProviderAuthResult"]); diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index 5ad80c20d6d..6e01a1e929c 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -174,6 +174,16 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], }, providerAuthChoices: [ + { + provider: "anthropic", + method: "cli", + choiceId: "anthropic-cli", + choiceLabel: "Anthropic Claude CLI", + choiceHint: "Reuse a local Claude CLI login on this host", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + setup-token + API key", + }, { provider: "anthropic", method: "setup-token", @@ -182,7 +192,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", groupId: "anthropic", groupLabel: "Anthropic", - groupHint: "setup-token + API key", + groupHint: "Claude CLI + setup-token + API key", }, { provider: "anthropic", @@ -191,7 +201,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ choiceLabel: "Anthropic API key", groupId: "anthropic", groupLabel: "Anthropic", - groupHint: "setup-token + API key", + groupHint: "Claude CLI + setup-token + API key", optionKey: "anthropicApiKey", cliFlag: "--anthropic-api-key", cliOption: "--anthropic-api-key ",