glm-coding-router 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +542 -534
- package/dist/cli.js +8 -3
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +33 -18
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +110 -8
- package/dist/templates/agents-block.js +53 -53
- package/dist/templates/claude-block.js +56 -56
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/render.js +70 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { version } from "./core/version.js";
|
|
|
4
4
|
import { ExitCode } from "./core/errors.js";
|
|
5
5
|
import { isMainModule } from "./core/main-guard.js";
|
|
6
6
|
import { applyGlobalOptions, reportError } from "./commands/context.js";
|
|
7
|
+
import { landingCommand } from "./commands/landing.js";
|
|
7
8
|
import { initCommand } from "./commands/init.js";
|
|
8
9
|
import { doctorCommand } from "./commands/doctor-command.js";
|
|
9
10
|
import { statusCommand } from "./commands/status.js";
|
|
@@ -31,7 +32,10 @@ program
|
|
|
31
32
|
.option("--verbose", "debug-level output")
|
|
32
33
|
.option("--dry-run", "preview file modifications without writing")
|
|
33
34
|
.option("--force", "apply actions even when already done")
|
|
34
|
-
.option("--yes", "assume defaults for all prompts")
|
|
35
|
+
.option("--yes", "assume defaults for all prompts")
|
|
36
|
+
// No subcommand: an offline landing page (specs/terminal-ui-doctor.md §B.1),
|
|
37
|
+
// not a call into any command that reads config/credentials or the network.
|
|
38
|
+
.action(() => execute(() => Promise.resolve(landingCommand(globalOptions()))));
|
|
35
39
|
/** Shared action wrapper: apply global flags, catch errors, set the exit code. */
|
|
36
40
|
async function execute(action) {
|
|
37
41
|
applyGlobalOptions(globalOptions());
|
|
@@ -51,8 +55,9 @@ program
|
|
|
51
55
|
.action(() => execute(() => initCommand(globalOptions())));
|
|
52
56
|
program
|
|
53
57
|
.command("doctor")
|
|
54
|
-
.description("diagnose the full runtime")
|
|
55
|
-
.option("--network", "also probe
|
|
58
|
+
.description("diagnose the full runtime, authenticating the effective Z.ai key by default")
|
|
59
|
+
.option("--network", "also probe Anthropic endpoint reachability")
|
|
60
|
+
.option("--offline", "local checks only; skip online key authentication")
|
|
56
61
|
.action((commandOptions) => execute(() => doctorCommand({ ...globalOptions(), ...commandOptions })));
|
|
57
62
|
program
|
|
58
63
|
.command("status")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure credential-authentication state and doctor verdict logic
|
|
3
|
+
* (specs/terminal-ui-doctor.md §D, §E). Kept separate from doctor-command.ts
|
|
4
|
+
* so the precedence rules are unit-testable without mocking fetch/stdout.
|
|
5
|
+
*/
|
|
6
|
+
import { ZaiQuotaError, fetchZaiQuota } from "../core/zai-quota.js";
|
|
7
|
+
/**
|
|
8
|
+
* Authenticate exactly the effective key once. Never retries with a
|
|
9
|
+
* different key on rejection (spec §C.6) — that would hide the key the next
|
|
10
|
+
* worker will actually use.
|
|
11
|
+
*/
|
|
12
|
+
export async function authenticateZaiKey(key, fetchImpl, offline) {
|
|
13
|
+
if (offline) {
|
|
14
|
+
return {
|
|
15
|
+
state: "skipped",
|
|
16
|
+
checked: false,
|
|
17
|
+
method: "zai-quota-monitor",
|
|
18
|
+
reason: "offline",
|
|
19
|
+
detail: "Online authentication was not performed (--offline).",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!key) {
|
|
23
|
+
return {
|
|
24
|
+
state: "missing",
|
|
25
|
+
checked: false,
|
|
26
|
+
method: "zai-quota-monitor",
|
|
27
|
+
reason: "missing-key",
|
|
28
|
+
detail: "No ZAI_API_KEY was found in the process environment or the per-user store.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await fetchZaiQuota(key, fetchImpl);
|
|
33
|
+
return {
|
|
34
|
+
state: "verified",
|
|
35
|
+
checked: true,
|
|
36
|
+
method: "zai-quota-monitor",
|
|
37
|
+
reason: "accepted",
|
|
38
|
+
detail: "The Z.ai monitor endpoint accepted the selected key.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (!(error instanceof ZaiQuotaError)) {
|
|
43
|
+
return {
|
|
44
|
+
state: "unverified",
|
|
45
|
+
checked: true,
|
|
46
|
+
method: "zai-quota-monitor",
|
|
47
|
+
reason: "network-error",
|
|
48
|
+
detail: "Authentication could not be completed.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
switch (error.kind) {
|
|
52
|
+
case "unauthorized":
|
|
53
|
+
return { state: "rejected", checked: true, method: "zai-quota-monitor", reason: "http-401", detail: error.message };
|
|
54
|
+
case "forbidden":
|
|
55
|
+
return { state: "rejected", checked: true, method: "zai-quota-monitor", reason: "http-403", detail: error.message };
|
|
56
|
+
case "rate-limited":
|
|
57
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "rate-limited", detail: error.message };
|
|
58
|
+
case "http":
|
|
59
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "http-error", detail: error.message };
|
|
60
|
+
case "network":
|
|
61
|
+
return {
|
|
62
|
+
state: "unverified",
|
|
63
|
+
checked: true,
|
|
64
|
+
method: "zai-quota-monitor",
|
|
65
|
+
reason: error.timeout ? "timeout" : "network-error",
|
|
66
|
+
detail: error.message,
|
|
67
|
+
};
|
|
68
|
+
case "invalid-response":
|
|
69
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "invalid-response", detail: error.message };
|
|
70
|
+
case "provider":
|
|
71
|
+
return { state: "unverified", checked: true, method: "zai-quota-monitor", reason: "provider-error", detail: error.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The Credentials-section row status for the authentication check (spec §D table's "Check" column). */
|
|
76
|
+
export function authenticationCheckStatus(state) {
|
|
77
|
+
switch (state) {
|
|
78
|
+
case "verified":
|
|
79
|
+
return "ok";
|
|
80
|
+
case "rejected":
|
|
81
|
+
case "missing":
|
|
82
|
+
return "fail";
|
|
83
|
+
case "unverified":
|
|
84
|
+
case "skipped":
|
|
85
|
+
return "warn";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Overall summary precedence (spec §E, first matching row wins). `networkProbeFailed`
|
|
90
|
+
* only applies when `--network` was explicitly requested; it is `false`/`undefined`
|
|
91
|
+
* whenever that probe was not run or came back reachable.
|
|
92
|
+
*/
|
|
93
|
+
export function deriveDoctorVerdict(input) {
|
|
94
|
+
const { localFailure, authentication, keyComparison, offline, networkProbeFailed } = input;
|
|
95
|
+
if (localFailure || authentication.state === "missing" || authentication.state === "rejected") {
|
|
96
|
+
return { status: "ISSUES", exitCode: 1 };
|
|
97
|
+
}
|
|
98
|
+
if (authentication.state === "unverified" || authentication.state === "skipped" || networkProbeFailed) {
|
|
99
|
+
const exitCode = offline && authentication.state === "skipped" ? 0 : 1;
|
|
100
|
+
return { status: "UNVERIFIED", exitCode };
|
|
101
|
+
}
|
|
102
|
+
// authentication.state === "verified" from here on.
|
|
103
|
+
if (keyComparison === "different" || keyComparison === "unavailable") {
|
|
104
|
+
return { status: "ATTENTION", exitCode: 0 };
|
|
105
|
+
}
|
|
106
|
+
return { status: "HEALTHY", exitCode: 0 };
|
|
107
|
+
}
|
|
@@ -1,36 +1,13 @@
|
|
|
1
1
|
import { runDoctorChecks, doctorHasFailures } from "./doctor.js";
|
|
2
2
|
import { emitJson } from "./context.js";
|
|
3
3
|
import { logger } from "../core/logging.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let currentSection = "";
|
|
12
|
-
for (const result of results) {
|
|
13
|
-
if (result.section !== currentSection) {
|
|
14
|
-
currentSection = result.section;
|
|
15
|
-
lines.push(currentSection);
|
|
16
|
-
}
|
|
17
|
-
lines.push(` ${SYMBOL[result.status]} ${result.name}`);
|
|
18
|
-
if (result.detail) {
|
|
19
|
-
lines.push(` ${result.detail}`);
|
|
20
|
-
}
|
|
21
|
-
if (result.note) {
|
|
22
|
-
lines.push(` ${result.note}`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
if (networkResult) {
|
|
26
|
-
lines.push("Network");
|
|
27
|
-
lines.push(` ${networkResult === "reachable" ? "✓" : "⚠"} Z.ai endpoint ${networkResult}`);
|
|
28
|
-
}
|
|
29
|
-
const failures = doctorHasFailures(results);
|
|
30
|
-
lines.push("");
|
|
31
|
-
lines.push(`Status: ${failures ? "ISSUES DETECTED" : "HEALTHY"}`);
|
|
32
|
-
return lines.join("\n");
|
|
33
|
-
}
|
|
4
|
+
import { Errors } from "../core/errors.js";
|
|
5
|
+
import { inspectZaiKey } from "../core/key-inspector.js";
|
|
6
|
+
import { authenticateZaiKey, authenticationCheckStatus, deriveDoctorVerdict, } from "./doctor-auth.js";
|
|
7
|
+
import { describeKeyStore, detectUserEnvStore } from "../core/user-env.js";
|
|
8
|
+
import { createWriter } from "../tui/render.js";
|
|
9
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
10
|
+
import { version } from "../core/version.js";
|
|
34
11
|
/** Lightweight endpoint reachability probe (spec §42). Never consumes coding quota. */
|
|
35
12
|
export async function probeEndpoint(baseUrl, fetchImpl = fetch) {
|
|
36
13
|
try {
|
|
@@ -45,24 +22,177 @@ export async function probeEndpoint(baseUrl, fetchImpl = fetch) {
|
|
|
45
22
|
return "not reachable";
|
|
46
23
|
}
|
|
47
24
|
}
|
|
25
|
+
function shellUnsetLines(platform) {
|
|
26
|
+
if (platform === "win32") {
|
|
27
|
+
return ["PowerShell: Remove-Item Env:ZAI_API_KEY", "CMD: set ZAI_API_KEY="];
|
|
28
|
+
}
|
|
29
|
+
return ["POSIX shell: unset ZAI_API_KEY"];
|
|
30
|
+
}
|
|
31
|
+
function describeComparisonRow(inspection) {
|
|
32
|
+
switch (inspection.comparison) {
|
|
33
|
+
case "match":
|
|
34
|
+
return { status: "ok", value: "Matches the saved key" };
|
|
35
|
+
case "different":
|
|
36
|
+
return { status: "warn", value: "Different from the saved key" };
|
|
37
|
+
case "unavailable":
|
|
38
|
+
return { status: "warn", value: `Could not read ${describeKeyStore(inspection.store)} to compare` };
|
|
39
|
+
case "not-comparable":
|
|
40
|
+
if (inspection.store === "none") {
|
|
41
|
+
return { status: "info", value: "No persistent store on this platform" };
|
|
42
|
+
}
|
|
43
|
+
if (inspection.effectiveSource === "process-env") {
|
|
44
|
+
return { status: "info", value: "No saved key to compare against" };
|
|
45
|
+
}
|
|
46
|
+
if (inspection.effectiveSource === "user-store") {
|
|
47
|
+
return { status: "info", value: "No process override" };
|
|
48
|
+
}
|
|
49
|
+
return { status: "info", value: "Not applicable" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Ordered, actionable "next step" lines. Empty when the verdict is HEALTHY. */
|
|
53
|
+
function buildNextSteps(input) {
|
|
54
|
+
const { authentication, comparison, store, platform, networkProbeFailed, verdictStatus } = input;
|
|
55
|
+
if (verdictStatus === "HEALTHY") {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (comparison === "different") {
|
|
60
|
+
lines.push("This terminal's key takes priority over the saved key (process environment wins).", "Restart the terminal's hosting app to refresh its environment,", "or if this is an intentional process override, update that override instead.", ...shellUnsetLines(platform));
|
|
61
|
+
}
|
|
62
|
+
else if (comparison === "unavailable") {
|
|
63
|
+
lines.push(`Could not read ${describeKeyStore(store)} to compare against the process key.`);
|
|
64
|
+
}
|
|
65
|
+
switch (authentication.reason) {
|
|
66
|
+
case "missing-key":
|
|
67
|
+
lines.push("No ZAI_API_KEY was found.", platform === "win32" ? "Run: glm-router key set" : 'Run: glm-router key set, or export ZAI_API_KEY="<your-key>"');
|
|
68
|
+
break;
|
|
69
|
+
case "http-401":
|
|
70
|
+
lines.push("The selected key was rejected. To replace it: glm-router key set");
|
|
71
|
+
break;
|
|
72
|
+
case "http-403":
|
|
73
|
+
lines.push("Access was denied for the selected key. Check the key's account permissions — not necessarily expiry.");
|
|
74
|
+
break;
|
|
75
|
+
case "rate-limited":
|
|
76
|
+
lines.push("The Z.ai monitor endpoint is rate-limiting this key. Retry in a moment.");
|
|
77
|
+
break;
|
|
78
|
+
case "http-error":
|
|
79
|
+
case "network-error":
|
|
80
|
+
case "timeout":
|
|
81
|
+
lines.push("Could not reach the Z.ai monitor endpoint. Check network/proxy connectivity and retry.");
|
|
82
|
+
break;
|
|
83
|
+
case "invalid-response":
|
|
84
|
+
case "provider-error":
|
|
85
|
+
lines.push("The Z.ai monitor endpoint returned an unexpected response — this is not proof the key is invalid.");
|
|
86
|
+
break;
|
|
87
|
+
case "offline":
|
|
88
|
+
lines.push("Authentication was not checked (--offline). Run without --offline to verify the key online.");
|
|
89
|
+
break;
|
|
90
|
+
case "accepted":
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (networkProbeFailed) {
|
|
94
|
+
lines.push("The configured Anthropic endpoint reachability probe did not succeed (separate from key authentication).");
|
|
95
|
+
}
|
|
96
|
+
if (lines.length > 0 && authentication.reason !== "offline") {
|
|
97
|
+
lines.push("Then run: glm-router doctor");
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
48
101
|
export async function doctorCommand(options, deps = {}) {
|
|
49
|
-
|
|
102
|
+
if (options.offline && options.network) {
|
|
103
|
+
throw Errors.invalidArgs("--offline and --network cannot be used together.", [
|
|
104
|
+
"Use --offline for local-only checks, or --network for the extra reachability probe.",
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
const env = deps.env ?? process.env;
|
|
108
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
109
|
+
const offline = options.offline ?? false;
|
|
110
|
+
const platform = deps.platform ?? process.platform;
|
|
111
|
+
const store = deps.store ?? detectUserEnvStore({ platform, env });
|
|
112
|
+
const report = runDoctorChecks({ home: deps.home, env, readUserEnv: deps.readUserEnv, store });
|
|
113
|
+
const inspection = inspectZaiKey({ env, readUserEnvDiagnostic: deps.readUserEnvDiagnostic, store });
|
|
114
|
+
const [authentication, networkResult] = await Promise.all([
|
|
115
|
+
authenticateZaiKey(inspection.effectiveKey, fetchImpl, offline),
|
|
116
|
+
options.network ? probeEndpoint(report.config.provider.anthropicBaseUrl, fetchImpl) : Promise.resolve(undefined),
|
|
117
|
+
]);
|
|
118
|
+
const localFailure = doctorHasFailures(report.results);
|
|
119
|
+
const networkProbeFailed = networkResult !== undefined && networkResult !== "reachable";
|
|
120
|
+
const verdict = deriveDoctorVerdict({
|
|
121
|
+
localFailure,
|
|
122
|
+
authentication,
|
|
123
|
+
keyComparison: inspection.comparison,
|
|
124
|
+
offline,
|
|
125
|
+
networkProbeFailed,
|
|
126
|
+
});
|
|
50
127
|
if (options.json) {
|
|
51
|
-
const networkResult = options.network
|
|
52
|
-
? await probeEndpoint(report.config.provider.anthropicBaseUrl, deps.fetchImpl)
|
|
53
|
-
: undefined;
|
|
54
128
|
emitJson({
|
|
55
|
-
status:
|
|
129
|
+
status: verdict.status,
|
|
56
130
|
checks: report.results,
|
|
57
131
|
keySource: report.keySource,
|
|
58
132
|
network: networkResult,
|
|
133
|
+
authentication,
|
|
134
|
+
keyComparison: inspection.comparison,
|
|
135
|
+
keyMismatch: inspection.keyMismatch,
|
|
59
136
|
});
|
|
60
|
-
return
|
|
137
|
+
return verdict.exitCode;
|
|
61
138
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
139
|
+
const stream = deps.stdout ?? process.stdout;
|
|
140
|
+
const writer = createWriter(stream);
|
|
141
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
142
|
+
writer.line(renderText(ui, report.results, inspection, authentication, networkResult, verdict, platform));
|
|
66
143
|
logger.debug(`anthropic base url: ${report.config.provider.anthropicBaseUrl}`);
|
|
67
|
-
return
|
|
144
|
+
return verdict.exitCode;
|
|
145
|
+
}
|
|
146
|
+
function renderText(ui, results, inspection, authentication, networkResult, verdict, platform) {
|
|
147
|
+
const blocks = [];
|
|
148
|
+
const header = ui.header(`GLM CODING ROUTER v${version} / DOCTOR`, "Runtime and credential diagnostics");
|
|
149
|
+
if (header)
|
|
150
|
+
blocks.push(header);
|
|
151
|
+
let currentSection = "";
|
|
152
|
+
const localRows = [];
|
|
153
|
+
for (const result of results) {
|
|
154
|
+
if (result.section !== currentSection) {
|
|
155
|
+
currentSection = result.section;
|
|
156
|
+
localRows.push(ui.section(currentSection.toUpperCase()));
|
|
157
|
+
}
|
|
158
|
+
const status = result.status === "ok" ? "ok" : result.status === "warn" ? "warn" : "fail";
|
|
159
|
+
localRows.push(ui.row(result.name, result.detail ?? "", status));
|
|
160
|
+
if (result.note)
|
|
161
|
+
localRows.push(ui.detail(result.note));
|
|
162
|
+
}
|
|
163
|
+
blocks.push(localRows.join("\n"));
|
|
164
|
+
const credentialRows = [ui.section("CREDENTIALS")];
|
|
165
|
+
credentialRows.push(ui.row("Selected source", inspection.effectiveSource === "process-env" ? "Process environment" : inspection.effectiveSource === "user-store" ? describeKeyStore(inspection.store) : "none", "info"));
|
|
166
|
+
const comparisonRow = describeComparisonRow(inspection);
|
|
167
|
+
credentialRows.push(ui.row("Saved key comparison", comparisonRow.value, comparisonRow.status));
|
|
168
|
+
credentialRows.push(ui.row("Monitor authentication", authentication.detail, authenticationCheckStatus(authentication.state)));
|
|
169
|
+
blocks.push(credentialRows.join("\n"));
|
|
170
|
+
if (networkResult !== undefined) {
|
|
171
|
+
const networkRows = [
|
|
172
|
+
ui.section("NETWORK"),
|
|
173
|
+
ui.row("Z.ai endpoint reachability", networkResult, networkResult === "reachable" ? "ok" : "warn"),
|
|
174
|
+
];
|
|
175
|
+
blocks.push(networkRows.join("\n"));
|
|
176
|
+
}
|
|
177
|
+
const nextSteps = buildNextSteps({
|
|
178
|
+
authentication,
|
|
179
|
+
comparison: inspection.comparison,
|
|
180
|
+
store: inspection.store,
|
|
181
|
+
platform,
|
|
182
|
+
networkProbeFailed: networkResult !== undefined && networkResult !== "reachable",
|
|
183
|
+
verdictStatus: verdict.status,
|
|
184
|
+
});
|
|
185
|
+
if (nextSteps.length > 0) {
|
|
186
|
+
const footer = ui.footer(nextSteps.join("\n"));
|
|
187
|
+
if (footer)
|
|
188
|
+
blocks.push([ui.section("NEXT STEP"), footer].join("\n"));
|
|
189
|
+
}
|
|
190
|
+
const resultLabel = {
|
|
191
|
+
HEALTHY: "HEALTHY",
|
|
192
|
+
ATTENTION: "ATTENTION",
|
|
193
|
+
UNVERIFIED: "UNVERIFIED",
|
|
194
|
+
ISSUES: "ISSUES DETECTED",
|
|
195
|
+
};
|
|
196
|
+
blocks.push(`RESULT ${resultLabel[verdict.status]}`);
|
|
197
|
+
return blocks.join("\n\n");
|
|
68
198
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `glm-router` with no subcommand (specs/terminal-ui-doctor.md §B.1). Purely
|
|
3
|
+
* static: no provider request, no key/config read, no side effects. Exists
|
|
4
|
+
* so a first-time user sees "what do I run" instead of nothing.
|
|
5
|
+
*/
|
|
6
|
+
import { Errors } from "../core/errors.js";
|
|
7
|
+
import { version } from "../core/version.js";
|
|
8
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
9
|
+
import { createWriter } from "../tui/render.js";
|
|
10
|
+
export function landingCommand(options, deps = {}) {
|
|
11
|
+
if (options.json) {
|
|
12
|
+
// Root --json has no data to emit — a data-producing command must be named.
|
|
13
|
+
throw Errors.invalidArgs("--json requires a command that produces data.", [
|
|
14
|
+
"Try:", "", " glm-router status --json", " glm-router doctor --json", " glm-router usage --json",
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
const stream = deps.stdout ?? process.stdout;
|
|
18
|
+
const writer = createWriter(stream);
|
|
19
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
20
|
+
const blocks = [];
|
|
21
|
+
const header = ui.header(`GLM CODING ROUTER v${version}`, "Coding Plan workers for Claude Code and Codex");
|
|
22
|
+
if (header)
|
|
23
|
+
blocks.push(header);
|
|
24
|
+
blocks.push([
|
|
25
|
+
ui.section("CHECK & MONITOR"),
|
|
26
|
+
ui.row("glm-router doctor", "Check setup and verify API key"),
|
|
27
|
+
ui.row("glm-router status", "Quick offline overview"),
|
|
28
|
+
ui.row("glm-router usage", "Coding Plan quota and reset times"),
|
|
29
|
+
ui.row("glm-router dashboard", "Live quota and worker activity"),
|
|
30
|
+
].join("\n"));
|
|
31
|
+
blocks.push([
|
|
32
|
+
ui.section("WORK"),
|
|
33
|
+
ui.row('glm-worker "<task>"', "Run an implementation task"),
|
|
34
|
+
ui.row('glm-review "<task>"', "Run a read-only review"),
|
|
35
|
+
ui.row("glm-router runs", "Inspect recorded runs"),
|
|
36
|
+
].join("\n"));
|
|
37
|
+
blocks.push([
|
|
38
|
+
ui.section("SETUP"),
|
|
39
|
+
ui.row("glm-router init", "Guided setup"),
|
|
40
|
+
ui.row("glm-router key set", "Save a replacement API key"),
|
|
41
|
+
].join("\n"));
|
|
42
|
+
const footer = ui.footer("Start with: glm-router doctor\nAll commands: glm-router --help");
|
|
43
|
+
if (footer)
|
|
44
|
+
blocks.push(footer);
|
|
45
|
+
writer.line(blocks.join("\n\n"));
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -6,7 +6,9 @@ import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
|
6
6
|
import { skillTargets } from "../integrations/skill.js";
|
|
7
7
|
import { GLM_DELEGATION_SKILL_NAME } from "../templates/glm-delegation-skill.js";
|
|
8
8
|
import { emitJson } from "./context.js";
|
|
9
|
-
|
|
9
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
10
|
+
import { createWriter } from "../tui/render.js";
|
|
11
|
+
/** Fast, fully offline summary (spec §41, specs/terminal-ui-doctor.md §B.4) — no API requests, no key values. */
|
|
10
12
|
export function statusCommand(options, deps = {}) {
|
|
11
13
|
const home = deps.home ?? os.homedir();
|
|
12
14
|
const env = deps.env ?? process.env;
|
|
@@ -51,23 +53,34 @@ export function statusCommand(options, deps = {}) {
|
|
|
51
53
|
});
|
|
52
54
|
return 0;
|
|
53
55
|
}
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"",
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
const stream = deps.stdout ?? process.stdout;
|
|
57
|
+
const writer = createWriter(stream);
|
|
58
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
59
|
+
const blocks = [];
|
|
60
|
+
const header = ui.header(`GLM CODING ROUTER v${version} / STATUS`, "Offline overview — credentials not verified this run");
|
|
61
|
+
if (header)
|
|
62
|
+
blocks.push(header);
|
|
63
|
+
blocks.push([
|
|
64
|
+
ui.section("SYSTEM"),
|
|
65
|
+
// Presence only, never validity — that claim belongs to `doctor` (spec §B.4).
|
|
66
|
+
ui.row("Z.ai key", resolved ? "configured (not verified — run: glm-router doctor)" : "not configured", resolved ? "ok" : "fail"),
|
|
67
|
+
ui.row("Claude", claudeInstalled ? "installed" : "missing", claudeInstalled ? "ok" : "fail"),
|
|
68
|
+
ui.row("Codex", codexInstalled ? "installed" : "missing", codexInstalled ? "ok" : "warn"),
|
|
69
|
+
].join("\n"));
|
|
70
|
+
const integrationRows = [
|
|
71
|
+
ui.section("INTEGRATIONS"),
|
|
72
|
+
ui.row("Claude policy", config.integrations.claude ? "enabled" : "disabled", config.integrations.claude ? "ok" : "info"),
|
|
73
|
+
ui.row("Codex policy", config.integrations.codex ? "enabled" : "disabled", config.integrations.codex ? "ok" : "info"),
|
|
65
74
|
];
|
|
66
75
|
for (const row of skillState) {
|
|
67
76
|
const enabled = row.homeDetected && row.installed;
|
|
68
|
-
|
|
77
|
+
integrationRows.push(ui.row(`${row.agent} skill`, enabled ? "enabled" : "disabled", enabled ? "ok" : "info"));
|
|
69
78
|
}
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
blocks.push(integrationRows.join("\n"));
|
|
80
|
+
blocks.push([ui.section("MODELS"), ui.row("Main model", config.models.main), ui.row("Fast model", config.models.fast)].join("\n"));
|
|
81
|
+
const footer = ui.footer("Run: glm-router doctor to verify credentials and connectivity.");
|
|
82
|
+
if (footer)
|
|
83
|
+
blocks.push(footer);
|
|
84
|
+
writer.line(blocks.join("\n\n"));
|
|
72
85
|
return 0;
|
|
73
86
|
}
|
package/dist/commands/usage.js
CHANGED
|
@@ -7,6 +7,8 @@ import { version } from "../core/version.js";
|
|
|
7
7
|
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
8
8
|
import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
|
|
9
9
|
import { emitJson } from "./context.js";
|
|
10
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
11
|
+
import { createWriter } from "../tui/render.js";
|
|
10
12
|
/** Aggregate saved benchmark reports (specs/benchmark.md) into one local summary. */
|
|
11
13
|
export function aggregateLocalUsage(home) {
|
|
12
14
|
const dir = path.join(configDir(home), "benchmarks");
|
|
@@ -85,39 +87,52 @@ export async function usageCommand(options, deps = {}) {
|
|
|
85
87
|
emitJson(json);
|
|
86
88
|
return quotaError ? 1 : 0;
|
|
87
89
|
}
|
|
88
|
-
const
|
|
90
|
+
const stream = deps.stdout ?? process.stdout;
|
|
91
|
+
const writer = createWriter(stream);
|
|
92
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
93
|
+
const blocks = [];
|
|
94
|
+
const header = ui.header(`GLM CODING ROUTER v${version} / USAGE`, "Coding Plan quota snapshot");
|
|
95
|
+
if (header)
|
|
96
|
+
blocks.push(header);
|
|
97
|
+
const quotaRows = [
|
|
98
|
+
ui.section(`Z.AI CODING PLAN${quota?.level ? ` / ${quota.level}` : ""}`),
|
|
99
|
+
];
|
|
89
100
|
if (quotaError) {
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
quotaRows.push(ui.row("Z.ai Coding Plan", quotaError, "fail"));
|
|
102
|
+
}
|
|
103
|
+
else if (limits.length === 0) {
|
|
104
|
+
quotaRows.push(ui.detail("(no quota windows reported)"));
|
|
92
105
|
}
|
|
93
106
|
else {
|
|
94
|
-
lines.push(`Z.ai Coding Plan${quota?.level ? ` (level: ${quota.level})` : ""}`);
|
|
95
|
-
if (limits.length === 0) {
|
|
96
|
-
lines.push(" (no quota windows reported)");
|
|
97
|
-
}
|
|
98
107
|
for (const limit of limits) {
|
|
99
108
|
const consumed = limit.currentValue ?? "?";
|
|
100
109
|
const total = limit.usage ?? "?";
|
|
101
|
-
|
|
110
|
+
// Never treat a missing consumed/total as zero quota — only a finite
|
|
111
|
+
// ratio with total > 0 may be turned into a percentage (spec §B.5).
|
|
112
|
+
const percentNumeric = typeof limit.percentage === "number"
|
|
102
113
|
? limit.percentage
|
|
103
114
|
: typeof limit.currentValue === "number" && typeof limit.usage === "number" && limit.usage > 0
|
|
104
115
|
? Math.round((limit.currentValue / limit.usage) * 100)
|
|
105
|
-
:
|
|
116
|
+
: undefined;
|
|
117
|
+
const percentage = percentNumeric ?? "?";
|
|
106
118
|
const resets = typeof limit.nextResetTime === "number" ? ` — resets ${new Date(limit.nextResetTime).toISOString()}` : "";
|
|
107
|
-
|
|
119
|
+
quotaRows.push(ui.row(describeWindow(limit), `${consumed} / ${total} credits (${percentage}%)${resets}`));
|
|
120
|
+
const remaining = typeof limit.remaining === "number" ? `${limit.remaining} remaining` : "remaining unknown";
|
|
121
|
+
quotaRows.push(ui.detail(`${ui.bar(percentNumeric)} · ${remaining}`));
|
|
108
122
|
}
|
|
109
123
|
}
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
blocks.push(quotaRows.join("\n"));
|
|
125
|
+
const benchmarkRows = [ui.section("LOCAL BENCHMARKS")];
|
|
112
126
|
if (local.runs === 0) {
|
|
113
|
-
|
|
127
|
+
benchmarkRows.push(ui.detail("(none yet — run glm-router benchmark)"));
|
|
114
128
|
}
|
|
115
129
|
else {
|
|
116
|
-
|
|
130
|
+
benchmarkRows.push(ui.row("Runs", String(local.runs)));
|
|
131
|
+
benchmarkRows.push(ui.row("Tokens", `${local.tokensIn} in / ${local.tokensOut} out`));
|
|
132
|
+
benchmarkRows.push(ui.row("Last run", String(local.lastFinishedAt)));
|
|
117
133
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
process.stdout.write(lines.join("\n") + "\n");
|
|
134
|
+
blocks.push(benchmarkRows.join("\n"));
|
|
135
|
+
blocks.push([ui.section("OTHER PROVIDERS"), ui.row("Claude", json.claude), ui.row("Codex", json.codex)].join("\n"));
|
|
136
|
+
writer.line(blocks.join("\n\n"));
|
|
122
137
|
return quotaError ? 1 : 0;
|
|
123
138
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-source credential comparison for `doctor` (specs/terminal-ui-doctor.md
|
|
3
|
+
* §C). This module never changes runtime key precedence — `resolveZaiApiKey`
|
|
4
|
+
* in zai-key.ts remains the single source of truth for which key an agent
|
|
5
|
+
* actually uses. It only *observes* both sources once, privately, so doctor
|
|
6
|
+
* can warn when the process value that wins is stale.
|
|
7
|
+
*
|
|
8
|
+
* `effectiveKey` on the returned snapshot is the real secret value. Never put
|
|
9
|
+
* it in a `DoctorReport`, JSON payload, log line or thrown error — only the
|
|
10
|
+
* `comparison` / `keyMismatch` / `effectiveSource` fields are safe to expose.
|
|
11
|
+
*/
|
|
12
|
+
import { detectUserEnvStore, readUserEnvDiagnostic } from "./user-env.js";
|
|
13
|
+
import { ZAI_API_KEY_ENV } from "./zai-key.js";
|
|
14
|
+
/**
|
|
15
|
+
* Read both sources once and compare them without changing which one wins.
|
|
16
|
+
* Precedence mirrors `resolveZaiApiKey`: process environment, then the
|
|
17
|
+
* per-user store.
|
|
18
|
+
*/
|
|
19
|
+
export function inspectZaiKey(options = {}) {
|
|
20
|
+
const env = options.env ?? process.env;
|
|
21
|
+
const store = options.store ?? detectUserEnvStore();
|
|
22
|
+
const diagnose = options.readUserEnvDiagnostic ?? ((name) => readUserEnvDiagnostic(name));
|
|
23
|
+
const rawProcess = env[ZAI_API_KEY_ENV];
|
|
24
|
+
const processValue = rawProcess && rawProcess.trim() ? rawProcess.trim() : undefined;
|
|
25
|
+
const storeResult = diagnose(ZAI_API_KEY_ENV);
|
|
26
|
+
const storeValue = storeResult.value;
|
|
27
|
+
const effectiveKey = processValue ?? storeValue;
|
|
28
|
+
const effectiveSource = processValue
|
|
29
|
+
? "process-env"
|
|
30
|
+
: storeValue
|
|
31
|
+
? "user-store"
|
|
32
|
+
: undefined;
|
|
33
|
+
let comparison;
|
|
34
|
+
if (!storeResult.readable) {
|
|
35
|
+
comparison = "unavailable";
|
|
36
|
+
}
|
|
37
|
+
else if (processValue && storeValue) {
|
|
38
|
+
comparison = processValue === storeValue ? "match" : "different";
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
comparison = "not-comparable";
|
|
42
|
+
}
|
|
43
|
+
const keyMismatch = comparison === "different" ? true : comparison === "match" ? false : null;
|
|
44
|
+
return { effectiveKey, effectiveSource, comparison, keyMismatch, store };
|
|
45
|
+
}
|
package/dist/core/user-env.js
CHANGED
|
@@ -63,12 +63,17 @@ export function describeKeyStore(store) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
* Read a variable from the per-user store
|
|
67
|
-
*
|
|
66
|
+
* Read a variable from the per-user store, distinguishing "no store" / "not
|
|
67
|
+
* set" (readable: true, value: undefined) from "the read itself failed"
|
|
68
|
+
* (readable: false). `readUserEnv` below is a thin wrapper that collapses
|
|
69
|
+
* both to `undefined` for existing callers.
|
|
68
70
|
*/
|
|
69
|
-
export function
|
|
71
|
+
export function readUserEnvDiagnostic(name, deps = {}) {
|
|
70
72
|
assertEnvVarName(name);
|
|
71
73
|
const store = detectUserEnvStore(deps);
|
|
74
|
+
if (store === "none") {
|
|
75
|
+
return { readable: true, value: undefined };
|
|
76
|
+
}
|
|
72
77
|
const run = deps.run ?? defaultRun;
|
|
73
78
|
try {
|
|
74
79
|
let value;
|
|
@@ -87,16 +92,21 @@ export function readUserEnv(name, deps = {}) {
|
|
|
87
92
|
case "libsecret":
|
|
88
93
|
value = run("secret-tool", ["lookup", "service", KEY_STORE_SERVICE, "account", name], { capture: true });
|
|
89
94
|
break;
|
|
90
|
-
case "none":
|
|
91
|
-
return undefined;
|
|
92
95
|
}
|
|
93
96
|
const trimmed = value.trim();
|
|
94
|
-
return trimmed || undefined;
|
|
97
|
+
return { readable: true, value: trimmed || undefined };
|
|
95
98
|
}
|
|
96
99
|
catch {
|
|
97
|
-
return undefined;
|
|
100
|
+
return { readable: false, value: undefined };
|
|
98
101
|
}
|
|
99
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Read a variable from the per-user store. Returns undefined on any failure —
|
|
105
|
+
* callers fall back or raise their own error.
|
|
106
|
+
*/
|
|
107
|
+
export function readUserEnv(name, deps = {}) {
|
|
108
|
+
return readUserEnvDiagnostic(name, deps).value;
|
|
109
|
+
}
|
|
100
110
|
/**
|
|
101
111
|
* Write a variable to the per-user store. Throws when there is no store —
|
|
102
112
|
* callers print platform-appropriate guidance instead.
|