glm-coding-router 0.5.0 → 1.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.
@@ -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,181 @@
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.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
+ const found = candidates.find((rel) => exists(path.join(home, rel)));
178
+ const profile = path.join(home, found ?? candidates[0]);
179
+ const line = shell === "fish" ? `set -gx ${name} <your-key>` : `export ${name}="<your-key>"`;
180
+ return { line, profile };
181
+ }
@@ -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
  }
@@ -3,33 +3,52 @@ import path from "node:path";
3
3
  import { atomicWriteFile } from "../project/atomic-write.js";
4
4
  import { GLM_DELEGATION_SKILL_MD, GLM_DELEGATION_SKILL_NAME, } from "../templates/glm-delegation-skill.js";
5
5
  /**
6
- * Installs SKILL.md-based skills into the Codex home (~/.codex/skills).
7
- * Detection is conservative: if ~/.codex does not exist there is no
8
- * supported Codex installation to enhance — return null and let callers warn+skip.
6
+ * Shared SKILL.md-folder mechanics; subclasses only pick the agent home dir
7
+ * (specs/v1-architecture.md). Detection stays conservative: a missing home
8
+ * means no supported installation to enhance — return null, callers warn+skip.
9
9
  */
10
- export class CodexSkillInstaller {
11
- codexHome;
12
- constructor(home) {
13
- this.codexHome = path.join(home, ".codex");
10
+ class HomeDirSkillInstaller {
11
+ agentHome;
12
+ constructor(agentHome) {
13
+ this.agentHome = agentHome;
14
14
  }
15
15
  detect() {
16
- if (!fs.existsSync(this.codexHome)) {
16
+ if (!fs.existsSync(this.agentHome)) {
17
17
  return null;
18
18
  }
19
- return { skillsDir: path.join(this.codexHome, "skills") };
19
+ return { skillsDir: path.join(this.agentHome, "skills") };
20
20
  }
21
21
  install(skill) {
22
- const skillDir = path.join(this.codexHome, "skills", skill.name);
22
+ const skillDir = path.join(this.agentHome, "skills", skill.name);
23
23
  atomicWriteFile(path.join(skillDir, "SKILL.md"), skill.content);
24
24
  }
25
25
  remove(name) {
26
- const skillDir = path.join(this.codexHome, "skills", name);
26
+ const skillDir = path.join(this.agentHome, "skills", name);
27
27
  fs.rmSync(skillDir, { recursive: true, force: true });
28
28
  }
29
29
  isInstalled(name) {
30
- return fs.existsSync(path.join(this.codexHome, "skills", name, "SKILL.md"));
30
+ return fs.existsSync(path.join(this.agentHome, "skills", name, "SKILL.md"));
31
+ }
32
+ }
33
+ /** Installs skills into the Codex home (~/.codex/skills). */
34
+ export class CodexSkillInstaller extends HomeDirSkillInstaller {
35
+ constructor(home) {
36
+ super(path.join(home, ".codex"));
31
37
  }
32
38
  }
39
+ /** Installs skills into the Claude Code home (~/.claude/skills, specs/v1-architecture.md). */
40
+ export class ClaudeSkillInstaller extends HomeDirSkillInstaller {
41
+ constructor(home) {
42
+ super(path.join(home, ".claude"));
43
+ }
44
+ }
45
+ /** Every agent the delegation skill supports, in stable display order. */
46
+ export function skillTargets(home) {
47
+ return [
48
+ { agent: "Claude", installer: new ClaudeSkillInstaller(home) },
49
+ { agent: "Codex", installer: new CodexSkillInstaller(home) },
50
+ ];
51
+ }
33
52
  export function glmDelegationSkill() {
34
53
  return { name: GLM_DELEGATION_SKILL_NAME, content: GLM_DELEGATION_SKILL_MD };
35
54
  }