glm-coding-router 1.0.0 → 1.1.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GLM Coding Router
2
2
 
3
- GLM Coding Plan workers for Claude Code and Codex, on Windows.
3
+ GLM Coding Plan workers for Claude Code and Codex on Windows, Linux, and (experimentally) macOS.
4
4
 
5
5
  Claude Code and Codex stay your orchestrators — they keep responsibility for requirements,
6
6
  architecture, review, and integration. `glm-coding-router` delegates well-scoped
@@ -43,7 +43,8 @@ Claude / Codex → shell → glm-worker → claude.exe harness → Z.ai endpoint
43
43
 
44
44
  ## Requirements
45
45
 
46
- - Windows 10/11 (v0.1; Linux/macOS planned for v0.2)
46
+ - Windows 10/11 or Linux (both verified); macOS is experimental — the suite has not been
47
+ run on a Mac
47
48
  - Node.js >= 20
48
49
  - Claude Code (`claude.exe`) — the GLM commands run on the Claude Code harness
49
50
  - Codex (optional — Claude-only setups are fully supported)
@@ -292,7 +293,7 @@ except JSON-RPC frames.
292
293
  glm-router init guided setup
293
294
  glm-router doctor [--network] full runtime diagnosis
294
295
  glm-router status quick offline overview
295
- glm-router key set store ZAI_API_KEY (Windows User Environment)
296
+ glm-router key set store ZAI_API_KEY in this platform's per-user store
296
297
  glm-router key check key configured? from which source?
297
298
  glm-router config show
298
299
  glm-router config set models.main glm-5.3
@@ -343,14 +344,15 @@ after Orca starts is invisible to those terminals. Every GLM command therefore r
343
344
  the key in this order:
344
345
 
345
346
  1. `process.env.ZAI_API_KEY`
346
- 2. Windows User Environment (via PowerShell)
347
+ 2. This platform's per-user store — Windows User Environment (PowerShell), macOS login
348
+ keychain (`security`), or libsecret (`secret-tool`, when installed)
347
349
  3. fail with an actionable error
348
350
 
349
351
  The key is never cached to disk.
350
352
 
351
353
  ## Security model
352
354
 
353
- - The key lives only in the Windows User Environment; it is never written to
355
+ - The key lives only in that per-user store; it is never written to
354
356
  `config.json`, the repo, logs, or stack traces. Debug output redacts
