offrouter-cli 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/catalog-cache.d.ts +60 -0
  2. package/dist/catalog-cache.d.ts.map +1 -0
  3. package/dist/catalog-cache.js +113 -0
  4. package/dist/catalog-cache.js.map +1 -0
  5. package/dist/commands/config-error.d.ts +8 -0
  6. package/dist/commands/config-error.d.ts.map +1 -0
  7. package/dist/commands/config-error.js +22 -0
  8. package/dist/commands/config-error.js.map +1 -0
  9. package/dist/commands/detection.d.ts +55 -0
  10. package/dist/commands/detection.d.ts.map +1 -0
  11. package/dist/commands/detection.js +131 -0
  12. package/dist/commands/detection.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +6 -0
  14. package/dist/commands/doctor.d.ts.map +1 -0
  15. package/dist/commands/doctor.js +369 -0
  16. package/dist/commands/doctor.js.map +1 -0
  17. package/dist/commands/hooks.d.ts +4 -0
  18. package/dist/commands/hooks.d.ts.map +1 -0
  19. package/dist/commands/hooks.js +86 -0
  20. package/dist/commands/hooks.js.map +1 -0
  21. package/dist/commands/init.d.ts +12 -0
  22. package/dist/commands/init.d.ts.map +1 -0
  23. package/dist/commands/init.js +38 -0
  24. package/dist/commands/init.js.map +1 -0
  25. package/dist/commands/install.d.ts +8 -0
  26. package/dist/commands/install.d.ts.map +1 -0
  27. package/dist/commands/install.js +267 -0
  28. package/dist/commands/install.js.map +1 -0
  29. package/dist/commands/login.d.ts +4 -0
  30. package/dist/commands/login.d.ts.map +1 -0
  31. package/dist/commands/login.js +25 -0
  32. package/dist/commands/login.js.map +1 -0
  33. package/dist/commands/mcp.d.ts +4 -0
  34. package/dist/commands/mcp.d.ts.map +1 -0
  35. package/dist/commands/mcp.js +27 -0
  36. package/dist/commands/mcp.js.map +1 -0
  37. package/dist/commands/models.d.ts +15 -0
  38. package/dist/commands/models.d.ts.map +1 -0
  39. package/dist/commands/models.js +124 -0
  40. package/dist/commands/models.js.map +1 -0
  41. package/dist/commands/output.d.ts +6 -0
  42. package/dist/commands/output.d.ts.map +1 -0
  43. package/dist/commands/output.js +7 -0
  44. package/dist/commands/output.js.map +1 -0
  45. package/dist/commands/profiles.d.ts +4 -0
  46. package/dist/commands/profiles.d.ts.map +1 -0
  47. package/dist/commands/profiles.js +46 -0
  48. package/dist/commands/profiles.js.map +1 -0
  49. package/dist/commands/proxy.d.ts +10 -0
  50. package/dist/commands/proxy.d.ts.map +1 -0
  51. package/dist/commands/proxy.js +53 -0
  52. package/dist/commands/proxy.js.map +1 -0
  53. package/dist/commands/route.d.ts +4 -0
  54. package/dist/commands/route.d.ts.map +1 -0
  55. package/dist/commands/route.js +123 -0
  56. package/dist/commands/route.js.map +1 -0
  57. package/dist/commands/status.d.ts +4 -0
  58. package/dist/commands/status.d.ts.map +1 -0
  59. package/dist/commands/status.js +86 -0
  60. package/dist/commands/status.js.map +1 -0
  61. package/dist/commands/update.d.ts +9 -0
  62. package/dist/commands/update.d.ts.map +1 -0
  63. package/dist/commands/update.js +44 -0
  64. package/dist/commands/update.js.map +1 -0
  65. package/dist/index.d.ts +25 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +137 -0
  68. package/dist/index.js.map +1 -0
  69. package/package.json +24 -0
  70. package/src/catalog-cache.test.ts +258 -0
  71. package/src/catalog-cache.ts +169 -0
  72. package/src/catalog-sample.json +30 -0
  73. package/src/cli.test.ts +1577 -0
  74. package/src/commands/config-error.ts +31 -0
  75. package/src/commands/detection.ts +209 -0
  76. package/src/commands/doctor.ts +503 -0
  77. package/src/commands/hooks.ts +125 -0
  78. package/src/commands/init.ts +59 -0
  79. package/src/commands/install.ts +438 -0
  80. package/src/commands/login.ts +33 -0
  81. package/src/commands/mcp.ts +35 -0
  82. package/src/commands/models.ts +168 -0
  83. package/src/commands/output.ts +11 -0
  84. package/src/commands/profiles.ts +58 -0
  85. package/src/commands/proxy.ts +87 -0
  86. package/src/commands/route.ts +160 -0
  87. package/src/commands/status.ts +111 -0
  88. package/src/commands/update.ts +62 -0
  89. package/src/index.ts +174 -0
  90. package/tsconfig.json +17 -0
