feat(cli): Claude Code launcher + setup commands (remote mode + profiles) (#4274)

Brings Claude Code to parity with the Codex CLI integration.

- `omniroute launch` is now remote-aware: --remote <url>, --profile <name>,
  --api-key. Resolves base URL + auth from the active context (so
  `omniroute connect <vps>` then `omniroute launch` just works), accepts a bare
  port OR a full base URL, health-checks the (possibly remote) server, and sets
  CLAUDE_CONFIG_DIR for the chosen profile.
- `omniroute setup-claude` (new): fetches the live /v1/models catalog and writes
  ~/.claude/profiles/<name>/settings.json per model. Claude Code has no native
  profile files, so CLAUDE_CONFIG_DIR is the idiomatic mechanism. Reuses the SAME
  profile names as setup-codex (glm52, kimi-k27, …) via the shared categoriseModel
  (now exported). The auth token is NEVER written to disk — launch injects it.
- Docs: docs/guides/CLAUDE-CODE-CONFIGURATION.md + README index. i18n (en + pt-BR).
- Tests: setup-claude profile generation (incl. "no token on disk"), buildClaudeEnv
  (port + URL + CLAUDE_CONFIG_DIR), resolveLaunchTarget.

check:cli-i18n + check-docs-sync green; 13 unit tests pass.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 10:27:24 -03:00
committed by GitHub
parent 3517d935e4
commit 3e6be47012
9 changed files with 475 additions and 25 deletions

View File

@@ -861,6 +861,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles |
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
### 🔧 Operations & Deployment

View File

@@ -1,53 +1,119 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import os from "node:os";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
function stripTrailingSlash(value) {
let s = String(value);
let end = s.length;
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
return end === s.length ? s : s.slice(0, end);
}
/**
* Build a clean child env for Claude Code pointed at the local proxy.
* Strips any inherited ANTHROPIC_* (avoids a stale shell token leaking through),
* then injects the proxy base URL, gateway model discovery, and auto-compact window.
* Build a clean child env for Claude Code pointed at OmniRoute.
*
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
* injects the base URL, gateway model discovery, and auto-compact window.
*
* @param {Record<string,string>} baseEnv
* @param {number} port
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
* @param {string|undefined} authToken
* @param {{ configDir?:string, model?:string }} [opts]
* @returns {Record<string,string>}
*/
export function buildClaudeEnv(baseEnv, port, authToken) {
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
const env = { ...baseEnv };
for (const key of Object.keys(env)) {
if (key.startsWith("ANTHROPIC_")) delete env[key];
}
env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
// Accept a bare port (number/numeric string → localhost) or a full base URL.
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
let baseUrl;
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
} else {
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
}
env.ANTHROPIC_BASE_URL = baseUrl;
if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken;
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
return env;
}
/**
* @param {{port?:string, token?:string}} opts
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
* explicit flags → the active context (remote mode) → localhost:<port>.
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
* @returns {{ baseUrl:string, authToken:string|undefined }}
*/
export function resolveLaunchTarget(opts = {}) {
const explicit = opts.remote ?? opts.baseUrl;
let baseUrl;
if (explicit) {
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
} else {
let fromCtx;
try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
fromCtx = ctx?.baseUrl;
} catch {
/* no context */
}
baseUrl = fromCtx
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
if (!authToken) {
try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
} catch {
/* no context auth */
}
}
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
return { baseUrl, authToken };
}
/**
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
* @param {string[]} claudeArgs pass-through args for the claude binary
* @returns {Promise<number>} exit code
*/
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
const { baseUrl, authToken } = resolveLaunchTarget(opts);
// Health check the proxy before launching.
// Health check the (possibly remote) proxy before launching.
try {
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
signal: AbortSignal.timeout(1500),
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) throw new Error(`status ${res.status}`);
} catch (e) {
} catch {
console.error(
(t("launch.notRunning") || "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.").replace(
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
String(port)
baseUrl
)
);
return 1;
}
const token = opts.token ?? process.env.ANTHROPIC_AUTH_TOKEN ?? undefined;
const env = buildClaudeEnv(process.env, port, token);
const configDir = opts.profile
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
return await new Promise((resolve) => {
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
@@ -67,9 +133,14 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
export function registerLaunch(program) {
program
.command("launch")
.description(t("launch.description") || "Launch Claude Code pointed at the local OmniRoute proxy")
.description(
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
)
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--token <token>", t("launch.token") || "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[claudeArgs...]", "arguments passed through to the claude binary")

View File

@@ -58,6 +58,7 @@ import { registerRepl } from "./repl.mjs";
import { registerLaunch } from "./launch.mjs";
import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs";
import { registerConnect } from "./connect.mjs";
import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs";
@@ -126,6 +127,7 @@ export function registerCommands(program) {
registerLaunch(program);
registerLaunchCodex(program);
registerSetupCodex(program);
registerSetupClaude(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);

View File

@@ -0,0 +1,152 @@
/**
* 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/<name>/settings.json` for each supported model,
* reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …).
*
* Launch a profile with: omniroute launch --profile <name>
* (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/<name> 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 } 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;
}
/** 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";
}
/**
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
* @returns {Promise<number>}
*/
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"]);
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
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}`);
if (!dryRun && !existsSync(profilesRoot)) {
mkdirSync(profilesRoot, { recursive: true });
}
let written = 0;
for (const m of models) {
const id = typeof m === "string" ? m : m.id ?? "";
if (!id) continue;
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
const cfg = categoriseModel(id);
if (!cfg) continue;
const dir = join(profilesRoot, cfg.name);
const filePath = join(dir, "settings.json");
const content = buildProfileSettings(id, baseUrl, cfg);
if (dryRun) {
console.log(`\n── [dry-run] ${filePath} ──`);
console.log(content);
} else {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, content, "utf8");
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
}
written++;
}
const skipped = models.length - written;
if (!dryRun) {
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 <name> # e.g. omniroute launch --profile glm52");
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> 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/<name>/ Claude Code profiles (CLAUDE_CONFIG_DIR) for each model"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
.option("--only <patterns>", "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);
});
}

View File

@@ -24,10 +24,13 @@ import { t } from "../i18n.mjs";
* Map a model ID (as returned by /v1/models) to a Codex profile configuration.
* Returns null for models that should not get their own profile (e.g. aliases).
*
* Exported so the Claude Code profile generator (`setup-claude`) reuses the SAME
* profile names (glm52, kimi-k27, …) for cross-CLI consistency.
*
* @param {string} modelId
* @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null}
*/
function categoriseModel(modelId) {
export function categoriseModel(modelId) {
const id = modelId.toLowerCase();
// ── Thinking models (max effort, detailed summary) ────────────────────────

View File

@@ -1258,11 +1258,14 @@
"scaffold": "Scaffold a new plugin boilerplate"
},
"launch": {
"description": "Launch Claude Code pointed at the local OmniRoute proxy",
"token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)",
"notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.",
"description": "Launch Claude Code pointed at OmniRoute (local or remote, with --profile)",
"token": "Token the Claude client should send (ANTHROPIC_AUTH_TOKEN)",
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
"notFound": "The 'claude' CLI was not found in PATH."
},
"setupClaude": {
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
},
"connect": {
"description": "Connect to a remote OmniRoute server and enter remote mode"
},

View File

@@ -1257,11 +1257,14 @@
"scaffold": "Gerar boilerplate de novo plugin"
},
"launch": {
"description": "Inicia o Claude Code apontando para o proxy local do OmniRoute",
"token": "Chave de API que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)",
"notRunning": "OmniRoute não está rodando na porta {port}. Inicie com 'omniroute serve'.",
"description": "Inicia o Claude Code apontando para o OmniRoute (local ou remoto, com --profile)",
"token": "Token que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)",
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
"notFound": "O CLI 'claude' não foi encontrado no PATH."
},
"setupClaude": {
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
},
"connect": {
"description": "Conecta a um servidor OmniRoute remoto e entra no modo remoto"
},

View File

@@ -0,0 +1,144 @@
---
title: "Claude Code CLI — Configuration with OmniRoute"
version: 3.8.30
lastUpdated: 2026-06-19
---
# Claude Code CLI — Configuration with OmniRoute
Point the **Claude Code** CLI (`claude`) at OmniRoute — local or a remote VPS —
with per-model profiles, mirroring the Codex setup.
---
## Quick start
```bash
# Launch Claude Code against a local OmniRoute (auto-detects the active context)
omniroute launch
# Against a remote OmniRoute (after `omniroute connect <host>`, this is automatic)
omniroute launch --remote http://192.168.0.15:20128 --api-key oma_live_xxx
# Generate per-model profiles, then launch one
omniroute setup-claude # writes ~/.claude/profiles/<name>/settings.json
omniroute launch --profile glm52 # Claude Code using glm/glm-5.2 via OmniRoute
```
---
## How Claude Code connects to a gateway
Claude Code talks the **Anthropic Messages API** and is pointed at a custom
endpoint with environment variables (it has no `--base-url` flag):
| Variable | Purpose |
|----------|---------|
| `ANTHROPIC_BASE_URL` | Gateway root URL (Claude Code appends `/v1/messages`). **No `/v1` suffix.** |
| `ANTHROPIC_AUTH_TOKEN` | Sent as `Authorization: Bearer …` — use your OmniRoute access token / API key |
| `ANTHROPIC_API_KEY` | Alternative: sent as `x-api-key`. If both set, `AUTH_TOKEN` wins |
| `ANTHROPIC_MODEL` | Force a specific model (overrides the `/model` picker default) |
| `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` | `1` → the native `/model` picker lists `claude*`/`anthropic*` models from `/v1/models` |
| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output tokens per response (e.g. `65536`) |
| `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | Token threshold for auto-compaction |
> Env vars are read **once at startup** — restart Claude Code after changing them.
`omniroute launch` sets all of these for you: it resolves the base URL + token
from the active context (so `omniroute connect <vps>` then `omniroute launch`
just works), health-checks the server, and execs `claude`.
---
## Profiles (`CLAUDE_CONFIG_DIR`)
Claude Code has **no native profile files** (unlike Codex's `~/.codex/<name>.config.toml`).
The idiomatic mechanism is `CLAUDE_CONFIG_DIR` — a separate config directory per
profile, each with its own `settings.json`, credentials, history and cache.
`omniroute setup-claude` fetches the live `/v1/models` catalog and writes one
profile per model at `~/.claude/profiles/<name>/settings.json`, reusing the
**same names as `setup-codex`** (`glm52`, `kimi-k27`, `deepseek-pro`, …):
```jsonc
// ~/.claude/profiles/glm52/settings.json
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"model": "glm/glm-5.2",
"effortLevel": "xhigh",
"env": {
"ANTHROPIC_BASE_URL": "http://192.168.0.15:20128",
"ANTHROPIC_MODEL": "glm/glm-5.2",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000"
}
}
```
> **The auth token is never written to the profile.** Launch with
> `omniroute launch --profile <name>` (it injects `ANTHROPIC_AUTH_TOKEN` from the
> active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run
> `CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude`.
### Generating + using profiles
```bash
# Local OmniRoute
omniroute setup-claude
# Remote VPS (bakes the VPS URL into every profile)
omniroute setup-claude --remote http://192.168.0.15:20128 --api-key oma_live_xxx
# Only some providers
omniroute setup-claude --only glm,kimi
# Preview without writing
omniroute setup-claude --dry-run
# Launch a profile
omniroute launch --profile kimi-k27
```
---
## Model tiers (optional)
Claude Code routes to capability tiers. Map each to an OmniRoute model via env /
settings if you want different providers per tier:
```bash
export ANTHROPIC_DEFAULT_OPUS_MODEL="glm/glm-5.2"
export ANTHROPIC_DEFAULT_SONNET_MODEL="kmc/kimi-k2.6"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm/glm-4.7-flash"
```
Otherwise a single `ANTHROPIC_MODEL` (what profiles set) is used for everything.
---
## Remote mode
Once you've run `omniroute connect <host>` (see
[Remote Mode](./REMOTE-MODE.md)), `omniroute launch` and `omniroute setup-claude`
automatically target that remote server and use its scoped access token — no
extra flags needed. Override per-invocation with `--remote` / `--api-key`.
---
## Troubleshooting
**Claude Code ignores the gateway** — confirm `ANTHROPIC_BASE_URL` has **no
`/v1`** and restart `claude` (env is read once at startup). `omniroute launch`
handles this for you.
**`/model` picker is empty / missing gateway models** — needs Claude Code
v2.1.129+ and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Only `claude*` /
`anthropic*` model IDs appear in the picker; force any other model with
`ANTHROPIC_MODEL=<id>` (this is what profiles do).
**Auth errors** — the profile holds no token. Use `omniroute launch --profile`
(injects it) or export `ANTHROPIC_AUTH_TOKEN`.
**Profiles don't isolate** — each profile is a distinct `CLAUDE_CONFIG_DIR`;
verify `echo $CLAUDE_CONFIG_DIR` inside the session points at
`~/.claude/profiles/<name>`.

View File

@@ -0,0 +1,71 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildProfileSettings } from "../../../bin/cli/commands/setup-claude.mjs";
import { buildClaudeEnv, resolveLaunchTarget } from "../../../bin/cli/commands/launch.mjs";
import { categoriseModel } from "../../../bin/cli/commands/setup-codex.mjs";
// ── setup-claude profile generation ──────────────────────────────────────────
test("buildProfileSettings pins the model + base URL + gateway discovery", () => {
const cfg = categoriseModel("glm/glm-5.2"); // thinking → effort xhigh
const json = JSON.parse(buildProfileSettings("glm/glm-5.2", "http://vps:20128", cfg));
assert.equal(json.model, "glm/glm-5.2");
assert.equal(json.env.ANTHROPIC_BASE_URL, "http://vps:20128");
assert.equal(json.env.ANTHROPIC_MODEL, "glm/glm-5.2");
assert.equal(json.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
assert.equal(json.effortLevel, "xhigh");
});
test("buildProfileSettings NEVER writes the auth token to disk", () => {
const cfg = categoriseModel("kmc/kimi-k2.7");
const raw = buildProfileSettings("kmc/kimi-k2.7", "http://vps:20128", cfg);
assert.equal(raw.includes("ANTHROPIC_AUTH_TOKEN"), false);
assert.equal(raw.includes("ANTHROPIC_API_KEY"), false);
});
test("buildProfileSettings omits effortLevel for the simple tier", () => {
const cfg = categoriseModel("ollamacloud/gemma4:31b"); // simple → no effort
const json = JSON.parse(buildProfileSettings("ollamacloud/gemma4:31b", "http://x:20128", cfg));
assert.equal("effortLevel" in json, false);
});
test("profile names match setup-codex (cross-CLI consistency)", () => {
assert.equal(categoriseModel("glm/glm-5.2").name, "glm52");
assert.equal(categoriseModel("kmc/kimi-k2.7").name, "kimi-k27");
});
// ── launch env (Claude Code) ─────────────────────────────────────────────────
test("buildClaudeEnv still accepts a bare port (backward compatible)", () => {
const env = buildClaudeEnv({ PATH: "/bin" }, 20128, "secret");
assert.equal(env.ANTHROPIC_BASE_URL, "http://localhost:20128");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "secret");
assert.equal(env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
});
test("buildClaudeEnv accepts a full base URL and strips /v1", () => {
const env = buildClaudeEnv({}, "https://vps.example.com:20128/v1", "t");
assert.equal(env.ANTHROPIC_BASE_URL, "https://vps.example.com:20128");
});
test("buildClaudeEnv sets CLAUDE_CONFIG_DIR for a profile", () => {
const env = buildClaudeEnv({}, 20128, "t", { configDir: "/home/u/.claude/profiles/glm52" });
assert.equal(env.CLAUDE_CONFIG_DIR, "/home/u/.claude/profiles/glm52");
});
test("buildClaudeEnv strips inherited ANTHROPIC_* and does not mutate input", () => {
const input = { ANTHROPIC_API_KEY: "leak", PATH: "/bin" };
const env = buildClaudeEnv(input, 20128, "x");
assert.equal(env.ANTHROPIC_API_KEY, undefined);
assert.equal(input.ANTHROPIC_API_KEY, "leak");
});
test("resolveLaunchTarget: explicit --remote wins, strips /v1", () => {
const { baseUrl } = resolveLaunchTarget({ remote: "https://vps:20128/v1" });
assert.equal(baseUrl, "https://vps:20128");
});
test("resolveLaunchTarget: explicit token wins over everything", () => {
const { authToken } = resolveLaunchTarget({ remote: "http://x:20128", token: "tok-explicit" });
assert.equal(authToken, "tok-explicit");
});