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.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline";
3
+ import { isMainModule } from "../core/main-guard.js";
4
+ import { createMcpServer } from "../mcp/server.js";
5
+ /**
6
+ * glm-mcp (specs/v1-architecture.md): MCP server over stdio. Responses are
7
+ * serialized through a write queue so concurrent tool calls can never
8
+ * interleave frames. Stderr stays free for anything unexpected.
9
+ */
10
+ if (isMainModule(import.meta.url)) {
11
+ const server = createMcpServer();
12
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
13
+ let queue = Promise.resolve();
14
+ rl.on("line", (line) => {
15
+ queue = queue.then(async () => {
16
+ const frame = await server.handleLine(line);
17
+ if (frame !== null) {
18
+ process.stdout.write(`${frame}\n`);
19
+ }
20
+ });
21
+ });
22
+ rl.on("close", () => {
23
+ void queue.then(() => process.exit(0));
24
+ });
25
+ }
@@ -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();
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { uninstallCommand } from "./commands/uninstall.js";
16
16
  import { delegateCommand } from "./commands/delegate.js";
17
17
  import { benchmarkCommand } from "./commands/benchmark.js";
18
18
  import { usageCommand } from "./commands/usage.js";
19
+ import { mcpCommand } from "./commands/mcp.js";
19
20
  const program = new Command();
20
21
  program
21
22
  .name("glm-router")
@@ -108,6 +109,18 @@ program
108
109
  .command("usage")
109
110
  .description("provider usage snapshots: Z.ai Coding Plan quota + local benchmark totals")
110
111
  .action(() => execute(() => usageCommand(globalOptions())));
112
+ const mcp = program
113
+ .command("mcp")
114
+ .description("optional glm-mcp MCP server: snippet, install, remove")
115
+ .action(() => execute(() => mcpCommand(globalOptions(), "info")));
116
+ mcp
117
+ .command("install")
118
+ .description("register glm-mcp with Claude Code (claude mcp add -s user)")
119
+ .action(() => execute(() => mcpCommand(globalOptions(), "install")));
120
+ mcp
121
+ .command("remove")
122
+ .description("unregister glm-mcp from Claude Code (claude mcp remove -s user)")
123
+ .action(() => execute(() => mcpCommand(globalOptions(), "remove")));
111
124
  program
112
125
  .command("uninstall")
113
126
  .description("guided removal (keeps ZAI_API_KEY by default)")
@@ -2,11 +2,12 @@ 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";
9
- import { CodexSkillInstaller } from "../integrations/skill.js";
10
+ import { skillTargets } from "../integrations/skill.js";
10
11
  import { GLM_DELEGATION_SKILL_NAME } from "../templates/glm-delegation-skill.js";
