offrouter-cli 0.2.1 → 0.3.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/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +59 -3
- package/dist/commands/login.js.map +1 -1
- package/package.json +13 -8
- package/src/catalog-cache.test.ts +0 -258
- package/src/catalog-cache.ts +0 -169
- package/src/catalog-sample.json +0 -30
- package/src/cli.test.ts +0 -1705
- package/src/commands/config-error.ts +0 -31
- package/src/commands/detection.ts +0 -209
- package/src/commands/doctor.ts +0 -555
- package/src/commands/hooks.ts +0 -125
- package/src/commands/init.ts +0 -59
- package/src/commands/install.ts +0 -438
- package/src/commands/login.test.ts +0 -247
- package/src/commands/login.ts +0 -413
- package/src/commands/mcp.ts +0 -35
- package/src/commands/models.ts +0 -168
- package/src/commands/output.ts +0 -11
- package/src/commands/profiles.ts +0 -58
- package/src/commands/proxy.ts +0 -87
- package/src/commands/route.ts +0 -190
- package/src/commands/status.ts +0 -139
- package/src/commands/update.ts +0 -62
- package/src/index.ts +0 -174
- package/tsconfig.json +0 -17
package/src/cli.test.ts
DELETED
|
@@ -1,1705 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { FileUsageStore } from "offrouter-core";
|
|
7
|
-
import { createProgram, runCli } from "./index.js";
|
|
8
|
-
|
|
9
|
-
async function capture(
|
|
10
|
-
argv: string[],
|
|
11
|
-
env: NodeJS.ProcessEnv = {
|
|
12
|
-
...process.env,
|
|
13
|
-
OFFROUTER_HOME: "/tmp/offrouter-cli-test-empty",
|
|
14
|
-
},
|
|
15
|
-
options: { stdin?: string; cwd?: string } = {},
|
|
16
|
-
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
17
|
-
let stdout = "";
|
|
18
|
-
let stderr = "";
|
|
19
|
-
const code = await runCli(argv, {
|
|
20
|
-
env,
|
|
21
|
-
cwd: options.cwd ?? process.cwd(),
|
|
22
|
-
stdin: options.stdin,
|
|
23
|
-
stdout: {
|
|
24
|
-
write(chunk: string) {
|
|
25
|
-
stdout += chunk;
|
|
26
|
-
return true;
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
stderr: {
|
|
30
|
-
write(chunk: string) {
|
|
31
|
-
stderr += chunk;
|
|
32
|
-
return true;
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
return { code, stdout, stderr };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const fixturePromptPath = fileURLToPath(
|
|
40
|
-
new URL(
|
|
41
|
-
"../../../tests/fixtures/claude/user-prompt-submit.json",
|
|
42
|
-
import.meta.url,
|
|
43
|
-
),
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
const HONESTY =
|
|
47
|
-
"Claude Code's primary model is unchanged in V1. OffRouter routes delegated work.";
|
|
48
|
-
|
|
49
|
-
const CODEX_HONESTY =
|
|
50
|
-
"Codex's primary model is unchanged in V1. OffRouter routes delegated work.";
|
|
51
|
-
|
|
52
|
-
const GEMINI_HONESTY =
|
|
53
|
-
"Gemini CLI's primary model is unchanged in V1. OffRouter routes delegated work.";
|
|
54
|
-
|
|
55
|
-
const GROK_HONESTY =
|
|
56
|
-
"Grok Build's primary model is unchanged in V1. OffRouter routes delegated work.";
|
|
57
|
-
|
|
58
|
-
describe("offrouter CLI", () => {
|
|
59
|
-
it("offrouter --help lists core commands", async () => {
|
|
60
|
-
const { code, stdout } = await capture(["--help"]);
|
|
61
|
-
expect(code).toBe(0);
|
|
62
|
-
expect(stdout).toMatch(/Usage:/i);
|
|
63
|
-
for (const cmd of [
|
|
64
|
-
"init",
|
|
65
|
-
"doctor",
|
|
66
|
-
"login",
|
|
67
|
-
"models",
|
|
68
|
-
"profiles",
|
|
69
|
-
"route",
|
|
70
|
-
"status",
|
|
71
|
-
"update",
|
|
72
|
-
"mcp",
|
|
73
|
-
"hooks",
|
|
74
|
-
"install",
|
|
75
|
-
"uninstall",
|
|
76
|
-
]) {
|
|
77
|
-
expect(stdout).toContain(cmd);
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("offrouter mcp serve --help is available without starting the stdio server", async () => {
|
|
82
|
-
const { code, stdout, stderr } = await capture(["mcp", "serve", "--help"]);
|
|
83
|
-
expect(code).toBe(0);
|
|
84
|
-
expect(stdout + stderr).toMatch(/mcp|stdio|server/i);
|
|
85
|
-
expect(stdout + stderr).not.toMatch(/Unknown command|error:/i);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("offrouter hooks claude user-prompt-submit emits exact JSON with no profile", async () => {
|
|
89
|
-
const { readFile } = await import("node:fs/promises");
|
|
90
|
-
const stdin = await readFile(fixturePromptPath, "utf8");
|
|
91
|
-
const { code, stdout, stderr } = await capture(
|
|
92
|
-
["hooks", "claude", "user-prompt-submit"],
|
|
93
|
-
{
|
|
94
|
-
...process.env,
|
|
95
|
-
OFFROUTER_HOME: "/tmp/offrouter-cli-hook-no-profile",
|
|
96
|
-
},
|
|
97
|
-
{ stdin },
|
|
98
|
-
);
|
|
99
|
-
expect(code).toBe(0);
|
|
100
|
-
expect(stderr).toBe("");
|
|
101
|
-
// Exact JSON object on stdout (no trailing prose).
|
|
102
|
-
const parsed = JSON.parse(stdout) as {
|
|
103
|
-
decision?: string;
|
|
104
|
-
hookSpecificOutput?: { additionalContext?: string };
|
|
105
|
-
};
|
|
106
|
-
expect(parsed.decision).toBeUndefined();
|
|
107
|
-
expect(parsed.hookSpecificOutput?.additionalContext).toMatch(
|
|
108
|
-
/no active profile/i,
|
|
109
|
-
);
|
|
110
|
-
expect(stdout.trim()).toBe(stdout);
|
|
111
|
-
expect(stdout).not.toContain("\n");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("offrouter hooks claude user-prompt-submit blocks denied profiles when profile is supplied", async () => {
|
|
115
|
-
const { readFile } = await import("node:fs/promises");
|
|
116
|
-
const stdin = await readFile(fixturePromptPath, "utf8");
|
|
117
|
-
const { code, stdout, stderr } = await capture(
|
|
118
|
-
["hooks", "claude", "user-prompt-submit", "--profile", "claude-work"],
|
|
119
|
-
{
|
|
120
|
-
...process.env,
|
|
121
|
-
OFFROUTER_HOME: "/tmp/offrouter-cli-hook-work",
|
|
122
|
-
},
|
|
123
|
-
{ stdin },
|
|
124
|
-
);
|
|
125
|
-
expect(code).toBe(0);
|
|
126
|
-
expect(stderr).toBe("");
|
|
127
|
-
const parsed = JSON.parse(stdout) as {
|
|
128
|
-
decision?: string;
|
|
129
|
-
reason?: string;
|
|
130
|
-
};
|
|
131
|
-
expect(parsed.decision).toBe("block");
|
|
132
|
-
expect(String(parsed.reason ?? "").toLowerCase()).toMatch(
|
|
133
|
-
/work|denied|not allowlisted|policy/,
|
|
134
|
-
);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("offrouter doctor --json reports readiness and honesty line", async () => {
|
|
138
|
-
const { code, stdout } = await capture(["doctor", "--json"]);
|
|
139
|
-
expect(code).toBe(0);
|
|
140
|
-
const json = JSON.parse(stdout) as Record<string, unknown>;
|
|
141
|
-
expect(json.honesty).toBe(HONESTY);
|
|
142
|
-
expect(json).toHaveProperty("node");
|
|
143
|
-
expect(json).toHaveProperty("harnessProfiles");
|
|
144
|
-
expect(json).toHaveProperty("authTiers");
|
|
145
|
-
expect(json).toHaveProperty("subscription");
|
|
146
|
-
expect(json).toHaveProperty("apiKeyReadiness");
|
|
147
|
-
expect(json).toHaveProperty("providerAuth");
|
|
148
|
-
expect(json).toHaveProperty("localRuntime");
|
|
149
|
-
expect(json).toHaveProperty("profile");
|
|
150
|
-
expect(json).toHaveProperty("claudeCli");
|
|
151
|
-
expect(json).toHaveProperty("mcpServe");
|
|
152
|
-
expect(json).toHaveProperty("limits");
|
|
153
|
-
expect(json).toHaveProperty("lastProviderError");
|
|
154
|
-
expect(JSON.stringify(json)).not.toMatch(/sk-[a-zA-Z0-9]/);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("offrouter doctor --json reports config warnings, profile allow, command paths, and readiness without secrets", async () => {
|
|
158
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-"));
|
|
159
|
-
const secretDir = await mkdtemp(
|
|
160
|
-
join(tmpdir(), "offrouter-cli-sk-test-secret-value-123456-"),
|
|
161
|
-
);
|
|
162
|
-
try {
|
|
163
|
-
const binDir = join(secretDir, "bin");
|
|
164
|
-
await mkdir(binDir);
|
|
165
|
-
const claudePath = join(binDir, "claude");
|
|
166
|
-
const offrouterPath = join(binDir, "offrouter");
|
|
167
|
-
await writeFile(claudePath, "#!/bin/sh\nexit 0\n", "utf8");
|
|
168
|
-
await writeFile(offrouterPath, "#!/bin/sh\nexit 0\n", "utf8");
|
|
169
|
-
await chmod(claudePath, 0o755);
|
|
170
|
-
await chmod(offrouterPath, 0o755);
|
|
171
|
-
await writeFile(
|
|
172
|
-
join(home, "config.toml"),
|
|
173
|
-
[
|
|
174
|
-
'allowlisted_profiles = ["claude-personal"]',
|
|
175
|
-
"unexpected_root_key = true",
|
|
176
|
-
"[providers.anthropic]",
|
|
177
|
-
"enabled = true",
|
|
178
|
-
'unknown_provider_key = "warn"',
|
|
179
|
-
"",
|
|
180
|
-
].join("\n"),
|
|
181
|
-
"utf8",
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const { code, stdout } = await capture(
|
|
185
|
-
["doctor", "--profile", "claude-personal", "--json"],
|
|
186
|
-
{
|
|
187
|
-
...process.env,
|
|
188
|
-
OFFROUTER_HOME: home,
|
|
189
|
-
PATH: binDir,
|
|
190
|
-
OLLAMA_HOST: "http://127.0.0.1:11434",
|
|
191
|
-
ANTHROPIC_API_KEY: "sk-test-secret-value-123456",
|
|
192
|
-
},
|
|
193
|
-
);
|
|
194
|
-
expect(code).toBe(0);
|
|
195
|
-
const json = JSON.parse(stdout) as {
|
|
196
|
-
node: {
|
|
197
|
-
version: string;
|
|
198
|
-
engineRequirement: string;
|
|
199
|
-
satisfiesEngine: boolean;
|
|
200
|
-
};
|
|
201
|
-
config: {
|
|
202
|
-
valid: boolean;
|
|
203
|
-
warnings: number;
|
|
204
|
-
warningDetails: Array<{ path: string; message: string }>;
|
|
205
|
-
providersConfigured: string[];
|
|
206
|
-
};
|
|
207
|
-
profile: {
|
|
208
|
-
profile: string;
|
|
209
|
-
allowed: boolean;
|
|
210
|
-
status: string;
|
|
211
|
-
denials: Array<{ code: string }>;
|
|
212
|
-
routeReadiness: { needsConfiguration: boolean };
|
|
213
|
-
};
|
|
214
|
-
claudeCli: { available: boolean; path: string | null };
|
|
215
|
-
mcpServe: { available: boolean; path: string | null };
|
|
216
|
-
providerAuth: {
|
|
217
|
-
ready: boolean;
|
|
218
|
-
envPresent: string[];
|
|
219
|
-
configuredProviders: string[];
|
|
220
|
-
};
|
|
221
|
-
localRuntime: { ready: boolean };
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
expect(json.node.version).toMatch(/^v\d+\.\d+\.\d+/);
|
|
225
|
-
expect(json.node.engineRequirement).toBe(">=22");
|
|
226
|
-
expect(json.node.satisfiesEngine).toBe(true);
|
|
227
|
-
expect(json.config.valid).toBe(true);
|
|
228
|
-
expect(json.config.warnings).toBeGreaterThanOrEqual(2);
|
|
229
|
-
expect(json.config.warningDetails).toEqual(
|
|
230
|
-
expect.arrayContaining([
|
|
231
|
-
expect.objectContaining({ path: "unexpected_root_key" }),
|
|
232
|
-
expect.objectContaining({
|
|
233
|
-
path: "providers.anthropic.unknown_provider_key",
|
|
234
|
-
}),
|
|
235
|
-
]),
|
|
236
|
-
);
|
|
237
|
-
expect(json.config.providersConfigured).toEqual(["anthropic"]);
|
|
238
|
-
expect(json.profile.profile).toBe("claude-personal");
|
|
239
|
-
expect(json.profile.allowed).toBe(true);
|
|
240
|
-
expect(json.profile.status).toBe("allowed");
|
|
241
|
-
expect(json.profile.denials).toEqual([]);
|
|
242
|
-
expect(json.profile.routeReadiness.needsConfiguration).toBe(true);
|
|
243
|
-
expect(json.claudeCli.available).toBe(true);
|
|
244
|
-
expect(json.claudeCli.path).toContain("[REDACTED]");
|
|
245
|
-
expect(json.mcpServe.available).toBe(true);
|
|
246
|
-
expect(json.mcpServe.path).toContain("[REDACTED]");
|
|
247
|
-
expect(json.providerAuth.ready).toBe(true);
|
|
248
|
-
expect(json.providerAuth.envPresent).toEqual(["ANTHROPIC_API_KEY"]);
|
|
249
|
-
expect(json.providerAuth.configuredProviders).toEqual(["anthropic"]);
|
|
250
|
-
expect(json.localRuntime.ready).toBe(true);
|
|
251
|
-
expect(stdout).not.toContain("sk-test-secret-value-123456");
|
|
252
|
-
} finally {
|
|
253
|
-
await rm(home, { recursive: true, force: true });
|
|
254
|
-
await rm(secretDir, { recursive: true, force: true });
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it("offrouter doctor --json reports selected profile denial", async () => {
|
|
259
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-deny-"));
|
|
260
|
-
try {
|
|
261
|
-
await writeFile(
|
|
262
|
-
join(home, "config.toml"),
|
|
263
|
-
'allowlisted_profiles = ["claude-work"]\n',
|
|
264
|
-
"utf8",
|
|
265
|
-
);
|
|
266
|
-
const { code, stdout } = await capture(
|
|
267
|
-
["doctor", "--profile", "claude-work", "--json"],
|
|
268
|
-
{
|
|
269
|
-
...process.env,
|
|
270
|
-
OFFROUTER_HOME: home,
|
|
271
|
-
},
|
|
272
|
-
);
|
|
273
|
-
expect(code).toBe(0);
|
|
274
|
-
const json = JSON.parse(stdout) as {
|
|
275
|
-
profile: {
|
|
276
|
-
allowed: boolean;
|
|
277
|
-
status: string;
|
|
278
|
-
denials: Array<{ code: string; id: string }>;
|
|
279
|
-
};
|
|
280
|
-
};
|
|
281
|
-
expect(json.profile.allowed).toBe(false);
|
|
282
|
-
expect(json.profile.status).toBe("denied");
|
|
283
|
-
expect(json.profile.denials).toContainEqual(
|
|
284
|
-
expect.objectContaining({
|
|
285
|
-
code: "work_profile_denied",
|
|
286
|
-
id: "claude-work",
|
|
287
|
-
}),
|
|
288
|
-
);
|
|
289
|
-
} finally {
|
|
290
|
-
await rm(home, { recursive: true, force: true });
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it("offrouter doctor --json denies work-like staging ids even when allowlisted", async () => {
|
|
295
|
-
const home = await mkdtemp(
|
|
296
|
-
join(tmpdir(), "offrouter-cli-doctor-deny-staging-"),
|
|
297
|
-
);
|
|
298
|
-
try {
|
|
299
|
-
// Allowlisted on purpose: work-like ids must still be denied by default.
|
|
300
|
-
await writeFile(
|
|
301
|
-
join(home, "config.toml"),
|
|
302
|
-
'allowlisted_profiles = ["claude-work-staging"]\n',
|
|
303
|
-
"utf8",
|
|
304
|
-
);
|
|
305
|
-
const { code, stdout } = await capture(
|
|
306
|
-
["doctor", "--profile", "claude-work-staging", "--json"],
|
|
307
|
-
{
|
|
308
|
-
...process.env,
|
|
309
|
-
OFFROUTER_HOME: home,
|
|
310
|
-
},
|
|
311
|
-
);
|
|
312
|
-
expect(code).toBe(0);
|
|
313
|
-
const json = JSON.parse(stdout) as {
|
|
314
|
-
profile: {
|
|
315
|
-
allowed: boolean;
|
|
316
|
-
status: string;
|
|
317
|
-
denials: Array<{ code: string; id: string }>;
|
|
318
|
-
};
|
|
319
|
-
};
|
|
320
|
-
expect(json.profile.allowed).toBe(false);
|
|
321
|
-
expect(json.profile.status).toBe("denied");
|
|
322
|
-
expect(json.profile.denials).toContainEqual(
|
|
323
|
-
expect.objectContaining({
|
|
324
|
-
code: "work_profile_denied",
|
|
325
|
-
id: "claude-work-staging",
|
|
326
|
-
}),
|
|
327
|
-
);
|
|
328
|
-
} finally {
|
|
329
|
-
await rm(home, { recursive: true, force: true });
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("offrouter doctor --json reports current CLI entrypoint as MCP fallback", async () => {
|
|
334
|
-
const home = await mkdtemp(
|
|
335
|
-
join(tmpdir(), "offrouter-cli-doctor-entrypoint-home-"),
|
|
336
|
-
);
|
|
337
|
-
const entryRoot = await mkdtemp(
|
|
338
|
-
join(tmpdir(), "offrouter-cli-doctor-entrypoint-"),
|
|
339
|
-
);
|
|
340
|
-
const previousArgv1 = process.argv[1];
|
|
341
|
-
try {
|
|
342
|
-
const entryDir = join(entryRoot, "packages", "cli", "dist");
|
|
343
|
-
await mkdir(entryDir, { recursive: true });
|
|
344
|
-
const entryPoint = join(entryDir, "index.js");
|
|
345
|
-
await writeFile(entryPoint, "#!/usr/bin/env node\n", "utf8");
|
|
346
|
-
process.argv[1] = entryPoint;
|
|
347
|
-
|
|
348
|
-
const { code, stdout } = await capture(["doctor", "--json"], {
|
|
349
|
-
...process.env,
|
|
350
|
-
OFFROUTER_HOME: home,
|
|
351
|
-
PATH: "/definitely-no-offrouter-here",
|
|
352
|
-
});
|
|
353
|
-
expect(code).toBe(0);
|
|
354
|
-
const json = JSON.parse(stdout) as {
|
|
355
|
-
mcpServe: {
|
|
356
|
-
available: boolean;
|
|
357
|
-
command: string;
|
|
358
|
-
path: string | null;
|
|
359
|
-
};
|
|
360
|
-
};
|
|
361
|
-
expect(json.mcpServe.available).toBe(true);
|
|
362
|
-
expect(json.mcpServe.command).toContain("mcp serve");
|
|
363
|
-
expect(json.mcpServe.path).toBe(entryPoint);
|
|
364
|
-
} finally {
|
|
365
|
-
process.argv[1] = previousArgv1;
|
|
366
|
-
await rm(home, { recursive: true, force: true });
|
|
367
|
-
await rm(entryRoot, { recursive: true, force: true });
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("offrouter doctor human output reports diagnostics without secrets", async () => {
|
|
372
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-human-"));
|
|
373
|
-
try {
|
|
374
|
-
await writeFile(
|
|
375
|
-
join(home, "config.toml"),
|
|
376
|
-
["unexpected_root_key = true", ""].join("\n"),
|
|
377
|
-
"utf8",
|
|
378
|
-
);
|
|
379
|
-
const { code, stdout } = await capture(
|
|
380
|
-
["doctor", "--profile", "claude-work"],
|
|
381
|
-
{
|
|
382
|
-
...process.env,
|
|
383
|
-
OFFROUTER_HOME: home,
|
|
384
|
-
ANTHROPIC_API_KEY: "sk-test-secret-value-123456",
|
|
385
|
-
},
|
|
386
|
-
);
|
|
387
|
-
expect(code).toBe(0);
|
|
388
|
-
expect(stdout).toContain(HONESTY);
|
|
389
|
-
expect(stdout).toMatch(/Node:/);
|
|
390
|
-
expect(stdout).toMatch(/Config: valid/);
|
|
391
|
-
expect(stdout).toMatch(/warnings: 1/);
|
|
392
|
-
expect(stdout).toMatch(/Selected profile: claude-work/);
|
|
393
|
-
expect(stdout).toMatch(/denied/);
|
|
394
|
-
expect(stdout).toMatch(/Claude CLI:/);
|
|
395
|
-
expect(stdout).toMatch(/MCP serve:/);
|
|
396
|
-
expect(stdout).toMatch(/Provider auth:/);
|
|
397
|
-
expect(stdout).toMatch(/Local runtime:/);
|
|
398
|
-
expect(stdout).not.toContain("sk-test-secret-value-123456");
|
|
399
|
-
} finally {
|
|
400
|
-
await rm(home, { recursive: true, force: true });
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it("offrouter doctor --json never prints API key values", async () => {
|
|
405
|
-
const { code, stdout } = await capture(["doctor", "--json"], {
|
|
406
|
-
...process.env,
|
|
407
|
-
OFFROUTER_HOME: "/tmp/offrouter-cli-test-empty",
|
|
408
|
-
ANTHROPIC_API_KEY: "sk-test-secret-value",
|
|
409
|
-
});
|
|
410
|
-
expect(code).toBe(0);
|
|
411
|
-
const json = JSON.parse(stdout) as {
|
|
412
|
-
apiKeyReadiness: { envPresent: string[] };
|
|
413
|
-
};
|
|
414
|
-
expect(json.apiKeyReadiness.envPresent).toContain("ANTHROPIC_API_KEY");
|
|
415
|
-
expect(stdout).not.toContain("sk-test-secret-value");
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it("offrouter doctor --json surfaces invalid config instead of hiding it", async () => {
|
|
419
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-bad-config-"));
|
|
420
|
-
try {
|
|
421
|
-
await writeFile(
|
|
422
|
-
join(home, "config.toml"),
|
|
423
|
-
"allowlisted_profiles = [",
|
|
424
|
-
"utf8",
|
|
425
|
-
);
|
|
426
|
-
const { code, stdout } = await capture(["doctor", "--json"], {
|
|
427
|
-
...process.env,
|
|
428
|
-
OFFROUTER_HOME: home,
|
|
429
|
-
});
|
|
430
|
-
expect(code).toBe(0);
|
|
431
|
-
const json = JSON.parse(stdout) as {
|
|
432
|
-
configError: { message: string } | null;
|
|
433
|
-
};
|
|
434
|
-
expect(json.configError?.message.toLowerCase()).toContain("invalid");
|
|
435
|
-
} finally {
|
|
436
|
-
await rm(home, { recursive: true, force: true });
|
|
437
|
-
}
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("offrouter init --dry-run --json shows readiness without writing config", async () => {
|
|
441
|
-
const home = "/tmp/offrouter-cli-init-dry-run";
|
|
442
|
-
const { code, stdout } = await capture(["init", "--dry-run", "--json"], {
|
|
443
|
-
...process.env,
|
|
444
|
-
OFFROUTER_HOME: home,
|
|
445
|
-
});
|
|
446
|
-
expect(code).toBe(0);
|
|
447
|
-
const json = JSON.parse(stdout) as {
|
|
448
|
-
dryRun: boolean;
|
|
449
|
-
wroteConfig: boolean;
|
|
450
|
-
honesty: string;
|
|
451
|
-
detection: Record<string, unknown>;
|
|
452
|
-
};
|
|
453
|
-
expect(json.dryRun).toBe(true);
|
|
454
|
-
expect(json.wroteConfig).toBe(false);
|
|
455
|
-
expect(json.honesty).toBe(HONESTY);
|
|
456
|
-
expect(json.detection).toHaveProperty("harnessProfiles");
|
|
457
|
-
expect(json.detection).toHaveProperty("subscription");
|
|
458
|
-
expect(json.detection).toHaveProperty("apiKeyReadiness");
|
|
459
|
-
expect(json.detection).toHaveProperty("localRuntime");
|
|
460
|
-
expect(json.detection).toHaveProperty("authTiers");
|
|
461
|
-
expect(json.detection).toHaveProperty("limits");
|
|
462
|
-
expect(json.detection).toHaveProperty("lastProviderError");
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
it("offrouter status --providers --json includes provider-facing diagnostics", async () => {
|
|
466
|
-
const { code, stdout } = await capture(["status", "--providers", "--json"]);
|
|
467
|
-
expect(code).toBe(0);
|
|
468
|
-
const json = JSON.parse(stdout) as Record<string, unknown>;
|
|
469
|
-
expect(json.honesty).toBe(HONESTY);
|
|
470
|
-
expect(json).toHaveProperty("providers");
|
|
471
|
-
expect(json).toHaveProperty("harnessProfiles");
|
|
472
|
-
expect(json).toHaveProperty("authTiers");
|
|
473
|
-
expect(json).toHaveProperty("subscription");
|
|
474
|
-
expect(json).toHaveProperty("apiKeyReadiness");
|
|
475
|
-
expect(json).toHaveProperty("localRuntime");
|
|
476
|
-
expect(json).toHaveProperty("limits");
|
|
477
|
-
expect(json).toHaveProperty("lastProviderError");
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('offrouter route explain "explain this repo" --profile claude-personal', async () => {
|
|
481
|
-
const { code, stdout } = await capture([
|
|
482
|
-
"route",
|
|
483
|
-
"explain",
|
|
484
|
-
"explain this repo",
|
|
485
|
-
"--profile",
|
|
486
|
-
"claude-personal",
|
|
487
|
-
]);
|
|
488
|
-
expect(code).toBe(0);
|
|
489
|
-
expect(stdout.length).toBeGreaterThan(0);
|
|
490
|
-
// Incomplete setup or policy should be explained, not silent success with a fake route.
|
|
491
|
-
expect(stdout.toLowerCase()).toMatch(
|
|
492
|
-
/configur|no provider|allowlist|cannot route|blocked|needs configuration|denied|setup/i,
|
|
493
|
-
);
|
|
494
|
-
expect(stdout.toLowerCase()).toContain("setup incomplete");
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it("offrouter route explain --json marks empty personal setup as needsConfiguration", async () => {
|
|
498
|
-
const { code, stdout } = await capture([
|
|
499
|
-
"route",
|
|
500
|
-
"explain",
|
|
501
|
-
"explain this repo",
|
|
502
|
-
"--profile",
|
|
503
|
-
"claude-personal",
|
|
504
|
-
"--json",
|
|
505
|
-
]);
|
|
506
|
-
expect(code).toBe(0);
|
|
507
|
-
const json = JSON.parse(stdout) as {
|
|
508
|
-
blocked: boolean;
|
|
509
|
-
needsConfiguration: boolean;
|
|
510
|
-
setupIncomplete: boolean;
|
|
511
|
-
reason: string;
|
|
512
|
-
};
|
|
513
|
-
expect(json.blocked).toBe(true);
|
|
514
|
-
expect(json.needsConfiguration).toBe(true);
|
|
515
|
-
expect(json.setupIncomplete).toBe(true);
|
|
516
|
-
expect(json.reason).toMatch(/configuration|no_candidate/i);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
it("offrouter route explain --json keeps allowlisted personal profiles in setup-incomplete state", async () => {
|
|
520
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-allowlisted-"));
|
|
521
|
-
try {
|
|
522
|
-
await writeFile(
|
|
523
|
-
join(home, "config.toml"),
|
|
524
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
525
|
-
"utf8",
|
|
526
|
-
);
|
|
527
|
-
const { code, stdout } = await capture(
|
|
528
|
-
[
|
|
529
|
-
"route",
|
|
530
|
-
"explain",
|
|
531
|
-
"explain this repo",
|
|
532
|
-
"--profile",
|
|
533
|
-
"claude-personal",
|
|
534
|
-
"--json",
|
|
535
|
-
],
|
|
536
|
-
{
|
|
537
|
-
...process.env,
|
|
538
|
-
OFFROUTER_HOME: home,
|
|
539
|
-
},
|
|
540
|
-
);
|
|
541
|
-
expect(code).toBe(0);
|
|
542
|
-
const json = JSON.parse(stdout) as {
|
|
543
|
-
blocked: boolean;
|
|
544
|
-
needsConfiguration: boolean;
|
|
545
|
-
setupIncomplete: boolean;
|
|
546
|
-
policyDenials: Array<{ code: string }>;
|
|
547
|
-
};
|
|
548
|
-
expect(json.blocked).toBe(true);
|
|
549
|
-
expect(json.needsConfiguration).toBe(true);
|
|
550
|
-
expect(json.setupIncomplete).toBe(true);
|
|
551
|
-
expect(json.policyDenials).not.toContainEqual(
|
|
552
|
-
expect.objectContaining({ code: "project_untrusted" }),
|
|
553
|
-
);
|
|
554
|
-
} finally {
|
|
555
|
-
await rm(home, { recursive: true, force: true });
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it('offrouter route explain "explain this repo" --profile claude-work', async () => {
|
|
560
|
-
const { code, stdout } = await capture([
|
|
561
|
-
"route",
|
|
562
|
-
"explain",
|
|
563
|
-
"explain this repo",
|
|
564
|
-
"--profile",
|
|
565
|
-
"claude-work",
|
|
566
|
-
]);
|
|
567
|
-
expect(code).toBe(0);
|
|
568
|
-
expect(stdout.toLowerCase()).toMatch(
|
|
569
|
-
/work|denied|blocked|not allowlisted|policy/i,
|
|
570
|
-
);
|
|
571
|
-
expect(stdout.toLowerCase()).not.toContain("setup incomplete");
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("offrouter route explain --json hard-blocks work profiles", async () => {
|
|
575
|
-
const { code, stdout } = await capture([
|
|
576
|
-
"route",
|
|
577
|
-
"explain",
|
|
578
|
-
"explain this repo",
|
|
579
|
-
"--profile",
|
|
580
|
-
"claude-work",
|
|
581
|
-
"--json",
|
|
582
|
-
]);
|
|
583
|
-
expect(code).toBe(0);
|
|
584
|
-
const json = JSON.parse(stdout) as {
|
|
585
|
-
blocked: boolean;
|
|
586
|
-
needsConfiguration: boolean;
|
|
587
|
-
setupIncomplete: boolean;
|
|
588
|
-
policyDenials: Array<{ code: string }>;
|
|
589
|
-
};
|
|
590
|
-
expect(json.blocked).toBe(true);
|
|
591
|
-
expect(json.needsConfiguration).toBe(false);
|
|
592
|
-
expect(json.setupIncomplete).toBe(false);
|
|
593
|
-
expect(json.policyDenials).toContainEqual(
|
|
594
|
-
expect.objectContaining({ code: "work_profile_denied" }),
|
|
595
|
-
);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("offrouter route explain --json hard-blocks work-like staging profiles", async () => {
|
|
599
|
-
const { code, stdout } = await capture([
|
|
600
|
-
"route",
|
|
601
|
-
"explain",
|
|
602
|
-
"explain this repo",
|
|
603
|
-
"--profile",
|
|
604
|
-
"claude-work-staging",
|
|
605
|
-
"--json",
|
|
606
|
-
]);
|
|
607
|
-
expect(code).toBe(0);
|
|
608
|
-
const json = JSON.parse(stdout) as {
|
|
609
|
-
blocked: boolean;
|
|
610
|
-
needsConfiguration: boolean;
|
|
611
|
-
setupIncomplete: boolean;
|
|
612
|
-
policyDenials: Array<{ code: string }>;
|
|
613
|
-
};
|
|
614
|
-
expect(json.blocked).toBe(true);
|
|
615
|
-
expect(json.needsConfiguration).toBe(false);
|
|
616
|
-
expect(json.setupIncomplete).toBe(false);
|
|
617
|
-
expect(json.policyDenials).toContainEqual(
|
|
618
|
-
expect.objectContaining({ code: "work_profile_denied" }),
|
|
619
|
-
);
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it("offrouter models --json lists catalog shape without secrets", async () => {
|
|
623
|
-
const { code, stdout } = await capture(["models", "--json"]);
|
|
624
|
-
expect(code).toBe(0);
|
|
625
|
-
const json = JSON.parse(stdout) as { models: unknown[] };
|
|
626
|
-
expect(Array.isArray(json.models)).toBe(true);
|
|
627
|
-
expect(stdout).not.toMatch(/sk-[a-zA-Z0-9]/);
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it("offrouter install claude --profile claude-personal --dry-run plans profile-scoped files only", async () => {
|
|
631
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-install-"));
|
|
632
|
-
try {
|
|
633
|
-
await writeFile(
|
|
634
|
-
join(home, "config.toml"),
|
|
635
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
636
|
-
"utf8",
|
|
637
|
-
);
|
|
638
|
-
const profilesRoot = join(home, "claude-profiles");
|
|
639
|
-
const { code, stdout, stderr } = await capture(
|
|
640
|
-
[
|
|
641
|
-
"install",
|
|
642
|
-
"claude",
|
|
643
|
-
"--profile",
|
|
644
|
-
"claude-personal",
|
|
645
|
-
"--dry-run",
|
|
646
|
-
"--json",
|
|
647
|
-
],
|
|
648
|
-
{
|
|
649
|
-
...process.env,
|
|
650
|
-
OFFROUTER_HOME: home,
|
|
651
|
-
HOME: home,
|
|
652
|
-
OFFROUTER_CLAUDE_PROFILES_DIR: profilesRoot,
|
|
653
|
-
},
|
|
654
|
-
);
|
|
655
|
-
expect(code).toBe(0);
|
|
656
|
-
expect(stderr).toBe("");
|
|
657
|
-
const json = JSON.parse(stdout) as {
|
|
658
|
-
honesty: string;
|
|
659
|
-
dryRun: boolean;
|
|
660
|
-
results: Array<{
|
|
661
|
-
ok: boolean;
|
|
662
|
-
profile: string;
|
|
663
|
-
dryRun: boolean;
|
|
664
|
-
profileDir: string;
|
|
665
|
-
changes: Array<{ relativePath: string; kind: string }>;
|
|
666
|
-
}>;
|
|
667
|
-
};
|
|
668
|
-
expect(json.honesty).toBe(HONESTY);
|
|
669
|
-
expect(json.dryRun).toBe(true);
|
|
670
|
-
expect(json.results).toHaveLength(1);
|
|
671
|
-
const result = json.results[0]!;
|
|
672
|
-
expect(result.ok).toBe(true);
|
|
673
|
-
expect(result.profile).toBe("claude-personal");
|
|
674
|
-
expect(result.profileDir).toBe(join(profilesRoot, "claude-personal"));
|
|
675
|
-
expect(
|
|
676
|
-
result.changes.some((c) =>
|
|
677
|
-
c.relativePath.includes("skills/offrouter"),
|
|
678
|
-
),
|
|
679
|
-
).toBe(true);
|
|
680
|
-
// Dry-run must not create the profile skills tree.
|
|
681
|
-
const { access } = await import("node:fs/promises");
|
|
682
|
-
const { constants } = await import("node:fs");
|
|
683
|
-
await expect(
|
|
684
|
-
access(join(profilesRoot, "claude-personal"), constants.F_OK),
|
|
685
|
-
).rejects.toBeTruthy();
|
|
686
|
-
} finally {
|
|
687
|
-
await rm(home, { recursive: true, force: true });
|
|
688
|
-
}
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
it("offrouter uninstall claude --profile claude-personal rolls back install state", async () => {
|
|
692
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-uninstall-"));
|
|
693
|
-
try {
|
|
694
|
-
await writeFile(
|
|
695
|
-
join(home, "config.toml"),
|
|
696
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
697
|
-
"utf8",
|
|
698
|
-
);
|
|
699
|
-
const profilesRoot = join(home, "claude-profiles");
|
|
700
|
-
const env = {
|
|
701
|
-
...process.env,
|
|
702
|
-
OFFROUTER_HOME: home,
|
|
703
|
-
HOME: home,
|
|
704
|
-
OFFROUTER_CLAUDE_PROFILES_DIR: profilesRoot,
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
const install = await capture(
|
|
708
|
-
["install", "claude", "--profile", "claude-personal", "--json"],
|
|
709
|
-
env,
|
|
710
|
-
);
|
|
711
|
-
expect(install.code).toBe(0);
|
|
712
|
-
|
|
713
|
-
const hookPath = join(
|
|
714
|
-
profilesRoot,
|
|
715
|
-
"claude-personal",
|
|
716
|
-
"skills",
|
|
717
|
-
"offrouter",
|
|
718
|
-
"hooks",
|
|
719
|
-
"hooks.json",
|
|
720
|
-
);
|
|
721
|
-
const { readFile, access } = await import("node:fs/promises");
|
|
722
|
-
const { constants } = await import("node:fs");
|
|
723
|
-
const hooks = await readFile(hookPath, "utf8");
|
|
724
|
-
expect(hooks).toContain("OFFROUTER_PROFILE=claude-personal");
|
|
725
|
-
|
|
726
|
-
const uninstall = await capture(
|
|
727
|
-
["uninstall", "claude", "--profile", "claude-personal", "--json"],
|
|
728
|
-
env,
|
|
729
|
-
);
|
|
730
|
-
expect(uninstall.code).toBe(0);
|
|
731
|
-
const json = JSON.parse(uninstall.stdout) as {
|
|
732
|
-
results: Array<{ ok: boolean; profile: string }>;
|
|
733
|
-
};
|
|
734
|
-
expect(json.results[0]?.ok).toBe(true);
|
|
735
|
-
expect(json.results[0]?.profile).toBe("claude-personal");
|
|
736
|
-
await expect(access(hookPath, constants.F_OK)).rejects.toBeTruthy();
|
|
737
|
-
} finally {
|
|
738
|
-
await rm(home, { recursive: true, force: true });
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it("offrouter install claude --all-personal skips bare global claude profile", async () => {
|
|
743
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-install-all-"));
|
|
744
|
-
try {
|
|
745
|
-
await writeFile(
|
|
746
|
-
join(home, "config.toml"),
|
|
747
|
-
'allowlisted_profiles = ["claude", "claude-personal", "claude-work"]\n',
|
|
748
|
-
"utf8",
|
|
749
|
-
);
|
|
750
|
-
const profilesRoot = join(home, "claude-profiles");
|
|
751
|
-
const { code, stdout, stderr } = await capture(
|
|
752
|
-
["install", "claude", "--all-personal", "--dry-run", "--json"],
|
|
753
|
-
{
|
|
754
|
-
...process.env,
|
|
755
|
-
OFFROUTER_HOME: home,
|
|
756
|
-
HOME: home,
|
|
757
|
-
OFFROUTER_CLAUDE_PROFILES_DIR: profilesRoot,
|
|
758
|
-
},
|
|
759
|
-
);
|
|
760
|
-
expect(code).toBe(0);
|
|
761
|
-
expect(stderr).toBe("");
|
|
762
|
-
const json = JSON.parse(stdout) as {
|
|
763
|
-
results: Array<{ profile: string; ok: boolean }>;
|
|
764
|
-
};
|
|
765
|
-
expect(json.results.map((r) => r.profile)).toEqual(["claude-personal"]);
|
|
766
|
-
expect(json.results.every((r) => r.ok)).toBe(true);
|
|
767
|
-
expect(json.results.map((r) => r.profile)).not.toContain("claude");
|
|
768
|
-
expect(json.results.map((r) => r.profile)).not.toContain("claude-work");
|
|
769
|
-
} finally {
|
|
770
|
-
await rm(home, { recursive: true, force: true });
|
|
771
|
-
}
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
it("offrouter install rejects --all-personal combined with --profile", async () => {
|
|
775
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-install-both-"));
|
|
776
|
-
try {
|
|
777
|
-
await writeFile(
|
|
778
|
-
join(home, "config.toml"),
|
|
779
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
780
|
-
"utf8",
|
|
781
|
-
);
|
|
782
|
-
const { code, stdout, stderr } = await capture(
|
|
783
|
-
[
|
|
784
|
-
"install",
|
|
785
|
-
"claude",
|
|
786
|
-
"--all-personal",
|
|
787
|
-
"--profile",
|
|
788
|
-
"claude-personal",
|
|
789
|
-
"--dry-run",
|
|
790
|
-
"--json",
|
|
791
|
-
],
|
|
792
|
-
{
|
|
793
|
-
...process.env,
|
|
794
|
-
OFFROUTER_HOME: home,
|
|
795
|
-
HOME: home,
|
|
796
|
-
},
|
|
797
|
-
);
|
|
798
|
-
expect(code).not.toBe(0);
|
|
799
|
-
const combined = `${stdout}\n${stderr}`;
|
|
800
|
-
expect(combined.toLowerCase()).toMatch(
|
|
801
|
-
/either|--profile|--all-personal|not both/,
|
|
802
|
-
);
|
|
803
|
-
} finally {
|
|
804
|
-
await rm(home, { recursive: true, force: true });
|
|
805
|
-
}
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
it("offrouter uninstall rejects --all-personal combined with --profile", async () => {
|
|
809
|
-
const home = await mkdtemp(
|
|
810
|
-
join(tmpdir(), "offrouter-cli-uninstall-both-"),
|
|
811
|
-
);
|
|
812
|
-
try {
|
|
813
|
-
await writeFile(
|
|
814
|
-
join(home, "config.toml"),
|
|
815
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
816
|
-
"utf8",
|
|
817
|
-
);
|
|
818
|
-
const { code, stdout, stderr } = await capture(
|
|
819
|
-
[
|
|
820
|
-
"uninstall",
|
|
821
|
-
"claude",
|
|
822
|
-
"--all-personal",
|
|
823
|
-
"--profile",
|
|
824
|
-
"claude-personal",
|
|
825
|
-
"--json",
|
|
826
|
-
],
|
|
827
|
-
{
|
|
828
|
-
...process.env,
|
|
829
|
-
OFFROUTER_HOME: home,
|
|
830
|
-
HOME: home,
|
|
831
|
-
},
|
|
832
|
-
);
|
|
833
|
-
expect(code).not.toBe(0);
|
|
834
|
-
const combined = `${stdout}\n${stderr}`;
|
|
835
|
-
expect(combined.toLowerCase()).toMatch(
|
|
836
|
-
/either|--profile|--all-personal|not both/,
|
|
837
|
-
);
|
|
838
|
-
} finally {
|
|
839
|
-
await rm(home, { recursive: true, force: true });
|
|
840
|
-
}
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
it("offrouter install denies work-like ids such as claude-work-staging even if allowlisted", async () => {
|
|
844
|
-
const home = await mkdtemp(
|
|
845
|
-
join(tmpdir(), "offrouter-cli-install-work-staging-"),
|
|
846
|
-
);
|
|
847
|
-
try {
|
|
848
|
-
await writeFile(
|
|
849
|
-
join(home, "config.toml"),
|
|
850
|
-
'allowlisted_profiles = ["claude-work-staging"]\n',
|
|
851
|
-
"utf8",
|
|
852
|
-
);
|
|
853
|
-
const profilesRoot = join(home, "claude-profiles");
|
|
854
|
-
const { code, stdout } = await capture(
|
|
855
|
-
[
|
|
856
|
-
"install",
|
|
857
|
-
"claude",
|
|
858
|
-
"--profile",
|
|
859
|
-
"claude-work-staging",
|
|
860
|
-
"--dry-run",
|
|
861
|
-
"--json",
|
|
862
|
-
],
|
|
863
|
-
{
|
|
864
|
-
...process.env,
|
|
865
|
-
OFFROUTER_HOME: home,
|
|
866
|
-
HOME: home,
|
|
867
|
-
OFFROUTER_CLAUDE_PROFILES_DIR: profilesRoot,
|
|
868
|
-
},
|
|
869
|
-
);
|
|
870
|
-
expect(code).not.toBe(0);
|
|
871
|
-
const json = JSON.parse(stdout) as {
|
|
872
|
-
results?: Array<{ ok: boolean; errorCode?: string; message?: string }>;
|
|
873
|
-
error?: { message?: string };
|
|
874
|
-
};
|
|
875
|
-
if (json.results?.[0]) {
|
|
876
|
-
expect(json.results[0].ok).toBe(false);
|
|
877
|
-
expect(json.results[0].errorCode).toBe("work_profile_denied");
|
|
878
|
-
} else {
|
|
879
|
-
expect(String(json.error?.message ?? "").toLowerCase()).toMatch(
|
|
880
|
-
/work|denied/,
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
} finally {
|
|
884
|
-
await rm(home, { recursive: true, force: true });
|
|
885
|
-
}
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it("offrouter uninstall claude --all-personal exits nonzero on invalid install state", async () => {
|
|
889
|
-
const home = await mkdtemp(
|
|
890
|
-
join(tmpdir(), "offrouter-cli-uninstall-all-"),
|
|
891
|
-
);
|
|
892
|
-
try {
|
|
893
|
-
await writeFile(
|
|
894
|
-
join(home, "config.toml"),
|
|
895
|
-
'allowlisted_profiles = ["claude-personal"]\n',
|
|
896
|
-
"utf8",
|
|
897
|
-
);
|
|
898
|
-
const profilesRoot = join(home, "claude-profiles");
|
|
899
|
-
const env = {
|
|
900
|
-
...process.env,
|
|
901
|
-
OFFROUTER_HOME: home,
|
|
902
|
-
HOME: home,
|
|
903
|
-
OFFROUTER_CLAUDE_PROFILES_DIR: profilesRoot,
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
const install = await capture(
|
|
907
|
-
["install", "claude", "--profile", "claude-personal", "--json"],
|
|
908
|
-
env,
|
|
909
|
-
);
|
|
910
|
-
expect(install.code).toBe(0);
|
|
911
|
-
|
|
912
|
-
const statePath = join(
|
|
913
|
-
profilesRoot,
|
|
914
|
-
"claude-personal",
|
|
915
|
-
".offrouter",
|
|
916
|
-
"claude-install-state.json",
|
|
917
|
-
);
|
|
918
|
-
await writeFile(statePath, "{not valid json", "utf8");
|
|
919
|
-
|
|
920
|
-
const uninstall = await capture(
|
|
921
|
-
["uninstall", "claude", "--all-personal", "--json"],
|
|
922
|
-
env,
|
|
923
|
-
);
|
|
924
|
-
expect(uninstall.code).not.toBe(0);
|
|
925
|
-
const json = JSON.parse(uninstall.stdout) as {
|
|
926
|
-
results: Array<{ ok: boolean; errorCode?: string }>;
|
|
927
|
-
};
|
|
928
|
-
expect(json.results[0]?.ok).toBe(false);
|
|
929
|
-
expect(json.results[0]?.errorCode).toBe("invalid_install_state");
|
|
930
|
-
} finally {
|
|
931
|
-
await rm(home, { recursive: true, force: true });
|
|
932
|
-
}
|
|
933
|
-
});
|
|
934
|
-
|
|
935
|
-
it("offrouter install codex --profile codex-personal --dry-run --json plans profile-scoped config.toml", async () => {
|
|
936
|
-
const home = await mkdtemp(
|
|
937
|
-
join(tmpdir(), "offrouter-cli-install-codex-"),
|
|
938
|
-
);
|
|
939
|
-
try {
|
|
940
|
-
await writeFile(
|
|
941
|
-
join(home, "config.toml"),
|
|
942
|
-
'allowlisted_profiles = ["codex-personal"]\n',
|
|
943
|
-
"utf8",
|
|
944
|
-
);
|
|
945
|
-
const profilesRoot = join(home, "codex-profiles");
|
|
946
|
-
const { code, stdout, stderr } = await capture(
|
|
947
|
-
[
|
|
948
|
-
"install",
|
|
949
|
-
"codex",
|
|
950
|
-
"--profile",
|
|
951
|
-
"codex-personal",
|
|
952
|
-
"--dry-run",
|
|
953
|
-
"--json",
|
|
954
|
-
],
|
|
955
|
-
{
|
|
956
|
-
...process.env,
|
|
957
|
-
OFFROUTER_HOME: home,
|
|
958
|
-
HOME: home,
|
|
959
|
-
OFFROUTER_CODEX_PROFILES_DIR: profilesRoot,
|
|
960
|
-
},
|
|
961
|
-
);
|
|
962
|
-
expect(code).toBe(0);
|
|
963
|
-
expect(stderr).toBe("");
|
|
964
|
-
const json = JSON.parse(stdout) as {
|
|
965
|
-
honesty: string;
|
|
966
|
-
dryRun: boolean;
|
|
967
|
-
results: Array<{
|
|
968
|
-
ok: boolean;
|
|
969
|
-
profile: string;
|
|
970
|
-
profileDir: string;
|
|
971
|
-
changes: Array<{ relativePath: string; kind: string }>;
|
|
972
|
-
}>;
|
|
973
|
-
};
|
|
974
|
-
expect(json.honesty).toBe(CODEX_HONESTY);
|
|
975
|
-
expect(json.dryRun).toBe(true);
|
|
976
|
-
expect(json.results).toHaveLength(1);
|
|
977
|
-
const result = json.results[0]!;
|
|
978
|
-
expect(result.ok).toBe(true);
|
|
979
|
-
expect(result.profile).toBe("codex-personal");
|
|
980
|
-
expect(result.profileDir).toBe(join(profilesRoot, "codex-personal"));
|
|
981
|
-
expect(result.changes.some((c) => c.relativePath === "config.toml")).toBe(
|
|
982
|
-
true,
|
|
983
|
-
);
|
|
984
|
-
// Dry-run must not create the profile directory.
|
|
985
|
-
const { access } = await import("node:fs/promises");
|
|
986
|
-
const { constants } = await import("node:fs");
|
|
987
|
-
await expect(
|
|
988
|
-
access(join(profilesRoot, "codex-personal"), constants.F_OK),
|
|
989
|
-
).rejects.toBeTruthy();
|
|
990
|
-
} finally {
|
|
991
|
-
await rm(home, { recursive: true, force: true });
|
|
992
|
-
}
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
it("offrouter install codex --all-personal --dry-run --json skips bare global codex", async () => {
|
|
996
|
-
const home = await mkdtemp(
|
|
997
|
-
join(tmpdir(), "offrouter-cli-install-codex-all-"),
|
|
998
|
-
);
|
|
999
|
-
try {
|
|
1000
|
-
await writeFile(
|
|
1001
|
-
join(home, "config.toml"),
|
|
1002
|
-
'allowlisted_profiles = ["codex", "codex-personal", "codex-work"]\n',
|
|
1003
|
-
"utf8",
|
|
1004
|
-
);
|
|
1005
|
-
const profilesRoot = join(home, "codex-profiles");
|
|
1006
|
-
const { code, stdout, stderr } = await capture(
|
|
1007
|
-
["install", "codex", "--all-personal", "--dry-run", "--json"],
|
|
1008
|
-
{
|
|
1009
|
-
...process.env,
|
|
1010
|
-
OFFROUTER_HOME: home,
|
|
1011
|
-
HOME: home,
|
|
1012
|
-
OFFROUTER_CODEX_PROFILES_DIR: profilesRoot,
|
|
1013
|
-
},
|
|
1014
|
-
);
|
|
1015
|
-
expect(code).toBe(0);
|
|
1016
|
-
expect(stderr).toBe("");
|
|
1017
|
-
const json = JSON.parse(stdout) as {
|
|
1018
|
-
results: Array<{ profile: string; ok: boolean }>;
|
|
1019
|
-
};
|
|
1020
|
-
expect(json.results.map((r) => r.profile)).toEqual(["codex-personal"]);
|
|
1021
|
-
expect(json.results.every((r) => r.ok)).toBe(true);
|
|
1022
|
-
expect(json.results.map((r) => r.profile)).not.toContain("codex");
|
|
1023
|
-
expect(json.results.map((r) => r.profile)).not.toContain("codex-work");
|
|
1024
|
-
} finally {
|
|
1025
|
-
await rm(home, { recursive: true, force: true });
|
|
1026
|
-
}
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
it("offrouter uninstall codex --profile codex-personal rolls back install state", async () => {
|
|
1030
|
-
const home = await mkdtemp(
|
|
1031
|
-
join(tmpdir(), "offrouter-cli-uninstall-codex-"),
|
|
1032
|
-
);
|
|
1033
|
-
try {
|
|
1034
|
-
await writeFile(
|
|
1035
|
-
join(home, "config.toml"),
|
|
1036
|
-
'allowlisted_profiles = ["codex-personal"]\n',
|
|
1037
|
-
"utf8",
|
|
1038
|
-
);
|
|
1039
|
-
const profilesRoot = join(home, "codex-profiles");
|
|
1040
|
-
const env = {
|
|
1041
|
-
...process.env,
|
|
1042
|
-
OFFROUTER_HOME: home,
|
|
1043
|
-
HOME: home,
|
|
1044
|
-
OFFROUTER_CODEX_PROFILES_DIR: profilesRoot,
|
|
1045
|
-
};
|
|
1046
|
-
|
|
1047
|
-
const installOut = await capture(
|
|
1048
|
-
["install", "codex", "--profile", "codex-personal", "--json"],
|
|
1049
|
-
env,
|
|
1050
|
-
);
|
|
1051
|
-
expect(installOut.code).toBe(0);
|
|
1052
|
-
|
|
1053
|
-
const configPath = join(profilesRoot, "codex-personal", "config.toml");
|
|
1054
|
-
const { readFile, access } = await import("node:fs/promises");
|
|
1055
|
-
const { constants } = await import("node:fs");
|
|
1056
|
-
const configRaw = await readFile(configPath, "utf8");
|
|
1057
|
-
expect(configRaw).toContain("[mcp_servers.offrouter]");
|
|
1058
|
-
|
|
1059
|
-
const uninstall = await capture(
|
|
1060
|
-
["uninstall", "codex", "--profile", "codex-personal", "--json"],
|
|
1061
|
-
env,
|
|
1062
|
-
);
|
|
1063
|
-
expect(uninstall.code).toBe(0);
|
|
1064
|
-
const json = JSON.parse(uninstall.stdout) as {
|
|
1065
|
-
results: Array<{ ok: boolean; profile: string }>;
|
|
1066
|
-
};
|
|
1067
|
-
expect(json.results[0]?.ok).toBe(true);
|
|
1068
|
-
expect(json.results[0]?.profile).toBe("codex-personal");
|
|
1069
|
-
await expect(access(configPath, constants.F_OK)).rejects.toBeTruthy();
|
|
1070
|
-
} finally {
|
|
1071
|
-
await rm(home, { recursive: true, force: true });
|
|
1072
|
-
}
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
it("offrouter install codex denies work-like ids such as codex-work-staging even if allowlisted", async () => {
|
|
1076
|
-
const home = await mkdtemp(
|
|
1077
|
-
join(tmpdir(), "offrouter-cli-install-codex-work-staging-"),
|
|
1078
|
-
);
|
|
1079
|
-
try {
|
|
1080
|
-
await writeFile(
|
|
1081
|
-
join(home, "config.toml"),
|
|
1082
|
-
'allowlisted_profiles = ["codex-work-staging"]\n',
|
|
1083
|
-
"utf8",
|
|
1084
|
-
);
|
|
1085
|
-
const profilesRoot = join(home, "codex-profiles");
|
|
1086
|
-
const { code, stdout } = await capture(
|
|
1087
|
-
[
|
|
1088
|
-
"install",
|
|
1089
|
-
"codex",
|
|
1090
|
-
"--profile",
|
|
1091
|
-
"codex-work-staging",
|
|
1092
|
-
"--dry-run",
|
|
1093
|
-
"--json",
|
|
1094
|
-
],
|
|
1095
|
-
{
|
|
1096
|
-
...process.env,
|
|
1097
|
-
OFFROUTER_HOME: home,
|
|
1098
|
-
HOME: home,
|
|
1099
|
-
OFFROUTER_CODEX_PROFILES_DIR: profilesRoot,
|
|
1100
|
-
},
|
|
1101
|
-
);
|
|
1102
|
-
expect(code).not.toBe(0);
|
|
1103
|
-
const json = JSON.parse(stdout) as {
|
|
1104
|
-
results?: Array<{ ok: boolean; errorCode?: string; message?: string }>;
|
|
1105
|
-
error?: { message?: string };
|
|
1106
|
-
};
|
|
1107
|
-
if (json.results?.[0]) {
|
|
1108
|
-
expect(json.results[0].ok).toBe(false);
|
|
1109
|
-
expect(json.results[0].errorCode).toBe("work_profile_denied");
|
|
1110
|
-
} else {
|
|
1111
|
-
expect(String(json.error?.message ?? "").toLowerCase()).toMatch(
|
|
1112
|
-
/work|denied/,
|
|
1113
|
-
);
|
|
1114
|
-
}
|
|
1115
|
-
} finally {
|
|
1116
|
-
await rm(home, { recursive: true, force: true });
|
|
1117
|
-
}
|
|
1118
|
-
});
|
|
1119
|
-
|
|
1120
|
-
it("offrouter install gemini --profile gemini-personal --dry-run --json plans profile-scoped settings.json", async () => {
|
|
1121
|
-
const home = await mkdtemp(
|
|
1122
|
-
join(tmpdir(), "offrouter-cli-install-gemini-"),
|
|
1123
|
-
);
|
|
1124
|
-
try {
|
|
1125
|
-
await writeFile(
|
|
1126
|
-
join(home, "config.toml"),
|
|
1127
|
-
'allowlisted_profiles = ["gemini-personal"]\n',
|
|
1128
|
-
"utf8",
|
|
1129
|
-
);
|
|
1130
|
-
const profilesRoot = join(home, "gemini-profiles");
|
|
1131
|
-
const { code, stdout, stderr } = await capture(
|
|
1132
|
-
[
|
|
1133
|
-
"install",
|
|
1134
|
-
"gemini",
|
|
1135
|
-
"--profile",
|
|
1136
|
-
"gemini-personal",
|
|
1137
|
-
"--dry-run",
|
|
1138
|
-
"--json",
|
|
1139
|
-
],
|
|
1140
|
-
{
|
|
1141
|
-
...process.env,
|
|
1142
|
-
OFFROUTER_HOME: home,
|
|
1143
|
-
HOME: home,
|
|
1144
|
-
OFFROUTER_GEMINI_PROFILES_DIR: profilesRoot,
|
|
1145
|
-
},
|
|
1146
|
-
);
|
|
1147
|
-
expect(code).toBe(0);
|
|
1148
|
-
expect(stderr).toBe("");
|
|
1149
|
-
const json = JSON.parse(stdout) as {
|
|
1150
|
-
honesty: string;
|
|
1151
|
-
dryRun: boolean;
|
|
1152
|
-
results: Array<{
|
|
1153
|
-
ok: boolean;
|
|
1154
|
-
profile: string;
|
|
1155
|
-
profileDir: string;
|
|
1156
|
-
changes: Array<{ relativePath: string; kind: string }>;
|
|
1157
|
-
}>;
|
|
1158
|
-
};
|
|
1159
|
-
expect(json.honesty).toBe(GEMINI_HONESTY);
|
|
1160
|
-
expect(json.dryRun).toBe(true);
|
|
1161
|
-
expect(json.results).toHaveLength(1);
|
|
1162
|
-
const result = json.results[0]!;
|
|
1163
|
-
expect(result.ok).toBe(true);
|
|
1164
|
-
expect(result.profile).toBe("gemini-personal");
|
|
1165
|
-
expect(result.profileDir).toBe(join(profilesRoot, "gemini-personal"));
|
|
1166
|
-
expect(
|
|
1167
|
-
result.changes.some((c) => c.relativePath === "settings.json"),
|
|
1168
|
-
).toBe(true);
|
|
1169
|
-
// Dry-run must not create the profile directory.
|
|
1170
|
-
const { access } = await import("node:fs/promises");
|
|
1171
|
-
const { constants } = await import("node:fs");
|
|
1172
|
-
await expect(
|
|
1173
|
-
access(join(profilesRoot, "gemini-personal"), constants.F_OK),
|
|
1174
|
-
).rejects.toBeTruthy();
|
|
1175
|
-
} finally {
|
|
1176
|
-
await rm(home, { recursive: true, force: true });
|
|
1177
|
-
}
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
it("offrouter install gemini --all-personal --dry-run --json skips bare global gemini", async () => {
|
|
1181
|
-
const home = await mkdtemp(
|
|
1182
|
-
join(tmpdir(), "offrouter-cli-install-gemini-all-"),
|
|
1183
|
-
);
|
|
1184
|
-
try {
|
|
1185
|
-
await writeFile(
|
|
1186
|
-
join(home, "config.toml"),
|
|
1187
|
-
'allowlisted_profiles = ["gemini", "gemini-personal", "gemini-work"]\n',
|
|
1188
|
-
"utf8",
|
|
1189
|
-
);
|
|
1190
|
-
const profilesRoot = join(home, "gemini-profiles");
|
|
1191
|
-
const { code, stdout, stderr } = await capture(
|
|
1192
|
-
["install", "gemini", "--all-personal", "--dry-run", "--json"],
|
|
1193
|
-
{
|
|
1194
|
-
...process.env,
|
|
1195
|
-
OFFROUTER_HOME: home,
|
|
1196
|
-
HOME: home,
|
|
1197
|
-
OFFROUTER_GEMINI_PROFILES_DIR: profilesRoot,
|
|
1198
|
-
},
|
|
1199
|
-
);
|
|
1200
|
-
expect(code).toBe(0);
|
|
1201
|
-
expect(stderr).toBe("");
|
|
1202
|
-
const json = JSON.parse(stdout) as {
|
|
1203
|
-
results: Array<{ profile: string; ok: boolean }>;
|
|
1204
|
-
};
|
|
1205
|
-
expect(json.results.map((r) => r.profile)).toEqual(["gemini-personal"]);
|
|
1206
|
-
expect(json.results.every((r) => r.ok)).toBe(true);
|
|
1207
|
-
expect(json.results.map((r) => r.profile)).not.toContain("gemini");
|
|
1208
|
-
expect(json.results.map((r) => r.profile)).not.toContain("gemini-work");
|
|
1209
|
-
} finally {
|
|
1210
|
-
await rm(home, { recursive: true, force: true });
|
|
1211
|
-
}
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
it("offrouter uninstall gemini --profile gemini-personal rolls back install state", async () => {
|
|
1215
|
-
const home = await mkdtemp(
|
|
1216
|
-
join(tmpdir(), "offrouter-cli-uninstall-gemini-"),
|
|
1217
|
-
);
|
|
1218
|
-
try {
|
|
1219
|
-
await writeFile(
|
|
1220
|
-
join(home, "config.toml"),
|
|
1221
|
-
'allowlisted_profiles = ["gemini-personal"]\n',
|
|
1222
|
-
"utf8",
|
|
1223
|
-
);
|
|
1224
|
-
const profilesRoot = join(home, "gemini-profiles");
|
|
1225
|
-
const env = {
|
|
1226
|
-
...process.env,
|
|
1227
|
-
OFFROUTER_HOME: home,
|
|
1228
|
-
HOME: home,
|
|
1229
|
-
OFFROUTER_GEMINI_PROFILES_DIR: profilesRoot,
|
|
1230
|
-
};
|
|
1231
|
-
|
|
1232
|
-
const installOut = await capture(
|
|
1233
|
-
["install", "gemini", "--profile", "gemini-personal", "--json"],
|
|
1234
|
-
env,
|
|
1235
|
-
);
|
|
1236
|
-
expect(installOut.code).toBe(0);
|
|
1237
|
-
|
|
1238
|
-
const configPath = join(profilesRoot, "gemini-personal", "settings.json");
|
|
1239
|
-
const { readFile, access } = await import("node:fs/promises");
|
|
1240
|
-
const { constants } = await import("node:fs");
|
|
1241
|
-
const configRaw = await readFile(configPath, "utf8");
|
|
1242
|
-
const parsed = JSON.parse(configRaw) as {
|
|
1243
|
-
mcpServers: Record<string, { command: string }>;
|
|
1244
|
-
};
|
|
1245
|
-
expect(parsed.mcpServers.offrouter).toBeDefined();
|
|
1246
|
-
expect(parsed.mcpServers.offrouter.command).toBe("offrouter");
|
|
1247
|
-
|
|
1248
|
-
const uninstall = await capture(
|
|
1249
|
-
["uninstall", "gemini", "--profile", "gemini-personal", "--json"],
|
|
1250
|
-
env,
|
|
1251
|
-
);
|
|
1252
|
-
expect(uninstall.code).toBe(0);
|
|
1253
|
-
const json = JSON.parse(uninstall.stdout) as {
|
|
1254
|
-
results: Array<{ ok: boolean; profile: string }>;
|
|
1255
|
-
};
|
|
1256
|
-
expect(json.results[0]?.ok).toBe(true);
|
|
1257
|
-
expect(json.results[0]?.profile).toBe("gemini-personal");
|
|
1258
|
-
// The install created settings.json from scratch, so rollback removes it.
|
|
1259
|
-
await expect(access(configPath, constants.F_OK)).rejects.toBeTruthy();
|
|
1260
|
-
} finally {
|
|
1261
|
-
await rm(home, { recursive: true, force: true });
|
|
1262
|
-
}
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
it("offrouter install gemini denies work-like ids such as gemini-work-staging even if allowlisted", async () => {
|
|
1266
|
-
const home = await mkdtemp(
|
|
1267
|
-
join(tmpdir(), "offrouter-cli-install-gemini-work-staging-"),
|
|
1268
|
-
);
|
|
1269
|
-
try {
|
|
1270
|
-
await writeFile(
|
|
1271
|
-
join(home, "config.toml"),
|
|
1272
|
-
'allowlisted_profiles = ["gemini-work-staging"]\n',
|
|
1273
|
-
"utf8",
|
|
1274
|
-
);
|
|
1275
|
-
const profilesRoot = join(home, "gemini-profiles");
|
|
1276
|
-
const { code, stdout } = await capture(
|
|
1277
|
-
[
|
|
1278
|
-
"install",
|
|
1279
|
-
"gemini",
|
|
1280
|
-
"--profile",
|
|
1281
|
-
"gemini-work-staging",
|
|
1282
|
-
"--dry-run",
|
|
1283
|
-
"--json",
|
|
1284
|
-
],
|
|
1285
|
-
{
|
|
1286
|
-
...process.env,
|
|
1287
|
-
OFFROUTER_HOME: home,
|
|
1288
|
-
HOME: home,
|
|
1289
|
-
OFFROUTER_GEMINI_PROFILES_DIR: profilesRoot,
|
|
1290
|
-
},
|
|
1291
|
-
);
|
|
1292
|
-
expect(code).not.toBe(0);
|
|
1293
|
-
const json = JSON.parse(stdout) as {
|
|
1294
|
-
results?: Array<{ ok: boolean; errorCode?: string; message?: string }>;
|
|
1295
|
-
error?: { message?: string };
|
|
1296
|
-
};
|
|
1297
|
-
if (json.results?.[0]) {
|
|
1298
|
-
expect(json.results[0].ok).toBe(false);
|
|
1299
|
-
expect(json.results[0].errorCode).toBe("work_profile_denied");
|
|
1300
|
-
} else {
|
|
1301
|
-
expect(String(json.error?.message ?? "").toLowerCase()).toMatch(
|
|
1302
|
-
/work|denied/,
|
|
1303
|
-
);
|
|
1304
|
-
}
|
|
1305
|
-
} finally {
|
|
1306
|
-
await rm(home, { recursive: true, force: true });
|
|
1307
|
-
}
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
it("offrouter install grok --profile grok-personal --dry-run --json plans profile-scoped config.toml", async () => {
|
|
1311
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-install-grok-"));
|
|
1312
|
-
try {
|
|
1313
|
-
await writeFile(
|
|
1314
|
-
join(home, "config.toml"),
|
|
1315
|
-
'allowlisted_profiles = ["grok-personal"]\n',
|
|
1316
|
-
"utf8",
|
|
1317
|
-
);
|
|
1318
|
-
const profilesRoot = join(home, "grok-profiles");
|
|
1319
|
-
const { code, stdout, stderr } = await capture(
|
|
1320
|
-
[
|
|
1321
|
-
"install",
|
|
1322
|
-
"grok",
|
|
1323
|
-
"--profile",
|
|
1324
|
-
"grok-personal",
|
|
1325
|
-
"--dry-run",
|
|
1326
|
-
"--json",
|
|
1327
|
-
],
|
|
1328
|
-
{
|
|
1329
|
-
...process.env,
|
|
1330
|
-
OFFROUTER_HOME: home,
|
|
1331
|
-
HOME: home,
|
|
1332
|
-
OFFROUTER_GROK_PROFILES_DIR: profilesRoot,
|
|
1333
|
-
},
|
|
1334
|
-
);
|
|
1335
|
-
expect(code).toBe(0);
|
|
1336
|
-
expect(stderr).toBe("");
|
|
1337
|
-
const json = JSON.parse(stdout) as {
|
|
1338
|
-
honesty: string;
|
|
1339
|
-
dryRun: boolean;
|
|
1340
|
-
results: Array<{
|
|
1341
|
-
ok: boolean;
|
|
1342
|
-
profile: string;
|
|
1343
|
-
profileDir: string;
|
|
1344
|
-
changes: Array<{ relativePath: string; kind: string }>;
|
|
1345
|
-
}>;
|
|
1346
|
-
};
|
|
1347
|
-
expect(json.honesty).toBe(GROK_HONESTY);
|
|
1348
|
-
expect(json.dryRun).toBe(true);
|
|
1349
|
-
expect(json.results).toHaveLength(1);
|
|
1350
|
-
const result = json.results[0]!;
|
|
1351
|
-
expect(result.ok).toBe(true);
|
|
1352
|
-
expect(result.profile).toBe("grok-personal");
|
|
1353
|
-
expect(result.profileDir).toBe(join(profilesRoot, "grok-personal"));
|
|
1354
|
-
expect(result.changes.some((c) => c.relativePath === "config.toml")).toBe(
|
|
1355
|
-
true,
|
|
1356
|
-
);
|
|
1357
|
-
// Dry-run must not create the profile directory.
|
|
1358
|
-
const { access } = await import("node:fs/promises");
|
|
1359
|
-
const { constants } = await import("node:fs");
|
|
1360
|
-
await expect(
|
|
1361
|
-
access(join(profilesRoot, "grok-personal"), constants.F_OK),
|
|
1362
|
-
).rejects.toBeTruthy();
|
|
1363
|
-
} finally {
|
|
1364
|
-
await rm(home, { recursive: true, force: true });
|
|
1365
|
-
}
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
it("offrouter install grok --all-personal --dry-run --json skips bare global grok", async () => {
|
|
1369
|
-
const home = await mkdtemp(
|
|
1370
|
-
join(tmpdir(), "offrouter-cli-install-grok-all-"),
|
|
1371
|
-
);
|
|
1372
|
-
try {
|
|
1373
|
-
await writeFile(
|
|
1374
|
-
join(home, "config.toml"),
|
|
1375
|
-
'allowlisted_profiles = ["grok", "grok-personal", "grok-work"]\n',
|
|
1376
|
-
"utf8",
|
|
1377
|
-
);
|
|
1378
|
-
const profilesRoot = join(home, "grok-profiles");
|
|
1379
|
-
const { code, stdout, stderr } = await capture(
|
|
1380
|
-
["install", "grok", "--all-personal", "--dry-run", "--json"],
|
|
1381
|
-
{
|
|
1382
|
-
...process.env,
|
|
1383
|
-
OFFROUTER_HOME: home,
|
|
1384
|
-
HOME: home,
|
|
1385
|
-
OFFROUTER_GROK_PROFILES_DIR: profilesRoot,
|
|
1386
|
-
},
|
|
1387
|
-
);
|
|
1388
|
-
expect(code).toBe(0);
|
|
1389
|
-
expect(stderr).toBe("");
|
|
1390
|
-
const json = JSON.parse(stdout) as {
|
|
1391
|
-
results: Array<{ profile: string; ok: boolean }>;
|
|
1392
|
-
};
|
|
1393
|
-
expect(json.results.map((r) => r.profile)).toEqual(["grok-personal"]);
|
|
1394
|
-
expect(json.results.every((r) => r.ok)).toBe(true);
|
|
1395
|
-
expect(json.results.map((r) => r.profile)).not.toContain("grok");
|
|
1396
|
-
expect(json.results.map((r) => r.profile)).not.toContain("grok-work");
|
|
1397
|
-
} finally {
|
|
1398
|
-
await rm(home, { recursive: true, force: true });
|
|
1399
|
-
}
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
it("offrouter uninstall grok --profile grok-personal rolls back install state", async () => {
|
|
1403
|
-
const home = await mkdtemp(
|
|
1404
|
-
join(tmpdir(), "offrouter-cli-uninstall-grok-"),
|
|
1405
|
-
);
|
|
1406
|
-
try {
|
|
1407
|
-
await writeFile(
|
|
1408
|
-
join(home, "config.toml"),
|
|
1409
|
-
'allowlisted_profiles = ["grok-personal"]\n',
|
|
1410
|
-
"utf8",
|
|
1411
|
-
);
|
|
1412
|
-
const profilesRoot = join(home, "grok-profiles");
|
|
1413
|
-
const env = {
|
|
1414
|
-
...process.env,
|
|
1415
|
-
OFFROUTER_HOME: home,
|
|
1416
|
-
HOME: home,
|
|
1417
|
-
OFFROUTER_GROK_PROFILES_DIR: profilesRoot,
|
|
1418
|
-
};
|
|
1419
|
-
|
|
1420
|
-
const installOut = await capture(
|
|
1421
|
-
["install", "grok", "--profile", "grok-personal", "--json"],
|
|
1422
|
-
env,
|
|
1423
|
-
);
|
|
1424
|
-
expect(installOut.code).toBe(0);
|
|
1425
|
-
|
|
1426
|
-
const configPath = join(profilesRoot, "grok-personal", "config.toml");
|
|
1427
|
-
const { readFile, access } = await import("node:fs/promises");
|
|
1428
|
-
const { constants } = await import("node:fs");
|
|
1429
|
-
const configRaw = await readFile(configPath, "utf8");
|
|
1430
|
-
expect(configRaw).toContain("[mcp_servers.offrouter]");
|
|
1431
|
-
expect(configRaw).toContain('command = "offrouter"');
|
|
1432
|
-
|
|
1433
|
-
const uninstall = await capture(
|
|
1434
|
-
["uninstall", "grok", "--profile", "grok-personal", "--json"],
|
|
1435
|
-
env,
|
|
1436
|
-
);
|
|
1437
|
-
expect(uninstall.code).toBe(0);
|
|
1438
|
-
const json = JSON.parse(uninstall.stdout) as {
|
|
1439
|
-
results: Array<{ ok: boolean; profile: string }>;
|
|
1440
|
-
};
|
|
1441
|
-
expect(json.results[0]?.ok).toBe(true);
|
|
1442
|
-
expect(json.results[0]?.profile).toBe("grok-personal");
|
|
1443
|
-
// The install created config.toml from scratch, so rollback removes it.
|
|
1444
|
-
await expect(access(configPath, constants.F_OK)).rejects.toBeTruthy();
|
|
1445
|
-
} finally {
|
|
1446
|
-
await rm(home, { recursive: true, force: true });
|
|
1447
|
-
}
|
|
1448
|
-
});
|
|
1449
|
-
|
|
1450
|
-
it("offrouter install grok denies work-like ids such as grok-work-staging even if allowlisted", async () => {
|
|
1451
|
-
const home = await mkdtemp(
|
|
1452
|
-
join(tmpdir(), "offrouter-cli-install-grok-work-staging-"),
|
|
1453
|
-
);
|
|
1454
|
-
try {
|
|
1455
|
-
await writeFile(
|
|
1456
|
-
join(home, "config.toml"),
|
|
1457
|
-
'allowlisted_profiles = ["grok-work-staging"]\n',
|
|
1458
|
-
"utf8",
|
|
1459
|
-
);
|
|
1460
|
-
const profilesRoot = join(home, "grok-profiles");
|
|
1461
|
-
const { code, stdout } = await capture(
|
|
1462
|
-
[
|
|
1463
|
-
"install",
|
|
1464
|
-
"grok",
|
|
1465
|
-
"--profile",
|
|
1466
|
-
"grok-work-staging",
|
|
1467
|
-
"--dry-run",
|
|
1468
|
-
"--json",
|
|
1469
|
-
],
|
|
1470
|
-
{
|
|
1471
|
-
...process.env,
|
|
1472
|
-
OFFROUTER_HOME: home,
|
|
1473
|
-
HOME: home,
|
|
1474
|
-
OFFROUTER_GROK_PROFILES_DIR: profilesRoot,
|
|
1475
|
-
},
|
|
1476
|
-
);
|
|
1477
|
-
expect(code).not.toBe(0);
|
|
1478
|
-
const json = JSON.parse(stdout) as {
|
|
1479
|
-
results?: Array<{ ok: boolean; errorCode?: string; message?: string }>;
|
|
1480
|
-
error?: { message?: string };
|
|
1481
|
-
};
|
|
1482
|
-
if (json.results?.[0]) {
|
|
1483
|
-
expect(json.results[0].ok).toBe(false);
|
|
1484
|
-
expect(json.results[0].errorCode).toBe("work_profile_denied");
|
|
1485
|
-
} else {
|
|
1486
|
-
expect(String(json.error?.message ?? "").toLowerCase()).toMatch(
|
|
1487
|
-
/work|denied/,
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
} finally {
|
|
1491
|
-
await rm(home, { recursive: true, force: true });
|
|
1492
|
-
}
|
|
1493
|
-
});
|
|
1494
|
-
|
|
1495
|
-
it("offrouter update --help shows update command", async () => {
|
|
1496
|
-
const { code, stdout } = await capture(["update", "--help"]);
|
|
1497
|
-
expect(code).toBe(0);
|
|
1498
|
-
expect(stdout).toMatch(/fetch|update|model catalog|cache/i);
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
|
-
it("offrouter update --json --dry-run returns JSON without network", async () => {
|
|
1502
|
-
const originalFetch = globalThis.fetch;
|
|
1503
|
-
const mockFamilies = [
|
|
1504
|
-
{
|
|
1505
|
-
providerId: "test-provider",
|
|
1506
|
-
modelId: "test-model",
|
|
1507
|
-
family: "test-family",
|
|
1508
|
-
},
|
|
1509
|
-
];
|
|
1510
|
-
const mockResponse = new Response(
|
|
1511
|
-
JSON.stringify({
|
|
1512
|
-
version: 1,
|
|
1513
|
-
generatedAt: "2025-01-01T00:00:00.000Z",
|
|
1514
|
-
families: mockFamilies,
|
|
1515
|
-
}),
|
|
1516
|
-
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1517
|
-
);
|
|
1518
|
-
try {
|
|
1519
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));
|
|
1520
|
-
|
|
1521
|
-
const { code, stdout } = await capture([
|
|
1522
|
-
"update",
|
|
1523
|
-
"--json",
|
|
1524
|
-
"--dry-run",
|
|
1525
|
-
]);
|
|
1526
|
-
expect(code).toBe(0);
|
|
1527
|
-
const json = JSON.parse(stdout) as {
|
|
1528
|
-
source: string;
|
|
1529
|
-
fetchedAt: string;
|
|
1530
|
-
familyCount: number;
|
|
1531
|
-
dryRun: boolean;
|
|
1532
|
-
};
|
|
1533
|
-
expect(json.source).toBe("network");
|
|
1534
|
-
expect(json.familyCount).toBe(1);
|
|
1535
|
-
expect(json.dryRun).toBe(true);
|
|
1536
|
-
} finally {
|
|
1537
|
-
globalThis.fetch = originalFetch;
|
|
1538
|
-
}
|
|
1539
|
-
});
|
|
1540
|
-
|
|
1541
|
-
it("offrouter models --json includes builtin models without secrets", async () => {
|
|
1542
|
-
const { code, stdout } = await capture(["models", "--json"]);
|
|
1543
|
-
expect(code).toBe(0);
|
|
1544
|
-
const json = JSON.parse(stdout) as {
|
|
1545
|
-
models: Array<{ providerId: string; modelId: string; source: string }>;
|
|
1546
|
-
builtinCount: number;
|
|
1547
|
-
cachedCount: number;
|
|
1548
|
-
configCount: number;
|
|
1549
|
-
};
|
|
1550
|
-
expect(Array.isArray(json.models)).toBe(true);
|
|
1551
|
-
expect(json.builtinCount).toBeGreaterThan(0);
|
|
1552
|
-
expect(json.models.every((m) => ["builtin", "cached", "config"].includes(m.source))).toBe(true);
|
|
1553
|
-
// All builtin models have real modelIds, not "*"
|
|
1554
|
-
const builtinModels = json.models.filter((m) => m.source === "builtin");
|
|
1555
|
-
expect(builtinModels.length).toBeGreaterThan(0);
|
|
1556
|
-
expect(builtinModels.every((m) => m.modelId !== "*")).toBe(true);
|
|
1557
|
-
expect(stdout).not.toMatch(/sk-[a-zA-Z0-9]/);
|
|
1558
|
-
});
|
|
1559
|
-
|
|
1560
|
-
it("offrouter models human output shows counts", async () => {
|
|
1561
|
-
const { code, stdout } = await capture(["models"]);
|
|
1562
|
-
expect(code).toBe(0);
|
|
1563
|
-
expect(stdout).toMatch(/Built-in: \d+ models/);
|
|
1564
|
-
expect(stdout).toMatch(/Cached: \d+ models/);
|
|
1565
|
-
expect(stdout).toMatch(/Config providers: \d+/);
|
|
1566
|
-
});
|
|
1567
|
-
|
|
1568
|
-
it("unknown command exits nonzero", async () => {
|
|
1569
|
-
const { code, stderr } = await capture(["not-a-real-command"]);
|
|
1570
|
-
expect(code).not.toBe(0);
|
|
1571
|
-
expect(stderr.length + code).toBeGreaterThan(0);
|
|
1572
|
-
});
|
|
1573
|
-
|
|
1574
|
-
it("createProgram returns a Commander program", () => {
|
|
1575
|
-
const program = createProgram();
|
|
1576
|
-
expect(program.name()).toBe("offrouter");
|
|
1577
|
-
});
|
|
1578
|
-
|
|
1579
|
-
it("offrouter status --providers --json shows real quotaRemaining from FileUsageStore", async () => {
|
|
1580
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-status-usage-"));
|
|
1581
|
-
try {
|
|
1582
|
-
await writeFile(
|
|
1583
|
-
join(home, "config.toml"),
|
|
1584
|
-
[
|
|
1585
|
-
'allowlisted_profiles = ["claude-personal"]',
|
|
1586
|
-
"",
|
|
1587
|
-
"[providers.anthropic]",
|
|
1588
|
-
"enabled = true",
|
|
1589
|
-
'accounts = [{ id = "my-account" }]',
|
|
1590
|
-
"",
|
|
1591
|
-
].join("\n"),
|
|
1592
|
-
"utf8",
|
|
1593
|
-
);
|
|
1594
|
-
const usageStore = new FileUsageStore({
|
|
1595
|
-
filePath: join(home, "state", "usage.json"),
|
|
1596
|
-
});
|
|
1597
|
-
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1598
|
-
used: 85,
|
|
1599
|
-
limit: 100,
|
|
1600
|
-
});
|
|
1601
|
-
|
|
1602
|
-
const { code, stdout } = await capture(
|
|
1603
|
-
["status", "--providers", "--json"],
|
|
1604
|
-
{ ...process.env, OFFROUTER_HOME: home },
|
|
1605
|
-
);
|
|
1606
|
-
expect(code).toBe(0);
|
|
1607
|
-
const json = JSON.parse(stdout) as {
|
|
1608
|
-
providers: Array<{ accounts: Array<{ quotaRemaining: number | null }> }>;
|
|
1609
|
-
};
|
|
1610
|
-
const account = json.providers?.[0]?.accounts?.[0];
|
|
1611
|
-
expect(account).toBeDefined();
|
|
1612
|
-
expect(account!.quotaRemaining).toBe(15);
|
|
1613
|
-
} finally {
|
|
1614
|
-
await rm(home, { recursive: true, force: true });
|
|
1615
|
-
}
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1618
|
-
it("offrouter doctor --json includes nearLimitWarnings when accounts are near-limit", async () => {
|
|
1619
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-near-"));
|
|
1620
|
-
try {
|
|
1621
|
-
await writeFile(
|
|
1622
|
-
join(home, "config.toml"),
|
|
1623
|
-
[
|
|
1624
|
-
'allowlisted_profiles = ["claude-personal"]',
|
|
1625
|
-
"",
|
|
1626
|
-
"[providers.anthropic]",
|
|
1627
|
-
"enabled = true",
|
|
1628
|
-
'accounts = [{ id = "my-account" }]',
|
|
1629
|
-
"",
|
|
1630
|
-
].join("\n"),
|
|
1631
|
-
"utf8",
|
|
1632
|
-
);
|
|
1633
|
-
const usageStore = new FileUsageStore({
|
|
1634
|
-
filePath: join(home, "state", "usage.json"),
|
|
1635
|
-
});
|
|
1636
|
-
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1637
|
-
used: 90,
|
|
1638
|
-
limit: 100,
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
const { code, stdout } = await capture(
|
|
1642
|
-
["doctor", "--profile", "claude-personal", "--json"],
|
|
1643
|
-
{ ...process.env, OFFROUTER_HOME: home },
|
|
1644
|
-
);
|
|
1645
|
-
expect(code).toBe(0);
|
|
1646
|
-
const json = JSON.parse(stdout) as {
|
|
1647
|
-
nearLimitWarnings?: string[];
|
|
1648
|
-
accounts: Array<{ nearLimit: boolean | null }>;
|
|
1649
|
-
};
|
|
1650
|
-
expect(json.nearLimitWarnings).toBeDefined();
|
|
1651
|
-
expect(json.nearLimitWarnings!.length).toBeGreaterThan(0);
|
|
1652
|
-
expect(json.nearLimitWarnings![0]).toMatch(/near its limit/i);
|
|
1653
|
-
expect(json.accounts?.[0]?.nearLimit).toBe(true);
|
|
1654
|
-
} finally {
|
|
1655
|
-
await rm(home, { recursive: true, force: true });
|
|
1656
|
-
}
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
it("offrouter route explain --json includes nearLimitWarning when an account is near-limit", async () => {
|
|
1660
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-route-near-"));
|
|
1661
|
-
try {
|
|
1662
|
-
await writeFile(
|
|
1663
|
-
join(home, "config.toml"),
|
|
1664
|
-
[
|
|
1665
|
-
'allowlisted_profiles = ["claude-personal"]',
|
|
1666
|
-
"",
|
|
1667
|
-
"[providers.anthropic]",
|
|
1668
|
-
"enabled = true",
|
|
1669
|
-
'accounts = [{ id = "my-account" }]',
|
|
1670
|
-
"",
|
|
1671
|
-
].join("\n"),
|
|
1672
|
-
"utf8",
|
|
1673
|
-
);
|
|
1674
|
-
const usageStore = new FileUsageStore({
|
|
1675
|
-
filePath: join(home, "state", "usage.json"),
|
|
1676
|
-
});
|
|
1677
|
-
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1678
|
-
used: 95,
|
|
1679
|
-
limit: 100,
|
|
1680
|
-
});
|
|
1681
|
-
|
|
1682
|
-
const { code, stdout } = await capture(
|
|
1683
|
-
[
|
|
1684
|
-
"route",
|
|
1685
|
-
"explain",
|
|
1686
|
-
"explain this repo",
|
|
1687
|
-
"--profile",
|
|
1688
|
-
"claude-personal",
|
|
1689
|
-
"--json",
|
|
1690
|
-
],
|
|
1691
|
-
{ ...process.env, OFFROUTER_HOME: home },
|
|
1692
|
-
);
|
|
1693
|
-
expect(code).toBe(0);
|
|
1694
|
-
const json = JSON.parse(stdout) as {
|
|
1695
|
-
nearLimitWarning?: string;
|
|
1696
|
-
blocked: boolean;
|
|
1697
|
-
};
|
|
1698
|
-
// The route is blocked (no candidates), but the warning should be present.
|
|
1699
|
-
expect(json.nearLimitWarning).toBeDefined();
|
|
1700
|
-
expect(json.nearLimitWarning).toMatch(/near its limit/i);
|
|
1701
|
-
} finally {
|
|
1702
|
-
await rm(home, { recursive: true, force: true });
|
|
1703
|
-
}
|
|
1704
|
-
});
|
|
1705
|
-
});
|