From 5c117a1cc3730ed13d0fd527cffdf662e01f5139 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:24:56 -0400 Subject: [PATCH] feat(core): provider `extends` + 6 presets (zai-glm, qwen-max, deepseek, ollama, claude-work/personal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `extends` field to `ProviderConfig` so a profile can inherit a builtin adapter (claude/codex/gemini/opencode) and layer env/model/command overrides on top. Ships 6 presets for the common adapter-extension cases: - zai-glm: Z.AI Anthropic-compat endpoint + GLM-4.6 - qwen-max: Alibaba DashScope Anthropic-compat + Qwen Max/Plus - deepseek-chat: DeepSeek Anthropic-compat + chat/reasoner models - ollama-claude-local: local Ollama Anthropic-compat loopback - claude-work / claude-personal: split CLAUDE_CONFIG_DIR for multi-account Changes: - packages/core/src/types.ts: ProviderConfig gains `extends`, `env`, `models`, `label`, `command` (all optional + additive). `baseUrl` is now optional since adapter-extending profiles don't need one. `ProviderExtends` type union covers the four builtin adapters. - packages/core/src/provider-presets.ts: new — `providerPresets` map + `getProviderPreset` / `listProviderPresets` helpers. - packages/core/src/config.ts: new `resolveProvider(profile)` — idempotent shallow merge of adapter defaults or preset base under the profile's own values; `env` is shallow-merged key-by-key (profile wins); `models` replaced only when profile sets it explicitly. - packages/core/src/index.ts: re-exports `providerPresets`, `listProviderPresets`, `getProviderPreset`, `ProviderExtends`. - packages/cli/src/commands/provider.ts: `arc provider set --preset ` copies the preset into the profile (explicit flags still win); `arc provider presets` now prints both the new extends-style presets and the legacy OpenAI-compat presets; `arc provider show` uses `resolveProvider` and renders `extends` + env when present. - packages/cli/src/cli.ts: wire `--preset` flag on `arc provider set`. Tests: new `tests/provider-extends.test.ts` (18 cases) covering preset lookup, `resolveProvider` merge semantics (idempotent, scalar override, env shallow-merge, unknown-extends fallback, no mutation), and the CLI `--preset` write/validate path. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/provider.ts | 120 ++++++++-- packages/core/src/config.ts | 59 ++++- packages/core/src/index.ts | 8 + packages/core/src/provider-presets.ts | 82 +++++++ packages/core/src/types.ts | 41 +++- tests/provider-extends.test.ts | 315 ++++++++++++++++++++++++++ 7 files changed, 602 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/provider-presets.ts create mode 100644 tests/provider-extends.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b2f19df..16f3239 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -748,11 +748,12 @@ Examples: provider .command("set ") .description("Set provider config on a profile") + .option("--preset ", "Apply a builtin preset (e.g. zai-glm, qwen-max, claude-work)") .option("--base-url ", "API base URL (e.g. https://openrouter.ai/api/v1)") .option("--model ", "Model identifier (e.g. anthropic/claude-3.5-sonnet)") .option("--api-key-var ", "Env var name for the API key (default: OPENAI_API_KEY)") .option("--display-name ", "Provider display name (e.g. OpenRouter, Ollama)") - .action(async (name: string, opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }) => { + .action(async (name: string, opts: { preset?: string; baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }) => { const mod = await import("./commands/provider.js"); await mod.handleProviderSet(name, opts); }); diff --git a/packages/cli/src/commands/provider.ts b/packages/cli/src/commands/provider.ts index b50f12f..4e3d900 100644 --- a/packages/cli/src/commands/provider.ts +++ b/packages/cli/src/commands/provider.ts @@ -1,6 +1,7 @@ import { loadConfig, saveConfig } from "../config.js"; import { success, error, info, cmd } from "../display.js"; import type { ProviderConfig } from "@axiom-labs/arc-core"; +import { providerPresets, resolveProvider } from "@axiom-labs/arc-core"; // ─── Known provider presets ───────────────────────────────────────── @@ -91,20 +92,71 @@ function getProfile(name?: string) { // ─── Handlers ─────────────────────────────────────────────────────── +export interface ProviderSetOpts { + baseUrl?: string; + model?: string; + apiKeyVar?: string; + displayName?: string; + /** Name of a builtin preset in `providerPresets` (e.g. "zai-glm", "claude-work"). */ + preset?: string; +} + export async function handleProviderSet( name: string, - opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }, + opts: ProviderSetOpts, ): Promise { const { config, profileName, profile } = getProfile(name); - if (!opts.baseUrl && !opts.model && !opts.apiKeyVar && !opts.displayName) { - error("Provide at least one option: --base-url, --model, --api-key-var, or --display-name"); - info(`Example: ${cmd("arc provider set " + profileName + " --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`); + const presetKey = opts.preset; + if (!presetKey && !opts.baseUrl && !opts.model && !opts.apiKeyVar && !opts.displayName) { + error("Provide at least one option: --preset, --base-url, --model, --api-key-var, or --display-name"); + info(`Example: ${cmd("arc provider set " + profileName + " --preset zai-glm")}`); + info(` or: ${cmd("arc provider set " + profileName + " --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`); process.exit(1); } - // Check if input matches a preset - const preset = PRESETS.find((p) => + if (presetKey) { + const preset = providerPresets[presetKey]; + if (!preset) { + const available = Object.keys(providerPresets).join(", "); + error(`Unknown preset "${presetKey}". Available: ${available}`); + info(`Run ${cmd("arc provider presets")} to see details.`); + process.exit(1); + } + + // Preset overwrites existing provider fields; explicit flags still win over both. + const merged: ProviderConfig = { + ...(profile.provider ?? {}), + ...preset, + }; + if (preset.env || profile.provider?.env) { + merged.env = { ...(preset.env ?? {}), ...(profile.provider?.env ?? {}) }; + } + if (opts.baseUrl) merged.baseUrl = opts.baseUrl; + if (opts.model) merged.model = opts.model; + if (opts.apiKeyVar) merged.apiKeyEnvVar = opts.apiKeyVar; + if (opts.displayName) merged.displayName = opts.displayName; + + profile.provider = merged; + saveConfig(config); + + const label = merged.label ?? merged.displayName ?? presetKey; + success(`Provider for "${profileName}" configured from preset "${presetKey}": ${label}`); + if (merged.extends) info(` Extends: ${merged.extends}`); + if (merged.baseUrl) info(` Base URL: ${merged.baseUrl}`); + if (merged.model) info(` Model: ${merged.model}`); + if (merged.models?.length) info(` Models: ${merged.models.join(", ")}`); + if (merged.apiKeyEnvVar) info(` API key env var: ${merged.apiKeyEnvVar}`); + if (merged.env && Object.keys(merged.env).length > 0) { + for (const [k, v] of Object.entries(merged.env)) { + info(` env ${k}=${v}`); + } + } + return; + } + + // OpenAI-compat path: match a legacy preset by baseUrl or displayName id. + const legacyPreset = PRESETS.find((p) => opts.baseUrl === p.baseUrl || opts.displayName?.toLowerCase() === p.id, ); @@ -115,15 +167,13 @@ export async function handleProviderSet( if (opts.apiKeyVar) existing.apiKeyEnvVar = opts.apiKeyVar; if (opts.displayName) existing.displayName = opts.displayName; - // Apply preset defaults for fields not explicitly set - if (preset) { - if (!opts.apiKeyVar && !existing.apiKeyEnvVar) existing.apiKeyEnvVar = preset.apiKeyEnvVar; - if (!opts.displayName && !existing.displayName) existing.displayName = preset.displayName; + if (legacyPreset) { + if (!opts.apiKeyVar && !existing.apiKeyEnvVar) existing.apiKeyEnvVar = legacyPreset.apiKeyEnvVar; + if (!opts.displayName && !existing.displayName) existing.displayName = legacyPreset.displayName; } profile.provider = existing; - // Auto-set authType to openai-compat if not already if (profile.authType !== "openai-compat" && profile.authType !== "api-key") { profile.authType = "openai-compat"; } @@ -136,7 +186,6 @@ export async function handleProviderSet( if (existing.model) info(` Model: ${existing.model}`); if (existing.apiKeyEnvVar) info(` API key env var: ${existing.apiKeyEnvVar}`); - // Hint about setting the key const keyVar = existing.apiKeyEnvVar ?? "OPENAI_API_KEY"; info(`\nSet your API key: ${cmd(`arc set-key ${profileName}`)}`); info(`Or via env: ${cmd(`export ${keyVar}=sk-...`)}`); @@ -144,20 +193,28 @@ export async function handleProviderSet( export async function handleProviderShow(name?: string): Promise { const { profileName, profile } = getProfile(name); - const p = profile.provider; + const resolved = resolveProvider(profile); - if (!p) { + if (!resolved) { info(`No provider configured for "${profileName}".`); info(`Set one with: ${cmd(`arc provider set ${profileName} --base-url `)}`); info(`Or use a preset: ${cmd("arc provider presets")}`); return; } - const label = p.displayName ?? "Custom Provider"; + const label = resolved.label ?? resolved.displayName ?? "Custom Provider"; info(`Provider for "${profileName}": ${label}`); - console.log(` Base URL: ${p.baseUrl || "(not set)"}`); - console.log(` Model: ${p.model || "(not set)"}`); - console.log(` API key var: ${p.apiKeyEnvVar || "OPENAI_API_KEY"}`); + if (resolved.extends) console.log(` Extends: ${resolved.extends}`); + console.log(` Base URL: ${resolved.baseUrl || "(not set)"}`); + console.log(` Model: ${resolved.model || "(not set)"}`); + if (resolved.models?.length) console.log(` Models: ${resolved.models.join(", ")}`); + console.log(` API key var: ${resolved.apiKeyEnvVar || "OPENAI_API_KEY"}`); + if (resolved.env && Object.keys(resolved.env).length > 0) { + console.log(` Environment:`); + for (const [k, v] of Object.entries(resolved.env)) { + console.log(` ${k}=${v}`); + } + } } export async function handleProviderClear(name: string): Promise { @@ -174,7 +231,28 @@ export async function handleProviderClear(name: string): Promise { } export async function handleProviderPresets(): Promise { - info("Known provider presets:\n"); + info("Adapter-extends presets (inherit a builtin adapter + env overrides):\n"); + + const idCol = Math.max("ID".length, ...Object.keys(providerPresets).map((k) => k.length)); + console.log( + ` ${"ID".padEnd(idCol)} ${"Extends".padEnd(9)} Label / Models` + ); + console.log( + ` ${"".padEnd(idCol, "-")} ${"".padEnd(9, "-")} --------------------------------` + ); + for (const [id, preset] of Object.entries(providerPresets)) { + const label = preset.label ?? preset.displayName ?? ""; + const models = preset.models?.length ? ` models: ${preset.models.join(", ")}` : ""; + console.log(` ${id.padEnd(idCol)} ${(preset.extends ?? "-").padEnd(9)} ${label}${models}`); + if (preset.env) { + for (const [k, v] of Object.entries(preset.env)) { + console.log(` ${"".padEnd(idCol)} ${"".padEnd(9)} ${k}=${v}`); + } + } + } + + console.log(); + info("OpenAI-compatible presets (set baseUrl + model directly):\n"); for (const p of PRESETS) { console.log(` ${p.displayName.padEnd(14)} ${p.baseUrl}`); @@ -184,8 +262,8 @@ export async function handleProviderPresets(): Promise { console.log(); } - info("Quick setup example:"); - console.log(` ${cmd("arc create openrouter --tool openai-compat --auth-type openai-compat")}`); + info("Quick setup examples:"); + console.log(` ${cmd("arc provider set myprof --preset zai-glm")}`); console.log(` ${cmd("arc provider set openrouter --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`); console.log(` ${cmd("arc set-key openrouter")}`); console.log(` ${cmd("arc launch openrouter")}`); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 162adfe..5c2d937 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,7 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import { getArcDir, getConfigPath, getProfileDir } from "./paths.js"; import { deepMerge } from "./shared-fs.js"; -import type { ArcConfig, Profile } from "./types.js"; +import { providerPresets } from "./provider-presets.js"; +import type { ArcConfig, Profile, ProviderConfig } from "./types.js"; const AUTH_TYPES = new Set(["oauth", "api-key", "bedrock", "vertex", "foundry", "openai-compat"]); @@ -209,6 +210,62 @@ export function resolveInstructions(profile: Profile): string | undefined { return profile.instructions; } +/** + * Built-in defaults applied when a provider extends a known adapter. These are + * the "base" fields a profile inherits before its own values override. For now + * the adapter-level defaults are minimal — the meaningful env/model data lives + * in {@link providerPresets}, which the CLI applies by copying the preset into + * the profile's provider config. + */ +const ADAPTER_DEFAULTS: Record> = { + claude: {}, + codex: {}, + gemini: {}, + opencode: {}, +}; + +/** + * Resolve the effective provider config for a profile. + * + * Merge rules: + * 1. If `profile.provider` is undefined → returns `undefined`. + * 2. If `provider.extends` is unset → returns a fresh shallow copy (idempotent). + * 3. If `provider.extends` names a known source (an adapter or a preset id), + * the source fields are layered **under** the profile's own values. Scalar + * fields: profile wins on conflict. `env` is shallow-merged key-by-key + * (source first, profile last). `models` is replaced by the profile when + * explicitly set; otherwise taken from the source. + * + * The returned object is always a fresh shallow copy — the profile is not + * mutated. + */ +export function resolveProvider(profile: Profile): ProviderConfig | undefined { + const own = profile.provider; + if (!own) return undefined; + if (!own.extends) return { ...own }; + + // `extends` may reference a builtin adapter OR (via CLI copy-through) carry + // over to the preset-id lookup. Adapter defaults take precedence if both match. + const base: Partial | undefined = + ADAPTER_DEFAULTS[own.extends] ?? providerPresets[own.extends]; + if (!base) return { ...own }; + + // Scalar merge: base defaults, profile overrides win. + const merged: ProviderConfig = { ...base, ...own }; + + // env: shallow merge (base first, profile last). + if (base.env || own.env) { + merged.env = { ...(base.env ?? {}), ...(own.env ?? {}) }; + } + + // models: profile replaces base when set; otherwise inherit. + if (own.models === undefined && base.models) { + merged.models = [...base.models]; + } + + return merged; +} + export interface CloneProfileOptions { /** When true (default), recursively copy the source configDir to the new profile directory. */ copyConfigDir?: boolean; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d2d68b..f83f30b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,7 @@ export type { AgentTool, Profile, ProviderConfig, + ProviderExtends, ArcSettings, ArcConfig, SharedManifest, @@ -51,6 +52,13 @@ export type { HookConfig, } from "./types.js"; +// Provider presets (Claude, Codex, Gemini, OpenCode — extends-style) +export { + providerPresets, + listProviderPresets, + getProviderPreset, +} from "./provider-presets.js"; + export * from "./adapters/types.js"; // hooks: named exports to avoid collisions with adapters/types.ts placeholders diff --git a/packages/core/src/provider-presets.ts b/packages/core/src/provider-presets.ts new file mode 100644 index 0000000..ef2ea48 --- /dev/null +++ b/packages/core/src/provider-presets.ts @@ -0,0 +1,82 @@ +import type { ProviderConfig } from "./types.js"; + +/** + * Built-in provider presets. + * + * Each preset is a partial {@link ProviderConfig}. Presets that use `extends` + * inherit a builtin adapter's launch behavior (command, auth) and layer env + * overrides on top — this lets a profile target an Anthropic-compatible + * endpoint (Z.AI, Qwen, DeepSeek, local Ollama) or isolate a second credential + * set (claude-work vs. claude-personal) without re-implementing the adapter. + * + * Presets are merged into a profile's provider via `resolveProvider()` — the + * profile's own fields win over preset fields (shallow merge; `env` is also + * shallow-merged key-by-key). + */ +export const providerPresets: Record> = { + "zai-glm": { + extends: "claude", + displayName: "Z.AI (GLM)", + label: "Z.AI GLM", + env: { + ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic", + ANTHROPIC_AUTH_TOKEN_ENV: "ZAI_API_KEY", + }, + models: ["glm-4.6"], + }, + "qwen-max": { + extends: "claude", + displayName: "Qwen (DashScope)", + label: "Qwen Max", + env: { + ANTHROPIC_BASE_URL: "https://dashscope-intl.aliyuncs.com/api/v2/apps/anthropic", + ANTHROPIC_AUTH_TOKEN_ENV: "DASHSCOPE_API_KEY", + }, + models: ["qwen-max", "qwen-plus"], + }, + "deepseek-chat": { + extends: "claude", + displayName: "DeepSeek (Anthropic)", + label: "DeepSeek", + env: { + ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic", + ANTHROPIC_AUTH_TOKEN_ENV: "DEEPSEEK_API_KEY", + }, + models: ["deepseek-chat", "deepseek-reasoner"], + }, + "ollama-claude-local": { + extends: "claude", + displayName: "Ollama (local Anthropic)", + label: "Ollama local", + env: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:11434/anthropic", + }, + models: ["llama3.1", "qwen2.5-coder"], + }, + "claude-work": { + extends: "claude", + displayName: "Claude (work)", + label: "Claude (work)", + env: { + CLAUDE_CONFIG_DIR: "~/.arc/profiles/claude-work/config", + }, + }, + "claude-personal": { + extends: "claude", + displayName: "Claude (personal)", + label: "Claude (personal)", + env: { + CLAUDE_CONFIG_DIR: "~/.arc/profiles/claude-personal/config", + }, + }, +}; + +/** Return the list of preset ids in stable, declaration order. */ +export function listProviderPresets(): string[] { + return Object.keys(providerPresets); +} + +/** Fetch a preset by id, or `undefined` if none matches. */ +export function getProviderPreset(id: string): Partial | undefined { + return providerPresets[id]; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1708d19..63a8f29 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,16 +20,51 @@ export interface HookConfig { options?: Record; } -/** OpenAI-compatible provider configuration. */ +/** + * Builtin adapter a provider can extend. When set, the provider inherits the + * adapter's launch behavior (command, argument handling, auth flow) and layers + * env/model/command overrides on top. + */ +export type ProviderExtends = "claude" | "codex" | "gemini" | "opencode"; + +/** + * Provider configuration. Supports two modes: + * + * - **OpenAI-compat**: set `baseUrl` (+ optional `model`, `apiKeyEnvVar`). The + * agent talks to an OpenAI-compatible endpoint. Used by OpenRouter, Ollama, + * LM Studio, Together, Groq, etc. + * + * - **Adapter extension**: set `extends` to a builtin adapter (e.g. `"claude"`). + * The profile inherits the adapter's command/auth but ARC injects `env` / `model` + * overrides at launch time. Used for Anthropic-compatible endpoints (Z.AI, + * Qwen, DeepSeek, local Ollama) and multi-account splits (claude-work, + * claude-personal). + * + * Both modes may coexist on one profile; `resolveProvider` merges a preset into + * the profile and returns the final config. + */ export interface ProviderConfig { + /** + * Optional builtin adapter to extend. When set, the provider inherits the + * adapter's launch behavior and layers the other fields on top. + */ + extends?: ProviderExtends; /** API base URL (e.g. https://openrouter.ai/api/v1, http://localhost:11434/v1) */ - baseUrl: string; - /** Model identifier (e.g. anthropic/claude-3.5-sonnet, llama3) */ + baseUrl?: string; + /** Default model identifier (e.g. anthropic/claude-3.5-sonnet, llama3). */ model?: string; + /** List of models this provider advertises (for UI / model pickers). */ + models?: string[]; /** Env var name that holds the API key. Defaults to OPENAI_API_KEY. */ apiKeyEnvVar?: string; /** Provider display name for UI (e.g. "OpenRouter", "Ollama", "LM Studio") */ displayName?: string; + /** Short label for compact UIs (e.g. "Claude (work)"). */ + label?: string; + /** Additional environment variables injected at launch. Shallow-merged. */ + env?: Record; + /** Override the launch command binary (rarely used — prefer `extends`). */ + command?: string; } export interface Profile { diff --git a/tests/provider-extends.test.ts b/tests/provider-extends.test.ts new file mode 100644 index 0000000..6235ada --- /dev/null +++ b/tests/provider-extends.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + providerPresets, + getProviderPreset, + listProviderPresets, + resolveProvider, +} from "@axiom-labs/arc-core"; +import type { Profile, ProviderConfig } from "@axiom-labs/arc-core"; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function baseProfile(overrides: Partial = {}): Profile { + return { + authType: "oauth", + tool: "claude", + configDir: "/tmp/p", + createdAt: "2026-04-19T00:00:00Z", + ...overrides, + }; +} + +// ─── Preset lookup ─────────────────────────────────────────────────── + +describe("providerPresets", () => { + const expected = [ + "zai-glm", + "qwen-max", + "deepseek-chat", + "ollama-claude-local", + "claude-work", + "claude-personal", + ] as const; + + it("exposes all 6 documented presets", () => { + for (const id of expected) { + expect(providerPresets[id]).toBeDefined(); + } + expect(listProviderPresets()).toEqual(expect.arrayContaining([...expected])); + }); + + it("every preset has `extends: claude` (for this v3 drop)", () => { + for (const id of expected) { + expect(providerPresets[id]!.extends).toBe("claude"); + } + }); + + it("zai-glm ships correct Anthropic-compat env and model", () => { + const p = getProviderPreset("zai-glm")!; + expect(p.env?.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(p.env?.ANTHROPIC_AUTH_TOKEN_ENV).toBe("ZAI_API_KEY"); + expect(p.models).toEqual(["glm-4.6"]); + }); + + it("qwen-max ships DashScope URL and dual models", () => { + const p = getProviderPreset("qwen-max")!; + expect(p.env?.ANTHROPIC_BASE_URL).toBe( + "https://dashscope-intl.aliyuncs.com/api/v2/apps/anthropic" + ); + expect(p.env?.ANTHROPIC_AUTH_TOKEN_ENV).toBe("DASHSCOPE_API_KEY"); + expect(p.models).toEqual(["qwen-max", "qwen-plus"]); + }); + + it("deepseek-chat ships Anthropic-compat URL and reasoner model", () => { + const p = getProviderPreset("deepseek-chat")!; + expect(p.env?.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic"); + expect(p.env?.ANTHROPIC_AUTH_TOKEN_ENV).toBe("DEEPSEEK_API_KEY"); + expect(p.models).toContain("deepseek-reasoner"); + }); + + it("ollama-claude-local points at loopback port 11434 with no auth var", () => { + const p = getProviderPreset("ollama-claude-local")!; + expect(p.env?.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:11434/anthropic"); + expect(p.env?.ANTHROPIC_AUTH_TOKEN_ENV).toBeUndefined(); + }); + + it("claude-work and claude-personal split CLAUDE_CONFIG_DIR", () => { + const work = getProviderPreset("claude-work")!; + const personal = getProviderPreset("claude-personal")!; + expect(work.env?.CLAUDE_CONFIG_DIR).toBe("~/.arc/profiles/claude-work/config"); + expect(personal.env?.CLAUDE_CONFIG_DIR).toBe( + "~/.arc/profiles/claude-personal/config" + ); + expect(work.label).toBe("Claude (work)"); + expect(personal.label).toBe("Claude (personal)"); + }); + + it("getProviderPreset returns undefined for unknown id", () => { + expect(getProviderPreset("does-not-exist")).toBeUndefined(); + }); +}); + +// ─── resolveProvider merge semantics ───────────────────────────────── + +describe("resolveProvider", () => { + it("returns undefined when profile has no provider", () => { + expect(resolveProvider(baseProfile())).toBeUndefined(); + }); + + it("idempotent: no `extends` returns the provider unchanged", () => { + const provider: ProviderConfig = { + baseUrl: "https://api.openrouter.ai/api/v1", + model: "anthropic/claude-sonnet-4", + }; + const profile = baseProfile({ provider }); + const resolved = resolveProvider(profile)!; + expect(resolved).toEqual(provider); + // must be a fresh object (caller may mutate without affecting profile) + expect(resolved).not.toBe(provider); + }); + + it("applies preset fields when profile copies a preset into provider", () => { + // NOTE: resolveProvider keys the preset lookup off `profile.provider.extends`. + // The preset id values (zai-glm, claude-work, ...) themselves are NOT listed + // in ProviderExtends — that enum is the builtin-adapter set. When a caller + // wants the preset's content, they shallow-copy the preset into `provider` + // (the CLI `--preset` path does exactly this), which makes `extends: "claude"` + // and all other fields available on the profile itself. + const profile = baseProfile({ + provider: { ...providerPresets["zai-glm"] } as ProviderConfig, + }); + const resolved = resolveProvider(profile)!; + expect(resolved.extends).toBe("claude"); + expect(resolved.env?.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(resolved.models).toEqual(["glm-4.6"]); + }); + + it("profile fields win over preset fields (scalar override)", () => { + const profile = baseProfile({ + provider: { + ...providerPresets["zai-glm"], + model: "glm-4.6-override", + displayName: "My Z.AI", + } as ProviderConfig, + }); + const resolved = resolveProvider(profile)!; + expect(resolved.model).toBe("glm-4.6-override"); + expect(resolved.displayName).toBe("My Z.AI"); + // Unchanged preset fields still flow through + expect(resolved.env?.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + }); + + it("env is shallow-merged key-by-key; profile values win over extends source", () => { + // resolveProvider looks up `extends` against adapter defaults first, then + // provider presets. To exercise a meaningful env-merge we point `extends` + // at a preset id (claude-work) — CLI copy-through doesn't normally produce + // this shape, but it's a valid merge path because ADAPTER_DEFAULTS[claude] + // is empty. + const profile = baseProfile({ + provider: { + extends: "claude-work" as unknown as ProviderConfig["extends"], + env: { + CLAUDE_CONFIG_DIR: "/custom/override/path", + EXTRA_KEY: "set-by-profile", + }, + }, + }); + + const resolved = resolveProvider(profile)!; + // Profile override wins + expect(resolved.env?.CLAUDE_CONFIG_DIR).toBe("/custom/override/path"); + // Profile's extra key is preserved + expect(resolved.env?.EXTRA_KEY).toBe("set-by-profile"); + }); + + it("unknown extends value is tolerated (fallback: profile unchanged)", () => { + const profile = baseProfile({ + provider: { + extends: "no-such-adapter" as unknown as ProviderConfig["extends"], + baseUrl: "https://example.com", + }, + }); + const resolved = resolveProvider(profile)!; + expect(resolved.baseUrl).toBe("https://example.com"); + expect(resolved.extends).toBe("no-such-adapter"); + }); + + it("does not mutate the profile's provider object", () => { + const provider: ProviderConfig = { + ...providerPresets["zai-glm"], + } as ProviderConfig; + const snapshot = JSON.parse(JSON.stringify(provider)); + const profile = baseProfile({ provider }); + resolveProvider(profile); + expect(profile.provider).toEqual(snapshot); + }); +}); + +// ─── CLI smoke: handleProviderSet with --preset ────────────────────── + +describe("arc provider set --preset", () => { + let tmpDir: string; + let origArcDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "arc-provider-test-")); + origArcDir = process.env.ARC_DIR; + process.env.ARC_DIR = tmpDir; + }); + + afterEach(() => { + if (origArcDir === undefined) delete process.env.ARC_DIR; + else process.env.ARC_DIR = origArcDir; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("--preset zai-glm writes merged provider to config", async () => { + // Seed config with a minimal profile so the CLI can find it. + const { saveConfig } = await import("@axiom-labs/arc-core"); + saveConfig({ + version: 1, + activeProfile: "myprof", + profiles: { + myprof: { + authType: "oauth", + tool: "claude", + configDir: path.join(tmpDir, "profiles", "myprof"), + createdAt: new Date().toISOString(), + }, + }, + }); + + // process.exit(1) on error — mock it so failures surface as thrown errors + const realExit = process.exit; + const exitSpy = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + (process as unknown as { exit: typeof process.exit }).exit = exitSpy; + try { + const mod = await import("../packages/cli/src/commands/provider.js"); + await mod.handleProviderSet("myprof", { preset: "zai-glm" }); + } finally { + (process as unknown as { exit: typeof process.exit }).exit = realExit; + } + + const { loadConfig } = await import("@axiom-labs/arc-core"); + const config = loadConfig(); + const prov = config.profiles["myprof"]!.provider!; + expect(prov.extends).toBe("claude"); + expect(prov.env?.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(prov.env?.ANTHROPIC_AUTH_TOKEN_ENV).toBe("ZAI_API_KEY"); + expect(prov.models).toEqual(["glm-4.6"]); + }); + + it("--preset unknown-id exits non-zero and does not mutate config", async () => { + const { saveConfig } = await import("@axiom-labs/arc-core"); + saveConfig({ + version: 1, + activeProfile: "myprof", + profiles: { + myprof: { + authType: "oauth", + tool: "claude", + configDir: path.join(tmpDir, "profiles", "myprof"), + createdAt: new Date().toISOString(), + }, + }, + }); + + const realExit = process.exit; + const exitSpy = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + (process as unknown as { exit: typeof process.exit }).exit = exitSpy; + try { + const mod = await import("../packages/cli/src/commands/provider.js"); + await expect( + mod.handleProviderSet("myprof", { preset: "does-not-exist" }) + ).rejects.toThrow(/process\.exit\(1\)/); + } finally { + (process as unknown as { exit: typeof process.exit }).exit = realExit; + } + + const { loadConfig } = await import("@axiom-labs/arc-core"); + expect(loadConfig().profiles["myprof"]!.provider).toBeUndefined(); + }); + + it("--preset + --model explicit override wins over preset model", async () => { + const { saveConfig } = await import("@axiom-labs/arc-core"); + saveConfig({ + version: 1, + activeProfile: "myprof", + profiles: { + myprof: { + authType: "oauth", + tool: "claude", + configDir: path.join(tmpDir, "profiles", "myprof"), + createdAt: new Date().toISOString(), + }, + }, + }); + + const realExit = process.exit; + const exitSpy = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + (process as unknown as { exit: typeof process.exit }).exit = exitSpy; + try { + const mod = await import("../packages/cli/src/commands/provider.js"); + await mod.handleProviderSet("myprof", { + preset: "qwen-max", + model: "qwen-turbo", + }); + } finally { + (process as unknown as { exit: typeof process.exit }).exit = realExit; + } + + const { loadConfig } = await import("@axiom-labs/arc-core"); + const prov = loadConfig().profiles["myprof"]!.provider!; + expect(prov.model).toBe("qwen-turbo"); + expect(prov.extends).toBe("claude"); + }); +});