/** * omniroute setup-claude — Remote-aware Claude Code profile generator. * * Claude Code has no native profile files (unlike Codex). The idiomatic way to * keep multiple named configs is `CLAUDE_CONFIG_DIR` — a separate config dir per * profile (its own settings.json, credentials, history, cache). This command * fetches the live /v1/models catalog from a (possibly remote) OmniRoute and * writes `~/.claude/profiles//settings.json` for each supported model, * reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …). * * Launch a profile with: omniroute launch --profile * (which injects ANTHROPIC_AUTH_TOKEN from the active context — the token is * never written to disk). Or export ANTHROPIC_AUTH_TOKEN and run: * CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude * * Idempotent: re-running overwrites each profile's settings.json in place. */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { categoriseModel, isCodexCompatibleTextModel, profileNameFromModelId, } from "./setup-codex.mjs"; /** Map a Codex-style effort to a Claude Code settings.json effortLevel. */ function effortLevelFor(cfg) { // Codex categories use xhigh/high/low/undefined; Claude Code accepts the same // names (low|medium|high|xhigh). Pass through, omit for the "simple" tier. return cfg.effort || undefined; } /** * Generic profile for a live-catalog model that `categoriseModel()` doesn't * recognize (e.g. any provider added after the hardcoded glm/kimi/mimo/… * pattern list was written). Mirrors setup-codex.mjs's fallbackCodexProfile() * so setup-claude never silently produces zero profiles for a fresh catalog. */ export function fallbackClaudeProfile(modelId, model) { if (!isCodexCompatibleTextModel(model)) return null; return { name: profileNameFromModelId(modelId) }; } /** Build the settings.json content for one Claude Code profile. */ export function buildProfileSettings(modelId, baseUrl, cfg) { const env = { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_MODEL: modelId, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", CLAUDE_CODE_AUTO_COMPACT_WINDOW: "190000", }; const settings = { $schema: "https://json.schemastore.org/claude-code-settings.json", model: modelId, env, }; const effort = effortLevelFor(cfg); if (effort) settings.effortLevel = effort; // NOTE: ANTHROPIC_AUTH_TOKEN is intentionally NOT written here — `omniroute // launch --profile` injects it from the active context, keeping the secret off // disk. For direct `CLAUDE_CONFIG_DIR=… claude` use, export it in your shell. return JSON.stringify(settings, null, 2) + "\n"; } /** * Generate Claude Code profile files for a live model catalog. Shared by the * `setup-claude` CLI command and the post-model-sync auto-sync so both stay * behaviorally identical. Writes `/profiles//settings.json` * (directory-per-profile); never touches the active/default Claude config. * @param {Array} models * @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string, log?:(line:string)=>void}} opts * @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>} */ export async function syncClaudeProfilesFromModels(models, opts = {}) { const claudeHome = opts.claudeHome || join(os.homedir(), ".claude"); const profilesRoot = join(claudeHome, "profiles"); const baseUrl = opts.baseUrl; const dryRun = Boolean(opts.dryRun); // Injectable dry-run printer (#5959): under the node:test runner, a child // process writing multi-byte UTF-8 (the "──" box-drawing heading) to stdout // corrupts the runner's V8-serialized event stream ~50% of the time // ("Unable to deserialize cloned data due to invalid or unsupported // version"). Tests inject a collector; the CLI default stays console.log. const log = opts.log ?? console.log; const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; if (!dryRun && !existsSync(profilesRoot)) { mkdirSync(profilesRoot, { recursive: true }); } let written = 0; let skipped = 0; const profiles = []; for (const m of models) { const id = typeof m === "string" ? m : (m.id ?? ""); if (!id) { skipped++; continue; } if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) { skipped++; continue; } const cfg = categoriseModel(id) ?? fallbackClaudeProfile(id, m); if (!cfg) { skipped++; continue; } const dir = join(profilesRoot, cfg.name); const filePath = join(dir, "settings.json"); const content = buildProfileSettings(id, baseUrl, cfg); if (dryRun) { log(`\n── [dry-run] ${filePath} ──`); log(content); } else { mkdirSync(dir, { recursive: true }); writeFileSync(filePath, content, "utf8"); } profiles.push({ name: cfg.name, model: id, filePath }); written++; } return { written, skipped, profiles }; } /** * @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts * @returns {Promise} */ export async function runSetupClaudeCommand(opts = {}) { const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; const baseUrl = (opts.remote ?? `http://localhost:${port}`) .replace(/\/+$/, "") .replace(/\/v1$/, ""); const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? ""; const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude"); const profilesRoot = join(claudeHome, "profiles"); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); printHeading("OmniRoute → Claude Code profile generator"); printInfo(`Connecting to ${baseUrl} …`); // ── Fetch model catalog ─────────────────────────────────────────────────── let models; try { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); const body = await res.json(); models = body.data ?? body.models ?? []; } catch (err) { printError(`Failed to fetch models: ${err.message}`); printInfo( "Make sure OmniRoute is running and the --remote URL is correct.\n" + "You may also need --api-key if OmniRoute requires authentication." ); return 1; } printInfo(`Received ${models.length} models from ${baseUrl}`); const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, { claudeHome, baseUrl, dryRun, only: opts.only, }); if (!dryRun) { for (const profile of profiles) { printSuccess(` ✓ profiles/${profile.name}/settings.json (${profile.model})`); } console.log(""); printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`); if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`); console.log("\nTo use a profile:"); console.log(" omniroute launch --profile # e.g. omniroute launch --profile glm52"); console.log( " # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/ claude (export ANTHROPIC_AUTH_TOKEN first)" ); } else { console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`); } return 0; } export function registerSetupClaude(program) { program .command("setup-claude") .description( "Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " + "~/.claude/profiles// Claude Code profiles (CLAUDE_CONFIG_DIR) for each model" ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--claude-home ", "Claude home dir (default: ~/.claude)") .option( "--only ", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)" ) .option("--dry-run", "Print what would be written without touching the filesystem") .action(async (opts) => { const exitCode = await runSetupClaudeCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); }