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.
- package/dist/catalog-cache.d.ts +60 -0
- package/dist/catalog-cache.d.ts.map +1 -0
- package/dist/catalog-cache.js +113 -0
- package/dist/catalog-cache.js.map +1 -0
- package/dist/commands/config-error.d.ts +8 -0
- package/dist/commands/config-error.d.ts.map +1 -0
- package/dist/commands/config-error.js +22 -0
- package/dist/commands/config-error.js.map +1 -0
- package/dist/commands/detection.d.ts +55 -0
- package/dist/commands/detection.d.ts.map +1 -0
- package/dist/commands/detection.js +131 -0
- package/dist/commands/detection.js.map +1 -0
- package/dist/commands/doctor.d.ts +6 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +369 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/hooks.d.ts +4 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +86 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +38 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +267 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/login.d.ts +4 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +25 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/mcp.d.ts +4 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +27 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/models.d.ts +15 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/commands/models.js +124 -0
- package/dist/commands/models.js.map +1 -0
- package/dist/commands/output.d.ts +6 -0
- package/dist/commands/output.d.ts.map +1 -0
- package/dist/commands/output.js +7 -0
- package/dist/commands/output.js.map +1 -0
- package/dist/commands/profiles.d.ts +4 -0
- package/dist/commands/profiles.d.ts.map +1 -0
- package/dist/commands/profiles.js +46 -0
- package/dist/commands/profiles.js.map +1 -0
- package/dist/commands/proxy.d.ts +10 -0
- package/dist/commands/proxy.d.ts.map +1 -0
- package/dist/commands/proxy.js +53 -0
- package/dist/commands/proxy.js.map +1 -0
- package/dist/commands/route.d.ts +4 -0
- package/dist/commands/route.d.ts.map +1 -0
- package/dist/commands/route.js +123 -0
- package/dist/commands/route.js.map +1 -0
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +86 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/update.d.ts +9 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +44 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +1 -0
- package/package.json +24 -0
- package/src/catalog-cache.test.ts +258 -0
- package/src/catalog-cache.ts +169 -0
- package/src/catalog-sample.json +30 -0
- package/src/cli.test.ts +1577 -0
- package/src/commands/config-error.ts +31 -0
- package/src/commands/detection.ts +209 -0
- package/src/commands/doctor.ts +503 -0
- package/src/commands/hooks.ts +125 -0
- package/src/commands/init.ts +59 -0
- package/src/commands/install.ts +438 -0
- package/src/commands/login.ts +33 -0
- package/src/commands/mcp.ts +35 -0
- package/src/commands/models.ts +168 -0
- package/src/commands/output.ts +11 -0
- package/src/commands/profiles.ts +58 -0
- package/src/commands/proxy.ts +87 -0
- package/src/commands/route.ts +160 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/update.ts +62 -0
- package/src/index.ts +174 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "offrouter-core";
|
|
3
|
+
import { writeJson, writeText } from "./output.js";
|
|
4
|
+
import type { CommandContext } from "./init.js";
|
|
5
|
+
|
|
6
|
+
export function registerProfilesCommand(
|
|
7
|
+
program: Command,
|
|
8
|
+
ctx: () => CommandContext,
|
|
9
|
+
): void {
|
|
10
|
+
program
|
|
11
|
+
.command("profiles")
|
|
12
|
+
.description("List OffRouter profiles and allowlist status")
|
|
13
|
+
.option("--json", "Emit JSON", false)
|
|
14
|
+
.action(async (opts: { json?: boolean }) => {
|
|
15
|
+
const { env, cwd, stdout } = ctx();
|
|
16
|
+
try {
|
|
17
|
+
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
18
|
+
const profiles = Object.values(config.profiles)
|
|
19
|
+
.map((p) => ({
|
|
20
|
+
id: p.id,
|
|
21
|
+
allowlisted: config.policy.allowlistedProfiles.includes(p.id),
|
|
22
|
+
deniedProviders: p.deniedProviders ?? [],
|
|
23
|
+
}))
|
|
24
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
25
|
+
|
|
26
|
+
const payload = {
|
|
27
|
+
allowlistedProfiles: [...config.policy.allowlistedProfiles].sort(),
|
|
28
|
+
deniedProfilePatterns: [
|
|
29
|
+
...(config.policy.deniedProfilePatterns ?? []),
|
|
30
|
+
].sort(),
|
|
31
|
+
profiles,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (opts.json) {
|
|
35
|
+
writeJson(stdout, payload);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
writeText(
|
|
39
|
+
stdout,
|
|
40
|
+
`Allowlisted: ${payload.allowlistedProfiles.join(", ") || "(none)"}`,
|
|
41
|
+
);
|
|
42
|
+
writeText(
|
|
43
|
+
stdout,
|
|
44
|
+
`Denied patterns: ${payload.deniedProfilePatterns.join(", ") || "(none)"}`,
|
|
45
|
+
);
|
|
46
|
+
for (const p of profiles) {
|
|
47
|
+
writeText(stdout, `profile ${p.id}: allowlisted=${p.allowlisted}`);
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (opts.json) {
|
|
52
|
+
writeJson(stdout, { error: message, profiles: [] });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
writeText(stdout, `Failed to load profiles: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
createResponsesProxyContext,
|
|
4
|
+
loadConfig,
|
|
5
|
+
startResponsesProxy,
|
|
6
|
+
type HarnessRef,
|
|
7
|
+
} from "offrouter-core";
|
|
8
|
+
import type { CommandContext } from "./init.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal CLI bridge for the local OpenAI Responses-compatible proxy.
|
|
12
|
+
* Provider discovery (candidates/adapters) lands in a later phase; this starts
|
|
13
|
+
* the transparent gateway with the configured policy so custom-base-URL apps
|
|
14
|
+
* can point at OffRouter today.
|
|
15
|
+
*/
|
|
16
|
+
export function registerProxyCommand(
|
|
17
|
+
program: Command,
|
|
18
|
+
ctx: () => CommandContext,
|
|
19
|
+
): void {
|
|
20
|
+
const proxy = program
|
|
21
|
+
.command("proxy")
|
|
22
|
+
.description("OffRouter transparent-gateway proxy bridges");
|
|
23
|
+
|
|
24
|
+
proxy
|
|
25
|
+
.command("serve")
|
|
26
|
+
.description(
|
|
27
|
+
"Start the local OpenAI Responses-compatible proxy (transparent gateway)",
|
|
28
|
+
)
|
|
29
|
+
.option(
|
|
30
|
+
"-p, --port <port>",
|
|
31
|
+
"TCP port (default 8787, env OFFROUTER_PROXY_PORT)",
|
|
32
|
+
(value: string) => Number.parseInt(value, 10),
|
|
33
|
+
)
|
|
34
|
+
.option("-H, --host <host>", "Bind host (default 127.0.0.1)")
|
|
35
|
+
.option(
|
|
36
|
+
"--profile <profile>",
|
|
37
|
+
"Harness profile to route as (must be allowlisted; default proxy)",
|
|
38
|
+
)
|
|
39
|
+
.action(
|
|
40
|
+
async (opts: {
|
|
41
|
+
port?: number;
|
|
42
|
+
host?: string;
|
|
43
|
+
profile?: string;
|
|
44
|
+
}) => {
|
|
45
|
+
const { env, cwd, stderr } = ctx();
|
|
46
|
+
const config = await loadConfig({
|
|
47
|
+
env,
|
|
48
|
+
workspaceDir: cwd,
|
|
49
|
+
workspaceTrusted: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const profile = opts.profile ?? "proxy";
|
|
53
|
+
const harness: HarnessRef = { kind: "other", profile };
|
|
54
|
+
const context = createResponsesProxyContext({
|
|
55
|
+
policy: config.policy,
|
|
56
|
+
// Provider discovery supplies candidates/adapters in a later phase.
|
|
57
|
+
candidates: [],
|
|
58
|
+
adapters: {},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const port =
|
|
62
|
+
opts.port ??
|
|
63
|
+
Number.parseInt(env.OFFROUTER_PROXY_PORT ?? "8787", 10);
|
|
64
|
+
const host = opts.host ?? env.OFFROUTER_PROXY_HOST ?? "127.0.0.1";
|
|
65
|
+
const token = env.OFFROUTER_PROXY_TOKEN;
|
|
66
|
+
|
|
67
|
+
const handle = await startResponsesProxy(context, {
|
|
68
|
+
port,
|
|
69
|
+
host,
|
|
70
|
+
token,
|
|
71
|
+
harness,
|
|
72
|
+
workspace: { cwd, trusted: true },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
stderr.write(
|
|
76
|
+
`OffRouter proxy listening on http://${host}:${handle.port} (profile=${profile})\n`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const shutdown = async (): Promise<void> => {
|
|
80
|
+
await handle.close();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
};
|
|
83
|
+
process.on("SIGINT", () => void shutdown());
|
|
84
|
+
process.on("SIGTERM", () => void shutdown());
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
loadConfig,
|
|
5
|
+
routeRequest,
|
|
6
|
+
type HarnessKind,
|
|
7
|
+
type RouteRequest,
|
|
8
|
+
} from "offrouter-core";
|
|
9
|
+
import { formatConfigError } from "./config-error.js";
|
|
10
|
+
import { writeJson, writeText } from "./output.js";
|
|
11
|
+
import type { CommandContext } from "./init.js";
|
|
12
|
+
|
|
13
|
+
function harnessKindFromProfile(profile: string): HarnessKind {
|
|
14
|
+
if (profile.startsWith("claude")) return "claude";
|
|
15
|
+
if (profile.startsWith("codex")) return "codex";
|
|
16
|
+
if (profile.startsWith("gemini")) return "gemini";
|
|
17
|
+
if (profile.startsWith("grok")) return "grok";
|
|
18
|
+
if (profile.startsWith("cursor")) return "cursor";
|
|
19
|
+
if (profile.startsWith("pi")) return "pi";
|
|
20
|
+
return "other";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildRequest(
|
|
24
|
+
prompt: string,
|
|
25
|
+
profile: string,
|
|
26
|
+
cwd: string,
|
|
27
|
+
): RouteRequest {
|
|
28
|
+
const preview = prompt.length > 200 ? `${prompt.slice(0, 200)}...` : prompt;
|
|
29
|
+
const digest = createHash("sha256").update(prompt).digest("hex");
|
|
30
|
+
return {
|
|
31
|
+
protocolVersion: "offrouter.route.v1",
|
|
32
|
+
requestId: `cli_route_${digest.slice(0, 12)}`,
|
|
33
|
+
harness: {
|
|
34
|
+
kind: harnessKindFromProfile(profile),
|
|
35
|
+
profile,
|
|
36
|
+
},
|
|
37
|
+
task: {
|
|
38
|
+
promptPreview: preview,
|
|
39
|
+
promptDigest: digest,
|
|
40
|
+
kind: "explain",
|
|
41
|
+
risk: "low",
|
|
42
|
+
},
|
|
43
|
+
workspace: {
|
|
44
|
+
cwd,
|
|
45
|
+
// CLI explain is an explicit local dry run and does not load project config.
|
|
46
|
+
// Harness adapters will pass real workspace trust when they route live work.
|
|
47
|
+
trusted: true,
|
|
48
|
+
},
|
|
49
|
+
constraints: {
|
|
50
|
+
subscriptionFirst: true,
|
|
51
|
+
allowApiKeyFallback: false,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function registerRouteCommand(
|
|
57
|
+
program: Command,
|
|
58
|
+
ctx: () => CommandContext,
|
|
59
|
+
): void {
|
|
60
|
+
const route = program
|
|
61
|
+
.command("route")
|
|
62
|
+
.description("Route or explain routing decisions (no live provider calls)");
|
|
63
|
+
|
|
64
|
+
route
|
|
65
|
+
.command("explain")
|
|
66
|
+
.description("Explain how OffRouter would route a prompt under policy")
|
|
67
|
+
.argument(
|
|
68
|
+
"<prompt>",
|
|
69
|
+
"Prompt text used only for preview + digest (not sent to providers)",
|
|
70
|
+
)
|
|
71
|
+
.requiredOption(
|
|
72
|
+
"--profile <profile>",
|
|
73
|
+
"Harness profile id (e.g. claude-personal)",
|
|
74
|
+
)
|
|
75
|
+
.option("--json", "Emit JSON decision", false)
|
|
76
|
+
.action(
|
|
77
|
+
async (prompt: string, opts: { profile: string; json?: boolean }) => {
|
|
78
|
+
const { env, cwd, stdout } = ctx();
|
|
79
|
+
const request = buildRequest(prompt, opts.profile, cwd);
|
|
80
|
+
|
|
81
|
+
let policyConfig;
|
|
82
|
+
try {
|
|
83
|
+
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
84
|
+
policyConfig = config.policy;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const configError = formatConfigError(err);
|
|
87
|
+
const decision = {
|
|
88
|
+
protocolVersion: request.protocolVersion,
|
|
89
|
+
decisionId: `dec_${request.requestId}_config_error`,
|
|
90
|
+
requestId: request.requestId,
|
|
91
|
+
route: null,
|
|
92
|
+
reason: "config_error",
|
|
93
|
+
explanation: `Invalid OffRouter config: ${configError.message}`,
|
|
94
|
+
fallbackChain: [],
|
|
95
|
+
policyDenials: [],
|
|
96
|
+
confidence: 1,
|
|
97
|
+
auditSummary: `Blocked config_error profile=${request.harness.profile} digest=${request.task.promptDigest}`,
|
|
98
|
+
blocked: true,
|
|
99
|
+
needsConfiguration: false,
|
|
100
|
+
setupIncomplete: false,
|
|
101
|
+
setupMessage: null,
|
|
102
|
+
configError,
|
|
103
|
+
};
|
|
104
|
+
if (opts.json) {
|
|
105
|
+
writeJson(stdout, decision);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
writeText(
|
|
109
|
+
stdout,
|
|
110
|
+
`Cannot route (config_error): ${decision.explanation}`,
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// No provider catalog in this milestone - empty candidates yield needsConfiguration
|
|
116
|
+
// or a profile hard block for work/denied profiles.
|
|
117
|
+
const decision = routeRequest(request, [], policyConfig);
|
|
118
|
+
const setupIncomplete = decision.needsConfiguration === true;
|
|
119
|
+
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
writeJson(stdout, {
|
|
122
|
+
...decision,
|
|
123
|
+
setupIncomplete,
|
|
124
|
+
setupMessage: setupIncomplete
|
|
125
|
+
? "Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile."
|
|
126
|
+
: null,
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
decision.blocked ||
|
|
133
|
+
decision.needsConfiguration ||
|
|
134
|
+
!decision.route
|
|
135
|
+
) {
|
|
136
|
+
writeText(
|
|
137
|
+
stdout,
|
|
138
|
+
`Cannot route (${decision.reason}): ${decision.explanation}`,
|
|
139
|
+
);
|
|
140
|
+
if (decision.policyDenials && decision.policyDenials.length > 0) {
|
|
141
|
+
for (const d of decision.policyDenials) {
|
|
142
|
+
writeText(stdout, `- [${d.code}] ${d.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (setupIncomplete) {
|
|
146
|
+
writeText(
|
|
147
|
+
stdout,
|
|
148
|
+
"Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
writeText(
|
|
155
|
+
stdout,
|
|
156
|
+
`Route: ${decision.route.provider}/${decision.route.model} (${decision.route.authTier}) - ${decision.explanation}`,
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "offrouter-core";
|
|
3
|
+
import { formatConfigError, type CliConfigError } from "./config-error.js";
|
|
4
|
+
import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
|
|
5
|
+
import { writeJson, writeText } from "./output.js";
|
|
6
|
+
import type { CommandContext } from "./init.js";
|
|
7
|
+
|
|
8
|
+
export function registerStatusCommand(
|
|
9
|
+
program: Command,
|
|
10
|
+
ctx: () => CommandContext,
|
|
11
|
+
): void {
|
|
12
|
+
program
|
|
13
|
+
.command("status")
|
|
14
|
+
.description("Show OffRouter status")
|
|
15
|
+
.option("--providers", "Include per-provider readiness detail", false)
|
|
16
|
+
.option("--json", "Emit JSON", false)
|
|
17
|
+
.action(async (opts: { providers?: boolean; json?: boolean }) => {
|
|
18
|
+
const { env, cwd, stdout } = ctx();
|
|
19
|
+
const detection = await detectEnvironment({ env, cwd });
|
|
20
|
+
|
|
21
|
+
let providers: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
authTierHints: string[];
|
|
25
|
+
subscriptionStatus: string;
|
|
26
|
+
apiKeyReady: boolean;
|
|
27
|
+
localReady: boolean;
|
|
28
|
+
limitStatus: string;
|
|
29
|
+
lastError: string | null;
|
|
30
|
+
}> = [];
|
|
31
|
+
let configError: CliConfigError | null = null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
35
|
+
providers = Object.values(config.providers)
|
|
36
|
+
.map((p) => ({
|
|
37
|
+
id: p.id,
|
|
38
|
+
enabled: p.enabled,
|
|
39
|
+
authTierHints: [
|
|
40
|
+
...(detection.authTiers.subscription
|
|
41
|
+
? (["subscription"] as const)
|
|
42
|
+
: []),
|
|
43
|
+
...(detection.authTiers.local ? (["local"] as const) : []),
|
|
44
|
+
...(detection.authTiers["api-key"] ? (["api-key"] as const) : []),
|
|
45
|
+
],
|
|
46
|
+
subscriptionStatus: detection.subscription.status,
|
|
47
|
+
apiKeyReady: detection.apiKeyReadiness.anyReady,
|
|
48
|
+
localReady: detection.localRuntime.health !== "unconfigured",
|
|
49
|
+
limitStatus: detection.limits.status,
|
|
50
|
+
lastError: detection.lastProviderError,
|
|
51
|
+
}))
|
|
52
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
configError = formatConfigError(err);
|
|
55
|
+
providers = [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// When no providers are configured, still expose a readiness row for status --providers.
|
|
59
|
+
if (opts.providers && providers.length === 0) {
|
|
60
|
+
providers = [
|
|
61
|
+
{
|
|
62
|
+
id: "(none-configured)",
|
|
63
|
+
enabled: false,
|
|
64
|
+
authTierHints: Object.entries(detection.authTiers)
|
|
65
|
+
.filter(([, v]) => v)
|
|
66
|
+
.map(([k]) => k),
|
|
67
|
+
subscriptionStatus: detection.subscription.status,
|
|
68
|
+
apiKeyReady: detection.apiKeyReadiness.anyReady,
|
|
69
|
+
localReady: detection.localRuntime.health !== "unconfigured",
|
|
70
|
+
limitStatus: detection.limits.status,
|
|
71
|
+
lastError: detection.lastProviderError,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const payload = {
|
|
77
|
+
honesty: V1_HONESTY_LINE,
|
|
78
|
+
harnessProfiles: detection.harnessProfiles,
|
|
79
|
+
authTiers: detection.authTiers,
|
|
80
|
+
subscription: detection.subscription,
|
|
81
|
+
apiKeyReadiness: detection.apiKeyReadiness,
|
|
82
|
+
localRuntime: detection.localRuntime,
|
|
83
|
+
limits: detection.limits,
|
|
84
|
+
lastProviderError: detection.lastProviderError,
|
|
85
|
+
configError,
|
|
86
|
+
...(opts.providers ? { providers } : {}),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (opts.json) {
|
|
90
|
+
writeJson(stdout, payload);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
writeText(stdout, V1_HONESTY_LINE);
|
|
95
|
+
writeText(
|
|
96
|
+
stdout,
|
|
97
|
+
`Subscription: ${detection.subscription.status}; API-key ready: ${detection.apiKeyReadiness.anyReady}; local: ${detection.localRuntime.health}`,
|
|
98
|
+
);
|
|
99
|
+
if (opts.providers) {
|
|
100
|
+
for (const p of providers) {
|
|
101
|
+
writeText(
|
|
102
|
+
stdout,
|
|
103
|
+
`provider ${p.id}: enabled=${p.enabled} subscription=${p.subscriptionStatus} apiKeyReady=${p.apiKeyReady} localReady=${p.localReady} limits=${p.limitStatus}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (configError) {
|
|
108
|
+
writeText(stdout, `Config error: ${configError.message}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { CommandContext } from "./init.js";
|
|
3
|
+
import { writeJson, writeText } from "./output.js";
|
|
4
|
+
import { fetchCatalogCache } from "../catalog-cache.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `offrouter update` — fetch the latest model catalog and cache it locally.
|
|
8
|
+
* No telemetry. The only network call is a GET to the public static catalog
|
|
9
|
+
* file; no user data is transmitted.
|
|
10
|
+
*/
|
|
11
|
+
export function registerUpdateCommand(
|
|
12
|
+
program: Command,
|
|
13
|
+
ctx: () => CommandContext,
|
|
14
|
+
): void {
|
|
15
|
+
program
|
|
16
|
+
.command("update")
|
|
17
|
+
.description(
|
|
18
|
+
"Fetch and cache the latest model catalog from the remote source",
|
|
19
|
+
)
|
|
20
|
+
.option("--json", "Emit JSON", false)
|
|
21
|
+
.option("--dry-run", "Fetch but do not write cache", false)
|
|
22
|
+
.action(async (opts: { json?: boolean; dryRun?: boolean }) => {
|
|
23
|
+
const { env, stdout } = ctx();
|
|
24
|
+
const dryRun = opts.dryRun ?? false;
|
|
25
|
+
|
|
26
|
+
const result = await fetchCatalogCache({
|
|
27
|
+
env,
|
|
28
|
+
dryRun,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const familyCount = result.data?.families.length ?? 0;
|
|
32
|
+
|
|
33
|
+
if (opts.json) {
|
|
34
|
+
writeJson(stdout, {
|
|
35
|
+
source: result.source,
|
|
36
|
+
fetchedAt: result.fetchedAt,
|
|
37
|
+
familyCount,
|
|
38
|
+
dryRun,
|
|
39
|
+
version: result.data?.version ?? null,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (result.source === "network") {
|
|
45
|
+
const verb = dryRun ? "Fetched (dry-run, not cached)" : "Updated";
|
|
46
|
+
writeText(
|
|
47
|
+
stdout,
|
|
48
|
+
`${verb} catalog: ${familyCount} model families from remote (${result.fetchedAt})`,
|
|
49
|
+
);
|
|
50
|
+
} else if (result.source === "cache") {
|
|
51
|
+
writeText(
|
|
52
|
+
stdout,
|
|
53
|
+
`Using cached catalog: ${familyCount} model families (offline, fetched ${result.fetchedAt})`,
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
writeText(
|
|
57
|
+
stdout,
|
|
58
|
+
"No catalog data available. Using built-in catalog.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import type { CommandContext } from "./commands/init.js";
|
|
6
|
+
import { registerInitCommand } from "./commands/init.js";
|
|
7
|
+
import { registerDoctorCommand } from "./commands/doctor.js";
|
|
8
|
+
import { registerLoginCommand } from "./commands/login.js";
|
|
9
|
+
import { registerModelsCommand } from "./commands/models.js";
|
|
10
|
+
import { registerProfilesCommand } from "./commands/profiles.js";
|
|
11
|
+
import { registerRouteCommand } from "./commands/route.js";
|
|
12
|
+
import { registerStatusCommand } from "./commands/status.js";
|
|
13
|
+
import { registerUpdateCommand } from "./commands/update.js";
|
|
14
|
+
import { registerMcpCommand } from "./commands/mcp.js";
|
|
15
|
+
import { registerHooksCommand } from "./commands/hooks.js";
|
|
16
|
+
import { registerProxyCommand } from "./commands/proxy.js";
|
|
17
|
+
import {
|
|
18
|
+
registerInstallCommand,
|
|
19
|
+
registerUninstallCommand,
|
|
20
|
+
} from "./commands/install.js";
|
|
21
|
+
import type { IoStreams } from "./commands/output.js";
|
|
22
|
+
|
|
23
|
+
export const CLI_PACKAGE = "offrouter-cli" as const;
|
|
24
|
+
|
|
25
|
+
export interface RunCliOptions {
|
|
26
|
+
/** Working directory for config / workspace trust. */
|
|
27
|
+
cwd?: string;
|
|
28
|
+
/** Env override (tests). */
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** Injected stdin for hook bridges (tests). */
|
|
31
|
+
stdin?: string;
|
|
32
|
+
stdout?: IoStreams;
|
|
33
|
+
stderr?: IoStreams;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultStream(stream: NodeJS.WriteStream): IoStreams {
|
|
37
|
+
return {
|
|
38
|
+
write(chunk: string) {
|
|
39
|
+
return stream.write(chunk);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readProcessStdin(): Promise<string> {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
for await (const chunk of process.stdin) {
|
|
47
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the Commander program without parsing argv.
|
|
54
|
+
* Register commands against a shared context factory for injectable IO.
|
|
55
|
+
*/
|
|
56
|
+
export function createProgram(options: RunCliOptions = {}): Command {
|
|
57
|
+
let stdinCache: string | undefined;
|
|
58
|
+
const readStdin = async (): Promise<string> => {
|
|
59
|
+
if (options.stdin !== undefined) {
|
|
60
|
+
return options.stdin;
|
|
61
|
+
}
|
|
62
|
+
if (stdinCache !== undefined) {
|
|
63
|
+
return stdinCache;
|
|
64
|
+
}
|
|
65
|
+
stdinCache = await readProcessStdin();
|
|
66
|
+
return stdinCache;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ctx = (): CommandContext => ({
|
|
70
|
+
env: options.env ?? process.env,
|
|
71
|
+
cwd: options.cwd ?? process.cwd(),
|
|
72
|
+
stdout: options.stdout ?? defaultStream(process.stdout),
|
|
73
|
+
stderr: options.stderr ?? defaultStream(process.stderr),
|
|
74
|
+
readStdin,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const stdout = options.stdout ?? defaultStream(process.stdout);
|
|
78
|
+
const stderr = options.stderr ?? defaultStream(process.stderr);
|
|
79
|
+
|
|
80
|
+
const program = new Command();
|
|
81
|
+
program
|
|
82
|
+
.name("offrouter")
|
|
83
|
+
.description(
|
|
84
|
+
"Local-first router for coding-agent harnesses. Claude Code's primary model is unchanged in V1; OffRouter routes delegated work.",
|
|
85
|
+
)
|
|
86
|
+
.version("0.0.0")
|
|
87
|
+
.showHelpAfterError()
|
|
88
|
+
.configureOutput({
|
|
89
|
+
writeOut: (str) => {
|
|
90
|
+
stdout.write(str);
|
|
91
|
+
},
|
|
92
|
+
writeErr: (str) => {
|
|
93
|
+
stderr.write(str);
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
.exitOverride();
|
|
97
|
+
|
|
98
|
+
registerInitCommand(program, ctx);
|
|
99
|
+
registerDoctorCommand(program, ctx);
|
|
100
|
+
registerLoginCommand(program, ctx);
|
|
101
|
+
registerModelsCommand(program, ctx);
|
|
102
|
+
registerProfilesCommand(program, ctx);
|
|
103
|
+
registerRouteCommand(program, ctx);
|
|
104
|
+
registerStatusCommand(program, ctx);
|
|
105
|
+
registerUpdateCommand(program, ctx);
|
|
106
|
+
registerMcpCommand(program, ctx);
|
|
107
|
+
registerHooksCommand(program, ctx);
|
|
108
|
+
registerProxyCommand(program, ctx);
|
|
109
|
+
registerInstallCommand(program, ctx);
|
|
110
|
+
registerUninstallCommand(program, ctx);
|
|
111
|
+
|
|
112
|
+
return program;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse argv (without node/script) and run the selected command.
|
|
117
|
+
* @returns process exit code (0 success, nonzero usage/runtime failure)
|
|
118
|
+
*/
|
|
119
|
+
export async function runCli(
|
|
120
|
+
argv: string[] = process.argv.slice(2),
|
|
121
|
+
options: RunCliOptions = {},
|
|
122
|
+
): Promise<number> {
|
|
123
|
+
const stderr = options.stderr ?? defaultStream(process.stderr);
|
|
124
|
+
const program = createProgram(options);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await program.parseAsync(argv, { from: "user" });
|
|
128
|
+
return 0;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Commander exitOverride throws CommanderError with exitCode.
|
|
131
|
+
if (
|
|
132
|
+
err &&
|
|
133
|
+
typeof err === "object" &&
|
|
134
|
+
"exitCode" in err &&
|
|
135
|
+
typeof (err as { exitCode: unknown }).exitCode === "number"
|
|
136
|
+
) {
|
|
137
|
+
const code = (
|
|
138
|
+
err as { exitCode: number; message?: string; code?: string }
|
|
139
|
+
).exitCode;
|
|
140
|
+
// help/version use exitCode 0
|
|
141
|
+
if (code === 0) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const message = err instanceof Error ? err.message : "Command failed";
|
|
145
|
+
if (message) {
|
|
146
|
+
stderr.write(`${message}\n`);
|
|
147
|
+
}
|
|
148
|
+
return code || 1;
|
|
149
|
+
}
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
stderr.write(`${message}\n`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isEntrypoint(): boolean {
|
|
157
|
+
if (!process.argv[1]) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
return (
|
|
162
|
+
realpathSync(process.argv[1]) ===
|
|
163
|
+
realpathSync(fileURLToPath(import.meta.url))
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (isEntrypoint()) {
|
|
171
|
+
void runCli(process.argv.slice(2)).then((code) => {
|
|
172
|
+
process.exitCode = code;
|
|
173
|
+
});
|
|
174
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist"
|
|
6
|
+
},
|
|
7
|
+
"include": ["src/**/*.ts"],
|
|
8
|
+
"exclude": ["src/**/*.test.ts"],
|
|
9
|
+
"references": [
|
|
10
|
+
{ "path": "../core" },
|
|
11
|
+
{ "path": "../adapter-claude" },
|
|
12
|
+
{ "path": "../adapter-codex" },
|
|
13
|
+
{ "path": "../adapter-gemini" },
|
|
14
|
+
{ "path": "../adapter-grok" },
|
|
15
|
+
{ "path": "../mcp" }
|
|
16
|
+
]
|
|
17
|
+
}
|