355
357
  `ZAI_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, and Authorization headers.
356
358
  - Z.ai routing environment variables (`ANTHROPIC_AUTH_TOKEN`,
@@ -368,7 +370,9 @@ The key is never cached to disk.
368
370
  | --- | --- |
369
371
  | `ERROR [ZAI_KEY_MISSING]` | `glm-router key set`, then open a **new** terminal |
370
372
  | `ERROR [CLAUDE_NOT_FOUND]` | Install Claude Code, or `glm-router config set claudePath C:\path\to\claude.exe` |
371
- | Key works in a new terminal but not inside Orca | Expected — workers re-read the Windows User Environment automatically; run `glm-router doctor` to confirm |
373
+ | Key works in a new terminal but not inside Orca | Expected — workers re-read the per-user store automatically; run `glm-router doctor` to confirm |
374
+ | `glm-router key set` prints an `export` line instead of saving | This platform has no secret store (e.g. Linux without `secret-tool`). Add the line to your shell profile; `glm-router key check` verifies it |
375
+ | The worker creates files but never runs the tests | Its Bash allowlist is empty. `glm-router config show` → `worker.allowedBash`; the default list covers common test commands |
372
376
  | `glm-*` not on PATH after install | Reopen the terminal; check `npm config get prefix` is on PATH |
373
377
  | `ERROR [MANAGED_BLOCK_CORRUPT]` | Fix the marker pair in the named file manually, then re-run |
374
378
 
@@ -396,7 +400,7 @@ npm run dev # tsx src/cli.ts <args>
396
400
 
397
401
  Integration tests spawn `tests/fixtures/fake-agent.mjs` (via `node.exe`) to verify
398
402
  argument passing, environment injection, and exit-code propagation without spending
399
- API quota. See the `GLM Coding Router — Technical Specification v0.1.md` for the full
403
+ API quota. See the `docs/GLM Coding Router — Technical Specification v0.1.md` for the full
400
404
  v0.1 contract (exit codes, managed-block test matrix, acceptance criteria).
401
405
 
402
406
  ## Publishing
@@ -11,8 +11,20 @@ import { spawnAgent } from "../core/process.js";
11
11
  import { resolveZaiApiKey } from "../core/zai-key.js";
12
12
  /** Worker tool surface (spec §16). */
13
13
  export const WORKER_TOOLS = "Read,Glob,Grep,Edit,Write,Bash";
14
+ /** Same surface minus Bash, used when no Bash command is allowed. */
15
+ export const WORKER_TOOLS_NO_BASH = "Read,Glob,Grep,Edit,Write";
16
+ /**
17
+ * Build the child arguments (spec §16, specs/worker-bash-permissions.md).
18
+ *
19
+ * `--permission-mode acceptEdits` auto-approves file edits but NOT shell
20
+ * commands, and headless `-p` has no prompt to answer — so without an explicit
21
+ * `--allowedTools` every Bash call comes back "This command requires
22
+ * approval". When the allowlist is empty we drop Bash from `--tools` entirely
23
+ * rather than advertising a tool the worker can never use.
24
+ */
14
25
  export function buildWorkerArgs(prompt, config) {
15
- return [
26
+ const allowedBash = config.worker.allowedBash;
27
+ const args = [
16
28
  "-p",
17
29
  prompt,
18
30
  "--max-turns",
@@ -20,8 +32,25 @@ export function buildWorkerArgs(prompt, config) {
20
32
  "--permission-mode",
21
33
  "acceptEdits",
22
34
  "--tools",
23
- WORKER_TOOLS,
35
+ allowedBash.length > 0 ? WORKER_TOOLS : WORKER_TOOLS_NO_BASH,
24
36
  ];
37
+ if (allowedBash.length > 0) {
38
+ args.push("--allowedTools", ...allowedBash.map((pattern) => `Bash(${pattern})`));
39
+ }
40
+ return args;
41
+ }
42
+ /** Strip `--no-bash`, which empties the allowlist for one invocation. */
43
+ export function extractNoBashFlag(argv) {
44
+ const rest = [];
45
+ let noBash = false;
46
+ for (const arg of argv) {
47
+ if (arg === "--no-bash") {
48
+ noBash = true;
49
+ continue;
50
+ }
51
+ rest.push(arg);
52
+ }
53
+ return { rest, noBash };
25
54
  }
26
55
  /**
27
56
  * glm-worker (spec §15, §16): headless implementation worker.
@@ -29,9 +58,13 @@ export function buildWorkerArgs(prompt, config) {
29
58
  * --dangerously-skip-permissions.
30
59
  */
31
60
  export async function runWorker(argv) {
32
- const { rest, profile } = extractProfileFlag(argv);
61
+ const { rest: withoutProfile, profile } = extractProfileFlag(argv);
62
+ const { rest, noBash } = extractNoBashFlag(withoutProfile);
33
63
  const prompt = await resolvePrompt(rest);
34
- const config = applyProfile(loadConfig(), profile);
64
+ const loaded = applyProfile(loadConfig(), profile);
65
+ const config = noBash
66
+ ? { ...loaded, worker: { ...loaded.worker, allowedBash: [] } }
67
+ : loaded;
35
68
  const resolved = resolveZaiApiKey();
36
69
  if (!resolved) {
37
70
  throw Errors.zaiKeyMissing();
@@ -2,7 +2,8 @@ import os from "node:os";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { defaultConfig, loadConfig } from "../core/config.js";
4
4
  import { locateClaude, locateCodex, searchPathFor } from "../core/claude.js";
5
- import { isWindows, windowsVersionName } from "../core/platform.js";
5
+ import { platformName, platformSupport } from "../core/platform.js";
6
+ import { describeKeyStore, detectUserEnvStore } from "../core/user-env.js";
6
7
  import { resolveZaiApiKey } from "../core/zai-key.js";
7
8
  import { configPath } from "../core/paths.js";
8
9
  import fs from "node:fs";
@@ -28,8 +29,13 @@ export function runDoctorChecks(options = {}) {
28
29
  const results = [];
29
30
  const home = options.home ?? os.homedir();
30
31
  const env = options.env ?? process.env;
31
- // --- System ---
32
- results.push(check("System", isWindows() ? windowsVersionName() : `Platform ${process.platform}`, isWindows() ? "ok" : "fail", undefined, isWindows() ? undefined : "v0.1 targets Windows only."));
32
+ // --- System (specs/cross-platform.md) ---
33
+ const support = platformSupport();
34
+ results.push(check("System", platformName(), support === "supported" ? "ok" : support === "experimental" ? "warn" : "fail", undefined, support === "experimental"
35
+ ? "macOS support is experimental — the suite has not been run on a Mac."
36
+ : support === "unsupported"
37
+ ? "Supported: Windows, Linux. macOS is experimental."
38
+ : undefined));
33
39
  const nodeMajor = nodeVersionMajor();
34
40
  results.push(check("System", `Node.js ${process.versions.node}`, nodeMajor >= 20 ? "ok" : "fail", undefined, nodeMajor >= 20 ? undefined : "glm-coding-router requires Node.js >= 20."));
35
41
  results.push(check("System", "Git", gitFound() ? "ok" : "warn"));
@@ -60,9 +66,19 @@ export function runDoctorChecks(options = {}) {
60
66
  const codexPath = locateCodex(config, env);
61
67
  results.push(check("Agents", "Codex", codexPath ? "ok" : "warn", codexPath, codexPath ? undefined : "Optional — Claude-only setups are supported."));
62
68
  // --- Z.ai key ---
69
+ const store = options.store ?? detectUserEnvStore();
63
70
  const resolved = resolveZaiApiKey({ env, readUserEnv: options.readUserEnv });
64
- results.push(check("Z.ai", "ZAI_API_KEY", resolved ? "ok" : "fail", resolved ? `configured (${resolved.source})` : "not found", resolved ? undefined : "Run: glm-router key set"));
71
+ const sourceLabel = resolved?.source === "process-env" ? "process environment" : describeKeyStore(store);
72
+ results.push(check("Z.ai", "ZAI_API_KEY", resolved ? "ok" : "fail", resolved ? `configured (${sourceLabel})` : "not found", resolved
73
+ ? undefined
74
+ : store === "none"
75
+ ? 'Set it: export ZAI_API_KEY="<your-key>" (see: glm-router key set)'
76
+ : "Run: glm-router key set"));
65
77
  results.push(check("Z.ai", "Anthropic endpoint", "ok", config.provider.anthropicBaseUrl));
78
+ // specs/worker-bash-permissions.md — what the worker is allowed to execute.
79
+ results.push(check("Z.ai", "Worker shell access", "ok", config.worker.allowedBash.length > 0
80
+ ? `${config.worker.allowedBash.length} allowed Bash patterns`
81
+ : "disabled (Bash not offered to the worker)"));
66
82
  // --- Commands (PATH shims; a dev checkout warns instead of failing) ---
67
83
  for (const command of ["glm-chat", "glm-worker", "glm-review"]) {
68
84
  const found = searchPathFor(command);
@@ -86,7 +102,7 @@ export function runDoctorChecks(options = {}) {
86
102
  results.push(check("Environment", "Process environment", "ok", "ZAI_API_KEY visible in current process"));
87
103
  }
88
104
  else if (resolved) {
89
- results.push(check("Environment", "Process environment", "warn", "Current process does not contain ZAI_API_KEY", "This is safe. GLM workers reload the key automatically from the Windows User Environment."));
105
+ results.push(check("Environment", "Process environment", "warn", "Current process does not contain ZAI_API_KEY", `This is safe. GLM workers reload the key automatically from ${describeKeyStore(store)}.`));
90
106
  }
91
107
  return { results, config, keySource: resolved?.source };
92
108
  }
@@ -6,7 +6,8 @@ import { configPath } from "../core/paths.js";
6
6
  import { ExitCode } from "../core/errors.js";
7
7
  import { runDoctorChecks } from "./doctor.js";
8
8
  import { setWindowsUserEnv, ZAI_API_KEY_ENV } from "../core/zai-key.js";
9
- import { isWindows } from "../core/platform.js";
9
+ import { detectUserEnvStore } from "../core/user-env.js";
10
+ import { printShellKeyGuidance } from "./key.js";
10
11
  import { CodexSkillInstaller, glmDelegationSkill } from "../integrations/skill.js";
11
12
  import { version } from "../core/version.js";
12
13
  function renderEnvironment(results) {
@@ -84,8 +85,9 @@ export async function initCommand(options, deps = {}) {
84
85
  process.stdout.write(`✓ ${ZAI_API_KEY_ENV} already configured (${report.keySource})\n`);
85
86
  }
86
87
  else if (choices.configureKey) {
87
- if (!isWindows()) {
88
- process.stdout.write("⚠ Key storage requires Windows in v0.1 skipped\n");
88
+ if ((deps.store ?? detectUserEnvStore()) === "none") {
89
+ // No persistent store here; tell the user the one line that works.
90
+ printShellKeyGuidance({ env: deps.env });
89
91
  }
90
92
  else {
91
93
  const keyResponse = await prompt({
@@ -1,12 +1,41 @@
1
1
  import prompts from "prompts";
2
- import { assertWindows } from "../core/platform.js";
3
- import { Errors } from "../core/errors.js";
2
+ import { Errors, formatGlmError } from "../core/errors.js";
4
3
  import { logger } from "../core/logging.js";
4
+ import { describeKeyStore, describeShellExport, detectUserEnvStore, } from "../core/user-env.js";
5
5
  import { ZAI_API_KEY_ENV, deleteWindowsUserEnv, resolveZaiApiKey, setWindowsUserEnv, } from "../core/zai-key.js";
6
6
  import { emitJson } from "./context.js";
7
+ /**
8
+ * Print the shell-profile guidance used when the platform has no persistent
9
+ * secret store (the common Linux case: `secret-tool` is not installed by
10
+ * default). specs/cross-platform.md.
11
+ */
12
+ export function printShellKeyGuidance(deps = {}) {
13
+ const { line, profile } = describeShellExport(ZAI_API_KEY_ENV, {
14
+ env: deps.env,
15
+ home: deps.home,
16
+ });
17
+ process.stdout.write([
18
+ `This platform has ${describeKeyStore("none")} that ${ZAI_API_KEY_ENV} can be saved to,`,
19
+ "so set it yourself — it takes one line:",
20
+ "",
21
+ ` ${line}`,
22
+ "",
23
+ `Add that to ${profile} so new shells inherit it, then run:`,
24
+ "",
25
+ " glm-router key check",
26
+ "",
27
+ "The current shell keeps its old environment until you re-source that file",
28
+ "or open a new terminal.",
29
+ "",
30
+ ].join("\n"));
31
+ }
7
32
  /** glm-router key set (spec §11): prompt, save to Windows User Environment. */
8
33
  export async function keySetCommand(_options, deps = {}) {
9
- assertWindows();
34
+ const store = deps.store ?? detectUserEnvStore();
35
+ if (store === "none") {
36
+ printShellKeyGuidance(deps);
37
+ return 0;
38
+ }
10
39
  const prompt = deps.prompt ?? prompts;
11
40
  const setEnv = deps.setEnv ?? setWindowsUserEnv;
12
41
  const response = await prompt({
@@ -24,10 +53,10 @@ export async function keySetCommand(_options, deps = {}) {
24
53
  setEnv(ZAI_API_KEY_ENV, key);
25
54
  }
26
55
  catch (error) {
27
- logger.error(`Failed to write the Windows User Environment: ${error instanceof Error ? error.message : String(error)}`);
56
+ logger.error(`Failed to write ${describeKeyStore(store)}: ${error instanceof Error ? error.message : String(error)}`);
28
57
  return 1;
29
58
  }
30
- process.stdout.write(`\n✓ Saved to Windows User Environment:\n ${ZAI_API_KEY_ENV}\n`);
59
+ process.stdout.write(`\n✓ Saved to ${describeKeyStore(store)}:\n ${ZAI_API_KEY_ENV}\n`);
31
60
  process.stdout.write("\nOpen a NEW terminal (or restart your Orca terminal) so the current process picks it up.\n");
32
61
  return 0;
33
62
  }
@@ -42,18 +71,26 @@ export function keyCheckCommand(options, deps = {}) {
42
71
  return resolved ? 0 : 10;
43
72
  }
44
73
  if (!resolved) {
45
- // Formatted per spec §36; exit 10 (spec §35).
74
+ // Formatted per spec §36; exit 10 (spec §35). The hint is platform-aware,
75
+ // so it never points at a command that cannot finish the job here.
46
76
  const error = Errors.zaiKeyMissing();
47
- process.stderr.write(`ERROR [${error.codeName}]\n\n${error.message}\n\nRun:\n\n glm-router key set\n`);
77
+ process.stderr.write(formatGlmError(error) + "\n");
48
78
  return error.exitCode;
49
79
  }
50
- const sourceLabel = resolved.source === "process-env" ? "process environment" : "Windows User Environment";
80
+ const sourceLabel = resolved.source === "process-env"
81
+ ? "process environment"
82
+ : describeKeyStore(deps.store ?? detectUserEnvStore());
51
83
  process.stdout.write(`${ZAI_API_KEY_ENV}: configured\nSource: ${sourceLabel}\n`);
52
84
  return 0;
53
85
  }
54
86
  /** Used by uninstall; keeps the key by default (spec §44). */
55
87
  export async function keyRemoveCommand(deps = {}) {
56
- assertWindows();
88
+ const store = deps.store ?? detectUserEnvStore();
89
+ if (store === "none") {
90
+ // Nothing this tool owns; the key lives in a shell profile the user wrote.
91
+ process.stdout.write(`${ZAI_API_KEY_ENV} is not stored by glm-router on this platform — remove the export line from your shell profile.\n`);
92
+ return;
93
+ }
57
94
  const deleteEnv = deps.deleteEnv ?? deleteWindowsUserEnv;
58
95
  deleteEnv(ZAI_API_KEY_ENV);
59
96
  }
@@ -51,6 +51,8 @@ export function statusCommand(options, deps = {}) {
51
51
  });
52
52
  return 0;
53
53
  }
54
+ /** Every status row pads its label to this column (spec §41). */
55
+ const LABEL_WIDTH = 16;
54
56
  const lines = [
55
57
  `GLM Coding Router v${version}`,
56
58
  "",
@@ -63,7 +65,7 @@ export function statusCommand(options, deps = {}) {
63
65
  ];
64
66
  for (const row of skillState) {
65
67
  const enabled = row.homeDetected && row.installed;
66
- lines.push(`${row.agent} skill ${enabled ? "enabled" : "disabled"}`);
68
+ lines.push(`${`${row.agent} skill`.padEnd(LABEL_WIDTH)}${enabled ? "enabled" : "disabled"}`);
67
69
  }
68
70
  lines.push("", `Main model ${config.models.main}`, `Fast model ${config.models.fast}`);
69
71
  process.stdout.write(lines.join("\n") + "\n");
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { configDir } from "../core/paths.js";
5
5
  import { ExitCode } from "../core/errors.js";
6
+ import { describeKeyStore, detectUserEnvStore } from "../core/user-env.js";
6
7
  import { CodexSkillInstaller, glmDelegationSkill } from "../integrations/skill.js";
7
8
  import { keyRemoveCommand } from "./key.js";
8
9
  import { removeClaudeIntegration, removeCodexIntegration } from "../integrations/index.js";
@@ -85,7 +86,7 @@ export async function uninstallCommand(options, deps = {}) {
85
86
  }
86
87
  if (choices.removeKey) {
87
88
  await keyRemoveCommand({ deleteEnv: deps.deleteEnv });
88
- process.stdout.write("✓ ZAI_API_KEY removed from Windows User Environment\n");
89
+ process.stdout.write(`✓ ZAI_API_KEY removed from ${describeKeyStore(detectUserEnvStore())}\n`);
89
90
  }
90
91
  else {
91
92
  process.stdout.write("✓ ZAI_API_KEY kept\n");
@@ -9,7 +9,7 @@ import { emitJson } from "./context.js";
9
9
  /** Z.ai monitor API used by their own dashboard (specs/usage.md). */
10
10
  const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
11
11
  /** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
12
- function describeWindow(limit) {
12
+ export function describeWindow(limit) {
13
13
  if (limit.unit === 3 && typeof limit.number === "number") {
14
14
  return `${limit.number}-hour window`;
15
15
  }
@@ -6,6 +6,34 @@ import { configDir, configPath } from "./paths.js";
6
6
  export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
7
7
  export const DEFAULT_MAIN_MODEL = "glm-5.3";
8
8
  export const DEFAULT_FAST_MODEL = "glm-5.3-flash";
9
+ /**
10
+ * Bash commands the worker may run without an approval prompt
11
+ * (specs/worker-bash-permissions.md). Headless `-p` has no prompt to answer,
12
+ * so without this every Bash call is denied and the worker cannot validate its
13
+ * own work. Validation commands only: no git writes, no rm, no network, no
14
+ * package installs.
15
+ */
16
+ export const DEFAULT_ALLOWED_BASH = [
17
+ "npm test",
18
+ "npm run *",
19
+ "npx vitest *",
20
+ "go test *",
21
+ "pytest*",
22
+ "python3 *",
23
+ "cargo test*",
24
+ "git status",
25
+ "git diff*",
26
+ ];
27
+ /** Shell metacharacters defeat pattern matching, so they may not appear. */
28
+ const BASH_PATTERN_FORBIDDEN = /[&;|`$><\n]/;
29
+ const AllowedBashSchema = z
30
+ .array(z
31
+ .string()
32
+ .min(1)
33
+ .refine((value) => !BASH_PATTERN_FORBIDDEN.test(value), {
34
+ message: "must not contain shell metacharacters (& ; | ` $ > <)",
35
+ }))
36
+ .default([...DEFAULT_ALLOWED_BASH]);
9
37
  /** Named model/maxTurns overlay selected via --profile (specs/glm-fast-profiles.md). */
