glm-coding-router 1.0.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.
@@ -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
  }
@@ -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");
@@ -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,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
+ }