@@ -0,0 +1,503 @@
1
+ import type { Command } from "commander";
2
+ import { access, constants, readFile } from "node:fs/promises";
3
+ import { delimiter } from "node:path";
4
+ import { dirname, join, normalize } from "node:path";
5
+ import {
6
+ evaluatePolicy,
7
+ loadConfig,
8
+ redact,
9
+ routeRequest,
10
+ type ConfigWarning,
11
+ type HarnessKind,
12
+ type PolicyConfig,
13
+ type RouteRequest,
14
+ } from "offrouter-core";
15
+ import { formatConfigError, type CliConfigError } from "./config-error.js";
16
+ import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
17
+ import { writeJson, writeText, type IoStreams } from "./output.js";
18
+ import type { CommandContext } from "./init.js";
19
+
20
+ interface NodeDiagnostic {
21
+ version: string;
22
+ engineRequirement: string | null;
23
+ engineSource: string | null;
24
+ satisfiesEngine: boolean | null;
25
+ }
26
+
27
+ interface CommandAvailability {
28
+ command: string;
29
+ executable: string;
30
+ available: boolean;
31
+ path: string | null;
32
+ }
33
+
34
+ function secretValues(env: NodeJS.ProcessEnv): string[] {
35
+ return Object.entries(env)
36
+ .filter(([key, value]) => {
37
+ if (typeof value !== "string" || value.length === 0) return false;
38
+ return /(api_?key|token|secret|password)/i.test(key);
39
+ })
40
+ .map(([, value]) => value as string);
41
+ }
42
+
43
+ function redactDiagnostic(value: string, env: NodeJS.ProcessEnv): string {
44
+ return redact(value, secretValues(env));
45
+ }
46
+
47
+ function redactConfigError(
48
+ error: CliConfigError | null,
49
+ env: NodeJS.ProcessEnv,
50
+ ): CliConfigError | null {
51
+ if (!error) return null;
52
+ return {
53
+ ...error,
54
+ message: redactDiagnostic(error.message, env),
55
+ filePath: error.filePath
56
+ ? redactDiagnostic(error.filePath, env)
57
+ : error.filePath,
58
+ issues: error.issues?.map((issue) => redactDiagnostic(issue, env)),
59
+ };
60
+ }
61
+
62
+ function redactConfigWarnings(
63
+ warnings: ConfigWarning[],
64
+ env: NodeJS.ProcessEnv,
65
+ ): ConfigWarning[] {
66
+ return warnings.map((warning) => ({
67
+ filePath: redactDiagnostic(warning.filePath, env),
68
+ path: warning.path,
69
+ message: redactDiagnostic(warning.message, env),
70
+ }));
71
+ }
72
+
73
+ function nodeVersionTuple(version: string): [number, number, number] | null {
74
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version.trim());
75
+ if (!match) return null;
76
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
77
+ }
78
+
79
+ function compareVersions(
80
+ current: [number, number, number],
81
+ required: [number, number, number],
82
+ ): number {
83
+ for (let i = 0; i < current.length; i += 1) {
84
+ const diff = current[i]! - required[i]!;
85
+ if (diff !== 0) return diff;
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ function satisfiesNodeEngine(
91
+ version: string,
92
+ requirement: string | null,
93
+ ): boolean | null {
94
+ if (!requirement) return null;
95
+ const current = nodeVersionTuple(version);
96
+ const required = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(
97
+ requirement.trim(),
98
+ );
99
+ if (!current || !required) return null;
100
+ return (
101
+ compareVersions(current, [
102
+ Number(required[1]),
103
+ Number(required[2] ?? 0),
104
+ Number(required[3] ?? 0),
105
+ ]) >= 0
106
+ );
107
+ }
108
+
109
+ async function findNearestPackageJson(cwd: string): Promise<string | null> {
110
+ let current = cwd;
111
+ for (;;) {
112
+ const candidate = join(current, "package.json");
113
+ try {
114
+ await access(candidate, constants.F_OK);
115
+ return candidate;
116
+ } catch {
117
+ const parent = dirname(current);
118
+ if (parent === current) return null;
119
+ current = parent;
120
+ }
121
+ }
122
+ }
123
+
124
+ async function readNodeDiagnostic(cwd: string): Promise<NodeDiagnostic> {
125
+ const engineSource = await findNearestPackageJson(cwd);
126
+ let engineRequirement: string | null = null;
127
+ if (engineSource) {
128
+ try {
129
+ const pkg = JSON.parse(await readFile(engineSource, "utf8")) as {
130
+ engines?: { node?: unknown };
131
+ };
132
+ if (typeof pkg.engines?.node === "string") {
133
+ engineRequirement = pkg.engines.node;
134
+ }
135
+ } catch {
136
+ engineRequirement = null;
137
+ }
138
+ }
139
+
140
+ return {
141
+ version: process.version,
142
+ engineRequirement,
143
+ engineSource,
144
+ satisfiesEngine: satisfiesNodeEngine(process.version, engineRequirement),
145
+ };
146
+ }
147
+
148
+ async function executablePath(
149
+ executable: string,
150
+ env: NodeJS.ProcessEnv,
151
+ ): Promise<string | null> {
152
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
153
+ const suffixes =
154
+ process.platform === "win32"
155
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
156
+ .split(";")
157
+ .filter((suffix) => suffix.length > 0)
158
+ : [""];
159
+
160
+ for (const dir of pathValue.split(delimiter)) {
161
+ if (dir.trim() === "") continue;
162
+ for (const suffix of suffixes) {
163
+ const candidate = join(dir, `${executable}${suffix}`);
164
+ try {
165
+ await access(candidate, constants.X_OK);
166
+ return candidate;
167
+ } catch {
168
+ // Keep searching PATH.
169
+ }
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ async function detectCommandAvailability(
176
+ command: string,
177
+ executable: string,
178
+ env: NodeJS.ProcessEnv,
179
+ ): Promise<CommandAvailability> {
180
+ const path = await executablePath(executable, env);
181
+ return {
182
+ command,
183
+ executable,
184
+ available: path !== null,
185
+ path: path ? redactDiagnostic(path, env) : null,
186
+ };
187
+ }
188
+
189
+ // process.argv[1] is the running CLI script. When the CLI is launched from its
190
+ // built bundle the path ends in packages/cli/dist/index.js; doctor reports it
191
+ // as an `mcp serve` fallback when the `offrouter` binary is not on PATH.
192
+ // Match on a normalized path suffix (not a raw substring with manual separator
193
+ // rewriting) so platform separator differences do not break the check.
194
+ const DIST_ENTRY_SUFFIX = join("packages", "cli", "dist", "index.js");
195
+
196
+ function currentCliEntryPoint(): string | null {
197
+ const entryPoint = process.argv[1];
198
+ if (!entryPoint) return null;
199
+ if (!normalize(entryPoint).endsWith(DIST_ENTRY_SUFFIX)) {
200
+ return null;
201
+ }
202
+ return entryPoint;
203
+ }
204
+
205
+ async function detectMcpServeAvailability(
206
+ env: NodeJS.ProcessEnv,
207
+ ): Promise<CommandAvailability> {
208
+ const installed = await detectCommandAvailability(
209
+ "offrouter mcp serve",
210
+ "offrouter",
211
+ env,
212
+ );
213
+ if (installed.available) return installed;
214
+
215
+ const entryPoint = currentCliEntryPoint();
216
+ if (!entryPoint) return installed;
217
+ try {
218
+ await access(entryPoint, constants.F_OK);
219
+ } catch {
220
+ return installed;
221
+ }
222
+
223
+ const executable = redactDiagnostic(process.execPath, env);
224
+ const path = redactDiagnostic(entryPoint, env);
225
+ return {
226
+ command: `${executable} ${path} mcp serve`,
227
+ executable,
228
+ available: true,
229
+ path,
230
+ };
231
+ }
232
+
233
+ // Profile ids follow the convention "<harness>-<scope>" (for example
234
+ // "claude-personal" or "codex-personal"), so a profile maps to its HarnessKind
235
+ // by matching the known harness prefixes. Kept as an explicit, typed table
236
+ // rather than ad hoc string checks so the mapping is auditable and adding a
237
+ // harness is a single entry. None of the prefixes is a prefix of another, so
238
+ // the lookup order does not change the result.
239
+ const HARNESS_PROFILE_PREFIXES: ReadonlyArray<{
240
+ prefix: string;
241
+ kind: HarnessKind;
242
+ }> = [
243
+ { prefix: "claude", kind: "claude" },
244
+ { prefix: "codex", kind: "codex" },
245
+ { prefix: "gemini", kind: "gemini" },
246
+ { prefix: "grok", kind: "grok" },
247
+ { prefix: "cursor", kind: "cursor" },
248
+ { prefix: "pi", kind: "pi" },
249
+ ];
250
+
251
+ function harnessKindFromProfile(profile: string): HarnessKind {
252
+ const match = HARNESS_PROFILE_PREFIXES.find((entry) =>
253
+ profile.startsWith(entry.prefix),
254
+ );
255
+ return match?.kind ?? "other";
256
+ }
257
+
258
+ function buildDoctorRouteRequest(profile: string, cwd: string): RouteRequest {
259
+ return {
260
+ protocolVersion: "offrouter.route.v1",
261
+ requestId: `cli_doctor_${profile}`,
262
+ harness: {
263
+ kind: harnessKindFromProfile(profile),
264
+ profile,
265
+ },
266
+ task: {
267
+ promptPreview: "offrouter doctor profile policy check",
268
+ promptDigest: "doctor",
269
+ kind: "explain",
270
+ risk: "low",
271
+ },
272
+ workspace: {
273
+ cwd,
274
+ trusted: true,
275
+ },
276
+ constraints: {
277
+ subscriptionFirst: true,
278
+ allowApiKeyFallback: false,
279
+ },
280
+ };
281
+ }
282
+
283
+ function formatBoolean(value: boolean | null): string {
284
+ if (value === null) return "unknown";
285
+ return value ? "ok" : "not satisfied";
286
+ }
287
+
288
+ export function registerDoctorCommand(
289
+ program: Command,
290
+ ctx: () => CommandContext,
291
+ ): void {
292
+ program
293
+ .command("doctor")
294
+ .description("Inspect OffRouter readiness and configuration health")
295
+ .option("--json", "Emit JSON", false)
296
+ .option(
297
+ "--profile <profile>",
298
+ "Harness profile id to check against OffRouter policy",
299
+ "claude-personal",
300
+ )
301
+ .action(async (opts: { json?: boolean; profile: string }) => {
302
+ const { env, cwd, stdout } = ctx();
303
+ const [detection, rawNode, claudeCli, mcpServe] = await Promise.all([
304
+ detectEnvironment({ env, cwd }),
305
+ readNodeDiagnostic(cwd),
306
+ detectCommandAvailability("claude", "claude", env),
307
+ detectMcpServeAvailability(env),
308
+ ]);
309
+ const node: NodeDiagnostic = {
310
+ ...rawNode,
311
+ engineSource: rawNode.engineSource
312
+ ? redactDiagnostic(rawNode.engineSource, env)
313
+ : null,
314
+ };
315
+ const harnessProfiles = detection.harnessProfiles.map((profile) => ({
316
+ ...profile,
317
+ pathChecked: profile.pathChecked
318
+ ? redactDiagnostic(profile.pathChecked, env)
319
+ : profile.pathChecked,
320
+ }));
321
+ let configSummary: {
322
+ homeDir: string;
323
+ sources: string[];
324
+ allowlistedProfiles: string[];
325
+ providersConfigured: string[];
326
+ valid: boolean;
327
+ warnings: number;
328
+ warningDetails: ConfigWarning[];
329
+ };
330
+ let configError: CliConfigError | null = null;
331
+ let policyConfig: PolicyConfig;
332
+ try {
333
+ const config = await loadConfig({ env, workspaceDir: cwd });
334
+ policyConfig = config.policy;
335
+ configSummary = {
336
+ homeDir: redactDiagnostic(config.homeDir, env),
337
+ sources: config.sources.map((source) =>
338
+ redactDiagnostic(source, env),
339
+ ),
340
+ allowlistedProfiles: config.policy.allowlistedProfiles,
341
+ providersConfigured: Object.keys(config.providers).sort(),
342
+ valid: true,
343
+ warnings: config.warnings.length,
344
+ warningDetails: redactConfigWarnings(config.warnings, env),
345
+ };
346
+ } catch (err) {
347
+ configError = formatConfigError(err);
348
+ const safeConfigError = redactConfigError(configError, env);
349
+ configError = safeConfigError;
350
+ policyConfig = {
351
+ allowlistedProfiles: [],
352
+ deniedProfilePatterns: ["*-work"],
353
+ allowThirdPartySubscriptionAdapters: false,
354
+ deniedProviders: [],
355
+ };
356
+ configSummary = {
357
+ homeDir: env.OFFROUTER_HOME
358
+ ? redactDiagnostic(env.OFFROUTER_HOME, env)
359
+ : "(default)",
360
+ sources: [],
361
+ allowlistedProfiles: [],
362
+ providersConfigured: [],
363
+ valid: false,
364
+ warnings: 0,
365
+ warningDetails: [],
366
+ };
367
+ }
368
+
369
+ const request = buildDoctorRouteRequest(opts.profile, cwd);
370
+ const policy = evaluatePolicy(request, [], policyConfig);
371
+ const routeDecision = routeRequest(request, [], policyConfig);
372
+ const profileDenials = policy.denials
373
+ .filter((denial) => denial.target === "profile")
374
+ .map((denial) => ({
375
+ ...denial,
376
+ id: redactDiagnostic(denial.id, env),
377
+ message: redactDiagnostic(denial.message, env),
378
+ }));
379
+ const profileAllowed = profileDenials.length === 0;
380
+ const profile = {
381
+ profile: opts.profile,
382
+ allowed: profileAllowed,
383
+ status: profileAllowed ? "allowed" : "denied",
384
+ denials: profileDenials,
385
+ routeReadiness: {
386
+ blocked: routeDecision.blocked === true,
387
+ needsConfiguration: routeDecision.needsConfiguration === true,
388
+ reason: routeDecision.reason,
389
+ explanation: redactDiagnostic(routeDecision.explanation, env),
390
+ },
391
+ };
392
+
393
+ const localRuntime = {
394
+ ...detection.localRuntime,
395
+ ready: detection.localRuntime.health !== "unconfigured",
396
+ };
397
+ const providerAuth = {
398
+ ready:
399
+ detection.authTiers.subscription ||
400
+ detection.authTiers.local ||
401
+ detection.authTiers["api-key"],
402
+ authTiers: detection.authTiers,
403
+ envPresent: detection.apiKeyReadiness.envPresent,
404
+ configuredProviders: configSummary.providersConfigured,
405
+ apiKeyReady: detection.apiKeyReadiness.anyReady,
406
+ };
407
+
408
+ const payload = {
409
+ honesty: V1_HONESTY_LINE,
410
+ node,
411
+ harnessProfiles,
412
+ authTiers: detection.authTiers,
413
+ subscription: detection.subscription,
414
+ apiKeyReadiness: detection.apiKeyReadiness,
415
+ providerAuth,
416
+ localRuntime,
417
+ profile,
418
+ claudeCli,
419
+ mcpServe,
420
+ limits: detection.limits,
421
+ lastProviderError: detection.lastProviderError,
422
+ config: configSummary,
423
+ configError,
424
+ };
425
+
426
+ if (opts.json) {
427
+ writeJson(stdout, payload);
428
+ return;
429
+ }
430
+
431
+ writeText(stdout, V1_HONESTY_LINE);
432
+ writeText(
433
+ stdout,
434
+ `Node: ${node.version} (requires ${node.engineRequirement ?? "unknown"}: ${formatBoolean(node.satisfiesEngine)})`,
435
+ );
436
+ writeText(
437
+ stdout,
438
+ `Config: ${configSummary.valid ? "valid" : "invalid"}; warnings: ${configSummary.warnings}; sources: ${configSummary.sources.length}`,
439
+ );
440
+ for (const warning of configSummary.warningDetails) {
441
+ writeText(
442
+ stdout,
443
+ `- Config warning [${warning.path}]: ${warning.message}`,
444
+ );
445
+ }
446
+ writeText(
447
+ stdout,
448
+ `Selected profile: ${profile.profile} - ${profile.status}`,
449
+ );
450
+ if (profile.denials.length > 0) {
451
+ for (const denial of profile.denials) {
452
+ writeText(stdout, `- [${denial.code}] ${denial.message}`);
453
+ }
454
+ } else {
455
+ writeText(
456
+ stdout,
457
+ `Profile readiness: ${profile.routeReadiness.reason}; needs configuration: ${profile.routeReadiness.needsConfiguration}`,
458
+ );
459
+ }
460
+ writeText(
461
+ stdout,
462
+ `Claude CLI: ${claudeCli.available ? "available" : "missing"}${claudeCli.path ? ` at ${claudeCli.path}` : ""}`,
463
+ );
464
+ writeText(
465
+ stdout,
466
+ `MCP serve: ${mcpServe.available ? "available" : "missing"} via ${mcpServe.command}${mcpServe.path ? ` at ${mcpServe.path}` : ""}`,
467
+ );
468
+ writeText(
469
+ stdout,
470
+ `Provider auth: ready=${providerAuth.ready}; API keys present: ${detection.apiKeyReadiness.envPresent.join(", ") || "(none)"}; configured providers: ${configSummary.providersConfigured.join(", ") || "(none)"}`,
471
+ );
472
+ writeText(
473
+ stdout,
474
+ `Subscription: ${detection.subscription.status}; API keys present: ${detection.apiKeyReadiness.anyReady}; local runtime: ${localRuntime.health}`,
475
+ );
476
+ writeText(
477
+ stdout,
478
+ `Local runtime: ${localRuntime.health}; ready=${localRuntime.ready}; ollama host configured=${localRuntime.ollamaHostConfigured}; local binary present=${localRuntime.localBinaryPresent}`,
479
+ );
480
+ writeText(
481
+ stdout,
482
+ `Allowlisted profiles: ${configSummary.allowlistedProfiles.join(", ") || "(none)"}`,
483
+ );
484
+ writeText(
485
+ stdout,
486
+ `Configured providers: ${configSummary.providersConfigured.join(", ") || "(none)"}`,
487
+ );
488
+ writeText(
489
+ stdout,
490
+ `Limits: ${detection.limits.status} - ${detection.limits.detail}`,
491
+ );
492
+ writeText(
493
+ stdout,
494
+ `Last provider error: ${detection.lastProviderError ?? "(none)"}`,
495
+ );
496
+ if (configError) {
497
+ writeText(stdout, `Config error: ${configError.message}`);
498
+ }
499
+ });
500
+ }
501
+
502
+ // Re-export for status registration whenever status lives with doctor-like output.
503
+ export type { IoStreams };
@@ -0,0 +1,125 @@
1
+ import type { Command } from "commander";
2
+ import { loadConfig } from "offrouter-core";
3
+ import { runUserPromptSubmitHook } from "offrouter-adapter-claude";
4
+ import type { CommandContext } from "./init.js";
5
+
6
+ const HOOK_EVENT = "UserPromptSubmit" as const;
7
+
8
+ function noActiveProfileOutput(): string {
9
+ return JSON.stringify({
10
+ hookSpecificOutput: {
11
+ hookEventName: HOOK_EVENT,
12
+ additionalContext:
13
+ "OffRouter has no active profile. Set --profile or OFFROUTER_PROFILE to enable routing or policy blocking. Claude Code's primary model is unchanged in V1; OffRouter routes delegated work.",
14
+ },
15
+ });
16
+ }
17
+
18
+ function isWorkspaceTrusted(
19
+ explicit: boolean | undefined,
20
+ env: NodeJS.ProcessEnv,
21
+ ): boolean {
22
+ if (explicit === true) {
23
+ return true;
24
+ }
25
+ return env.OFFROUTER_WORKSPACE_TRUSTED === "true";
26
+ }
27
+
28
+ function resolveProfile(
29
+ optionProfile: string | undefined,
30
+ env: NodeJS.ProcessEnv,
31
+ ): string | undefined {
32
+ const fromOpt = optionProfile?.trim();
33
+ if (fromOpt) {
34
+ return fromOpt;
35
+ }
36
+ const fromEnv = env.OFFROUTER_PROFILE?.trim();
37
+ return fromEnv || undefined;
38
+ }
39
+
40
+ export function registerHooksCommand(
41
+ program: Command,
42
+ ctx: () => CommandContext,
43
+ ): void {
44
+ const hooks = program
45
+ .command("hooks")
46
+ .description("Harness hook bridges (stdin JSON in, JSON out)");
47
+
48
+ const claude = hooks
49
+ .command("claude")
50
+ .description("Claude Code hook bridges");
51
+
52
+ claude
53
+ .command("user-prompt-submit")
54
+ .description(
55
+ "Claude UserPromptSubmit bridge: read stdin, emit hook JSON only",
56
+ )
57
+ .option(
58
+ "--profile <profile>",
59
+ "Active harness profile (or set OFFROUTER_PROFILE)",
60
+ )
61
+ .option(
62
+ "--trusted-workspace",
63
+ "Mark workspace trusted for project config loading",
64
+ false,
65
+ )
66
+ .action(
67
+ async (opts: { profile?: string; trustedWorkspace?: boolean }) => {
68
+ const { env, cwd, stdout, stderr, readStdin } = ctx();
69
+ const profile = resolveProfile(opts.profile, env);
70
+
71
+ // No profile: safe context only; do not route or block.
72
+ if (!profile) {
73
+ stdout.write(noActiveProfileOutput());
74
+ return;
75
+ }
76
+
77
+ const trustedWorkspace = isWorkspaceTrusted(
78
+ opts.trustedWorkspace,
79
+ env,
80
+ );
81
+ const stdin = await readStdin();
82
+
83
+ try {
84
+ const config = await loadConfig({
85
+ env,
86
+ workspaceDir: cwd,
87
+ workspaceTrusted: trustedWorkspace,
88
+ });
89
+ const profileOverlay = config.profiles[profile];
90
+ const result = await runUserPromptSubmitHook(stdin, {
91
+ profile,
92
+ policy: config.policy,
93
+ // Live catalog discovery lands later; empty candidates still allow
94
+ // profile denials and configuration-needed context.
95
+ candidates: [],
96
+ trustedWorkspace,
97
+ routeConstraints: {
98
+ subscriptionFirst:
99
+ profileOverlay?.subscriptionFirst ??
100
+ config.policyDefaults.subscriptionFirst,
101
+ allowApiKeyFallback:
102
+ profileOverlay?.allowApiKeyFallback ??
103
+ config.policyDefaults.allowApiKeyFallback,
104
+ },
105
+ });
106
+ // Exact hook JSON only; no pretty-print banners or extra lines.
107
+ stdout.write(result.stdout);
108
+ if (result.stderr) {
109
+ stderr.write(result.stderr);
110
+ }
111
+ } catch (err) {
112
+ const message =
113
+ err instanceof Error ? err.message : "OffRouter hook failed";
114
+ stdout.write(
115
+ JSON.stringify({
116
+ hookSpecificOutput: {
117
+ hookEventName: HOOK_EVENT,
118
+ additionalContext: `OffRouter could not load config for hook processing: ${message}. Claude Code's primary model is unchanged in V1; OffRouter routes delegated work.`,
119
+ },
120
+ }),
121
+ );
122
+ }
123
+ },
124
+ );
125
+ }
@@ -0,0 +1,59 @@
1
+ import type { Command } from "commander";
2
+ import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
3
+ import { writeJson, writeText, type IoStreams } from "./output.js";
4
+
5
+ export interface CommandContext {
6
+ env: NodeJS.ProcessEnv;
7
+ cwd: string;
8
+ stdout: IoStreams;
9
+ stderr: IoStreams;
10
+ /** Read full stdin (injected string in tests, process.stdin otherwise). */
11
+ readStdin: () => Promise<string>;
12
+ }
13
+
14
+ export function registerInitCommand(
15
+ program: Command,
16
+ ctx: () => CommandContext,
17
+ ): void {
18
+ program
19
+ .command("init")
20
+ .description("Detect environment and scaffold OffRouter config")
21
+ .option("--dry-run", "Report detection without writing config", false)
22
+ .option("--json", "Emit JSON", false)
23
+ .action(async (opts: { dryRun?: boolean; json?: boolean }) => {
24
+ const { env, cwd, stdout } = ctx();
25
+ const detection = await detectEnvironment({ env, cwd });
26
+ const dryRun = Boolean(opts.dryRun);
27
+ const wroteConfig = false;
28
+
29
+ if (opts.json) {
30
+ writeJson(stdout, {
31
+ dryRun,
32
+ wroteConfig,
33
+ honesty: V1_HONESTY_LINE,
34
+ detection: {
35
+ harnessProfiles: detection.harnessProfiles,
36
+ authTiers: detection.authTiers,
37
+ subscription: detection.subscription,
38
+ apiKeyReadiness: detection.apiKeyReadiness,
39
+ localRuntime: detection.localRuntime,
40
+ limits: detection.limits,
41
+ lastProviderError: detection.lastProviderError,
42
+ },
43
+ });
44
+ return;
45
+ }
46
+
47
+ writeText(stdout, V1_HONESTY_LINE);
48
+ writeText(
49
+ stdout,
50
+ dryRun
51
+ ? "Dry run: no config files written."
52
+ : "Init writing is reserved for a later milestone; use --dry-run for detection.",
53
+ );
54
+ writeText(
55
+ stdout,
56
+ `Subscription: ${detection.subscription.status}; API-key ready: ${detection.apiKeyReadiness.anyReady}; local: ${detection.localRuntime.health}`,
57
+ );
58
+ });
59
+ }