10
38
  export const ProfileSchema = z.object({
11
39
  main: z.string().min(1).optional(),
@@ -25,7 +53,8 @@ export const ConfigSchema = z.object({
25
53
  }),
26
54
  worker: z.object({
27
55
  maxTurns: z.number().int().positive(),
28
- }).default({ maxTurns: 20 }),
56
+ allowedBash: AllowedBashSchema,
57
+ }).default({ maxTurns: 20, allowedBash: [...DEFAULT_ALLOWED_BASH] }),
29
58
  review: z.object({
30
59
  maxTurns: z.number().int().positive(),
31
60
  }).default({ maxTurns: 15 }),
@@ -51,7 +80,7 @@ export function defaultConfig() {
51
80
  main: DEFAULT_MAIN_MODEL,
52
81
  fast: DEFAULT_FAST_MODEL,
53
82
  },
54
- worker: { maxTurns: 20 },
83
+ worker: { maxTurns: 20, allowedBash: [...DEFAULT_ALLOWED_BASH] },
55
84
  review: { maxTurns: 15 },
56
85
  integrations: {
57
86
  claude: true,
@@ -1,3 +1,15 @@
1
+ /*
2
+ * This module deliberately does NOT import platform.ts: platform.ts imports
3
+ * Errors, so the dependency would be circular. The couple of platform checks
4
+ * below read process.platform directly (specs/cross-platform.md).
5
+ */
6
+ const onWindows = () => process.platform === "win32";
7
+ /** Config dir as the user's own platform spells it. */
8
+ function configPathHint() {
9
+ return onWindows()
10
+ ? "%USERPROFILE%\\.glm-coding-router\\config.json"
11
+ : "~/.glm-coding-router/config.json";
12
+ }
1
13
  /** Standard exit codes (spec §35). */
2
14
  export const ExitCode = {
3
15
  Success: 0,
@@ -29,17 +41,26 @@ export const Errors = {
29
41
  zaiKeyMissing: () => new GlmRouterError({
30
42
  name: "ZAI_KEY_MISSING",
31
43
  message: "ZAI_API_KEY was not found.",
32
- hint: ["Run:", "", " glm-router key set"],
44
+ // On platforms without a persistent store `key set` can only print
45
+ // guidance, so the export line is shown here too — otherwise this hint
46
+ // points at a command that cannot finish the job.
47
+ hint: onWindows()
48
+ ? ["Run:", "", " glm-router key set"]
49
+ : [
50
+ "Run:",
51
+ "",
52
+ " glm-router key set",
53
+ "",
54
+ "or set it in this shell and your shell profile:",
55
+ "",
56
+ ' export ZAI_API_KEY="<your-key>"',
57
+ ],
33
58
  exitCode: ExitCode.ZaiKeyMissing,
34
59
  }),
35
60
  configInvalid: (detail) => new GlmRouterError({
36
61
  name: "CONFIG_INVALID",
37
62
  message: `Configuration is invalid: ${detail}`,
38
- hint: [
39
- "Fix or remove the config file:",
40
- "",
41
- " %USERPROFILE%\\.glm-coding-router\\config.json",
42
- ],
63
+ hint: ["Fix or remove the config file:", "", ` ${configPathHint()}`],
43
64
  exitCode: ExitCode.ConfigInvalid,
44
65
  }),
45
66
  claudeNotFound: (detail) => new GlmRouterError({
@@ -49,11 +70,13 @@ export const Errors = {
49
70
  hint: [
50
71
  "Expected:",
51
72
  "",
52
- " claude.exe",
73
+ onWindows() ? " claude.exe" : " claude",
53
74
  "",
54
75
  "Install Claude Code or set an override:",
55
76
  "",
56
- " glm-router config set claudePath C:\\path\\to\\claude.exe",
77
+ onWindows()
78
+ ? " glm-router config set claudePath C:\\path\\to\\claude.exe"
79
+ : " glm-router config set claudePath /path/to/claude",
57
80
  ],
58
81
  exitCode: ExitCode.ClaudeNotFound,
59
82
  }),
@@ -81,8 +104,8 @@ export const Errors = {
81
104
  }),
82
105
  unsupportedPlatform: (platform) => new GlmRouterError({
83
106
  name: "UNSUPPORTED_PLATFORM",
84
- message: `This command requires Windows (detected: ${platform}).`,
85
- hint: ["Linux and macOS support is planned for v0.2."],
107
+ message: `This platform is not supported (detected: ${platform}).`,
108
+ hint: ["Supported: Windows, Linux. macOS is experimental."],
86
109
  exitCode: ExitCode.UnsupportedPlatform,
87
110
  }),
88
111
  promptRequired: (command = "glm-worker") => new GlmRouterError({
@@ -3,18 +3,47 @@ import { Errors } from "./errors.js";
3
3
  export function isWindows() {
4
4
  return process.platform === "win32";
5
5
  }
6
- /** Human-readable Windows version from os.release() ("10.0.26200" → "Windows 11"). */
7
- export function windowsVersionName() {
8
- if (!isWindows()) {
9
- return process.platform;
6
+ /**
7
+ * Support level per platform:
8
+ * win32 — verified through the registry-verification ritual
9
+ * linux — verified 2026-09-20 on Ubuntu 24.04
10
+ * darwin — designed but never executed; no Mac has run the suite
11
+ */
12
+ export function platformSupport(platform = process.platform) {
13
+ if (platform === "win32" || platform === "linux")
14
+ return "supported";
15
+ if (platform === "darwin")
16
+ return "experimental";
17
+ return "unsupported";
18
+ }
19
+ export function isSupportedPlatform(platform = process.platform) {
20
+ return platformSupport(platform) !== "unsupported";
21
+ }
22
+ /** Human-readable platform name for doctor/status. */
23
+ export function platformName(platform = process.platform, release = os.release()) {
24
+ if (platform === "win32") {
25
+ const build = Number.parseInt(release.split(".")[2] ?? "0", 10);
26
+ return build >= 22000 ? "Windows 11" : "Windows 10";
10
27
  }
11
- const release = os.release();
12
- const build = Number.parseInt(release.split(".")[2] ?? "0", 10);
13
- return build >= 22000 ? "Windows 11" : "Windows 10";
28
+ if (platform === "darwin")
29
+ return `macOS (darwin ${release.split(".")[0] ?? "?"})`;
30
+ if (platform === "linux")
31
+ return `Linux ${release.split("-")[0] ?? release}`;
32
+ return `Platform ${platform}`;
14
33
  }
15
- /** Guard for commands that require Windows-specific machinery (PowerShell user env, etc.). */
34
+ /** @deprecated use platformName(); kept so existing callers keep compiling. */
35
+ export function windowsVersionName() {
36
+ return platformName();
37
+ }
38
+ /** Guard for the few code paths that are genuinely Windows-only. */
16
39
  export function assertWindows() {
17
40
  if (!isWindows()) {
18
41
  throw Errors.unsupportedPlatform(process.platform);
19
42
  }
20
43
  }
44
+ /** Guard for commands that need a platform this package supports at all. */
45
+ export function assertSupportedPlatform() {
46
+ if (!isSupportedPlatform()) {
47
+ throw Errors.unsupportedPlatform(process.platform);
48
+ }
49
+ }
@@ -51,7 +51,12 @@ export function applyProfile(config, name) {
51
51
  main: profile.main ?? config.models.main,
52
52
  fast: profile.fast ?? config.models.fast,
53
53
  },
54
- worker: { maxTurns: profile.workerMaxTurns ?? config.worker.maxTurns },
54
+ worker: {
55
+ maxTurns: profile.workerMaxTurns ?? config.worker.maxTurns,
56
+ // Profiles tune models and turn budgets, never the Bash allowlist —
57
+ // that is a security setting, not a performance knob.
58
+ allowedBash: config.worker.allowedBash,
59
+ },
55
60
  review: { maxTurns: profile.reviewMaxTurns ?? config.review.maxTurns },
56
61
  };
57
62
  }
@@ -0,0 +1,183 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /** Keychain/libsecret service name; the variable name is the account. */
5
+ export const KEY_STORE_SERVICE = "glm-coding-router";
6
+ /** Only well-formed variable names may reach a child process (spec §38). */
7
+ function assertEnvVarName(name) {
8
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
9
+ throw new Error(`Invalid environment variable name: ${name}`);
10
+ }
11
+ }
12
+ function defaultRun(file, args, options) {
13
+ const result = execFileSync(file, [...args], {
14
+ encoding: "utf8",
15
+ windowsHide: true,
16
+ input: options.input,
17
+ stdio: options.input === undefined
18
+ ? ["ignore", options.capture ? "pipe" : "ignore", "ignore"]
19
+ : ["pipe", options.capture ? "pipe" : "ignore", "ignore"],
20
+ });
21
+ return typeof result === "string" ? result : "";
22
+ }
23
+ /** Is `name` an executable file somewhere on PATH? */
24
+ function defaultHasCommand(name, env = process.env) {
25
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter((d) => d.length > 0);
26
+ return dirs.some((dir) => {
27
+ try {
28
+ return fs.statSync(path.join(dir, name)).isFile();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ });
34
+ }
35
+ /**
36
+ * Which store this machine has. `libsecret` requires `secret-tool` on PATH —
37
+ * it is NOT installed by default on Ubuntu, so "none" is a normal Linux
38
+ * outcome, not an error (specs/cross-platform.md).
39
+ */
40
+ export function detectUserEnvStore(deps = {}) {
41
+ const platform = deps.platform ?? process.platform;
42
+ const env = deps.env ?? process.env;
43
+ const hasCommand = deps.hasCommand ?? ((name) => defaultHasCommand(name, env));
44
+ if (platform === "win32")
45
+ return "windows-user-env";
46
+ if (platform === "darwin")
47
+ return hasCommand("security") ? "macos-keychain" : "none";
48
+ if (platform === "linux")
49
+ return hasCommand("secret-tool") ? "libsecret" : "none";
50
+ return "none";
51
+ }
52
+ /** Human wording for the store, used by key/doctor/uninstall messages. */
53
+ export function describeKeyStore(store) {
54
+ switch (store) {
55
+ case "windows-user-env":
56
+ return "Windows User Environment";
57
+ case "macos-keychain":
58
+ return "macOS login keychain";
59
+ case "libsecret":
60
+ return "the system keyring (libsecret)";
61
+ case "none":
62
+ return "no persistent store";
63
+ }
64
+ }
65
+ /**
66
+ * Read a variable from the per-user store. Returns undefined on any failure —
67
+ * callers fall back or raise their own error.
68
+ */
69
+ export function readUserEnv(name, deps = {}) {
70
+ assertEnvVarName(name);
71
+ const store = detectUserEnvStore(deps);
72
+ const run = deps.run ?? defaultRun;
73
+ try {
74
+ let value;
75
+ switch (store) {
76
+ case "windows-user-env":
77
+ value = run("powershell.exe", [
78
+ "-NoProfile",
79
+ "-NonInteractive",
80
+ "-Command",
81
+ `[Environment]::GetEnvironmentVariable('${name}','User')`,
82
+ ], { capture: true });
83
+ break;
84
+ case "macos-keychain":
85
+ value = run("security", ["find-generic-password", "-s", KEY_STORE_SERVICE, "-a", name, "-w"], { capture: true });
86
+ break;
87
+ case "libsecret":
88
+ value = run("secret-tool", ["lookup", "service", KEY_STORE_SERVICE, "account", name], { capture: true });
89
+ break;
90
+ case "none":
91
+ return undefined;
92
+ }
93
+ const trimmed = value.trim();
94
+ return trimmed || undefined;
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ /**
101
+ * Write a variable to the per-user store. Throws when there is no store —
102
+ * callers print platform-appropriate guidance instead.
103
+ *
104
+ * The value never goes through a shell. On Windows and Linux it is passed via
105
+ * a child environment variable / stdin respectively, so it never appears in
106
+ * any process's argv. The macOS backend has no stdin form, so the value is in
107
+ * `security`'s argv for the duration of that call — a known limitation of the
108
+ * experimental darwin support (specs/cross-platform.md).
109
+ */
110
+ export function writeUserEnv(name, value, deps = {}) {
111
+ assertEnvVarName(name);
112
+ const store = detectUserEnvStore(deps);
113
+ const run = deps.run ?? defaultRun;
114
+ switch (store) {
115
+ case "windows-user-env":
116
+ execFileSync("powershell.exe", [
117
+ "-NoProfile",
118
+ "-NonInteractive",
119
+ "-Command",
120
+ `[Environment]::SetEnvironmentVariable('${name}', $env:GLM_ROUTER_VALUE, 'User')`,
121
+ ], {
122
+ windowsHide: true,
123
+ stdio: ["ignore", "ignore", "pipe"],
124
+ env: { ...process.env, GLM_ROUTER_VALUE: value },
125
+ });
126
+ return;
127
+ case "macos-keychain":
128
+ run("security", ["add-generic-password", "-U", "-s", KEY_STORE_SERVICE, "-a", name, "-w", value], { capture: false });
129
+ return;
130
+ case "libsecret":
131
+ run("secret-tool", ["store", "--label", `${KEY_STORE_SERVICE} ${name}`, "service", KEY_STORE_SERVICE, "account", name], { input: value, capture: false });
132
+ return;
133
+ case "none":
134
+ throw new Error("no persistent secret store is available on this platform");
135
+ }
136
+ }
137
+ /** Remove the variable from the per-user store. No store → nothing to do. */
138
+ export function deleteUserEnv(name, deps = {}) {
139
+ assertEnvVarName(name);
140
+ const store = detectUserEnvStore(deps);
141
+ const run = deps.run ?? defaultRun;
142
+ switch (store) {
143
+ case "windows-user-env":
144
+ run("powershell.exe", [
145
+ "-NoProfile",
146
+ "-NonInteractive",
147
+ "-Command",
148
+ `[Environment]::SetEnvironmentVariable('${name}', $null, 'User')`,
149
+ ], { capture: false });
150
+ return;
151
+ case "macos-keychain":
152
+ run("security", ["delete-generic-password", "-s", KEY_STORE_SERVICE, "-a", name], { capture: false });
153
+ return;
154
+ case "libsecret":
155
+ run("secret-tool", ["clear", "service", KEY_STORE_SERVICE, "account", name], {
156
+ capture: false,
157
+ });
158
+ return;
159
+ case "none":
160
+ return;
161
+ }
162
+ }
163
+ /**
164
+ * The shell line a user must add when there is no store, plus the profile file
165
+ * to put it in. Chosen from $SHELL, preferring a file that already exists.
166
+ */
167
+ export function describeShellExport(name, deps = {}) {
168
+ const env = deps.env ?? process.env;
169
+ const home = deps.home ?? env.HOME ?? "~";
170
+ const exists = deps.exists ?? ((file) => fs.existsSync(file));
171
+ const shell = path.posix.basename(env.SHELL ?? "bash");
172
+ const candidates = shell === "zsh"
173
+ ? [".zshrc", ".zprofile", ".profile"]
174
+ : shell === "fish"
175
+ ? [".config/fish/config.fish"]
176
+ : [".bashrc", ".bash_profile", ".profile"];
177
+ // POSIX paths by definition: this guidance only ever names a shell rc file,
178
+ // so it must not pick up Windows separators when the process runs on win32.
179
+ const found = candidates.find((rel) => exists(path.posix.join(home, rel)));
180
+ const profile = path.posix.join(home, found ?? candidates[0]);
181
+ const line = shell === "fish" ? `set -gx ${name} <your-key>` : `export ${name}="<your-key>"`;
182
+ return { line, profile };
183
+ }
@@ -1,69 +1,25 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { deleteUserEnv, detectUserEnvStore, readUserEnv as readUserEnvStore, writeUserEnv, } from "./user-env.js";
2
2
  export const ZAI_API_KEY_ENV = "ZAI_API_KEY";
3
- /** Only well-formed variable names may reach powershell.exe (spec §38). */
4
- function assertEnvVarName(name) {
5
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
6
- throw new Error(`Invalid environment variable name: ${name}`);
7
- }
8
- }
9
3
  /**
10
- * Read a variable from the Windows User Environment via PowerShell (spec §10).
11
- * Returns undefined on any failure callers fall back or fail with their own error.
4
+ * Read the variable from this platform's per-user store (spec §10).
5
+ * The Windows implementation is unchanged; it now lives in user-env.ts.
12
6
  */
13
- export function readWindowsUserEnv(name) {
14
- assertEnvVarName(name);
15
- try {
16
- const result = execFileSync("powershell.exe", [
17
- "-NoProfile",
18
- "-NonInteractive",
19
- "-Command",
20
- `[Environment]::GetEnvironmentVariable('${name}','User')`,
21
- ], {
22
- encoding: "utf8",
23
- windowsHide: true,
24
- stdio: ["ignore", "pipe", "ignore"],
25
- });
26
- const value = result.trim();
27
- return value || undefined;
28
- }
29
- catch {
30
- return undefined;
31
- }
7
+ export function readWindowsUserEnv(name, deps = {}) {
8
+ return readUserEnvStore(name, deps);
32
9
  }
33
- /**
34
- * Write a variable to the Windows User Environment (spec §11).
35
- * The value is passed through a child-process env var so it never needs
36
- * PowerShell string escaping (spec §38: no unescaped user data in commands).
37
- */
38
- export function setWindowsUserEnv(name, value) {
39
- assertEnvVarName(name);
40
- execFileSync("powershell.exe", [
41
- "-NoProfile",
42
- "-NonInteractive",
43
- "-Command",
44
- `[Environment]::SetEnvironmentVariable('${name}', $env:GLM_ROUTER_VALUE, 'User')`,
45
- ], {
46
- windowsHide: true,
47
- stdio: ["ignore", "ignore", "pipe"],
48
- env: { ...process.env, GLM_ROUTER_VALUE: value },
49
- });
10
+ /** Write the variable to this platform's per-user store (spec §11). */
11
+ export function setWindowsUserEnv(name, value, deps = {}) {
12
+ writeUserEnv(name, value, deps);
50
13
  }
51
- export function deleteWindowsUserEnv(name) {
52
- assertEnvVarName(name);
53
- execFileSync("powershell.exe", [
54
- "-NoProfile",
55
- "-NonInteractive",
56
- "-Command",
57
- `[Environment]::SetEnvironmentVariable('${name}', $null, 'User')`,
58
- ], {
59
- windowsHide: true,
60
- stdio: ["ignore", "ignore", "pipe"],
61
- });
14
+ export function deleteWindowsUserEnv(name, deps = {}) {
15
+ deleteUserEnv(name, deps);
62
16
  }
17
+ /** Re-exported so callers do not need two imports. */
18
+ export { detectUserEnvStore };
63
19
  /**
64
20
  * Resolve the Z.ai key with the mandatory fallback order (spec §10):
65
21
  * 1. process.env.ZAI_API_KEY
66
- * 2. Windows User Environment
22
+ * 2. this platform's per-user store
67
23
  * 3. fail (undefined)
68
24
  *
69
25
  * The fallback exists because Orca terminals snapshot a stale environment and
@@ -71,14 +27,14 @@ export function deleteWindowsUserEnv(name) {
71
27
  */
72
28
  export function resolveZaiApiKey(options = {}) {
73
29
  const env = options.env ?? process.env;
74
- const readUserEnv = options.readUserEnv ?? readWindowsUserEnv;
30
+ const readUserEnv = options.readUserEnv ?? ((name) => readUserEnvStore(name));
75
31
  const fromProcess = env[ZAI_API_KEY_ENV];
76
32
  if (fromProcess && fromProcess.trim()) {
77
33
  return { key: fromProcess.trim(), source: "process-env" };
78
34
  }
79
35
  const fromUserEnv = readUserEnv(ZAI_API_KEY_ENV);
80
36
  if (fromUserEnv && fromUserEnv.trim()) {
81
- return { key: fromUserEnv.trim(), source: "windows-user-env" };
37
+ return { key: fromUserEnv.trim(), source: "user-store" };
82
38
  }
83
39
  return undefined;
84
40
  }
@@ -11,7 +11,7 @@ import { applyProfile } from "../core/profile.js";
11
11
  import { version } from "../core/version.js";
12
12
  import { createDelegateWorktree, delegateBranch, removeDelegateWorktree, rollbackDelegateBranch, validateDelegateName, } from "../core/worktree.js";
13
13
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
- import { aggregateLocalUsage, fetchZaiQuota } from "../commands/usage.js";
14
+ import { aggregateLocalUsage, describeWindow, fetchZaiQuota } from "../commands/usage.js";
15
15
  const PROMPT_PROPERTY = { type: "string", description: "The task prompt for the GLM agent." };
16
16
  export const MCP_TOOLS = [
17
17
  {
@@ -138,7 +138,7 @@ async function usage(deps) {
138
138
  lines.push(`Z.ai Coding Plan${quota.level ? ` (level: ${quota.level})` : ""}`);
139
139
  for (const limit of quota.limits ?? []) {
140
140
  const resets = typeof limit.nextResetTime === "number" ? ` — resets ${new Date(limit.nextResetTime).toISOString()}` : "";
141
- lines.push(` ${String(limit.number ?? "?")}x unit ${String(limit.unit ?? "?")}: ${String(limit.currentValue ?? "?")} / ${String(limit.usage ?? "?")} credits (${String(limit.percentage ?? "?")}%)${resets}`);
141
+ lines.push(` ${describeWindow(limit).padEnd(15)} ${String(limit.currentValue ?? "?")} / ${String(limit.usage ?? "?")} credits (${String(limit.percentage ?? "?")}%)${resets}`);
142
142
  }
143
143
  }
144
144
  catch (error) {
package/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "glm-coding-router",
3
- "version": "1.0.0",
4
- "description": "GLM Coding Plan workers for Claude Code and Codex",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "hieu9721",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/hieu9721/GLM-coding-router.git"
11
- },
12
- "bin": {
13
- "glm-router": "./dist/cli.js",
14
- "glm-chat": "./dist/bin/glm-chat.js",
15
- "glm-worker": "./dist/bin/glm-worker.js",
16
- "glm-review": "./dist/bin/glm-review.js",
17
- "glm-fast": "./dist/bin/glm-fast.js",
18
- "glm-mcp": "./dist/bin/glm-mcp.js"
19
- },
20
- "files": [
21
- "dist"
22
- ],
23
- "scripts": {
24
- "dev": "tsx src/cli.ts",
25
- "build": "tsc",
26
- "test": "vitest run",
27
- "test:watch": "vitest",
28
- "lint": "eslint src tests",
29
- "prepublishOnly": "npm run build && npm test"
30
- },
31
- "engines": {
32
- "node": ">=20"
33
- },
34
- "dependencies": {
35
- "commander": "^15.0.0",
36
- "prompts": "^2.4.2",
37
- "zod": "^4.6.5"
38
- },
39
- "devDependencies": {
40
- "@types/node": "^22.20.3",
41
- "@types/prompts": "^2.4.9",
42
- "eslint": "^9.39.5",
43
- "tsx": "^4.23.13",
44
- "typescript": "^5.9.3",
45
- "typescript-eslint": "^8.70.0",
46
- "vitest": "^5.0.1"
47
- }
48
- }
1
+ {
2
+ "name": "glm-coding-router",
3
+ "version": "1.1.1",
4
+ "description": "GLM Coding Plan workers for Claude Code and Codex",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "hieu9721",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hieu9721/GLM-coding-router.git"
11
+ },
12
+ "bin": {
13
+ "glm-router": "./dist/cli.js",
14
+ "glm-chat": "./dist/bin/glm-chat.js",
15
+ "glm-worker": "./dist/bin/glm-worker.js",
16
+ "glm-review": "./dist/bin/glm-review.js",
17
+ "glm-fast": "./dist/bin/glm-fast.js",
18
+ "glm-mcp": "./dist/bin/glm-mcp.js"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "dev": "tsx src/cli.ts",
25
+ "build": "tsc",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "lint": "eslint src tests",
29
+ "prepublishOnly": "npm run build && npm test"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^15.0.0",
36
+ "prompts": "^2.4.2",
37
+ "zod": "^4.6.5"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.20.3",
41
+ "@types/prompts": "^2.4.9",
42
+ "eslint": "^9.39.5",
43
+ "tsx": "^4.23.13",
44
+ "typescript": "^5.9.3",
45
+ "typescript-eslint": "^8.70.0",
46
+ "vitest": "^5.0.1"
47
+ }
48
+ }