glm-coding-router 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -253,6 +253,39 @@ glm-router usage
253
253
 
254
254
  `--json` emits the same data machine-readably. No key configured → `ERROR [10]`.
255
255
 
256
+ ## Agent skills (Claude Code + Codex)
257
+
258
+ `glm-router skill install` writes the `glm-delegation` SKILL.md into **both**
259
+ agent homes — `~/.claude/skills/` and `~/.codex/skills/` — so either
260
+ orchestrator natively knows how to delegate to GLM workers. Missing homes are
261
+ skipped with a note (optional enhancement, never fatal); `skill remove`
262
+ cleans both. `status` shows one skill row per agent.
263
+
264
+ ## MCP server (optional)
265
+
266
+ `glm-mcp` (installed with the package) exposes the router as MCP tools over
267
+ stdio — any MCP client can delegate without shell syntax:
268
+
269
+ | Tool | What it does |
270
+ |---|---|
271
+ | `glm_worker(prompt, profile?)` | implementation worker, returns output |
272
+ | `glm_review(prompt, profile?)` | read-only review/exploration |
273
+ | `glm_delegate(name, prompt)` | worker in an isolated git worktree |
274
+ | `glm_usage()` | Z.ai quota windows + local benchmark totals |
275
+
276
+ Register it with Claude Code (we never edit `~/.claude.json` ourselves — it
277
+ goes through Claude's own CLI):
278
+
279
+ ```powershell
280
+ glm-router mcp # prints the snippet + the exact command
281
+ glm-router mcp install # claude mcp add -s user glm-coding-router -- node .../glm-mcp.js
282
+ glm-router mcp remove # claude mcp remove -s user glm-coding-router
283
+ ```
284
+
285
+ Tool-level failures return `isError` results (missing key, no claude, outside
286
+ a git repo, unreachable endpoint); the server never prints anything to stdout
287
+ except JSON-RPC frames.
288
+
256
289
  ## CLI reference
257
290
 
258
291
  ```text
@@ -266,6 +299,7 @@ glm-router config set models.main glm-5.3
266
299
  glm-router delegate <name> run a GLM worker in an isolated git worktree
267
300
  glm-router benchmark measure the Claude+GLM stack on built-in tasks
268
301
  glm-router usage Z.ai quota snapshot + local benchmark totals
302
+ glm-router mcp optional MCP server registration (glm-mcp)
269
303
  glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
270
304
  glm-router project remove
271
305
  glm-router skill install optional Codex delegation skill
@@ -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
+ }
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)")
@@ -6,7 +6,7 @@ import { isWindows, windowsVersionName } from "../core/platform.js";
6
6
  import { resolveZaiApiKey } from "../core/zai-key.js";
7
7
  import { configPath } from "../core/paths.js";
8
8
  import fs from "node:fs";
9
- import { CodexSkillInstaller } from "../integrations/skill.js";
9
+ import { skillTargets } from "../integrations/skill.js";
10
10
  import { GLM_DELEGATION_SKILL_NAME } from "../templates/glm-delegation-skill.js";
11
11
  function check(section, name, status, detail, note) {
12
12
  return { section, name, status, detail, note };
@@ -71,14 +71,15 @@ export function runDoctorChecks(options = {}) {
71
71
  // --- Claude / Codex integrations ---
72
72
  results.push(check("Claude", "Integration", config.integrations.claude ? "ok" : "warn", config.integrations.claude ? "enabled" : "disabled in config"));
73
73
  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)"));
74
+ for (const { agent, installer } of skillTargets(home)) {
75
+ const homeDetected = installer.detect() !== null;
76
+ const skillInstalled = homeDetected && installer.isInstalled(GLM_DELEGATION_SKILL_NAME);
77
+ results.push(check(agent, "Delegation skill", skillInstalled ? "ok" : "warn", skillInstalled
78
+ ? "installed"
79
+ : homeDetected
80
+ ? "not installed (optional)"
81
+ : `${agent} home not detected — skill skipped (optional)`));
82
+ }
82
83
  // --- Environment: the Orca stale-env case (spec §9, §10) ---
83
84
  const hasProcessKey = Boolean(env.ZAI_API_KEY && env.ZAI_API_KEY.trim());
84
85
  if (hasProcessKey) {
@@ -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
  }
@@ -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, {
@@ -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
  }
@@ -0,0 +1,232 @@
1
+ import os from "node:os";
2
+ import { buildWorkerArgs } from "../bin/glm-worker.js";
3
+ import { buildReviewArgs } from "../bin/glm-review.js";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { locateClaude } from "../core/claude.js";
6
+ import { createGlmEnv } from "../core/env.js";
7
+ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
8
+ import { gitTopLevel } from "../core/git.js";
9
+ import { spawnAgentCapture } from "../core/process.js";
10
+ import { applyProfile } from "../core/profile.js";
11
+ import { version } from "../core/version.js";
12
+ import { createDelegateWorktree, delegateBranch, removeDelegateWorktree, rollbackDelegateBranch, validateDelegateName, } from "../core/worktree.js";
13
+ import { resolveZaiApiKey } from "../core/zai-key.js";
14
+ import { aggregateLocalUsage, fetchZaiQuota } from "../commands/usage.js";
15
+ const PROMPT_PROPERTY = { type: "string", description: "The task prompt for the GLM agent." };
16
+ export const MCP_TOOLS = [
17
+ {
18
+ name: "glm_worker",
19
+ description: "Run a GLM implementation worker (claude.exe + Z.ai env; tools Read,Glob,Grep,Edit,Write,Bash; acceptEdits). Returns the worker's output.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: { prompt: PROMPT_PROPERTY, profile: { type: "string", description: "Config profile overlay (optional)." } },
23
+ required: ["prompt"],
24
+ },
25
+ },
26
+ {
27
+ name: "glm_review",
28
+ description: "Run a read-only GLM review/exploration worker (tools Read,Glob,Grep only — cannot edit or run commands).",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: { prompt: PROMPT_PROPERTY, profile: { type: "string", description: "Config profile overlay (optional)." } },
32
+ required: ["prompt"],
33
+ },
34
+ },
35
+ {
36
+ name: "glm_delegate",
37
+ description: "Run a GLM worker in an isolated git worktree (branch glm/delegate/<name> from HEAD). Worktree and branch are kept afterwards — nothing is committed or merged automatically.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ name: { type: "string", description: "Delegate slug, e.g. backend or tests." },
42
+ prompt: PROMPT_PROPERTY,
43
+ },
44
+ required: ["name", "prompt"],
45
+ },
46
+ },
47
+ {
48
+ name: "glm_usage",
49
+ description: "Usage snapshot: Z.ai Coding Plan credit windows (5h/weekly) and local benchmark totals.",
50
+ inputSchema: { type: "object", properties: {} },
51
+ },
52
+ ];
53
+ function requiredString(args, key) {
54
+ const value = args[key];
55
+ if (typeof value !== "string" || value.trim().length === 0) {
56
+ throw new Error(`"${key}" is required and must be a non-empty string.`);
57
+ }
58
+ return value;
59
+ }
60
+ function optionalString(args, key) {
61
+ const value = args[key];
62
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
63
+ }
64
+ function errorText(error) {
65
+ if (error instanceof GlmRouterError) {
66
+ return { text: formatGlmError(error), isError: true };
67
+ }
68
+ return { text: error instanceof Error ? error.message : String(error), isError: true };
69
+ }
70
+ function tail(text, max = 400) {
71
+ return text.length > max ? `…${text.slice(-max)}` : text;
72
+ }
73
+ async function runAgent(prompt, profile, kind, deps) {
74
+ const home = deps.home ?? os.homedir();
75
+ const env = deps.env ?? process.env;
76
+ const config = applyProfile(loadConfig(home), profile);
77
+ const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
78
+ if (!resolved) {
79
+ throw Errors.zaiKeyMissing();
80
+ }
81
+ const claudePath = locateClaude(config, env);
82
+ const args = kind === "worker" ? buildWorkerArgs(prompt, config) : buildReviewArgs(prompt, config);
83
+ const spawn = deps.spawn ?? spawnAgentCapture;
84
+ const captured = await spawn(claudePath, {
85
+ args,
86
+ cwd: deps.cwd ?? process.cwd(),
87
+ env: createGlmEnv(config, resolved.key, env),
88
+ interactive: false,
89
+ });
90
+ const text = captured.stdout.trim() || "(no output)";
91
+ if (captured.code !== 0) {
92
+ return { text: `${text}\n[worker exited ${captured.code}]${captured.stderr ? `\n${tail(captured.stderr.trim())}` : ""}`, isError: true };
93
+ }
94
+ return { text, isError: false };
95
+ }
96
+ async function delegate(name, prompt, deps) {
97
+ validateDelegateName(name);
98
+ const cwd = deps.cwd ?? process.cwd();
99
+ const repoRoot = await gitTopLevel(cwd, deps.runGit);
100
+ if (!repoRoot) {
101
+ return { text: `Not inside a git repository (cwd: ${cwd}) — glm_delegate needs a repo root.`, isError: true };
102
+ }
103
+ const worktree = await createDelegateWorktree(repoRoot, name, { runGit: deps.runGit });
104
+ const branch = delegateBranch(name);
105
+ const summary = (exitCode, output) => ({
106
+ text: [
107
+ output.trim() || "(no output)",
108
+ "",
109
+ `worker exited ${exitCode}`,
110
+ `worktree kept: ${worktree}`,
111
+ `branch kept: ${branch}`,
112
+ `next: inspect ${worktree}, then merge ${branch} (or discard with git worktree remove)`,
113
+ ].join("\n"),
114
+ isError: exitCode !== 0,
115
+ });
116
+ try {
117
+ const result = await runAgent(prompt, undefined, "worker", { ...deps, cwd: worktree });
118
+ return summary(result.isError ? 1 : 0, result.text);
119
+ }
120
+ catch (error) {
121
+ // Worker never started — roll back the pristine worktree + branch (specs/delegate-worktrees.md).
122
+ await removeDelegateWorktree(repoRoot, worktree, { runGit: deps.runGit });
123
+ await rollbackDelegateBranch(repoRoot, branch, { runGit: deps.runGit });
124
+ return errorText(error);
125
+ }
126
+ }
127
+ async function usage(deps) {
128
+ const home = deps.home ?? os.homedir();
129
+ const env = deps.env ?? process.env;
130
+ const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
131
+ if (!resolved) {
132
+ throw Errors.zaiKeyMissing();
133
+ }
134
+ const lines = [];
135
+ let isError = false;
136
+ try {
137
+ const quota = await fetchZaiQuota(resolved.key, deps.fetchImpl ?? fetch);
138
+ lines.push(`Z.ai Coding Plan${quota.level ? ` (level: ${quota.level})` : ""}`);
139
+ for (const limit of quota.limits ?? []) {
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}`);
142
+ }
143
+ }
144
+ catch (error) {
145
+ isError = true;
146
+ lines.push(`Z.ai Coding Plan: ✗ ${error instanceof Error ? error.message : String(error)}`);
147
+ }
148
+ const local = aggregateLocalUsage(home);
149
+ lines.push("");
150
+ lines.push(local.runs === 0
151
+ ? "Local (benchmark reports): (none yet)"
152
+ : `Local (benchmark reports): runs ${local.runs} · tokens ${local.tokensIn} in / ${local.tokensOut} out · last ${local.lastFinishedAt}`);
153
+ return { text: lines.join("\n"), isError };
154
+ }
155
+ /** Execute one MCP tool call. Tool-level failures return isError, never throw. */
156
+ export async function callMcpTool(name, args, deps = {}) {
157
+ try {
158
+ switch (name) {
159
+ case "glm_worker":
160
+ return await runAgent(requiredString(args, "prompt"), optionalString(args, "profile"), "worker", deps);
161
+ case "glm_review":
162
+ return await runAgent(requiredString(args, "prompt"), optionalString(args, "profile"), "review", deps);
163
+ case "glm_delegate":
164
+ return await delegate(requiredString(args, "name"), requiredString(args, "prompt"), deps);
165
+ case "glm_usage":
166
+ return await usage(deps);
167
+ default:
168
+ return { text: `Unknown tool "${name}". Available: ${MCP_TOOLS.map((tool) => tool.name).join(", ")}.`, isError: true };
169
+ }
170
+ }
171
+ catch (error) {
172
+ return errorText(error);
173
+ }
174
+ }
175
+ /**
176
+ * One MCP server instance: `handleLine` takes a stdin line and resolves the
177
+ * JSON-RPC response frame to write back, or null (nothing to send —
178
+ * notifications, blank or malformed lines). Pure with respect to stdio.
179
+ */
180
+ export function createMcpServer(deps = {}) {
181
+ return {
182
+ async handleLine(line) {
183
+ const trimmed = line.trim();
184
+ if (trimmed.length === 0)
185
+ return null;
186
+ let message;
187
+ try {
188
+ message = JSON.parse(trimmed);
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ if (message.jsonrpc !== "2.0" || typeof message.method !== "string")
194
+ return null;
195
+ const id = message.id;
196
+ if (id === undefined || id === null)
197
+ return null; // notification
198
+ const params = (typeof message.params === "object" && message.params !== null ? message.params : {});
199
+ let body;
200
+ try {
201
+ switch (message.method) {
202
+ case "initialize":
203
+ body = {
204
+ result: {
205
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2024-11-05",
206
+ capabilities: { tools: {} },
207
+ serverInfo: { name: "glm-coding-router", version },
208
+ },
209
+ };
210
+ break;
211
+ case "ping":
212
+ body = { result: {} };
213
+ break;
214
+ case "tools/list":
215
+ body = { result: { tools: MCP_TOOLS } };
216
+ break;
217
+ case "tools/call": {
218
+ const result = await callMcpTool(String(params.name ?? ""), (params.arguments ?? {}), deps);
219
+ body = { result: { content: [{ type: "text", text: result.text }], isError: result.isError } };
220
+ break;
221
+ }
222
+ default:
223
+ body = { error: { code: -32601, message: `Method not found: ${message.method}` } };
224
+ }
225
+ }
226
+ catch (error) {
227
+ body = { error: { code: -32603, message: `Internal error: ${error instanceof Error ? error.message : String(error)}` } };
228
+ }
229
+ return JSON.stringify({ jsonrpc: "2.0", id, ...body });
230
+ },
231
+ };
232
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glm-coding-router",
3
- "version": "0.5.0",
3
+ "version": "1.0.0",
4
4
  "description": "GLM Coding Plan workers for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,8 @@
14
14
  "glm-chat": "./dist/bin/glm-chat.js",
15
15
  "glm-worker": "./dist/bin/glm-worker.js",
16
16
  "glm-review": "./dist/bin/glm-review.js",
17
- "glm-fast": "./dist/bin/glm-fast.js"
17
+ "glm-fast": "./dist/bin/glm-fast.js",
18
+ "glm-mcp": "./dist/bin/glm-mcp.js"
18
19
  },
19
20
  "files": [
20
21
  "dist"