@velum-labs/routekit-tool-claude 0.9.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/LICENSE +201 -0
- package/README.md +20 -0
- package/dist/driver.d.ts +23 -0
- package/dist/driver.js +379 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +34 -0
- package/dist/install.d.ts +26 -0
- package/dist/install.js +519 -0
- package/dist/launch.d.ts +6 -0
- package/dist/launch.js +51 -0
- package/dist/test/driver.test.d.ts +1 -0
- package/dist/test/driver.test.js +153 -0
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +646 -0
- package/dist/test/launch.test.d.ts +1 -0
- package/dist/test/launch.test.js +96 -0
- package/package.json +48 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type ClaudeInstallOwner = {
|
|
2
|
+
id: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
installCommand: string;
|
|
5
|
+
uninstallCommand: string;
|
|
6
|
+
startCommand: string;
|
|
7
|
+
};
|
|
8
|
+
export type ClaudeInstallInput = {
|
|
9
|
+
gatewayUrl: string;
|
|
10
|
+
authToken?: string;
|
|
11
|
+
owner: ClaudeInstallOwner;
|
|
12
|
+
claudeConfigDir?: string;
|
|
13
|
+
};
|
|
14
|
+
export type ClaudeInstallResult = {
|
|
15
|
+
configPath: string;
|
|
16
|
+
action: "installed" | "updated";
|
|
17
|
+
managedKeys: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare function installClaudeIntegration(input: ClaudeInstallInput): Promise<ClaudeInstallResult>;
|
|
20
|
+
export declare function uninstallClaudeIntegration(input: {
|
|
21
|
+
ownerId: string;
|
|
22
|
+
claudeConfigDir?: string;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
configPath: string;
|
|
25
|
+
removed: boolean;
|
|
26
|
+
}>;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
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
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
10
|
+
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
|
|
11
|
+
];
|
|
12
|
+
function reached(boundary) {
|
|
13
|
+
// Deliberately not part of the package API: tests install a same-process
|
|
14
|
+
// throw hook to model termination immediately after an atomic boundary.
|
|
15
|
+
const testingGlobal = globalThis;
|
|
16
|
+
testingGlobal.__routekitClaudeInstallFaultInjector?.(boundary);
|
|
17
|
+
}
|
|
18
|
+
function assertSafeOwnerId(ownerId) {
|
|
19
|
+
if (ownerId.length === 0 ||
|
|
20
|
+
ownerId.includes("/") ||
|
|
21
|
+
ownerId.includes("\\") ||
|
|
22
|
+
ownerId === "." ||
|
|
23
|
+
ownerId === "..") {
|
|
24
|
+
throw new Error(`Claude integration owner id is not path-safe: ${JSON.stringify(ownerId)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function defaultClaudeConfigDir() {
|
|
28
|
+
const registryPath = SUBSCRIPTIONS["claude-code"].configPath ?? "~/.claude/settings.json";
|
|
29
|
+
const configPath = registryPath.startsWith("~/")
|
|
30
|
+
? join(homedir(), registryPath.slice(2))
|
|
31
|
+
: registryPath;
|
|
32
|
+
return dirname(configPath);
|
|
33
|
+
}
|
|
34
|
+
function paths(input) {
|
|
35
|
+
assertSafeOwnerId(input.ownerId);
|
|
36
|
+
const configDirectory = input.claudeConfigDir ??
|
|
37
|
+
process.env.CLAUDE_CONFIG_DIR ??
|
|
38
|
+
defaultClaudeConfigDir();
|
|
39
|
+
return {
|
|
40
|
+
configDirectory,
|
|
41
|
+
configPath: join(configDirectory, "settings.json"),
|
|
42
|
+
manifestPath: join(configDirectory, `.${input.ownerId}-integration.json`),
|
|
43
|
+
lockPath: join(configDirectory, ".routekit-claude-integration.lock")
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function hash(content) {
|
|
47
|
+
return createHash("sha256").update(content).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
function parseSettings(content, configPath) {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(content);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
56
|
+
throw new Error(`your Claude settings (${configPath}) are not valid JSON (${detail})`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error(`your Claude settings (${configPath}) must contain a JSON object`);
|
|
60
|
+
}
|
|
61
|
+
const settings = parsed;
|
|
62
|
+
if (settings.env !== undefined &&
|
|
63
|
+
(typeof settings.env !== "object" || settings.env === null || Array.isArray(settings.env))) {
|
|
64
|
+
throw new Error(`the "env" field in your Claude settings (${configPath}) must be an object`);
|
|
65
|
+
}
|
|
66
|
+
return settings;
|
|
67
|
+
}
|
|
68
|
+
function unsupportedManifest(manifestPath) {
|
|
69
|
+
return new Error(`RouteKit's Claude ownership metadata (${manifestPath}) has an unsupported format`);
|
|
70
|
+
}
|
|
71
|
+
function isRecord(value) {
|
|
72
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
function isSnapshot(value) {
|
|
75
|
+
if (!isRecord(value))
|
|
76
|
+
return false;
|
|
77
|
+
const validMode = value.mode === null ||
|
|
78
|
+
(typeof value.mode === "number" &&
|
|
79
|
+
Number.isInteger(value.mode) &&
|
|
80
|
+
value.mode >= 0 &&
|
|
81
|
+
value.mode <= 0o777);
|
|
82
|
+
return ((typeof value.content === "string" || value.content === null) &&
|
|
83
|
+
validMode &&
|
|
84
|
+
(value.content !== null || value.mode === null) &&
|
|
85
|
+
(typeof value.hash === "string" || value.hash === null) &&
|
|
86
|
+
value.hash === (value.content === null ? null : hash(value.content)));
|
|
87
|
+
}
|
|
88
|
+
function isStringRecord(value) {
|
|
89
|
+
return (isRecord(value) &&
|
|
90
|
+
Object.values(value).every((entry) => typeof entry === "string"));
|
|
91
|
+
}
|
|
92
|
+
function isInstalledManifest(value) {
|
|
93
|
+
return (isRecord(value) &&
|
|
94
|
+
value.version === 2 &&
|
|
95
|
+
value.state === "installed" &&
|
|
96
|
+
typeof value.ownerId === "string" &&
|
|
97
|
+
isSnapshot(value.original) &&
|
|
98
|
+
typeof value.exactRestoreEligible === "boolean" &&
|
|
99
|
+
typeof value.installedContentHash === "string" &&
|
|
100
|
+
isStringRecord(value.managedEnvValues));
|
|
101
|
+
}
|
|
102
|
+
function parseManifest(content, manifestPath) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(content);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`RouteKit's Claude ownership metadata (${manifestPath}) is invalid; ` +
|
|
109
|
+
"move it aside and restore settings.json before retrying");
|
|
110
|
+
}
|
|
111
|
+
if (isRecord(parsed) &&
|
|
112
|
+
parsed.version === 1 &&
|
|
113
|
+
typeof parsed.ownerId === "string" &&
|
|
114
|
+
(typeof parsed.originalContent === "string" || parsed.originalContent === null) &&
|
|
115
|
+
typeof parsed.exactRestoreEligible === "boolean" &&
|
|
116
|
+
Array.isArray(parsed.installedContentHashes) &&
|
|
117
|
+
parsed.installedContentHashes.every((entry) => typeof entry === "string") &&
|
|
118
|
+
isRecord(parsed.managedEnvValues) &&
|
|
119
|
+
Object.values(parsed.managedEnvValues).every((entry) => Array.isArray(entry) &&
|
|
120
|
+
entry.every((candidate) => typeof candidate === "string"))) {
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
if (isInstalledManifest(parsed))
|
|
124
|
+
return parsed;
|
|
125
|
+
if (isRecord(parsed) &&
|
|
126
|
+
parsed.version === 2 &&
|
|
127
|
+
parsed.state === "install-pending" &&
|
|
128
|
+
typeof parsed.ownerId === "string" &&
|
|
129
|
+
isSnapshot(parsed.beforeSettings) &&
|
|
130
|
+
(parsed.beforeManifest === null ||
|
|
131
|
+
isInstalledManifest(parsed.beforeManifest)) &&
|
|
132
|
+
isSnapshot(parsed.targetSettings) &&
|
|
133
|
+
isInstalledManifest(parsed.targetManifest)) {
|
|
134
|
+
const pending = parsed;
|
|
135
|
+
if (pending.targetManifest.ownerId !== pending.ownerId ||
|
|
136
|
+
pending.targetManifest.installedContentHash !==
|
|
137
|
+
pending.targetSettings.hash ||
|
|
138
|
+
(pending.beforeManifest !== null &&
|
|
139
|
+
pending.beforeManifest.ownerId !== pending.ownerId)) {
|
|
140
|
+
throw unsupportedManifest(manifestPath);
|
|
141
|
+
}
|
|
142
|
+
return pending;
|
|
143
|
+
}
|
|
144
|
+
if (isRecord(parsed) &&
|
|
145
|
+
parsed.version === 2 &&
|
|
146
|
+
parsed.state === "uninstall-pending" &&
|
|
147
|
+
typeof parsed.ownerId === "string" &&
|
|
148
|
+
isSnapshot(parsed.beforeSettings) &&
|
|
149
|
+
isSnapshot(parsed.targetSettings)) {
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
throw unsupportedManifest(manifestPath);
|
|
153
|
+
}
|
|
154
|
+
function serialize(value) {
|
|
155
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
156
|
+
}
|
|
157
|
+
function managedEnv(input) {
|
|
158
|
+
return {
|
|
159
|
+
ANTHROPIC_BASE_URL: trimTrailingSlashes(input.gatewayUrl),
|
|
160
|
+
ANTHROPIC_AUTH_TOKEN: input.authToken ?? "routekit",
|
|
161
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function entryIfExists(path) {
|
|
165
|
+
try {
|
|
166
|
+
return lstatSync(path);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code === "ENOENT")
|
|
170
|
+
return undefined;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function assertConfigDirectory(configDirectory) {
|
|
175
|
+
const entry = entryIfExists(configDirectory);
|
|
176
|
+
if (entry === undefined)
|
|
177
|
+
return;
|
|
178
|
+
if (entry.isSymbolicLink()) {
|
|
179
|
+
throw new Error(`Claude config directory must not be a symlink: ${configDirectory}`);
|
|
180
|
+
}
|
|
181
|
+
if (!entry.isDirectory()) {
|
|
182
|
+
throw new Error(`Claude config path is not a directory: ${configDirectory}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function ensureConfigDirectory(configDirectory) {
|
|
186
|
+
assertConfigDirectory(configDirectory);
|
|
187
|
+
if (entryIfExists(configDirectory) !== undefined)
|
|
188
|
+
return;
|
|
189
|
+
mkdirSync(configDirectory, { recursive: true, mode: 0o700 });
|
|
190
|
+
assertConfigDirectory(configDirectory);
|
|
191
|
+
chmodSync(configDirectory, 0o700);
|
|
192
|
+
}
|
|
193
|
+
function assertRegularFileIfExists(path, label) {
|
|
194
|
+
const entry = entryIfExists(path);
|
|
195
|
+
if (entry === undefined)
|
|
196
|
+
return;
|
|
197
|
+
if (entry.isSymbolicLink()) {
|
|
198
|
+
throw new Error(`${label} must not be a symlink: ${path}`);
|
|
199
|
+
}
|
|
200
|
+
if (!entry.isFile()) {
|
|
201
|
+
throw new Error(`${label} must be a regular file: ${path}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function readSnapshot(path, label) {
|
|
205
|
+
assertRegularFileIfExists(path, label);
|
|
206
|
+
const entry = entryIfExists(path);
|
|
207
|
+
if (entry === undefined)
|
|
208
|
+
return { content: null, mode: null, hash: null };
|
|
209
|
+
const content = readFileSync(path, "utf8");
|
|
210
|
+
return {
|
|
211
|
+
content,
|
|
212
|
+
mode: entry.mode & 0o777,
|
|
213
|
+
hash: hash(content)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function snapshot(content, mode) {
|
|
217
|
+
return {
|
|
218
|
+
content,
|
|
219
|
+
mode,
|
|
220
|
+
hash: content === null ? null : hash(content)
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function writePrivateFile(path, content, label) {
|
|
224
|
+
assertRegularFileIfExists(path, label);
|
|
225
|
+
writeFileAtomic(path, content, { mode: 0o600 });
|
|
226
|
+
chmodSync(path, 0o600);
|
|
227
|
+
}
|
|
228
|
+
function applySnapshot(path, target, label) {
|
|
229
|
+
assertRegularFileIfExists(path, label);
|
|
230
|
+
if (target.content === null) {
|
|
231
|
+
rmSync(path, { force: true });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const mode = target.mode ?? 0o600;
|
|
235
|
+
writeFileAtomic(path, target.content, { mode });
|
|
236
|
+
chmodSync(path, mode);
|
|
237
|
+
}
|
|
238
|
+
function writeManifest(manifestPath, manifest) {
|
|
239
|
+
writePrivateFile(manifestPath, serialize(manifest), "Claude ownership metadata");
|
|
240
|
+
}
|
|
241
|
+
function currentManifest(manifestPath) {
|
|
242
|
+
assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
|
|
243
|
+
return entryIfExists(manifestPath) === undefined
|
|
244
|
+
? undefined
|
|
245
|
+
: parseManifest(readFileSync(manifestPath, "utf8"), manifestPath);
|
|
246
|
+
}
|
|
247
|
+
function assertExpectedSnapshot(current, expected, configPath, operation) {
|
|
248
|
+
if (expected.some((candidate) => candidate.hash === current.hash))
|
|
249
|
+
return;
|
|
250
|
+
throw new Error(`your Claude settings changed unexpectedly during ${operation} recovery (${configPath}); ` +
|
|
251
|
+
"RouteKit refused to overwrite the external edit");
|
|
252
|
+
}
|
|
253
|
+
function recoverPending(manifest, configPath, manifestPath) {
|
|
254
|
+
const current = readSnapshot(configPath, "Claude settings");
|
|
255
|
+
if (manifest.state === "install-pending") {
|
|
256
|
+
assertExpectedSnapshot(current, [manifest.beforeSettings, manifest.targetSettings], configPath, "install");
|
|
257
|
+
if (current.hash !== manifest.beforeSettings.hash) {
|
|
258
|
+
applySnapshot(configPath, manifest.beforeSettings, "Claude settings");
|
|
259
|
+
}
|
|
260
|
+
else if (current.content !== null &&
|
|
261
|
+
manifest.beforeSettings.mode !== null &&
|
|
262
|
+
current.mode !== manifest.beforeSettings.mode) {
|
|
263
|
+
chmodSync(configPath, manifest.beforeSettings.mode);
|
|
264
|
+
}
|
|
265
|
+
if (manifest.beforeManifest === null) {
|
|
266
|
+
assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
|
|
267
|
+
rmSync(manifestPath, { force: true });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
writeManifest(manifestPath, manifest.beforeManifest);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
assertExpectedSnapshot(current, [manifest.beforeSettings, manifest.targetSettings], configPath, "uninstall");
|
|
275
|
+
if (current.hash !== manifest.targetSettings.hash) {
|
|
276
|
+
applySnapshot(configPath, manifest.targetSettings, "Claude settings");
|
|
277
|
+
}
|
|
278
|
+
else if (current.content !== null &&
|
|
279
|
+
manifest.targetSettings.mode !== null &&
|
|
280
|
+
current.mode !== manifest.targetSettings.mode) {
|
|
281
|
+
chmodSync(configPath, manifest.targetSettings.mode);
|
|
282
|
+
}
|
|
283
|
+
assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
|
|
284
|
+
rmSync(manifestPath, { force: true });
|
|
285
|
+
}
|
|
286
|
+
function assertManagedValuesUnchanged(env, managedEnvValues, configPath) {
|
|
287
|
+
for (const [key, expected] of Object.entries(managedEnvValues)) {
|
|
288
|
+
const accepted = Array.isArray(expected) ? expected : [expected];
|
|
289
|
+
if (!accepted.includes(String(env[key]))) {
|
|
290
|
+
throw new Error(`your Claude settings changed RouteKit-managed env.${key} in ${configPath}; ` +
|
|
291
|
+
`remove or restore that value before rerunning the install command`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const CLAUDE_LOCK_TIMEOUT_MS = 5_000;
|
|
296
|
+
async function withConfigLock(resolved, operation) {
|
|
297
|
+
ensureConfigDirectory(resolved.configDirectory);
|
|
298
|
+
assertRegularFileIfExists(resolved.lockPath, "Claude integration lock");
|
|
299
|
+
assertRegularFileIfExists(`${resolved.lockPath}.reap`, "Claude integration reaper lock");
|
|
300
|
+
const lock = await acquireLifecycleLock(resolved.lockPath, {
|
|
301
|
+
timeoutMs: CLAUDE_LOCK_TIMEOUT_MS,
|
|
302
|
+
pollMs: 50
|
|
303
|
+
});
|
|
304
|
+
try {
|
|
305
|
+
assertConfigDirectory(resolved.configDirectory);
|
|
306
|
+
assertRegularFileIfExists(resolved.lockPath, "Claude integration lock");
|
|
307
|
+
return await operation();
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
lock.release();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function migrateLegacyManifest(manifest, current, configPath, manifestPath) {
|
|
314
|
+
const settings = parseSettings(current.content ?? "{}\n", configPath);
|
|
315
|
+
const env = { ...(settings.env ?? {}) };
|
|
316
|
+
const hasNoManagedValues = Object.keys(manifest.managedEnvValues).every((key) => env[key] === undefined);
|
|
317
|
+
if (current.content === manifest.originalContent &&
|
|
318
|
+
hasNoManagedValues) {
|
|
319
|
+
assertRegularFileIfExists(manifestPath, "Claude ownership metadata");
|
|
320
|
+
rmSync(manifestPath, { force: true });
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
if (current.hash === null ||
|
|
324
|
+
!manifest.installedContentHashes.includes(current.hash)) {
|
|
325
|
+
throw new Error(`RouteKit's legacy Claude ownership metadata (${manifestPath}) does not ` +
|
|
326
|
+
"match the current settings; refusing to overwrite them");
|
|
327
|
+
}
|
|
328
|
+
const selectedValues = {};
|
|
329
|
+
for (const [key, accepted] of Object.entries(manifest.managedEnvValues)) {
|
|
330
|
+
const value = env[key];
|
|
331
|
+
if (value === undefined || !accepted.includes(String(value))) {
|
|
332
|
+
throw new Error(`RouteKit's legacy Claude ownership metadata (${manifestPath}) cannot ` +
|
|
333
|
+
`safely identify the current env.${key} value; refusing to overwrite settings`);
|
|
334
|
+
}
|
|
335
|
+
selectedValues[key] = String(value);
|
|
336
|
+
}
|
|
337
|
+
const migrated = {
|
|
338
|
+
version: 2,
|
|
339
|
+
state: "installed",
|
|
340
|
+
ownerId: manifest.ownerId,
|
|
341
|
+
original: snapshot(manifest.originalContent, null),
|
|
342
|
+
exactRestoreEligible: manifest.exactRestoreEligible,
|
|
343
|
+
installedContentHash: current.hash,
|
|
344
|
+
managedEnvValues: selectedValues
|
|
345
|
+
};
|
|
346
|
+
writeManifest(manifestPath, migrated);
|
|
347
|
+
return migrated;
|
|
348
|
+
}
|
|
349
|
+
export async function installClaudeIntegration(input) {
|
|
350
|
+
const resolved = paths({
|
|
351
|
+
ownerId: input.owner.id,
|
|
352
|
+
...(input.claudeConfigDir !== undefined
|
|
353
|
+
? { claudeConfigDir: input.claudeConfigDir }
|
|
354
|
+
: {})
|
|
355
|
+
});
|
|
356
|
+
return await withConfigLock(resolved, () => {
|
|
357
|
+
const { configPath, manifestPath } = resolved;
|
|
358
|
+
let manifest = currentManifest(manifestPath);
|
|
359
|
+
if (manifest !== undefined &&
|
|
360
|
+
manifest.ownerId !== input.owner.id) {
|
|
361
|
+
throw new Error(`Claude ownership metadata in ${manifestPath} belongs to another integration`);
|
|
362
|
+
}
|
|
363
|
+
if (manifest?.version === 2 &&
|
|
364
|
+
manifest.state !== "installed") {
|
|
365
|
+
recoverPending(manifest, configPath, manifestPath);
|
|
366
|
+
manifest = currentManifest(manifestPath);
|
|
367
|
+
}
|
|
368
|
+
const beforeSettings = readSnapshot(configPath, "Claude settings");
|
|
369
|
+
let previousManifest;
|
|
370
|
+
if (manifest?.version === 1) {
|
|
371
|
+
previousManifest = migrateLegacyManifest(manifest, beforeSettings, configPath, manifestPath);
|
|
372
|
+
}
|
|
373
|
+
else if (manifest?.state === "installed") {
|
|
374
|
+
previousManifest = manifest;
|
|
375
|
+
}
|
|
376
|
+
else if (manifest !== undefined) {
|
|
377
|
+
throw unsupportedManifest(manifestPath);
|
|
378
|
+
}
|
|
379
|
+
const settings = parseSettings(beforeSettings.content ?? "{}\n", configPath);
|
|
380
|
+
const env = { ...(settings.env ?? {}) };
|
|
381
|
+
const nextManaged = managedEnv(input);
|
|
382
|
+
if (previousManifest === undefined) {
|
|
383
|
+
for (const key of MANAGED_ENV_KEYS) {
|
|
384
|
+
if (env[key] !== undefined) {
|
|
385
|
+
throw new Error(`your Claude settings already define env.${key} in ${configPath}; ` +
|
|
386
|
+
`remove it or use \`${input.owner.uninstallCommand}\` only after configuring RouteKit`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
assertManagedValuesUnchanged(env, previousManifest.managedEnvValues, configPath);
|
|
392
|
+
}
|
|
393
|
+
for (const key of Object.keys(previousManifest?.managedEnvValues ?? {})) {
|
|
394
|
+
delete env[key];
|
|
395
|
+
}
|
|
396
|
+
Object.assign(env, nextManaged);
|
|
397
|
+
const nextSettings = { ...settings, env };
|
|
398
|
+
const nextContent = serialize(nextSettings);
|
|
399
|
+
const targetSettings = snapshot(nextContent, 0o600);
|
|
400
|
+
const exactRestoreEligible = previousManifest === undefined
|
|
401
|
+
? true
|
|
402
|
+
: previousManifest.exactRestoreEligible &&
|
|
403
|
+
beforeSettings.hash === previousManifest.installedContentHash;
|
|
404
|
+
const targetManifest = {
|
|
405
|
+
version: 2,
|
|
406
|
+
state: "installed",
|
|
407
|
+
ownerId: input.owner.id,
|
|
408
|
+
original: previousManifest?.original ?? beforeSettings,
|
|
409
|
+
exactRestoreEligible,
|
|
410
|
+
installedContentHash: targetSettings.hash,
|
|
411
|
+
managedEnvValues: nextManaged
|
|
412
|
+
};
|
|
413
|
+
const pendingManifest = {
|
|
414
|
+
version: 2,
|
|
415
|
+
state: "install-pending",
|
|
416
|
+
ownerId: input.owner.id,
|
|
417
|
+
beforeSettings,
|
|
418
|
+
beforeManifest: previousManifest ?? null,
|
|
419
|
+
targetSettings,
|
|
420
|
+
targetManifest
|
|
421
|
+
};
|
|
422
|
+
writeManifest(manifestPath, pendingManifest);
|
|
423
|
+
reached("install-pending");
|
|
424
|
+
const unchanged = readSnapshot(configPath, "Claude settings");
|
|
425
|
+
assertExpectedSnapshot(unchanged, [beforeSettings], configPath, "install");
|
|
426
|
+
applySnapshot(configPath, targetSettings, "Claude settings");
|
|
427
|
+
reached("install-settings");
|
|
428
|
+
const pending = currentManifest(manifestPath);
|
|
429
|
+
if (pending?.version !== 2 ||
|
|
430
|
+
pending.state !== "install-pending") {
|
|
431
|
+
throw new Error(`Claude ownership metadata changed unexpectedly during install (${manifestPath})`);
|
|
432
|
+
}
|
|
433
|
+
writeManifest(manifestPath, targetManifest);
|
|
434
|
+
reached("install-committed");
|
|
435
|
+
return {
|
|
436
|
+
configPath,
|
|
437
|
+
action: previousManifest === undefined ? "installed" : "updated",
|
|
438
|
+
managedKeys: Object.keys(nextManaged)
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
export async function uninstallClaudeIntegration(input) {
|
|
443
|
+
const resolved = paths(input);
|
|
444
|
+
return await withConfigLock(resolved, () => {
|
|
445
|
+
const { configPath, manifestPath } = resolved;
|
|
446
|
+
let manifest = currentManifest(manifestPath);
|
|
447
|
+
if (manifest === undefined)
|
|
448
|
+
return { configPath, removed: false };
|
|
449
|
+
if (manifest.ownerId !== input.ownerId) {
|
|
450
|
+
throw new Error(`Claude ownership metadata in ${manifestPath} belongs to another integration`);
|
|
451
|
+
}
|
|
452
|
+
if (manifest.version === 2 && manifest.state !== "installed") {
|
|
453
|
+
recoverPending(manifest, configPath, manifestPath);
|
|
454
|
+
if (manifest.state === "uninstall-pending") {
|
|
455
|
+
reached("uninstall-committed");
|
|
456
|
+
return { configPath, removed: true };
|
|
457
|
+
}
|
|
458
|
+
manifest = currentManifest(manifestPath);
|
|
459
|
+
if (manifest === undefined)
|
|
460
|
+
return { configPath, removed: false };
|
|
461
|
+
}
|
|
462
|
+
const beforeSettings = readSnapshot(configPath, "Claude settings");
|
|
463
|
+
let installed;
|
|
464
|
+
if (manifest.version === 1) {
|
|
465
|
+
installed = migrateLegacyManifest(manifest, beforeSettings, configPath, manifestPath);
|
|
466
|
+
}
|
|
467
|
+
else if (manifest.state === "installed") {
|
|
468
|
+
installed = manifest;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
throw unsupportedManifest(manifestPath);
|
|
472
|
+
}
|
|
473
|
+
if (installed === undefined)
|
|
474
|
+
return { configPath, removed: false };
|
|
475
|
+
let targetSettings;
|
|
476
|
+
if (installed.exactRestoreEligible &&
|
|
477
|
+
beforeSettings.hash === installed.installedContentHash) {
|
|
478
|
+
targetSettings = installed.original;
|
|
479
|
+
}
|
|
480
|
+
else if (beforeSettings.content !== null) {
|
|
481
|
+
const settings = parseSettings(beforeSettings.content, configPath);
|
|
482
|
+
const env = { ...(settings.env ?? {}) };
|
|
483
|
+
for (const [key, accepted] of Object.entries(installed.managedEnvValues)) {
|
|
484
|
+
if (String(env[key]) === accepted)
|
|
485
|
+
delete env[key];
|
|
486
|
+
}
|
|
487
|
+
const next = { ...settings };
|
|
488
|
+
if (Object.keys(env).length === 0)
|
|
489
|
+
delete next.env;
|
|
490
|
+
else
|
|
491
|
+
next.env = env;
|
|
492
|
+
targetSettings = snapshot(serialize(next), beforeSettings.mode);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
targetSettings = beforeSettings;
|
|
496
|
+
}
|
|
497
|
+
const pendingManifest = {
|
|
498
|
+
version: 2,
|
|
499
|
+
state: "uninstall-pending",
|
|
500
|
+
ownerId: input.ownerId,
|
|
501
|
+
beforeSettings,
|
|
502
|
+
targetSettings
|
|
503
|
+
};
|
|
504
|
+
writeManifest(manifestPath, pendingManifest);
|
|
505
|
+
reached("uninstall-pending");
|
|
506
|
+
const unchanged = readSnapshot(configPath, "Claude settings");
|
|
507
|
+
assertExpectedSnapshot(unchanged, [beforeSettings], configPath, "uninstall");
|
|
508
|
+
applySnapshot(configPath, targetSettings, "Claude settings");
|
|
509
|
+
reached("uninstall-settings");
|
|
510
|
+
const pending = currentManifest(manifestPath);
|
|
511
|
+
if (pending?.version !== 2 ||
|
|
512
|
+
pending.state !== "uninstall-pending") {
|
|
513
|
+
throw new Error(`Claude ownership metadata changed unexpectedly during uninstall (${manifestPath})`);
|
|
514
|
+
}
|
|
515
|
+
rmSync(manifestPath, { force: true });
|
|
516
|
+
reached("uninstall-committed");
|
|
517
|
+
return { configPath, removed: true };
|
|
518
|
+
});
|
|
519
|
+
}
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AgentProfile, ToolLaunchContext } from "@velum-labs/routekit-tools";
|
|
2
|
+
export declare function claudeEnv(gatewayUrl: string, authToken?: string): Record<string, string>;
|
|
3
|
+
/** Serialize host-authored profiles once into Claude's session agent format. */
|
|
4
|
+
export declare function claudeAgentsJson(profiles: readonly AgentProfile[]): string;
|
|
5
|
+
export declare function claudeLaunchArgs(ctx: ToolLaunchContext): string[];
|
|
6
|
+
export declare function launchClaude(ctx: ToolLaunchContext): Promise<number>;
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawnTool } from "@velum-labs/routekit-runtime";
|
|
2
|
+
export function claudeEnv(gatewayUrl, authToken) {
|
|
3
|
+
return {
|
|
4
|
+
ANTHROPIC_BASE_URL: gatewayUrl,
|
|
5
|
+
ANTHROPIC_AUTH_TOKEN: authToken ?? "routekit",
|
|
6
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
|
7
|
+
...(process.env.CLAUDE_CONFIG_DIR !== undefined
|
|
8
|
+
? { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }
|
|
9
|
+
: {})
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function claudeModelId(modelId) {
|
|
13
|
+
const pickerId = modelId.startsWith("claude-code/")
|
|
14
|
+
? modelId.slice("claude-code/".length)
|
|
15
|
+
: modelId;
|
|
16
|
+
return pickerId.startsWith("claude") || pickerId.startsWith("anthropic")
|
|
17
|
+
? pickerId
|
|
18
|
+
: `claude-${pickerId}`;
|
|
19
|
+
}
|
|
20
|
+
/** Serialize host-authored profiles once into Claude's session agent format. */
|
|
21
|
+
export function claudeAgentsJson(profiles) {
|
|
22
|
+
return JSON.stringify(Object.fromEntries(profiles.map((profile) => [
|
|
23
|
+
profile.id,
|
|
24
|
+
{
|
|
25
|
+
description: profile.description,
|
|
26
|
+
prompt: profile.instructions,
|
|
27
|
+
model: claudeModelId(profile.model)
|
|
28
|
+
}
|
|
29
|
+
])));
|
|
30
|
+
}
|
|
31
|
+
function hasAgentsArg(args) {
|
|
32
|
+
return args.some((arg) => arg === "--agents" || arg.startsWith("--agents="));
|
|
33
|
+
}
|
|
34
|
+
function hasModelArg(args) {
|
|
35
|
+
return args.some((arg) => arg === "--model" || arg.startsWith("--model="));
|
|
36
|
+
}
|
|
37
|
+
export function claudeLaunchArgs(ctx) {
|
|
38
|
+
const args = [...ctx.spec.args];
|
|
39
|
+
if (!hasModelArg(args)) {
|
|
40
|
+
args.unshift("--model", claudeModelId(ctx.spec.defaultModel));
|
|
41
|
+
}
|
|
42
|
+
const profiles = ctx.spec.agentProfiles ?? [];
|
|
43
|
+
if (profiles.length > 0 && !hasAgentsArg(args)) {
|
|
44
|
+
args.push("--agents", claudeAgentsJson(profiles));
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
export async function launchClaude(ctx) {
|
|
49
|
+
ctx.prepareForPassthrough();
|
|
50
|
+
return await spawnTool("claude", claudeLaunchArgs(ctx), claudeEnv(ctx.spec.gatewayUrl, ctx.spec.auth?.token), ctx.spec.cwd);
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|