11
12
  function check(section, name, status, detail, note) {
12
13
  return { section, name, status, detail, note };
@@ -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);
@@ -71,21 +87,22 @@ export function runDoctorChecks(options = {}) {
71
87
  // --- Claude / Codex integrations ---
72
88
  results.push(check("Claude", "Integration", config.integrations.claude ? "ok" : "warn", config.integrations.claude ? "enabled" : "disabled in config"));
73
89
  results.push(check("Codex", "AGENTS.md integration", config.integrations.codex ? "ok" : "warn", config.integrations.codex ? "enabled" : "disabled in config"));
74
- const skillInstaller = new CodexSkillInstaller(home);
75
- const skillDetected = skillInstaller.detect() !== null;
76
- const skillInstalled = skillDetected && skillInstaller.isInstalled(GLM_DELEGATION_SKILL_NAME);
77
- results.push(check("Codex", "Delegation skill", skillInstalled ? "ok" : "warn", skillInstalled
78
- ? "installed"
79
- : skillDetected
80
- ? "not installed (optional)"
81
- : "Codex home not detected — skill skipped (optional)"));
90
+ for (const { agent, installer } of skillTargets(home)) {
91
+ const homeDetected = installer.detect() !== null;
92
+ const skillInstalled = homeDetected && installer.isInstalled(GLM_DELEGATION_SKILL_NAME);
93
+ results.push(check(agent, "Delegation skill", skillInstalled ? "ok" : "warn", skillInstalled
94
+ ? "installed"
95
+ : homeDetected
96
+ ? "not installed (optional)"
97
+ : `${agent} home not detected — skill skipped (optional)`));
98
+ }
82
99
  // --- Environment: the Orca stale-env case (spec §9, §10) ---
83
100
  const hasProcessKey = Boolean(env.ZAI_API_KEY && env.ZAI_API_KEY.trim());
84
101
  if (hasProcessKey) {
85
102
  results.push(check("Environment", "Process environment", "ok", "ZAI_API_KEY visible in current process"));
86
103
  }
87
104
  else if (resolved) {
88
- 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)}.`));
89
106
  }
90
107
  return { results, config, keySource: resolved?.source };
91
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
  }
@@ -0,0 +1,72 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import os from "node:os";
6
+ import { loadConfig } from "../core/config.js";
7
+ import { locateClaude } from "../core/claude.js";
8
+ import { Errors } from "../core/errors.js";
9
+ export const MCP_SERVER_NAME = "glm-coding-router";
10
+ /** Absolute path to the compiled glm-mcp entry next to this module. */
11
+ export function mcpServerScript() {
12
+ const here = path.dirname(fileURLToPath(import.meta.url)); // …/commands (dist or src)
13
+ const compiled = path.resolve(here, "..", "bin", "glm-mcp.js");
14
+ if (fs.existsSync(compiled)) {
15
+ return compiled;
16
+ }
17
+ return path.resolve(here, "..", "bin", "glm-mcp.ts"); // dev checkout via tsx
18
+ }
19
+ function defaultRunClaude(binPath, args) {
20
+ return new Promise((resolve) => {
21
+ execFile(binPath, args, { windowsHide: true, encoding: "utf8", timeout: 60_000 }, (error, stdout, stderr) => {
22
+ const code = error && typeof error.code === "number" ? error.code : 0;
23
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
24
+ });
25
+ });
26
+ }
27
+ /**
28
+ * glm-router mcp (specs/v1-architecture.md): opt-in registration of the
29
+ * glm-mcp server. We never edit ~/.claude.json ourselves — install/remove go
30
+ * through Claude Code's own `claude mcp add/remove` CLI.
31
+ */
32
+ export async function mcpCommand(options, action = "info", deps = {}) {
33
+ const script = mcpServerScript();
34
+ const node = process.execPath;
35
+ if (action === "info") {
36
+ const snippet = JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: node, args: [script] } } }, null, 2);
37
+ process.stdout.write([
38
+ "Optional MCP server: glm-mcp exposes glm_worker, glm_review, glm_delegate, glm_usage as MCP tools.",
39
+ "",
40
+ "Register it with Claude Code:",
41
+ "",
42
+ ` claude mcp add -s user ${MCP_SERVER_NAME} -- "${node}" "${script}"`,
43
+ "",
44
+ "Or add this to your MCP client config:",
45
+ "",
46
+ snippet,
47
+ "",
48
+ "Remove again with:",
49
+ "",
50
+ ` claude mcp remove -s user ${MCP_SERVER_NAME}`,
51
+ "",
52
+ ].join("\n"));
53
+ return 0;
54
+ }
55
+ const home = deps.home ?? os.homedir();
56
+ const env = deps.env ?? process.env;
57
+ const config = loadConfig(home);
58
+ const claudePath = locateClaude(config, env); // ERROR [20] when missing
59
+ const runClaude = deps.runClaude ?? defaultRunClaude;
60
+ const args = action === "install"
61
+ ? ["mcp", "add", "-s", "user", MCP_SERVER_NAME, "--", node, script]
62
+ : ["mcp", "remove", "-s", "user", MCP_SERVER_NAME];
63
+ const result = await runClaude(claudePath, args);
64
+ if (result.code !== 0) {
65
+ throw Errors.childAgentFailed(`claude mcp ${action} exited ${result.code}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`);
66
+ }
67
+ process.stdout.write(`✓ ${MCP_SERVER_NAME} MCP server ${action === "install" ? "registered" : "removed"} (claude -s user scope)\n`);
68
+ if (result.stdout.trim() && !options.quiet) {
69
+ process.stdout.write(`${result.stdout.trim()}\n`);
70
+ }
71
+ return 0;
72
+ }
@@ -1,43 +1,55 @@
1
1
  import os from "node:os";
2
- import { CodexSkillInstaller, glmDelegationSkill } from "../integrations/skill.js";
3
- function installer() {
4
- return new CodexSkillInstaller(os.homedir());
5
- }
6
- /** glm-router skill install (spec §27): optional enhancement; warn+skip when unsupported. */
7
- export function skillInstallCommand(options) {
8
- const skillInstaller = installer();
9
- const location = skillInstaller.detect();
10
- if (!location) {
11
- process.stdout.write("⚠ Codex skill directory not detected (~/.codex not found).\n" +
12
- " Skipping optional skill — AGENTS.md integration keeps working.\n");
13
- return 0;
14
- }
2
+ import { glmDelegationSkill, skillTargets } from "../integrations/skill.js";
3
+ function runForEach(options, action, home) {
15
4
  const skill = glmDelegationSkill();
16
- if (skillInstaller.isInstalled(skill.name) && !options.force) {
17
- process.stdout.write(`✓ Skill "${skill.name}" already installed\n`);
18
- return 0;
5
+ const results = [];
6
+ for (const { agent, installer } of skillTargets(home)) {
7
+ const location = installer.detect();
8
+ if (!location) {
9
+ results.push({ agent, outcome: "skipped", detail: "home not detected — skipping optional skill" });
10
+ continue;
11
+ }
12
+ if (action === "install") {
13
+ if (installer.isInstalled(skill.name) && !options.force) {
14
+ results.push({ agent, outcome: "already", detail: `already installed at ${location.skillsDir}` });
15
+ continue;
16
+ }
17
+ if (options.dryRun) {
18
+ results.push({ agent, outcome: "skipped", detail: `[dry-run] would install to ${location.skillsDir}` });
19
+ continue;
20
+ }
21
+ installer.install(skill);
22
+ results.push({ agent, outcome: "installed", detail: `installed at ${location.skillsDir}` });
23
+ }
24
+ else {
25
+ if (!installer.isInstalled(skill.name)) {
26
+ results.push({ agent, outcome: "absent", detail: "not installed" });
27
+ continue;
28
+ }
29
+ if (options.dryRun) {
30
+ results.push({ agent, outcome: "skipped", detail: "[dry-run] would remove skill" });
31
+ continue;
32
+ }
33
+ installer.remove(skill.name);
34
+ results.push({ agent, outcome: "removed", detail: "removed" });
35
+ }
19
36
  }
20
- if (options.dryRun) {
21
- process.stdout.write(`[dry-run] would install skill "${skill.name}" to ${location.skillsDir}\n`);
22
- return 0;
23
- }
24
- skillInstaller.install(skill);
25
- process.stdout.write(`✓ Skill "${skill.name}" installed at ${location.skillsDir}\n`);
26
- return 0;
37
+ return results;
27
38
  }
28
- /** glm-router skill remove. */
29
- export function skillRemoveCommand(options) {
30
- const skillInstaller = installer();
31
- const skill = glmDelegationSkill();
32
- if (!skillInstaller.isInstalled(skill.name)) {
33
- process.stdout.write(`✓ Skill "${skill.name}" is not installed\n`);
34
- return 0;
35
- }
36
- if (options.dryRun) {
37
- process.stdout.write(`[dry-run] would remove skill "${skill.name}"\n`);
38
- return 0;
39
+ function render(results) {
40
+ for (const result of results) {
41
+ process.stdout.write(`✓ ${result.agent}: ${result.detail}\n`);
39
42
  }
40
- skillInstaller.remove(skill.name);
41
- process.stdout.write(`✓ Skill "${skill.name}" removed\n`);
42
43
  return 0;
43
44
  }
45
+ /**
46
+ * glm-router skill install (spec §27, specs/v1-architecture.md): optional
47
+ * enhancement for BOTH agents; per-agent warn+skip, never fatal.
48
+ */
49
+ export function skillInstallCommand(options, deps = {}) {
50
+ return render(runForEach(options, "install", deps.home ?? os.homedir()));
51
+ }
52
+ /** glm-router skill remove — removes from both agents. */
53
+ export function skillRemoveCommand(options, deps = {}) {
54
+ return render(runForEach(options, "remove", deps.home ?? os.homedir()));
55
+ }
@@ -3,7 +3,7 @@ import { loadConfig } from "../core/config.js";
3
3
  import { locateClaude, locateCodex } from "../core/claude.js";
4
4
  import { version } from "../core/version.js";
5
5
  import { resolveZaiApiKey } from "../core/zai-key.js";
6
- import { CodexSkillInstaller } from "../integrations/skill.js";
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
  /** Fast, fully offline summary (spec §41) — no API requests, no key values. */
@@ -22,8 +22,17 @@ export function statusCommand(options, deps = {}) {
22
22
  }
23
23
  })();
24
24
  const codexInstalled = Boolean(locateCodex(config, env));
25
- const skillInstaller = new CodexSkillInstaller(home);
26
- const skillInstalled = skillInstaller.detect() !== null && skillInstaller.isInstalled(GLM_DELEGATION_SKILL_NAME);
25
+ const skillState = (() => {
26
+ const rows = [];
27
+ for (const { agent, installer } of skillTargets(home)) {
28
+ rows.push({
29
+ agent,
30
+ homeDetected: installer.detect() !== null,
31
+ installed: installer.isInstalled(GLM_DELEGATION_SKILL_NAME),
32
+ });
33
+ }
34
+ return rows;
35
+ })();
27
36
  if (options.json) {
28
37
  emitJson({
29
38
  version,
@@ -33,7 +42,10 @@ export function statusCommand(options, deps = {}) {
33
42
  integrations: {
34
43
  claude: config.integrations.claude,
35
44
  codex: config.integrations.codex,
36
- codexSkill: config.integrations.codexSkill && skillInstalled,
45
+ skills: skillState.map((row) => ({
46
+ agent: row.agent,
47
+ enabled: row.homeDetected && row.installed,
48
+ })),
37
49
  },
38
50
  models: config.models,
39
51
  });
@@ -48,11 +60,12 @@ export function statusCommand(options, deps = {}) {
48
60
  "",
49
61
  `Claude policy ${config.integrations.claude ? "enabled" : "disabled"}`,
50
62
  `Codex policy ${config.integrations.codex ? "enabled" : "disabled"}`,
51
- `Codex skill ${skillInstalled ? "enabled" : "disabled"}`,
52
- "",
53
- `Main model ${config.models.main}`,
54
- `Fast model ${config.models.fast}`,
55
63
  ];
64
+ for (const row of skillState) {
65
+ const enabled = row.homeDetected && row.installed;
66
+ lines.push(`${row.agent} skill ${enabled ? "enabled" : "disabled"}`);
67
+ }
68
+ lines.push("", `Main model ${config.models.main}`, `Fast model ${config.models.fast}`);
56
69
  process.stdout.write(lines.join("\n") + "\n");
57
70
  return 0;
58
71
  }
@@ -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");
@@ -19,7 +19,7 @@ function describeWindow(limit) {
19
19
  return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
20
20
  }
21
21
  /** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
22
- async function fetchZaiQuota(key, fetchImpl) {
22
+ export async function fetchZaiQuota(key, fetchImpl) {
23
23
  let response;
24
24
  try {
25
25
  response = await fetchImpl(ZAI_QUOTA_URL, {
@@ -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,