Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions src/engine/tools/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import { PROVIDER_NAME, type ZroModel } from "../constants.js";
import { jsonSerializer } from "../serializers.js";
import type { ToolModule } from "../types.js";

// Claude Code hands backgrounded sessions to a daemon whose workers do not
// inherit this launch's environment, and a respawned worker re-reads these
// files long after the launching process exits. Both therefore live at a
// stable path rather than the per-launch temp dir.
export function claudeConfigDir(homeDir: string, env: NodeJS.ProcessEnv): string {
return path.join(env.XDG_CACHE_HOME || path.join(homeDir, ".cache"), "zro", "claude");
}

export const claudeTool: ToolModule = {
id: "claude",
label: "Claude Code",
async launch(ctx) {
const mcpConfigPath = path.join(ctx.tempDir, "claude", "mcp.json");
const configDir = claudeConfigDir(ctx.homeDir, ctx.env);
const mcpConfigPath = path.join(configDir, "mcp.json");
const settingsPath = path.join(configDir, "settings.json");
if (!ctx.models.some((model) => model.id === ctx.model)) {
ctx.stderr.write(
`Warning: model "${ctx.model}" is not in the Zro catalog; Claude Code will assume a 200k context window.\n`
Expand All @@ -27,6 +37,8 @@ export const claudeTool: ToolModule = {
"--managed-settings",
buildClaudeManagedSettingsArg(ctx.models, aliasPlan),
...(ctx.noAuth ? [] : ["--mcp-config", mcpConfigPath]),
"--settings",
settingsPath,
...ctx.extraArgs
],
env: {
Expand All @@ -44,18 +56,31 @@ export const claudeTool: ToolModule = {
CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1",
DO_NOT_TRACK: "1"
},
files: ctx.noAuth ? [] : [{
path: mcpConfigPath,
contents: jsonSerializer.stringify({
mcpServers: {
zro: {
type: "http",
url: ctx.mcpUrl,
headers: { Authorization: `Bearer ${ctx.apiKey}` }
files: [
...(ctx.noAuth ? [] : [{
path: mcpConfigPath,
contents: jsonSerializer.stringify({
mcpServers: {
zro: {
type: "http",
url: ctx.mcpUrl,
headers: { Authorization: `Bearer ${ctx.apiKey}` }
}
}
}
})
}],
}),
persistent: true
}]),
{
path: settingsPath,
contents: jsonSerializer.stringify({
env: {
ANTHROPIC_BASE_URL: ctx.inferenceRoot,
ANTHROPIC_AUTH_TOKEN: ctx.apiKey
}
}),
persistent: true
}
],
message: "Launch Claude Code with Zro"
};
}
Expand Down
4 changes: 3 additions & 1 deletion src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
resolveApiKey,
writeStoredApiKey
} from "./engine/key.js";
import { CLAUDE_MODEL_ALIAS_SLOTS, claudeTool } from "./engine/tools/claude.js";
import { CLAUDE_MODEL_ALIAS_SLOTS, claudeConfigDir, claudeTool } from "./engine/tools/claude.js";
import {
codexAppCredentialFilePath,
codexAppTool,
Expand Down Expand Up @@ -598,6 +598,8 @@ async function logout(
deleteStoredApiKey({ homeDir: io.homeDir, env }),
deleteOptionalFile(codexAppCredentialFilePath(io.homeDir)),
invalidateModelCatalog({ homeDir: io.homeDir, env }),
fs.rm(claudeConfigDir(io.homeDir, env), { recursive: true, force: true })
.catch(() => undefined),
]);
const removed = storedKeyRemoved || codexAppKeyRemoved;
io.stdout.write(output === "json"
Expand Down
54 changes: 54 additions & 0 deletions tests/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { CLAUDE_MODEL_ALIAS_SLOTS, claudeTool } from "../src/engine/tools/claude.js";
import { ENDPOINT_ROOT } from "../src/engine/constants.js";
import { TEST_CATALOG_RESPONSE, TIER_MODELS } from "./fixtures.js";
import type { SpawnOptions, SpawnProcess } from "../src/engine/types.js";
import { run } from "../src/run.js";
Expand Down Expand Up @@ -337,6 +338,50 @@ describe("zro experience", () => {
expect(plan.env!.ANTHROPIC_CUSTOM_MODEL_OPTION).toBe("anthropic.injected");
});

it("launches claude with persistent config files for background sessions", async () => {
const stderr = new PassThrough();
const plan = await claudeTool.launch({
apiKey: "sk-stable-secret",
apiKeySource: "env",
inferenceRoot: "https://zro.moonmath.ai",
inferenceBaseUrl: "https://zro.moonmath.ai/v1",
mcpUrl: "https://zro.moonmath.ai/mcp/zro",
noAuth: false,
env: {},
model: "kimi-k3",
models: TIER_MODELS,
extraArgs: [],
homeDir: "/tmp/zro-stable-home",
cwd: "/tmp",
tempDir: "/tmp/zro-stable-session",
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr
});

const settingsPath = plan.args![plan.args!.indexOf("--settings") + 1];
expect(settingsPath).toBe(
path.join("/tmp/zro-stable-home", ".cache", "zro", "claude", "settings.json")
);
expect(plan.args![plan.args!.indexOf("--mcp-config") + 1]).toBe(
path.join("/tmp/zro-stable-home", ".cache", "zro", "claude", "mcp.json")
);

const settings = plan.files!.find((file) => file.path === settingsPath)!;
expect(settings.persistent).toBe(true);
expect(JSON.parse(settings.contents)).toEqual({
env: {
ANTHROPIC_BASE_URL: ENDPOINT_ROOT,
ANTHROPIC_AUTH_TOKEN: "sk-stable-secret"
}
});

const mcp = plan.files!.find((file) => file.path.endsWith("mcp.json"))!;
expect(mcp.persistent).toBe(true);
expect(JSON.parse(mcp.contents).mcpServers.zro.headers.Authorization)
.toBe("Bearer sk-stable-secret");
});

it("forwards --alias overrides through zro again", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-again-alias-"));
const stdout = new PassThrough();
Expand Down Expand Up @@ -670,6 +715,11 @@ describe("zro experience", () => {
const catalogPath = path.join(home, ".cache", "zro", "model-catalog.json");
await fs.mkdir(path.dirname(catalogPath), { recursive: true });
await fs.writeFile(catalogPath, JSON.stringify(dynamicCatalogResponse()));
const claudeConfigDir = path.join(home, ".cache", "zro", "claude");
await fs.mkdir(claudeConfigDir, { recursive: true });
await fs.writeFile(path.join(claudeConfigDir, "settings.json"),
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: "sk-stored" } }));
await fs.writeFile(path.join(claudeConfigDir, "mcp.json"), "{}");
const stdout = new PassThrough();

const code = await run(["logout", "--json"], io(home, stdout));
Expand All @@ -687,6 +737,10 @@ describe("zro experience", () => {
await expect(fs.stat(catalogPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(path.join(codexAppDir, "config.toml"), "utf8"))
.resolves.toBe("model = \"glm-5.3\"\n");
await expect(fs.stat(path.join(claudeConfigDir, "settings.json")))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(path.join(claudeConfigDir, "mcp.json")))
.rejects.toMatchObject({ code: "ENOENT" });
});

it("removes the Codex App key when it is the only stored credential", async () => {
Expand Down
Loading