@velum-labs/routekit-tool-claude 0.18.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/driver.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { z } from "zod";
2
1
  import type { Options, Query } from "@anthropic-ai/claude-agent-sdk";
3
2
  import type { HarnessDriver } from "@velum-labs/routekit-harness-core";
3
+ import { z } from "zod";
4
4
  export declare const claudeDriverConfigSchema: z.ZodObject<{
5
5
  command: z.ZodDefault<z.ZodString>;
6
6
  model: z.ZodOptional<z.ZodString>;
package/dist/driver.js CHANGED
@@ -1,6 +1,6 @@
1
- import { z } from "zod";
2
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
3
- import { HarnessError, DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, asHarnessError, buildChildEnv, createCachedHarnessDriver, decideApproval, probeCliVersion, resolveDriverEnv } from "@velum-labs/routekit-harness-core";
2
+ import { AsyncChannel, asHarnessError, buildChildEnv, createCachedHarnessDriver, DEFAULT_AUTOMATION_APPROVAL_POLICY, decideApproval, HarnessError, nowIso, PendingRequests, probeCliVersion, resolveDriverEnv, resumeStringField, SessionResourceRegistry, SingleFlightTurnController } from "@velum-labs/routekit-harness-core";
3
+ import { z } from "zod";
4
4
  const RESUME_CURSOR_VERSION = 1;
5
5
  const DEFAULT_COMMAND = "claude";
6
6
  const AUTH_ENV_NAMES = [
@@ -18,9 +18,6 @@ export const claudeDriverConfigSchema = z.object({
18
18
  /** Extra credential env var names forwarded into the claude child. */
19
19
  credentialEnvNames: z.array(z.string()).default([])
20
20
  });
21
- function nowIso() {
22
- return new Date().toISOString();
23
- }
24
21
  /**
25
22
  * The canonical approval request type for a claude tool name. Note `Task`
26
23
  * (Claude's sub-agent tool) deliberately lands in the generic `tool_approval`
@@ -50,6 +47,7 @@ class ClaudeSession {
50
47
  #sessionId;
51
48
  #activeQuery;
52
49
  #stopped = false;
50
+ #turns = new SingleFlightTurnController();
53
51
  #queryFn;
54
52
  constructor(input) {
55
53
  this.#config = input.config;
@@ -57,8 +55,7 @@ class ClaudeSession {
57
55
  this.#cwd = input.options.cwd;
58
56
  this.#model = input.options.model ?? input.config.model;
59
57
  this.#reasoning = input.options.reasoning;
60
- this.#approvalPolicy =
61
- input.options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY;
58
+ this.#approvalPolicy = input.options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY;
62
59
  this.#sessionId = resumeSessionId(input.options.resume) ?? "claude:pending";
63
60
  this.#queryFn = input.queryFn;
64
61
  }
@@ -116,109 +113,104 @@ class ClaudeSession {
116
113
  return decision;
117
114
  }
118
115
  #emit(event) {
119
- this.#channel?.(event);
116
+ this.#channel?.push(event);
120
117
  }
121
118
  async *sendTurn(input) {
122
119
  if (this.#stopped)
123
120
  throw new HarnessError("session_closed", "claude session is stopped");
121
+ const turn = this.#turns.start(input.signal);
124
122
  const controller = new AbortController();
125
- if (input.signal !== undefined) {
126
- if (input.signal.aborted)
127
- controller.abort(input.signal.reason);
128
- else
129
- input.signal.addEventListener("abort", () => controller.abort(input.signal?.reason), { once: true });
130
- }
131
- const resume = this.#sessionId !== "claude:pending" ? this.#sessionId : undefined;
132
- const reasoning = input.reasoning ?? this.#reasoning;
133
- const options = {
134
- cwd: this.#cwd,
135
- pathToClaudeCodeExecutable: this.#config.command,
136
- // The gateway carries this cursor across front-door turns. Make the
137
- // persistence requirement explicit: SDK defaults have changed across
138
- // releases and an ephemeral session id cannot be resumed by the next
139
- // candidate process.
140
- persistSession: true,
141
- permissionMode: "default",
142
- canUseTool: this.#canUseTool(),
143
- abortController: controller,
144
- env: this.#childEnv(),
145
- ...(this.#model !== undefined ? { model: this.#model } : {}),
146
- ...(reasoning?.mode === "effort"
147
- ? {
148
- thinking: { type: "adaptive" },
149
- effort: reasoning.effort
150
- }
151
- : reasoning?.mode === "budget"
152
- ? {
153
- thinking: {
154
- type: "enabled",
155
- budgetTokens: reasoning.budgetTokens
156
- }
157
- }
158
- : reasoning?.mode === "adaptive"
159
- ? { thinking: { type: "adaptive" } }
160
- : reasoning?.mode === "disabled"
161
- ? { thinking: { type: "disabled" } }
162
- : {}),
163
- ...(resume !== undefined ? { resume } : {})
164
- };
165
- const queued = [];
166
- let notify;
167
- this.#channel = (event) => {
168
- queued.push(event);
169
- notify?.();
170
- };
123
+ const onAbort = () => controller.abort(turn.signal.reason);
124
+ if (turn.signal.aborted)
125
+ onAbort();
126
+ else
127
+ turn.signal.addEventListener("abort", onAbort, { once: true });
128
+ const channel = new AsyncChannel({
129
+ capacity: 256,
130
+ onConsumerReturn: () => turn.dispose()
131
+ });
132
+ this.#channel = channel;
171
133
  const turnId = `${this.#sessionId}:turn:${Date.now()}`;
172
134
  // turn.started is emitted from mapMessage once system/init fixes the real
173
135
  // session id, so it (and every later event) carries the final id.
174
- const q = this.#queryFn({ prompt: input.prompt, options });
175
- this.#activeQuery = q;
136
+ let q;
137
+ let pump;
176
138
  let done = false;
177
- let failure;
178
- const pump = (async () => {
179
- try {
180
- for await (const message of q) {
181
- for (const event of this.#mapMessage(message, turnId))
182
- queued.push(event);
183
- notify?.();
184
- }
185
- }
186
- catch (error) {
187
- failure = error;
188
- }
189
- finally {
190
- done = true;
191
- notify?.();
192
- }
193
- })();
194
139
  try {
195
- for (;;) {
196
- if (queued.length > 0) {
197
- yield queued.shift();
198
- continue;
140
+ const resume = this.#sessionId !== "claude:pending" ? this.#sessionId : undefined;
141
+ const reasoning = input.reasoning ?? this.#reasoning;
142
+ const options = {
143
+ cwd: this.#cwd,
144
+ pathToClaudeCodeExecutable: this.#config.command,
145
+ // The gateway carries this cursor across front-door turns. Make the
146
+ // persistence requirement explicit: SDK defaults have changed across
147
+ // releases and an ephemeral session id cannot be resumed by the next
148
+ // candidate process.
149
+ persistSession: true,
150
+ permissionMode: "default",
151
+ canUseTool: this.#canUseTool(),
152
+ abortController: controller,
153
+ env: this.#childEnv(),
154
+ ...(this.#model !== undefined ? { model: this.#model } : {}),
155
+ ...(reasoning?.mode === "effort"
156
+ ? {
157
+ thinking: { type: "adaptive" },
158
+ effort: reasoning.effort
159
+ }
160
+ : reasoning?.mode === "budget"
161
+ ? {
162
+ thinking: {
163
+ type: "enabled",
164
+ budgetTokens: reasoning.budgetTokens
165
+ }
166
+ }
167
+ : reasoning?.mode === "adaptive"
168
+ ? { thinking: { type: "adaptive" } }
169
+ : reasoning?.mode === "disabled"
170
+ ? { thinking: { type: "disabled" } }
171
+ : {}),
172
+ ...(resume !== undefined ? { resume } : {})
173
+ };
174
+ q = this.#queryFn({ prompt: input.prompt, options });
175
+ this.#activeQuery = q;
176
+ pump = (async () => {
177
+ try {
178
+ for await (const message of q) {
179
+ for (const event of this.#mapMessage(message, turnId))
180
+ channel.push(event);
181
+ }
199
182
  }
200
- if (done)
201
- break;
202
- await new Promise((resolve) => {
203
- notify = () => {
204
- notify = undefined;
205
- resolve();
206
- };
207
- });
208
- }
209
- await pump;
210
- if (failure !== undefined) {
211
- const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso(), turnId };
212
- if (controller.signal.aborted) {
213
- yield { ...base, type: "turn.completed", endReason: "aborted" };
183
+ catch (error) {
184
+ const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso(), turnId };
185
+ if (controller.signal.aborted) {
186
+ channel.push({ ...base, type: "turn.completed", endReason: "aborted" });
187
+ }
188
+ else {
189
+ const harnessError = asHarnessError(error);
190
+ channel.push({
191
+ ...base,
192
+ type: "turn.failed",
193
+ errorCode: harnessError.code,
194
+ message: harnessError.message
195
+ });
196
+ }
214
197
  }
215
- else {
216
- const harnessError = asHarnessError(failure);
217
- yield { ...base, type: "turn.failed", errorCode: harnessError.code, message: harnessError.message };
198
+ finally {
199
+ done = true;
200
+ channel.close();
218
201
  }
219
- }
202
+ })();
203
+ yield* channel;
204
+ await pump;
220
205
  }
221
206
  finally {
207
+ if (done)
208
+ turn.complete();
209
+ turn.dispose();
210
+ turn.signal.removeEventListener("abort", onAbort);
211
+ if (!done)
212
+ await q?.interrupt().catch(() => undefined);
213
+ channel.close();
222
214
  this.#channel = undefined;
223
215
  this.#activeQuery = undefined;
224
216
  }
@@ -255,7 +247,13 @@ class ClaudeSession {
255
247
  yield { ...base, type: "content.delta", stream: "assistant_text", text: block.text, raw };
256
248
  }
257
249
  else if (block.type === "thinking" && block.thinking !== undefined) {
258
- yield { ...base, type: "content.delta", stream: "reasoning_text", text: block.thinking, raw };
250
+ yield {
251
+ ...base,
252
+ type: "content.delta",
253
+ stream: "reasoning_text",
254
+ text: block.thinking,
255
+ raw
256
+ };
259
257
  }
260
258
  else if (block.type === "tool_use") {
261
259
  yield {
@@ -288,7 +286,9 @@ class ClaudeSession {
288
286
  return buildChildEnv({
289
287
  base: resolveDriverEnv(this.#context),
290
288
  allow: [...AUTH_ENV_NAMES, ...this.#config.credentialEnvNames, /^CLAUDE_/],
291
- ...(this.#config.baseUrl !== undefined ? { extra: { ANTHROPIC_BASE_URL: this.#config.baseUrl } } : {})
289
+ ...(this.#config.baseUrl !== undefined
290
+ ? { extra: { ANTHROPIC_BASE_URL: this.#config.baseUrl } }
291
+ : {})
292
292
  });
293
293
  }
294
294
  async respondToRequest(requestId, decision) {
@@ -297,25 +297,30 @@ class ClaudeSession {
297
297
  }
298
298
  }
299
299
  async interrupt() {
300
+ this.#turns.interrupt();
300
301
  this.#pending.settleAll("cancel");
301
302
  await this.#activeQuery?.interrupt().catch(() => undefined);
302
303
  }
303
304
  resumeCursor() {
304
305
  if (this.#sessionId === "claude:pending")
305
306
  return undefined;
306
- return { version: RESUME_CURSOR_VERSION, kind: this.#kind, data: { sessionId: this.#sessionId } };
307
+ return {
308
+ version: RESUME_CURSOR_VERSION,
309
+ kind: this.#kind,
310
+ data: { sessionId: this.#sessionId }
311
+ };
307
312
  }
308
313
  async stop() {
314
+ if (this.#stopped)
315
+ return;
309
316
  this.#stopped = true;
317
+ this.#turns.interrupt(new Error("claude session stopped"));
310
318
  this.#pending.settleAll("cancel");
311
319
  await this.#activeQuery?.interrupt().catch(() => undefined);
312
320
  }
313
321
  }
314
322
  function resumeSessionId(resume) {
315
- if (resume === undefined || resume.kind !== "claude_code")
316
- return undefined;
317
- const data = resume.data;
318
- return typeof data.sessionId === "string" ? data.sessionId : undefined;
323
+ return resumeStringField(resume, "claude_code", "sessionId");
319
324
  }
320
325
  class ClaudeInstance {
321
326
  kind = "claude_code";
@@ -323,7 +328,7 @@ class ClaudeInstance {
323
328
  #context;
324
329
  #status;
325
330
  #queryFn;
326
- #sessions = new Set();
331
+ #sessions = new SessionResourceRegistry();
327
332
  constructor(input) {
328
333
  this.#config = input.config;
329
334
  this.#context = input.context;
@@ -334,19 +339,17 @@ class ClaudeInstance {
334
339
  return this.#status;
335
340
  }
336
341
  async startSession(options) {
342
+ this.#sessions.assertOpen();
337
343
  const session = new ClaudeSession({
338
344
  config: this.#config,
339
345
  context: this.#context,
340
346
  options,
341
347
  queryFn: this.#queryFn
342
348
  });
343
- this.#sessions.add(session);
344
- return session;
349
+ return this.#sessions.manage(session);
345
350
  }
346
351
  async dispose() {
347
- for (const session of this.#sessions)
348
- await session.stop();
349
- this.#sessions.clear();
352
+ await this.#sessions.dispose();
350
353
  }
351
354
  }
352
355
  /** Probe the claude CLI: version via `claude --version`, auth from credential env. */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { trimTrailingSlashes } from "@velum-labs/routekit-runtime";
1
+ import { gatewayOrigin } from "@velum-labs/routekit-runtime/network";
2
2
  import { claudeDriverConfigSchema, createClaudeDriver } from "./driver.js";
3
3
  import { launchClaude } from "./launch.js";
4
4
  const driver = createClaudeDriver();
@@ -12,7 +12,7 @@ export const claudeTool = {
12
12
  installHint: "install Claude Code: https://docs.anthropic.com/en/docs/claude-code/overview",
13
13
  authSummary: "Claude Code uses the gateway's Anthropic-compatible surface.",
14
14
  setupSnippet: ({ gatewayUrl, model = "gateway-model" }) => [
15
- `ANTHROPIC_BASE_URL=${trimTrailingSlashes(gatewayUrl)}`,
15
+ `ANTHROPIC_BASE_URL=${gatewayOrigin(gatewayUrl)}`,
16
16
  "ANTHROPIC_AUTH_TOKEN=routekit",
17
17
  `ANTHROPIC_MODEL=${model}`
18
18
  ].join("\n"),
@@ -0,0 +1,58 @@
1
+ export type ClaudeSettings = Record<string, unknown> & {
2
+ env?: Record<string, unknown>;
3
+ };
4
+ export type FileSnapshot = {
5
+ content: string | null;
6
+ mode: number | null;
7
+ hash: string | null;
8
+ };
9
+ export type InstalledManifest = {
10
+ version: 2;
11
+ state: "installed";
12
+ ownerId: string;
13
+ original: FileSnapshot;
14
+ exactRestoreEligible: boolean;
15
+ installedContentHash: string;
16
+ managedEnvValues: Record<string, string>;
17
+ /** `availableModels` entries contributed by RouteKit, never user entries. */
18
+ managedPickerModels?: string[];
19
+ /** True only when RouteKit created the `availableModels` array itself. */
20
+ managedAvailableModels?: true;
21
+ /** True only when RouteKit created this top-level policy setting. */
22
+ managedEnforceAvailableModels?: true;
23
+ /** Exact top-level apiKeyHelper string contributed by RouteKit. */
24
+ managedApiKeyHelper?: string;
25
+ };
26
+ export type InstallPendingManifest = {
27
+ version: 2;
28
+ state: "install-pending";
29
+ ownerId: string;
30
+ beforeSettings: FileSnapshot;
31
+ beforeManifest: InstalledManifest | null;
32
+ targetSettings: FileSnapshot;
33
+ targetManifest: InstalledManifest;
34
+ };
35
+ export type UninstallPendingManifest = {
36
+ version: 2;
37
+ state: "uninstall-pending";
38
+ ownerId: string;
39
+ beforeSettings: FileSnapshot;
40
+ targetSettings: FileSnapshot;
41
+ };
42
+ export type ClaudeInstallManifest = InstalledManifest | InstallPendingManifest | UninstallPendingManifest;
43
+ export declare const MANAGED_ENV_KEYS: readonly ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT"];
44
+ export declare function hash(content: string): string;
45
+ export declare function serialize(value: unknown): string;
46
+ export declare function snapshot(content: string | null, mode: number | null): FileSnapshot;
47
+ export declare function parseSettings(content: string, configPath: string): ClaudeSettings;
48
+ export declare function unsupportedManifest(manifestPath: string): Error;
49
+ export declare function parseManifest(content: string, manifestPath: string): ClaudeInstallManifest;
50
+ export declare function managedEnv(input: {
51
+ gatewayUrl: string;
52
+ }): Record<string, string>;
53
+ export declare function pickerModels(input: {
54
+ models: readonly string[];
55
+ }): string[];
56
+ export declare function availableModels(settings: ClaudeSettings, configPath: string): string[] | undefined;
57
+ export declare function enforceAvailableModels(settings: ClaudeSettings, configPath: string): boolean | undefined;
58
+ export declare function removableManagedKeys(env: Record<string, unknown>, managedEnvValues: Record<string, string | string[]>, configPath: string): string[];
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { gatewayOrigin } from "@velum-labs/routekit-runtime/network";
3
+ export const MANAGED_ENV_KEYS = ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT"];
4
+ const RETIRED_MANAGED_ENV_KEYS = [
5
+ "ANTHROPIC_AUTH_TOKEN",
6
+ "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
7
+ ];
8
+ const CLAUDE_PICKER_PREFIX = "anthropic.routekit.";
9
+ export function hash(content) {
10
+ return createHash("sha256").update(content).digest("hex");
11
+ }
12
+ export function serialize(value) {
13
+ return `${JSON.stringify(value, null, 2)}\n`;
14
+ }
15
+ export function snapshot(content, mode) {
16
+ return {
17
+ content,
18
+ mode,
19
+ hash: content === null ? null : hash(content)
20
+ };
21
+ }
22
+ export function parseSettings(content, configPath) {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(content);
26
+ }
27
+ catch (error) {
28
+ const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
29
+ throw new Error(`your Claude settings (${configPath}) are not valid JSON (${detail})`);
30
+ }
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
32
+ throw new Error(`your Claude settings (${configPath}) must contain a JSON object`);
33
+ }
34
+ const settings = parsed;
35
+ if (settings.env !== undefined &&
36
+ (typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env))) {
37
+ throw new Error(`the "env" field in your Claude settings (${configPath}) must be an object`);
38
+ }
39
+ return settings;
40
+ }
41
+ export function unsupportedManifest(manifestPath) {
42
+ return new Error(`RouteKit's Claude ownership metadata (${manifestPath}) has an unsupported format`);
43
+ }
44
+ function isRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+ function isSnapshot(value) {
48
+ if (!isRecord(value))
49
+ return false;
50
+ const validMode = value.mode === null ||
51
+ (typeof value.mode === "number" &&
52
+ Number.isInteger(value.mode) &&
53
+ value.mode >= 0 &&
54
+ value.mode <= 0o777);
55
+ return ((typeof value.content === "string" || value.content === null) &&
56
+ validMode &&
57
+ (value.content !== null || value.mode === null) &&
58
+ (typeof value.hash === "string" || value.hash === null) &&
59
+ value.hash === (value.content === null ? null : hash(value.content)));
60
+ }
61
+ function isStringRecord(value) {
62
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
63
+ }
64
+ function isStringArray(value) {
65
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
66
+ }
67
+ function isInstalledManifest(value) {
68
+ return (isRecord(value) &&
69
+ value.version === 2 &&
70
+ value.state === "installed" &&
71
+ typeof value.ownerId === "string" &&
72
+ isSnapshot(value.original) &&
73
+ typeof value.exactRestoreEligible === "boolean" &&
74
+ typeof value.installedContentHash === "string" &&
75
+ isStringRecord(value.managedEnvValues) &&
76
+ (value.managedPickerModels === undefined || isStringArray(value.managedPickerModels)) &&
77
+ (value.managedAvailableModels === undefined || value.managedAvailableModels === true) &&
78
+ (value.managedEnforceAvailableModels === undefined ||
79
+ value.managedEnforceAvailableModels === true) &&
80
+ (value.managedApiKeyHelper === undefined || typeof value.managedApiKeyHelper === "string"));
81
+ }
82
+ export function parseManifest(content, manifestPath) {
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(content);
86
+ }
87
+ catch {
88
+ throw new Error(`RouteKit's Claude ownership metadata (${manifestPath}) is invalid; ` +
89
+ "move it aside and restore settings.json before retrying");
90
+ }
91
+ if (isInstalledManifest(parsed))
92
+ return parsed;
93
+ if (isRecord(parsed) &&
94
+ parsed.version === 2 &&
95
+ parsed.state === "install-pending" &&
96
+ typeof parsed.ownerId === "string" &&
97
+ isSnapshot(parsed.beforeSettings) &&
98
+ (parsed.beforeManifest === null || isInstalledManifest(parsed.beforeManifest)) &&
99
+ isSnapshot(parsed.targetSettings) &&
100
+ isInstalledManifest(parsed.targetManifest)) {
101
+ const pending = parsed;
102
+ if (pending.targetManifest.ownerId !== pending.ownerId ||
103
+ pending.targetManifest.installedContentHash !== pending.targetSettings.hash ||
104
+ (pending.beforeManifest !== null && pending.beforeManifest.ownerId !== pending.ownerId)) {
105
+ throw unsupportedManifest(manifestPath);
106
+ }
107
+ return pending;
108
+ }
109
+ if (isRecord(parsed) &&
110
+ parsed.version === 2 &&
111
+ parsed.state === "uninstall-pending" &&
112
+ typeof parsed.ownerId === "string" &&
113
+ isSnapshot(parsed.beforeSettings) &&
114
+ isSnapshot(parsed.targetSettings)) {
115
+ return parsed;
116
+ }
117
+ throw unsupportedManifest(manifestPath);
118
+ }
119
+ export function managedEnv(input) {
120
+ return {
121
+ ANTHROPIC_BASE_URL: gatewayOrigin(input.gatewayUrl),
122
+ CLAUDE_CODE_ALWAYS_ENABLE_EFFORT: "1"
123
+ };
124
+ }
125
+ export function pickerModels(input) {
126
+ const models = new Set();
127
+ for (const model of input.models) {
128
+ if (typeof model !== "string" || model.length === 0) {
129
+ throw new Error("RouteKit's Claude picker catalog contains an invalid model id");
130
+ }
131
+ models.add(`${CLAUDE_PICKER_PREFIX}${model}`);
132
+ }
133
+ return [...models];
134
+ }
135
+ export function availableModels(settings, configPath) {
136
+ if (settings.availableModels === undefined)
137
+ return undefined;
138
+ if (!isStringArray(settings.availableModels)) {
139
+ throw new Error(`the "availableModels" field in your Claude settings (${configPath}) must be an array of strings`);
140
+ }
141
+ return [...settings.availableModels];
142
+ }
143
+ export function enforceAvailableModels(settings, configPath) {
144
+ if (settings.enforceAvailableModels === undefined)
145
+ return undefined;
146
+ if (typeof settings.enforceAvailableModels !== "boolean") {
147
+ throw new Error(`the "enforceAvailableModels" field in your Claude settings (${configPath}) must be a boolean`);
148
+ }
149
+ return settings.enforceAvailableModels;
150
+ }
151
+ export function removableManagedKeys(env, managedEnvValues, configPath) {
152
+ const removable = [];
153
+ for (const [key, expected] of Object.entries(managedEnvValues)) {
154
+ const accepted = Array.isArray(expected) ? expected : [expected];
155
+ if (accepted.includes(String(env[key]))) {
156
+ removable.push(key);
157
+ continue;
158
+ }
159
+ if (RETIRED_MANAGED_ENV_KEYS.includes(key))
160
+ continue;
161
+ throw new Error(`your Claude settings changed RouteKit-managed env.${key} in ${configPath}; ` +
162
+ `remove or restore that value before rerunning the install command`);
163
+ }
164
+ return removable;
165
+ }
package/dist/install.js CHANGED
@@ -1,17 +1,10 @@
1
1
  import { chmodSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
- import { createHash } from "node:crypto";
3
2
  import { homedir } from "node:os";
4
3
  import { dirname, join } from "node:path";
5
4
  import { SUBSCRIPTIONS } from "@velum-labs/routekit-registry";
6
- import { acquireLifecycleLock, trimTrailingSlashes, writeFileAtomic } from "@velum-labs/routekit-runtime";
7
- const MANAGED_ENV_KEYS = [
8
- "ANTHROPIC_BASE_URL",
9
- "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT"
10
- ];
11
- const RETIRED_MANAGED_ENV_KEYS = [
12
- "ANTHROPIC_AUTH_TOKEN",
13
- "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
14
- ];
5
+ import { acquireLifecycleLock } from "@velum-labs/routekit-runtime/service";
6
+ import { writeFileAtomic } from "@velum-labs/routekit-runtime/filesystem";
7
+ import { availableModels, enforceAvailableModels, MANAGED_ENV_KEYS, managedEnv, parseManifest, parseSettings, pickerModels, removableManagedKeys, serialize, snapshot, unsupportedManifest } from "./install-codec.js";
15
8
  function reached(boundary) {
16
9
  // Deliberately not part of the package API: tests install a same-process
17
10
  // throw hook to model termination immediately after an atomic boundary.
@@ -48,158 +41,6 @@ function paths(input) {
48
41
  lockPath: join(configDirectory, ".routekit-claude-integration.lock")
49
42
  };
50
43
  }
51
- function hash(content) {
52
- return createHash("sha256").update(content).digest("hex");
53
- }
54
- function parseSettings(content, configPath) {
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(content);
58
- }
59
- catch (error) {
60
- const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
61
- throw new Error(`your Claude settings (${configPath}) are not valid JSON (${detail})`);
62
- }
63
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
- throw new Error(`your Claude settings (${configPath}) must contain a JSON object`);
65
- }
66
- const settings = parsed;
67
- if (settings.env !== undefined &&
68
- (typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env))) {
69
- throw new Error(`the "env" field in your Claude settings (${configPath}) must be an object`);
70
- }
71
- return settings;
72
- }
73
- function unsupportedManifest(manifestPath) {
74
- return new Error(`RouteKit's Claude ownership metadata (${manifestPath}) has an unsupported format`);
75
- }
76
- function isRecord(value) {
77
- return typeof value === "object" && value !== null && !Array.isArray(value);
78
- }
79
- function isSnapshot(value) {
80
- if (!isRecord(value))
81
- return false;
82
- const validMode = value.mode === null ||
83
- (typeof value.mode === "number" &&
84
- Number.isInteger(value.mode) &&
85
- value.mode >= 0 &&
86
- value.mode <= 0o777);
87
- return ((typeof value.content === "string" || value.content === null) &&
88
- validMode &&
89
- (value.content !== null || value.mode === null) &&
90
- (typeof value.hash === "string" || value.hash === null) &&
91
- value.hash === (value.content === null ? null : hash(value.content)));
92
- }
93
- function isStringRecord(value) {
94
- return (isRecord(value) &&
95
- Object.values(value).every((entry) => typeof entry === "string"));
96
- }
97
- function isStringArray(value) {
98
- return Array.isArray(value) && value.every((entry) => typeof entry === "string");
99
- }
100
- function isInstalledManifest(value) {
101
- return (isRecord(value) &&
102
- value.version === 2 &&
103
- value.state === "installed" &&
104
- typeof value.ownerId === "string" &&
105
- isSnapshot(value.original) &&
106
- typeof value.exactRestoreEligible === "boolean" &&
107
- typeof value.installedContentHash === "string" &&
108
- isStringRecord(value.managedEnvValues) &&
109
- (value.managedPickerModels === undefined || isStringArray(value.managedPickerModels)) &&
110
- (value.managedAvailableModels === undefined || value.managedAvailableModels === true) &&
111
- (value.managedEnforceAvailableModels === undefined ||
112
- value.managedEnforceAvailableModels === true) &&
113
- (value.managedApiKeyHelper === undefined || typeof value.managedApiKeyHelper === "string"));
114
- }
115
- function parseManifest(content, manifestPath) {
116
- let parsed;
117
- try {
118
- parsed = JSON.parse(content);
119
- }
120
- catch {
121
- throw new Error(`RouteKit's Claude ownership metadata (${manifestPath}) is invalid; ` +
122
- "move it aside and restore settings.json before retrying");
123
- }
124
- if (isRecord(parsed) &&
125
- parsed.version === 1 &&
126
- typeof parsed.ownerId === "string" &&
127
- (typeof parsed.originalContent === "string" || parsed.originalContent === null) &&
128
- typeof parsed.exactRestoreEligible === "boolean" &&
129
- Array.isArray(parsed.installedContentHashes) &&
130
- parsed.installedContentHashes.every((entry) => typeof entry === "string") &&
131
- isRecord(parsed.managedEnvValues) &&
132
- Object.values(parsed.managedEnvValues).every((entry) => Array.isArray(entry) &&
133
- entry.every((candidate) => typeof candidate === "string"))) {
134
- return parsed;
135
- }
136
- if (isInstalledManifest(parsed))
137
- return parsed;
138
- if (isRecord(parsed) &&
139
- parsed.version === 2 &&
140
- parsed.state === "install-pending" &&
141
- typeof parsed.ownerId === "string" &&
142
- isSnapshot(parsed.beforeSettings) &&
143
- (parsed.beforeManifest === null ||
144
- isInstalledManifest(parsed.beforeManifest)) &&
145
- isSnapshot(parsed.targetSettings) &&
146
- isInstalledManifest(parsed.targetManifest)) {
147
- const pending = parsed;
148
- if (pending.targetManifest.ownerId !== pending.ownerId ||
149
- pending.targetManifest.installedContentHash !==
150
- pending.targetSettings.hash ||
151
- (pending.beforeManifest !== null &&
152
- pending.beforeManifest.ownerId !== pending.ownerId)) {
153
- throw unsupportedManifest(manifestPath);
154
- }
155
- return pending;
156
- }
157
- if (isRecord(parsed) &&
158
- parsed.version === 2 &&
159
- parsed.state === "uninstall-pending" &&
160
- typeof parsed.ownerId === "string" &&
161
- isSnapshot(parsed.beforeSettings) &&
162
- isSnapshot(parsed.targetSettings)) {
163
- return parsed;
164
- }
165
- throw unsupportedManifest(manifestPath);
166
- }
167
- function serialize(value) {
168
- return `${JSON.stringify(value, null, 2)}\n`;
169
- }
170
- function managedEnv(input) {
171
- return {
172
- ANTHROPIC_BASE_URL: trimTrailingSlashes(input.gatewayUrl),
173
- CLAUDE_CODE_ALWAYS_ENABLE_EFFORT: "1"
174
- };
175
- }
176
- const CLAUDE_PICKER_PREFIX = "anthropic.routekit.";
177
- function pickerModels(input) {
178
- const models = new Set();
179
- for (const model of input.models) {
180
- if (typeof model !== "string" || model.length === 0) {
181
- throw new Error("RouteKit's Claude picker catalog contains an invalid model id");
182
- }
183
- models.add(`${CLAUDE_PICKER_PREFIX}${model}`);
184
- }
185
- return [...models];
186
- }
187
- function availableModels(settings, configPath) {
188
- if (settings.availableModels === undefined)
189
- return undefined;
190
- if (!isStringArray(settings.availableModels)) {
191
- throw new Error(`the "availableModels" field in your Claude settings (${configPath}) must be an array of strings`);
192
- }
193
- return [...settings.availableModels];
194
- }
195
- function enforceAvailableModels(settings, configPath) {
196
- if (settings.enforceAvailableModels === undefined)
197
- return undefined;
198
- if (typeof settings.enforceAvailableModels !== "boolean") {
199
- throw new Error(`the "enforceAvailableModels" field in your Claude settings (${configPath}) must be a boolean`);
200
- }
201
- return settings.enforceAvailableModels;
202
- }
203
44
  function entryIfExists(path) {
204
45
  try {
205
46
  return lstatSync(path);
@@ -244,20 +85,8 @@ function readSnapshot(path, label) {
244
85
  assertRegularFileIfExists(path, label);
245
86
  const entry = entryIfExists(path);
246
87
  if (entry === undefined)
247
- return { content: null, mode: null, hash: null };
248
- const content = readFileSync(path, "utf8");
249
- return {
250
- content,
251
- mode: entry.mode & 0o777,
252
- hash: hash(content)
253
- };
254
- }
255
- function snapshot(content, mode) {
256
- return {
257
- content,
258
- mode,
259
- hash: content === null ? null : hash(content)
260
- };
88
+ return snapshot(null, null);
89
+ return snapshot(readFileSync(path, "utf8"), entry.mode & 0o777);
261
90
  }
262
91
  function writePrivateFile(path, content, label) {
263
92
  assertRegularFileIfExists(path, label);
@@ -322,21 +151,6 @@ function recoverPending(manifest, configPath, manifestPath) {
322
151
  assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
323
152
  rmSync(manifestPath, { force: true });
324
153
  }
325
- function removableManagedKeys(env, managedEnvValues, configPath) {
326
- const removable = [];
327
- for (const [key, expected] of Object.entries(managedEnvValues)) {
328
- const accepted = Array.isArray(expected) ? expected : [expected];
329
- if (accepted.includes(String(env[key]))) {
330
- removable.push(key);
331
- continue;
332
- }
333
- if (RETIRED_MANAGED_ENV_KEYS.includes(key))
334
- continue;
335
- throw new Error(`your Claude settings changed RouteKit-managed env.${key} in ${configPath}; ` +
336
- `remove or restore that value before rerunning the install command`);
337
- }
338
- return removable;
339
- }
340
154
  const CLAUDE_LOCK_TIMEOUT_MS = 5_000;
341
155
  async function withConfigLock(resolved, operation) {
342
156
  ensureConfigDirectory(resolved.configDirectory);
@@ -355,93 +169,24 @@ async function withConfigLock(resolved, operation) {
355
169
  lock.release();
356
170
  }
357
171
  }
358
- function migrateLegacyManifest(manifest, current, configPath, manifestPath) {
359
- const settings = parseSettings(current.content ?? "{}\n", configPath);
360
- const env = { ...(settings.env ?? {}) };
361
- const hasNoManagedValues = Object.keys(manifest.managedEnvValues).every((key) => env[key] === undefined);
362
- if (current.content === manifest.originalContent &&
363
- hasNoManagedValues) {
364
- assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
365
- rmSync(manifestPath, { force: true });
366
- return undefined;
367
- }
368
- const currentHash = current.hash;
369
- if (currentHash === null) {
370
- throw new Error(`RouteKit's legacy Claude ownership metadata (${manifestPath}) cannot ` +
371
- "match missing settings; refusing to overwrite them");
372
- }
373
- let recognizedContent = manifest.installedContentHashes.includes(currentHash);
374
- if (!recognizedContent) {
375
- for (const key of RETIRED_MANAGED_ENV_KEYS) {
376
- const accepted = manifest.managedEnvValues[key];
377
- const value = env[key];
378
- if (accepted === undefined || accepted.includes(String(value)))
379
- continue;
380
- for (const candidate of accepted) {
381
- const candidateSettings = {
382
- ...settings,
383
- env: { ...env, [key]: candidate }
384
- };
385
- if (manifest.installedContentHashes.includes(hash(serialize(candidateSettings)))) {
386
- recognizedContent = true;
387
- break;
388
- }
389
- }
390
- }
391
- }
392
- if (!recognizedContent) {
393
- throw new Error(`RouteKit's legacy Claude ownership metadata (${manifestPath}) does not ` +
394
- "match the current settings; refusing to overwrite them");
395
- }
396
- const selectedValues = {};
397
- for (const [key, accepted] of Object.entries(manifest.managedEnvValues)) {
398
- const value = env[key];
399
- if (value === undefined || !accepted.includes(String(value))) {
400
- if (RETIRED_MANAGED_ENV_KEYS.includes(key))
401
- continue;
402
- throw new Error(`RouteKit's legacy Claude ownership metadata (${manifestPath}) cannot ` +
403
- `safely identify the current env.${key} value; refusing to overwrite settings`);
404
- }
405
- selectedValues[key] = String(value);
406
- }
407
- const migrated = {
408
- version: 2,
409
- state: "installed",
410
- ownerId: manifest.ownerId,
411
- original: snapshot(manifest.originalContent, null),
412
- exactRestoreEligible: manifest.exactRestoreEligible &&
413
- manifest.installedContentHashes.includes(currentHash),
414
- installedContentHash: currentHash,
415
- managedEnvValues: selectedValues
416
- };
417
- writeManifest(manifestPath, migrated);
418
- return migrated;
419
- }
420
172
  export async function installClaudeIntegration(input) {
421
173
  const resolved = paths({
422
174
  ownerId: input.owner.id,
423
- ...(input.claudeConfigDir !== undefined
424
- ? { claudeConfigDir: input.claudeConfigDir }
425
- : {})
175
+ ...(input.claudeConfigDir !== undefined ? { claudeConfigDir: input.claudeConfigDir } : {})
426
176
  });
427
177
  return await withConfigLock(resolved, () => {
428
178
  const { configPath, manifestPath } = resolved;
429
179
  let manifest = currentManifest(manifestPath);
430
- if (manifest !== undefined &&
431
- manifest.ownerId !== input.owner.id) {
180
+ if (manifest !== undefined && manifest.ownerId !== input.owner.id) {
432
181
  throw new Error(`Claude ownership metadata in ${manifestPath} belongs to another integration`);
433
182
  }
434
- if (manifest?.version === 2 &&
435
- manifest.state !== "installed") {
183
+ if (manifest?.version === 2 && manifest.state !== "installed") {
436
184
  recoverPending(manifest, configPath, manifestPath);
437
185
  manifest = currentManifest(manifestPath);
438
186
  }
439
187
  const beforeSettings = readSnapshot(configPath, "Claude settings");
440
188
  let previousManifest;
441
- if (manifest?.version === 1) {
442
- previousManifest = migrateLegacyManifest(manifest, beforeSettings, configPath, manifestPath);
443
- }
444
- else if (manifest?.state === "installed") {
189
+ if (manifest?.state === "installed") {
445
190
  previousManifest = manifest;
446
191
  }
447
192
  else if (manifest !== undefined) {
@@ -450,14 +195,12 @@ export async function installClaudeIntegration(input) {
450
195
  const settings = parseSettings(beforeSettings.content ?? "{}\n", configPath);
451
196
  const env = { ...(settings.env ?? {}) };
452
197
  const nextManaged = managedEnv(input);
453
- if (settings.apiKeyHelper !== undefined &&
454
- typeof settings.apiKeyHelper !== "string") {
198
+ if (settings.apiKeyHelper !== undefined && typeof settings.apiKeyHelper !== "string") {
455
199
  throw new Error(`the "apiKeyHelper" field in your Claude settings (${configPath}) must be a string`);
456
200
  }
457
201
  const currentApiKeyHelper = settings.apiKeyHelper;
458
202
  const previousApiKeyHelper = previousManifest?.managedApiKeyHelper;
459
- if (previousApiKeyHelper !== undefined &&
460
- currentApiKeyHelper !== previousApiKeyHelper) {
203
+ if (previousApiKeyHelper !== undefined && currentApiKeyHelper !== previousApiKeyHelper) {
461
204
  throw new Error(`your Claude settings changed RouteKit-managed apiKeyHelper in ${configPath}; ` +
462
205
  "restore that value before rerunning the install command");
463
206
  }
@@ -508,12 +251,8 @@ export async function installClaudeIntegration(input) {
508
251
  ...settings,
509
252
  env,
510
253
  availableModels: nextPickerModels,
511
- ...(input.apiKeyHelper !== undefined
512
- ? { apiKeyHelper: input.apiKeyHelper }
513
- : {}),
514
- ...(nextManagedEnforceAvailableModels === true
515
- ? { enforceAvailableModels: true }
516
- : {})
254
+ ...(input.apiKeyHelper !== undefined ? { apiKeyHelper: input.apiKeyHelper } : {}),
255
+ ...(nextManagedEnforceAvailableModels === true ? { enforceAvailableModels: true } : {})
517
256
  };
518
257
  if (input.apiKeyHelper === undefined && previousApiKeyHelper !== undefined) {
519
258
  delete nextSettings.apiKeyHelper;
@@ -539,9 +278,7 @@ export async function installClaudeIntegration(input) {
539
278
  ...(nextManagedEnforceAvailableModels === true
540
279
  ? { managedEnforceAvailableModels: true }
541
280
  : {}),
542
- ...(input.apiKeyHelper !== undefined
543
- ? { managedApiKeyHelper: input.apiKeyHelper }
544
- : {})
281
+ ...(input.apiKeyHelper !== undefined ? { managedApiKeyHelper: input.apiKeyHelper } : {})
545
282
  };
546
283
  const pendingManifest = {
547
284
  version: 2,
@@ -559,8 +296,7 @@ export async function installClaudeIntegration(input) {
559
296
  applySnapshot(configPath, targetSettings, "Claude settings");
560
297
  reached("install-settings");
561
298
  const pending = currentManifest(manifestPath);
562
- if (pending?.version !== 2 ||
563
- pending.state !== "install-pending") {
299
+ if (pending?.version !== 2 || pending.state !== "install-pending") {
564
300
  throw new Error(`Claude ownership metadata changed unexpectedly during install (${manifestPath})`);
565
301
  }
566
302
  writeManifest(manifestPath, targetManifest);
@@ -599,10 +335,7 @@ export async function uninstallClaudeIntegration(input) {
599
335
  }
600
336
  const beforeSettings = readSnapshot(configPath, "Claude settings");
601
337
  let installed;
602
- if (manifest.version === 1) {
603
- installed = migrateLegacyManifest(manifest, beforeSettings, configPath, manifestPath);
604
- }
605
- else if (manifest.state === "installed") {
338
+ if (manifest.state === "installed") {
606
339
  installed = manifest;
607
340
  }
608
341
  else {
@@ -611,8 +344,7 @@ export async function uninstallClaudeIntegration(input) {
611
344
  if (installed === undefined)
612
345
  return { configPath, removed: false };
613
346
  let targetSettings;
614
- if (installed.exactRestoreEligible &&
615
- beforeSettings.hash === installed.installedContentHash) {
347
+ if (installed.exactRestoreEligible && beforeSettings.hash === installed.installedContentHash) {
616
348
  targetSettings = installed.original;
617
349
  }
618
350
  else if (beforeSettings.content !== null) {
@@ -665,8 +397,7 @@ export async function uninstallClaudeIntegration(input) {
665
397
  applySnapshot(configPath, targetSettings, "Claude settings");
666
398
  reached("uninstall-settings");
667
399
  const pending = currentManifest(manifestPath);
668
- if (pending?.version !== 2 ||
669
- pending.state !== "uninstall-pending") {
400
+ if (pending?.version !== 2 || pending.state !== "uninstall-pending") {
670
401
  throw new Error(`Claude ownership metadata changed unexpectedly during uninstall (${manifestPath})`);
671
402
  }
672
403
  rmSync(manifestPath, { force: true });
package/dist/launch.js CHANGED
@@ -1,7 +1,9 @@
1
- import { spawnTool } from "@velum-labs/routekit-runtime";
1
+ import { hasFlag } from "@velum-labs/routekit-runtime/args";
2
+ import { gatewayOrigin } from "@velum-labs/routekit-runtime/network";
3
+ import { spawnTool } from "@velum-labs/routekit-runtime/process";
2
4
  export function claudeEnv(gatewayUrl, authToken) {
3
5
  return {
4
- ANTHROPIC_BASE_URL: gatewayUrl,
6
+ ANTHROPIC_BASE_URL: gatewayOrigin(gatewayUrl),
5
7
  ANTHROPIC_AUTH_TOKEN: authToken ?? "routekit",
6
8
  CLAUDE_CODE_ALWAYS_ENABLE_EFFORT: "1",
7
9
  ...(process.env.CLAUDE_CONFIG_DIR !== undefined
@@ -23,25 +25,16 @@ export function claudeAgentsJson(profiles) {
23
25
  }
24
26
  ])));
25
27
  }
26
- function hasAgentsArg(args) {
27
- return args.some((arg) => arg === "--agents" || arg.startsWith("--agents="));
28
- }
29
- function hasModelArg(args) {
30
- return args.some((arg) => arg === "--model" || arg.startsWith("--model="));
31
- }
32
- function hasEffortArg(args) {
33
- return args.some((arg) => arg === "--effort" || arg.startsWith("--effort="));
34
- }
35
28
  export function claudeLaunchArgs(ctx) {
36
29
  const args = [...ctx.spec.args];
37
- if (!hasModelArg(args)) {
30
+ if (!hasFlag(args, "--model")) {
38
31
  args.unshift("--model", claudeModelId(ctx.spec.defaultModel));
39
- if (ctx.spec.reasoning?.mode === "effort" && !hasEffortArg(args)) {
32
+ if (ctx.spec.reasoning?.mode === "effort" && !hasFlag(args, "--effort")) {
40
33
  args.unshift("--effort", ctx.spec.reasoning.effort);
41
34
  }
42
35
  }
43
36
  const profiles = ctx.spec.agentProfiles ?? [];
44
- if (profiles.length > 0 && !hasAgentsArg(args)) {
37
+ if (profiles.length > 0 && !hasFlag(args, "--agents")) {
45
38
  args.push("--agents", claudeAgentsJson(profiles));
46
39
  }
47
40
  return args;
@@ -115,6 +115,35 @@ test("claude driver forwards opaque effort through SDK options", async () => {
115
115
  await instance.dispose();
116
116
  }
117
117
  });
118
+ test("claude driver releases the turn when query setup throws", async () => {
119
+ const delegate = scriptedQuery("claude-setup-retry");
120
+ let attempts = 0;
121
+ const setupDriver = createClaudeDriver({
122
+ queryFn: (input) => {
123
+ attempts += 1;
124
+ if (attempts === 1)
125
+ throw new Error("query setup failed");
126
+ return delegate(input);
127
+ }
128
+ });
129
+ const instance = await setupDriver.createInstance(setupDriver.configSchema.parse({ command: "claude" }));
130
+ try {
131
+ const session = await instance.startSession({ cwd: process.cwd() });
132
+ await assert.rejects(async () => {
133
+ for await (const _event of session.sendTurn({ prompt: "first" })) {
134
+ // Drain.
135
+ }
136
+ }, /query setup failed/);
137
+ const events = [];
138
+ for await (const event of session.sendTurn({ prompt: "second" })) {
139
+ events.push(event);
140
+ }
141
+ assert.equal(events.find((event) => event.type === "turn.completed")?.endReason, "completed");
142
+ }
143
+ finally {
144
+ await instance.dispose();
145
+ }
146
+ });
118
147
  test("claude driver auto-approves tools under the automation policy", async () => {
119
148
  const instance = await driver.createInstance(driver.configSchema.parse({ command: "claude" }));
120
149
  try {
@@ -489,195 +489,6 @@ test("updating a v2 install preserves a user-edited token", async () => {
489
489
  rmSync(configDirectory, { recursive: true, force: true });
490
490
  }
491
491
  });
492
- test("updating a legacy install removes its formerly managed ANTHROPIC_MODEL", async () => {
493
- const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-legacy-model-"));
494
- const configPath = join(configDirectory, "settings.json");
495
- const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
496
- const original = '{"theme":"light"}\n';
497
- const installed = `${JSON.stringify({
498
- theme: "light",
499
- env: {
500
- ANTHROPIC_BASE_URL: "http://127.0.0.1:7000",
501
- ANTHROPIC_AUTH_TOKEN: "routekit",
502
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
503
- ANTHROPIC_MODEL: "claude-code/legacy"
504
- }
505
- }, null, 2)}\n`;
506
- writeFileSync(configPath, installed);
507
- writeFileSync(manifestPath, `${JSON.stringify({
508
- version: 1,
509
- ownerId: OWNER.id,
510
- originalContent: original,
511
- exactRestoreEligible: true,
512
- installedContentHashes: [
513
- createHash("sha256").update(installed).digest("hex")
514
- ],
515
- managedEnvValues: {
516
- ANTHROPIC_BASE_URL: ["http://127.0.0.1:7000"],
517
- ANTHROPIC_AUTH_TOKEN: ["routekit"],
518
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"],
519
- ANTHROPIC_MODEL: ["claude-code/legacy"]
520
- }
521
- }, null, 2)}\n`);
522
- try {
523
- await install(configDirectory, "http://127.0.0.1:7001");
524
- const settings = JSON.parse(readFileSync(configPath, "utf8"));
525
- assert.equal("ANTHROPIC_MODEL" in settings.env, false);
526
- await uninstallClaudeIntegration({
527
- ownerId: OWNER.id,
528
- claudeConfigDir: configDirectory
529
- });
530
- assert.equal(readFileSync(configPath, "utf8"), original);
531
- }
532
- finally {
533
- rmSync(configDirectory, { recursive: true, force: true });
534
- }
535
- });
536
- function writeInterruptedLegacyUpdate(configDirectory, phase) {
537
- const configPath = join(configDirectory, "settings.json");
538
- const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
539
- const original = '{"theme":"light"}\n';
540
- const content = (baseUrl, authToken, model) => `${JSON.stringify({
541
- theme: "light",
542
- env: {
543
- ANTHROPIC_BASE_URL: baseUrl,
544
- ANTHROPIC_AUTH_TOKEN: authToken,
545
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
546
- ANTHROPIC_MODEL: model
547
- }
548
- }, null, 2)}\n`;
549
- const before = content("http://127.0.0.1:7000", "before-token", "claude-code/before");
550
- const after = content("http://127.0.0.1:7001", "after-token", "claude-code/after");
551
- writeFileSync(configPath, phase === "before-settings" ? before : after);
552
- writeFileSync(manifestPath, `${JSON.stringify({
553
- version: 1,
554
- ownerId: OWNER.id,
555
- originalContent: original,
556
- exactRestoreEligible: true,
557
- installedContentHashes: [before, after].map((value) => createHash("sha256").update(value).digest("hex")),
558
- managedEnvValues: {
559
- ANTHROPIC_BASE_URL: [
560
- "http://127.0.0.1:7000",
561
- "http://127.0.0.1:7001"
562
- ],
563
- ANTHROPIC_AUTH_TOKEN: ["before-token", "after-token"],
564
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"],
565
- ANTHROPIC_MODEL: [
566
- "claude-code/before",
567
- "claude-code/after"
568
- ]
569
- }
570
- }, null, 2)}\n`);
571
- return { configPath, manifestPath, original };
572
- }
573
- test("install migrates interrupted v1 updates before and after settings", async () => {
574
- for (const phase of ["before-settings", "after-settings"]) {
575
- const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-v1-install-${phase}-`));
576
- const { configPath, manifestPath, original } = writeInterruptedLegacyUpdate(configDirectory, phase);
577
- try {
578
- const result = await install(configDirectory, "http://127.0.0.1:7002", "final-token");
579
- assert.equal(result.action, "updated");
580
- const settings = JSON.parse(readFileSync(configPath, "utf8"));
581
- assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://127.0.0.1:7002");
582
- assert.equal("ANTHROPIC_AUTH_TOKEN" in settings.env, false);
583
- assert.equal("ANTHROPIC_MODEL" in settings.env, false);
584
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
585
- assert.deepEqual({ version: manifest.version, state: manifest.state }, { version: 2, state: "installed" });
586
- assert.equal("ANTHROPIC_AUTH_TOKEN" in manifest.managedEnvValues, false);
587
- assert.equal(readFileSync(manifestPath, "utf8").includes("final-token"), false);
588
- assert.equal(readFileSync(manifestPath, "utf8").includes("before-token"), false);
589
- assert.equal(readFileSync(manifestPath, "utf8").includes("after-token"), false);
590
- await uninstallClaudeIntegration({
591
- ownerId: OWNER.id,
592
- claudeConfigDir: configDirectory
593
- });
594
- assert.equal(readFileSync(configPath, "utf8"), original);
595
- }
596
- finally {
597
- rmSync(configDirectory, { recursive: true, force: true });
598
- }
599
- }
600
- });
601
- test("uninstall migrates interrupted v1 updates before and after settings", async () => {
602
- for (const phase of ["before-settings", "after-settings"]) {
603
- const configDirectory = mkdtempSync(join(tmpdir(), `routekit-claude-v1-uninstall-${phase}-`));
604
- const { configPath, manifestPath, original } = writeInterruptedLegacyUpdate(configDirectory, phase);
605
- try {
606
- assert.equal((await uninstallClaudeIntegration({
607
- ownerId: OWNER.id,
608
- claudeConfigDir: configDirectory
609
- })).removed, true);
610
- assert.equal(readFileSync(configPath, "utf8"), original);
611
- assert.equal(existsSync(manifestPath), false);
612
- }
613
- finally {
614
- rmSync(configDirectory, { recursive: true, force: true });
615
- }
616
- }
617
- });
618
- test("legacy migration refuses unrecognized settings without overwriting", async () => {
619
- const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-v1-external-"));
620
- const { configPath, manifestPath } = writeInterruptedLegacyUpdate(configDirectory, "after-settings");
621
- const external = '{"external":true}\n';
622
- writeFileSync(configPath, external);
623
- const manifest = readFileSync(manifestPath, "utf8");
624
- try {
625
- await assert.rejects(install(configDirectory), /does not match the current settings/);
626
- assert.equal(readFileSync(configPath, "utf8"), external);
627
- assert.equal(readFileSync(manifestPath, "utf8"), manifest);
628
- }
629
- finally {
630
- rmSync(configDirectory, { recursive: true, force: true });
631
- }
632
- });
633
- test("untouched original settings discard a stale pre-install v1 manifest", async () => {
634
- const configDirectory = mkdtempSync(join(tmpdir(), "routekit-claude-v1-original-"));
635
- const configPath = join(configDirectory, "settings.json");
636
- const manifestPath = join(configDirectory, `.${OWNER.id}-integration.json`);
637
- const original = '{"theme":"light"}\n';
638
- const target = `${JSON.stringify({
639
- theme: "light",
640
- env: {
641
- ANTHROPIC_BASE_URL: "http://127.0.0.1:7000",
642
- ANTHROPIC_AUTH_TOKEN: "routekit",
643
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
644
- }
645
- }, null, 2)}\n`;
646
- const writeStaleManifest = () => {
647
- writeFileSync(manifestPath, `${JSON.stringify({
648
- version: 1,
649
- ownerId: OWNER.id,
650
- originalContent: original,
651
- exactRestoreEligible: true,
652
- installedContentHashes: [original, target].map((value) => createHash("sha256").update(value).digest("hex")),
653
- managedEnvValues: {
654
- ANTHROPIC_BASE_URL: ["http://127.0.0.1:7000"],
655
- ANTHROPIC_AUTH_TOKEN: ["routekit"],
656
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: ["1"]
657
- }
658
- }, null, 2)}\n`);
659
- };
660
- writeFileSync(configPath, original);
661
- try {
662
- writeStaleManifest();
663
- assert.equal((await uninstallClaudeIntegration({
664
- ownerId: OWNER.id,
665
- claudeConfigDir: configDirectory
666
- })).removed, false);
667
- assert.equal(existsSync(manifestPath), false);
668
- assert.equal(readFileSync(configPath, "utf8"), original);
669
- writeStaleManifest();
670
- assert.equal((await install(configDirectory)).action, "installed");
671
- await uninstallClaudeIntegration({
672
- ownerId: OWNER.id,
673
- claudeConfigDir: configDirectory
674
- });
675
- assert.equal(readFileSync(configPath, "utf8"), original);
676
- }
677
- finally {
678
- rmSync(configDirectory, { recursive: true, force: true });
679
- }
680
- });
681
492
  test("new config directories are 0700 and exact restore preserves settings mode", async () => {
682
493
  const root = mkdtempSync(join(tmpdir(), "routekit-claude-mode-"));
683
494
  const createdDirectory = join(root, "nested", "claude");
@@ -78,6 +78,9 @@ test("Claude launcher forwards an explicit isolated config directory", () => {
78
78
  process.env.CLAUDE_CONFIG_DIR = previous;
79
79
  }
80
80
  });
81
+ test("Claude launcher strips an OpenAI API suffix from the gateway origin", () => {
82
+ assert.equal(claudeEnv("http://127.0.0.1:8080/v1/").ANTHROPIC_BASE_URL, "http://127.0.0.1:8080");
83
+ });
81
84
  test("claudeLaunchArgs adds profiles unless the user supplied agents", () => {
82
85
  const args = claudeLaunchArgs(context(["--verbose"]));
83
86
  assert.deepEqual(args.slice(0, 4), [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-tool-claude",
3
3
  "private": false,
4
- "version": "0.18.6",
4
+ "version": "1.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -14,6 +14,18 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
+ },
18
+ "./driver": {
19
+ "types": "./dist/driver.d.ts",
20
+ "default": "./dist/driver.js"
21
+ },
22
+ "./install": {
23
+ "types": "./dist/install.d.ts",
24
+ "default": "./dist/install.js"
25
+ },
26
+ "./launch": {
27
+ "types": "./dist/launch.d.ts",
28
+ "default": "./dist/launch.js"
17
29
  }
18
30
  },
19
31
  "files": [
@@ -30,11 +42,11 @@
30
42
  "@anthropic-ai/sdk": "0.115.0",
31
43
  "@modelcontextprotocol/sdk": "1.29.0",
32
44
  "zod": "4.4.3",
33
- "@velum-labs/routekit-contracts": "0.18.6",
34
- "@velum-labs/routekit-harness-core": "0.18.6",
35
- "@velum-labs/routekit-registry": "0.18.6",
36
- "@velum-labs/routekit-runtime": "0.18.6",
37
- "@velum-labs/routekit-tools": "0.18.6"
45
+ "@velum-labs/routekit-contracts": "1.0.0",
46
+ "@velum-labs/routekit-harness-core": "1.0.0",
47
+ "@velum-labs/routekit-runtime": "1.0.0",
48
+ "@velum-labs/routekit-tools": "1.0.0",
49
+ "@velum-labs/routekit-registry": "1.0.0"
38
50
  },
39
51
  "keywords": [
40
52
  "llm",
@@ -44,7 +56,7 @@
44
56
  "adapter"
45
57
  ],
46
58
  "scripts": {
47
- "build": "tsc -b",
59
+ "build": "tsc -p tsconfig.json",
48
60
  "clean": "tsc -b --clean",
49
61
  "test": "node --test \"dist/test/*.test.js\""
50
62
  }