feat: add anthropic claude cli migration

This commit is contained in:
Peter Steinberger
2026-03-26 23:01:44 +00:00
parent b96fccadb9
commit ebf5bd75f4
10 changed files with 394 additions and 12 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/provider-auth")>();
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": {},
},
},
},
});
});
});

View File

@@ -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<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["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<string, unknown>;
const next: Record<string, unknown> = { ...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<string, unknown> | undefined): {
value: Record<string, unknown> | 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<AgentDefaultsModels>;
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<AgentDefaultsModels>,
},
},
},
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(", ")}.`]
: []),
],
};
}

View File

@@ -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<ProviderAuthResult> {
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<ProviderAuthContext["config"] | null> {
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",
},
}),
],

View File

@@ -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 <key>",

View File

@@ -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".',
);
});
});

View File

@@ -10,9 +10,12 @@ export const AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI: ReadonlyArray<AuthChoice> = [
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");
}

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -510,6 +510,7 @@ describe("plugin-sdk subpath exports", () => {
expectSourceMentions("provider-auth", [
"buildOauthProviderAuthResult",
"generatePkceVerifierChallenge",
"readClaudeCliCredentialsCached",
"toFormUrlEncoded",
]);
expectSourceOmits("core", ["buildOauthProviderAuthResult"]);

View File

@@ -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 <key>",