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/commands/doctor.ts
DELETED
|
@@ -1,555 +0,0 @@
|
|
|
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
|
-
FileUsageStore,
|
|
8
|
-
loadConfig,
|
|
9
|
-
redact,
|
|
10
|
-
resolveOffRouterHome,
|
|
11
|
-
routeRequest,
|
|
12
|
-
type ConfigWarning,
|
|
13
|
-
type HarnessKind,
|
|
14
|
-
type PolicyConfig,
|
|
15
|
-
type RouteRequest,
|
|
16
|
-
} from "offrouter-core";
|
|
17
|
-
import { formatConfigError, type CliConfigError } from "./config-error.js";
|
|
18
|
-
import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
|
|
19
|
-
import { writeJson, writeText, type IoStreams } from "./output.js";
|
|
20
|
-
import type { CommandContext } from "./init.js";
|
|
21
|
-
|
|
22
|
-
interface NodeDiagnostic {
|
|
23
|
-
version: string;
|
|
24
|
-
engineRequirement: string | null;
|
|
25
|
-
engineSource: string | null;
|
|
26
|
-
satisfiesEngine: boolean | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface CommandAvailability {
|
|
30
|
-
command: string;
|
|
31
|
-
executable: string;
|
|
32
|
-
available: boolean;
|
|
33
|
-
path: string | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function secretValues(env: NodeJS.ProcessEnv): string[] {
|
|
37
|
-
return Object.entries(env)
|
|
38
|
-
.filter(([key, value]) => {
|
|
39
|
-
if (typeof value !== "string" || value.length === 0) return false;
|
|
40
|
-
return /(api_?key|token|secret|password)/i.test(key);
|
|
41
|
-
})
|
|
42
|
-
.map(([, value]) => value as string);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function redactDiagnostic(value: string, env: NodeJS.ProcessEnv): string {
|
|
46
|
-
return redact(value, secretValues(env));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function redactConfigError(
|
|
50
|
-
error: CliConfigError | null,
|
|
51
|
-
env: NodeJS.ProcessEnv,
|
|
52
|
-
): CliConfigError | null {
|
|
53
|
-
if (!error) return null;
|
|
54
|
-
return {
|
|
55
|
-
...error,
|
|
56
|
-
message: redactDiagnostic(error.message, env),
|
|
57
|
-
filePath: error.filePath
|
|
58
|
-
? redactDiagnostic(error.filePath, env)
|
|
59
|
-
: error.filePath,
|
|
60
|
-
issues: error.issues?.map((issue) => redactDiagnostic(issue, env)),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function redactConfigWarnings(
|
|
65
|
-
warnings: ConfigWarning[],
|
|
66
|
-
env: NodeJS.ProcessEnv,
|
|
67
|
-
): ConfigWarning[] {
|
|
68
|
-
return warnings.map((warning) => ({
|
|
69
|
-
filePath: redactDiagnostic(warning.filePath, env),
|
|
70
|
-
path: warning.path,
|
|
71
|
-
message: redactDiagnostic(warning.message, env),
|
|
72
|
-
}));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function nodeVersionTuple(version: string): [number, number, number] | null {
|
|
76
|
-
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version.trim());
|
|
77
|
-
if (!match) return null;
|
|
78
|
-
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function compareVersions(
|
|
82
|
-
current: [number, number, number],
|
|
83
|
-
required: [number, number, number],
|
|
84
|
-
): number {
|
|
85
|
-
for (let i = 0; i < current.length; i += 1) {
|
|
86
|
-
const diff = current[i]! - required[i]!;
|
|
87
|
-
if (diff !== 0) return diff;
|
|
88
|
-
}
|
|
89
|
-
return 0;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function satisfiesNodeEngine(
|
|
93
|
-
version: string,
|
|
94
|
-
requirement: string | null,
|
|
95
|
-
): boolean | null {
|
|
96
|
-
if (!requirement) return null;
|
|
97
|
-
const current = nodeVersionTuple(version);
|
|
98
|
-
const required = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(
|
|
99
|
-
requirement.trim(),
|
|
100
|
-
);
|
|
101
|
-
if (!current || !required) return null;
|
|
102
|
-
return (
|
|
103
|
-
compareVersions(current, [
|
|
104
|
-
Number(required[1]),
|
|
105
|
-
Number(required[2] ?? 0),
|
|
106
|
-
Number(required[3] ?? 0),
|
|
107
|
-
]) >= 0
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function findNearestPackageJson(cwd: string): Promise<string | null> {
|
|
112
|
-
let current = cwd;
|
|
113
|
-
for (;;) {
|
|
114
|
-
const candidate = join(current, "package.json");
|
|
115
|
-
try {
|
|
116
|
-
await access(candidate, constants.F_OK);
|
|
117
|
-
return candidate;
|
|
118
|
-
} catch {
|
|
119
|
-
const parent = dirname(current);
|
|
120
|
-
if (parent === current) return null;
|
|
121
|
-
current = parent;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function readNodeDiagnostic(cwd: string): Promise<NodeDiagnostic> {
|
|
127
|
-
const engineSource = await findNearestPackageJson(cwd);
|
|
128
|
-
let engineRequirement: string | null = null;
|
|
129
|
-
if (engineSource) {
|
|
130
|
-
try {
|
|
131
|
-
const pkg = JSON.parse(await readFile(engineSource, "utf8")) as {
|
|
132
|
-
engines?: { node?: unknown };
|
|
133
|
-
};
|
|
134
|
-
if (typeof pkg.engines?.node === "string") {
|
|
135
|
-
engineRequirement = pkg.engines.node;
|
|
136
|
-
}
|
|
137
|
-
} catch {
|
|
138
|
-
engineRequirement = null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
version: process.version,
|
|
144
|
-
engineRequirement,
|
|
145
|
-
engineSource,
|
|
146
|
-
satisfiesEngine: satisfiesNodeEngine(process.version, engineRequirement),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function executablePath(
|
|
151
|
-
executable: string,
|
|
152
|
-
env: NodeJS.ProcessEnv,
|
|
153
|
-
): Promise<string | null> {
|
|
154
|
-
const pathValue = env.PATH ?? process.env.PATH ?? "";
|
|
155
|
-
const suffixes =
|
|
156
|
-
process.platform === "win32"
|
|
157
|
-
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
|
158
|
-
.split(";")
|
|
159
|
-
.filter((suffix) => suffix.length > 0)
|
|
160
|
-
: [""];
|
|
161
|
-
|
|
162
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
163
|
-
if (dir.trim() === "") continue;
|
|
164
|
-
for (const suffix of suffixes) {
|
|
165
|
-
const candidate = join(dir, `${executable}${suffix}`);
|
|
166
|
-
try {
|
|
167
|
-
await access(candidate, constants.X_OK);
|
|
168
|
-
return candidate;
|
|
169
|
-
} catch {
|
|
170
|
-
// Keep searching PATH.
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function detectCommandAvailability(
|
|
178
|
-
command: string,
|
|
179
|
-
executable: string,
|
|
180
|
-
env: NodeJS.ProcessEnv,
|
|
181
|
-
): Promise<CommandAvailability> {
|
|
182
|
-
const path = await executablePath(executable, env);
|
|
183
|
-
return {
|
|
184
|
-
command,
|
|
185
|
-
executable,
|
|
186
|
-
available: path !== null,
|
|
187
|
-
path: path ? redactDiagnostic(path, env) : null,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// process.argv[1] is the running CLI script. When the CLI is launched from its
|
|
192
|
-
// built bundle the path ends in packages/cli/dist/index.js; doctor reports it
|
|
193
|
-
// as an `mcp serve` fallback when the `offrouter` binary is not on PATH.
|
|
194
|
-
// Match on a normalized path suffix (not a raw substring with manual separator
|
|
195
|
-
// rewriting) so platform separator differences do not break the check.
|
|
196
|
-
const DIST_ENTRY_SUFFIX = join("packages", "cli", "dist", "index.js");
|
|
197
|
-
|
|
198
|
-
function currentCliEntryPoint(): string | null {
|
|
199
|
-
const entryPoint = process.argv[1];
|
|
200
|
-
if (!entryPoint) return null;
|
|
201
|
-
if (!normalize(entryPoint).endsWith(DIST_ENTRY_SUFFIX)) {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
return entryPoint;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function detectMcpServeAvailability(
|
|
208
|
-
env: NodeJS.ProcessEnv,
|
|
209
|
-
): Promise<CommandAvailability> {
|
|
210
|
-
const installed = await detectCommandAvailability(
|
|
211
|
-
"offrouter mcp serve",
|
|
212
|
-
"offrouter",
|
|
213
|
-
env,
|
|
214
|
-
);
|
|
215
|
-
if (installed.available) return installed;
|
|
216
|
-
|
|
217
|
-
const entryPoint = currentCliEntryPoint();
|
|
218
|
-
if (!entryPoint) return installed;
|
|
219
|
-
try {
|
|
220
|
-
await access(entryPoint, constants.F_OK);
|
|
221
|
-
} catch {
|
|
222
|
-
return installed;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const executable = redactDiagnostic(process.execPath, env);
|
|
226
|
-
const path = redactDiagnostic(entryPoint, env);
|
|
227
|
-
return {
|
|
228
|
-
command: `${executable} ${path} mcp serve`,
|
|
229
|
-
executable,
|
|
230
|
-
available: true,
|
|
231
|
-
path,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Profile ids follow the convention "<harness>-<scope>" (for example
|
|
236
|
-
// "claude-personal" or "codex-personal"), so a profile maps to its HarnessKind
|
|
237
|
-
// by matching the known harness prefixes. Kept as an explicit, typed table
|
|
238
|
-
// rather than ad hoc string checks so the mapping is auditable and adding a
|
|
239
|
-
// harness is a single entry. None of the prefixes is a prefix of another, so
|
|
240
|
-
// the lookup order does not change the result.
|
|
241
|
-
const HARNESS_PROFILE_PREFIXES: ReadonlyArray<{
|
|
242
|
-
prefix: string;
|
|
243
|
-
kind: HarnessKind;
|
|
244
|
-
}> = [
|
|
245
|
-
{ prefix: "claude", kind: "claude" },
|
|
246
|
-
{ prefix: "codex", kind: "codex" },
|
|
247
|
-
{ prefix: "gemini", kind: "gemini" },
|
|
248
|
-
{ prefix: "grok", kind: "grok" },
|
|
249
|
-
{ prefix: "cursor", kind: "cursor" },
|
|
250
|
-
{ prefix: "pi", kind: "pi" },
|
|
251
|
-
];
|
|
252
|
-
|
|
253
|
-
function harnessKindFromProfile(profile: string): HarnessKind {
|
|
254
|
-
const match = HARNESS_PROFILE_PREFIXES.find((entry) =>
|
|
255
|
-
profile.startsWith(entry.prefix),
|
|
256
|
-
);
|
|
257
|
-
return match?.kind ?? "other";
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function buildDoctorRouteRequest(profile: string, cwd: string): RouteRequest {
|
|
261
|
-
return {
|
|
262
|
-
protocolVersion: "offrouter.route.v1",
|
|
263
|
-
requestId: `cli_doctor_${profile}`,
|
|
264
|
-
harness: {
|
|
265
|
-
kind: harnessKindFromProfile(profile),
|
|
266
|
-
profile,
|
|
267
|
-
},
|
|
268
|
-
task: {
|
|
269
|
-
promptPreview: "offrouter doctor profile policy check",
|
|
270
|
-
promptDigest: "doctor",
|
|
271
|
-
kind: "explain",
|
|
272
|
-
risk: "low",
|
|
273
|
-
},
|
|
274
|
-
workspace: {
|
|
275
|
-
cwd,
|
|
276
|
-
trusted: true,
|
|
277
|
-
},
|
|
278
|
-
constraints: {
|
|
279
|
-
subscriptionFirst: true,
|
|
280
|
-
allowApiKeyFallback: false,
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function formatBoolean(value: boolean | null): string {
|
|
286
|
-
if (value === null) return "unknown";
|
|
287
|
-
return value ? "ok" : "not satisfied";
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function registerDoctorCommand(
|
|
291
|
-
program: Command,
|
|
292
|
-
ctx: () => CommandContext,
|
|
293
|
-
): void {
|
|
294
|
-
program
|
|
295
|
-
.command("doctor")
|
|
296
|
-
.description("Inspect OffRouter readiness and configuration health")
|
|
297
|
-
.option("--json", "Emit JSON", false)
|
|
298
|
-
.option(
|
|
299
|
-
"--profile <profile>",
|
|
300
|
-
"Harness profile id to check against OffRouter policy",
|
|
301
|
-
"claude-personal",
|
|
302
|
-
)
|
|
303
|
-
.action(async (opts: { json?: boolean; profile: string }) => {
|
|
304
|
-
const { env, cwd, stdout } = ctx();
|
|
305
|
-
const [detection, rawNode, claudeCli, mcpServe] = await Promise.all([
|
|
306
|
-
detectEnvironment({ env, cwd }),
|
|
307
|
-
readNodeDiagnostic(cwd),
|
|
308
|
-
detectCommandAvailability("claude", "claude", env),
|
|
309
|
-
detectMcpServeAvailability(env),
|
|
310
|
-
]);
|
|
311
|
-
const node: NodeDiagnostic = {
|
|
312
|
-
...rawNode,
|
|
313
|
-
engineSource: rawNode.engineSource
|
|
314
|
-
? redactDiagnostic(rawNode.engineSource, env)
|
|
315
|
-
: null,
|
|
316
|
-
};
|
|
317
|
-
const harnessProfiles = detection.harnessProfiles.map((profile) => ({
|
|
318
|
-
...profile,
|
|
319
|
-
pathChecked: profile.pathChecked
|
|
320
|
-
? redactDiagnostic(profile.pathChecked, env)
|
|
321
|
-
: profile.pathChecked,
|
|
322
|
-
}));
|
|
323
|
-
let configSummary: {
|
|
324
|
-
homeDir: string;
|
|
325
|
-
sources: string[];
|
|
326
|
-
allowlistedProfiles: string[];
|
|
327
|
-
providersConfigured: string[];
|
|
328
|
-
valid: boolean;
|
|
329
|
-
warnings: number;
|
|
330
|
-
warningDetails: ConfigWarning[];
|
|
331
|
-
};
|
|
332
|
-
let configError: CliConfigError | null = null;
|
|
333
|
-
let policyConfig: PolicyConfig;
|
|
334
|
-
let accountReadiness: {
|
|
335
|
-
providerId: string;
|
|
336
|
-
accountId: string;
|
|
337
|
-
label: string;
|
|
338
|
-
nearLimit: boolean | null;
|
|
339
|
-
}[] = [];
|
|
340
|
-
const nearLimitWarnings: string[] = [];
|
|
341
|
-
try {
|
|
342
|
-
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
343
|
-
policyConfig = config.policy;
|
|
344
|
-
configSummary = {
|
|
345
|
-
homeDir: redactDiagnostic(config.homeDir, env),
|
|
346
|
-
sources: config.sources.map((source) =>
|
|
347
|
-
redactDiagnostic(source, env),
|
|
348
|
-
),
|
|
349
|
-
allowlistedProfiles: config.policy.allowlistedProfiles,
|
|
350
|
-
providersConfigured: Object.keys(config.providers).sort(),
|
|
351
|
-
valid: true,
|
|
352
|
-
warnings: config.warnings.length,
|
|
353
|
-
warningDetails: redactConfigWarnings(config.warnings, env),
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
// Read persistent usage data for near-limit detection.
|
|
357
|
-
const usageStore = new FileUsageStore({
|
|
358
|
-
filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
|
|
359
|
-
});
|
|
360
|
-
const allUsage = await usageStore.listAccountUsage();
|
|
361
|
-
const usageByKey = new Map(
|
|
362
|
-
allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
accountReadiness = Object.values(config.providers).flatMap((p) =>
|
|
366
|
-
p.accounts.map((a) => {
|
|
367
|
-
const snap = usageByKey.get(`${p.id}:${a.id}`);
|
|
368
|
-
return {
|
|
369
|
-
providerId: p.id,
|
|
370
|
-
accountId: a.id,
|
|
371
|
-
label: a.label ?? a.id,
|
|
372
|
-
nearLimit: snap?.nearLimit ?? null,
|
|
373
|
-
};
|
|
374
|
-
}),
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
for (const a of accountReadiness) {
|
|
378
|
-
if (a.nearLimit) {
|
|
379
|
-
nearLimitWarnings.push(
|
|
380
|
-
`Account ${a.providerId}/${a.accountId} is near its limit (${a.label})`,
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
} catch (err) {
|
|
385
|
-
configError = formatConfigError(err);
|
|
386
|
-
const safeConfigError = redactConfigError(configError, env);
|
|
387
|
-
configError = safeConfigError;
|
|
388
|
-
policyConfig = {
|
|
389
|
-
allowlistedProfiles: [],
|
|
390
|
-
deniedProfilePatterns: ["*-work"],
|
|
391
|
-
allowThirdPartySubscriptionAdapters: false,
|
|
392
|
-
deniedProviders: [],
|
|
393
|
-
};
|
|
394
|
-
configSummary = {
|
|
395
|
-
homeDir: env.OFFROUTER_HOME
|
|
396
|
-
? redactDiagnostic(env.OFFROUTER_HOME, env)
|
|
397
|
-
: "(default)",
|
|
398
|
-
sources: [],
|
|
399
|
-
allowlistedProfiles: [],
|
|
400
|
-
providersConfigured: [],
|
|
401
|
-
valid: false,
|
|
402
|
-
warnings: 0,
|
|
403
|
-
warningDetails: [],
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const request = buildDoctorRouteRequest(opts.profile, cwd);
|
|
408
|
-
const policy = evaluatePolicy(request, [], policyConfig);
|
|
409
|
-
const routeDecision = routeRequest(request, [], policyConfig);
|
|
410
|
-
const profileDenials = policy.denials
|
|
411
|
-
.filter((denial) => denial.target === "profile")
|
|
412
|
-
.map((denial) => ({
|
|
413
|
-
...denial,
|
|
414
|
-
id: redactDiagnostic(denial.id, env),
|
|
415
|
-
message: redactDiagnostic(denial.message, env),
|
|
416
|
-
}));
|
|
417
|
-
const profileAllowed = profileDenials.length === 0;
|
|
418
|
-
const profile = {
|
|
419
|
-
profile: opts.profile,
|
|
420
|
-
allowed: profileAllowed,
|
|
421
|
-
status: profileAllowed ? "allowed" : "denied",
|
|
422
|
-
denials: profileDenials,
|
|
423
|
-
routeReadiness: {
|
|
424
|
-
blocked: routeDecision.blocked === true,
|
|
425
|
-
needsConfiguration: routeDecision.needsConfiguration === true,
|
|
426
|
-
reason: routeDecision.reason,
|
|
427
|
-
explanation: redactDiagnostic(routeDecision.explanation, env),
|
|
428
|
-
},
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
const localRuntime = {
|
|
432
|
-
...detection.localRuntime,
|
|
433
|
-
ready: detection.localRuntime.health !== "unconfigured",
|
|
434
|
-
};
|
|
435
|
-
const providerAuth = {
|
|
436
|
-
ready:
|
|
437
|
-
detection.authTiers.subscription ||
|
|
438
|
-
detection.authTiers.local ||
|
|
439
|
-
detection.authTiers["api-key"],
|
|
440
|
-
authTiers: detection.authTiers,
|
|
441
|
-
envPresent: detection.apiKeyReadiness.envPresent,
|
|
442
|
-
configuredProviders: configSummary.providersConfigured,
|
|
443
|
-
apiKeyReady: detection.apiKeyReadiness.anyReady,
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
const payload = {
|
|
447
|
-
honesty: V1_HONESTY_LINE,
|
|
448
|
-
node,
|
|
449
|
-
harnessProfiles,
|
|
450
|
-
authTiers: detection.authTiers,
|
|
451
|
-
subscription: detection.subscription,
|
|
452
|
-
apiKeyReadiness: detection.apiKeyReadiness,
|
|
453
|
-
providerAuth,
|
|
454
|
-
localRuntime,
|
|
455
|
-
profile,
|
|
456
|
-
claudeCli,
|
|
457
|
-
mcpServe,
|
|
458
|
-
limits: detection.limits,
|
|
459
|
-
lastProviderError: detection.lastProviderError,
|
|
460
|
-
config: configSummary,
|
|
461
|
-
configError,
|
|
462
|
-
accounts: accountReadiness,
|
|
463
|
-
...(nearLimitWarnings.length > 0 ? { nearLimitWarnings } : {}),
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
if (opts.json) {
|
|
467
|
-
writeJson(stdout, payload);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
writeText(stdout, V1_HONESTY_LINE);
|
|
472
|
-
writeText(
|
|
473
|
-
stdout,
|
|
474
|
-
`Node: ${node.version} (requires ${node.engineRequirement ?? "unknown"}: ${formatBoolean(node.satisfiesEngine)})`,
|
|
475
|
-
);
|
|
476
|
-
writeText(
|
|
477
|
-
stdout,
|
|
478
|
-
`Config: ${configSummary.valid ? "valid" : "invalid"}; warnings: ${configSummary.warnings}; sources: ${configSummary.sources.length}`,
|
|
479
|
-
);
|
|
480
|
-
for (const warning of configSummary.warningDetails) {
|
|
481
|
-
writeText(
|
|
482
|
-
stdout,
|
|
483
|
-
`- Config warning [${warning.path}]: ${warning.message}`,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
writeText(
|
|
487
|
-
stdout,
|
|
488
|
-
`Accounts: ${accountReadiness.length} configured${accountReadiness.length > 0 ? "" : " (none)"}`,
|
|
489
|
-
);
|
|
490
|
-
for (const a of accountReadiness) {
|
|
491
|
-
writeText(stdout, ` account ${a.providerId}/${a.accountId}: ${a.label}`);
|
|
492
|
-
}
|
|
493
|
-
if (nearLimitWarnings.length > 0) {
|
|
494
|
-
for (const warning of nearLimitWarnings) {
|
|
495
|
-
writeText(stdout, ` ! ${warning}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
writeText(
|
|
499
|
-
stdout,
|
|
500
|
-
`Selected profile: ${profile.profile} - ${profile.status}`,
|
|
501
|
-
);
|
|
502
|
-
if (profile.denials.length > 0) {
|
|
503
|
-
for (const denial of profile.denials) {
|
|
504
|
-
writeText(stdout, `- [${denial.code}] ${denial.message}`);
|
|
505
|
-
}
|
|
506
|
-
} else {
|
|
507
|
-
writeText(
|
|
508
|
-
stdout,
|
|
509
|
-
`Profile readiness: ${profile.routeReadiness.reason}; needs configuration: ${profile.routeReadiness.needsConfiguration}`,
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
writeText(
|
|
513
|
-
stdout,
|
|
514
|
-
`Claude CLI: ${claudeCli.available ? "available" : "missing"}${claudeCli.path ? ` at ${claudeCli.path}` : ""}`,
|
|
515
|
-
);
|
|
516
|
-
writeText(
|
|
517
|
-
stdout,
|
|
518
|
-
`MCP serve: ${mcpServe.available ? "available" : "missing"} via ${mcpServe.command}${mcpServe.path ? ` at ${mcpServe.path}` : ""}`,
|
|
519
|
-
);
|
|
520
|
-
writeText(
|
|
521
|
-
stdout,
|
|
522
|
-
`Provider auth: ready=${providerAuth.ready}; API keys present: ${detection.apiKeyReadiness.envPresent.join(", ") || "(none)"}; configured providers: ${configSummary.providersConfigured.join(", ") || "(none)"}`,
|
|
523
|
-
);
|
|
524
|
-
writeText(
|
|
525
|
-
stdout,
|
|
526
|
-
`Subscription: ${detection.subscription.status}; API keys present: ${detection.apiKeyReadiness.anyReady}; local runtime: ${localRuntime.health}`,
|
|
527
|
-
);
|
|
528
|
-
writeText(
|
|
529
|
-
stdout,
|
|
530
|
-
`Local runtime: ${localRuntime.health}; ready=${localRuntime.ready}; ollama host configured=${localRuntime.ollamaHostConfigured}; local binary present=${localRuntime.localBinaryPresent}`,
|
|
531
|
-
);
|
|
532
|
-
writeText(
|
|
533
|
-
stdout,
|
|
534
|
-
`Allowlisted profiles: ${configSummary.allowlistedProfiles.join(", ") || "(none)"}`,
|
|
535
|
-
);
|
|
536
|
-
writeText(
|
|
537
|
-
stdout,
|
|
538
|
-
`Configured providers: ${configSummary.providersConfigured.join(", ") || "(none)"}`,
|
|
539
|
-
);
|
|
540
|
-
writeText(
|
|
541
|
-
stdout,
|
|
542
|
-
`Limits: ${detection.limits.status} - ${detection.limits.detail}`,
|
|
543
|
-
);
|
|
544
|
-
writeText(
|
|
545
|
-
stdout,
|
|
546
|
-
`Last provider error: ${detection.lastProviderError ?? "(none)"}`,
|
|
547
|
-
);
|
|
548
|
-
if (configError) {
|
|
549
|
-
writeText(stdout, `Config error: ${configError.message}`);
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Re-export for status registration whenever status lives with doctor-like output.
|
|
555
|
-
export type { IoStreams };
|
package/src/commands/hooks.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
}
|