glm-coding-router 0.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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/bin/glm-chat.js +40 -0
- package/dist/bin/glm-review.js +47 -0
- package/dist/bin/glm-worker.js +57 -0
- package/dist/cli.js +108 -0
- package/dist/commands/config.js +39 -0
- package/dist/commands/context.js +20 -0
- package/dist/commands/doctor-command.js +64 -0
- package/dist/commands/doctor.js +93 -0
- package/dist/commands/init.js +123 -0
- package/dist/commands/key.js +56 -0
- package/dist/commands/project-init.js +61 -0
- package/dist/commands/project-remove.js +36 -0
- package/dist/commands/skill.js +43 -0
- package/dist/commands/status.js +57 -0
- package/dist/commands/uninstall.js +81 -0
- package/dist/core/claude.js +104 -0
- package/dist/core/config.js +150 -0
- package/dist/core/env.js +22 -0
- package/dist/core/errors.js +121 -0
- package/dist/core/logging.js +54 -0
- package/dist/core/main-guard.js +20 -0
- package/dist/core/paths.js +13 -0
- package/dist/core/platform.js +20 -0
- package/dist/core/process.js +60 -0
- package/dist/core/prompt.js +32 -0
- package/dist/core/version.js +3 -0
- package/dist/core/zai-key.js +84 -0
- package/dist/integrations/claude.js +18 -0
- package/dist/integrations/codex.js +18 -0
- package/dist/integrations/index.js +3 -0
- package/dist/integrations/skill.js +35 -0
- package/dist/project/atomic-write.js +21 -0
- package/dist/project/managed-block.js +100 -0
- package/dist/project/managed-file.js +67 -0
- package/dist/project/ownership.js +42 -0
- package/dist/project/project-root.js +26 -0
- package/dist/templates/agents-block.js +46 -0
- package/dist/templates/claude-block.js +49 -0
- package/dist/templates/glm-delegation-skill.js +68 -0
- package/package.json +46 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { Errors } from "./errors.js";
|
|
5
|
+
import { configDir, configPath } from "./paths.js";
|
|
6
|
+
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic";
|
|
7
|
+
export const DEFAULT_MAIN_MODEL = "glm-5.3";
|
|
8
|
+
export const DEFAULT_FAST_MODEL = "glm-5.3-flash";
|
|
9
|
+
export const ConfigSchema = z.object({
|
|
10
|
+
schemaVersion: z.literal(1),
|
|
11
|
+
provider: z.object({
|
|
12
|
+
name: z.literal("zai"),
|
|
13
|
+
anthropicBaseUrl: z.string().url(),
|
|
14
|
+
}),
|
|
15
|
+
models: z.object({
|
|
16
|
+
main: z.string().min(1),
|
|
17
|
+
fast: z.string().min(1),
|
|
18
|
+
}),
|
|
19
|
+
worker: z.object({
|
|
20
|
+
maxTurns: z.number().int().positive(),
|
|
21
|
+
}).default({ maxTurns: 20 }),
|
|
22
|
+
review: z.object({
|
|
23
|
+
maxTurns: z.number().int().positive(),
|
|
24
|
+
}).default({ maxTurns: 15 }),
|
|
25
|
+
integrations: z.object({
|
|
26
|
+
claude: z.boolean(),
|
|
27
|
+
codex: z.boolean(),
|
|
28
|
+
codexSkill: z.boolean(),
|
|
29
|
+
}).default({ claude: true, codex: true, codexSkill: true }),
|
|
30
|
+
// Optional executable overrides used by discovery (spec §33, §34).
|
|
31
|
+
claudePath: z.string().min(1).optional(),
|
|
32
|
+
codexPath: z.string().min(1).optional(),
|
|
33
|
+
});
|
|
34
|
+
export function defaultConfig() {
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
provider: {
|
|
38
|
+
name: "zai",
|
|
39
|
+
anthropicBaseUrl: DEFAULT_ANTHROPIC_BASE_URL,
|
|
40
|
+
},
|
|
41
|
+
models: {
|
|
42
|
+
main: DEFAULT_MAIN_MODEL,
|
|
43
|
+
fast: DEFAULT_FAST_MODEL,
|
|
44
|
+
},
|
|
45
|
+
worker: { maxTurns: 20 },
|
|
46
|
+
review: { maxTurns: 15 },
|
|
47
|
+
integrations: {
|
|
48
|
+
claude: true,
|
|
49
|
+
codex: true,
|
|
50
|
+
codexSkill: true,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Load config from %USERPROFILE%\.glm-coding-router\config.json.
|
|
56
|
+
* A missing file yields defaults; anything present must validate (spec §29).
|
|
57
|
+
*/
|
|
58
|
+
export function loadConfig(home) {
|
|
59
|
+
const file = configPath(home);
|
|
60
|
+
if (!fs.existsSync(file)) {
|
|
61
|
+
return defaultConfig();
|
|
62
|
+
}
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = fs.readFileSync(file, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw Errors.configInvalid(`cannot read ${file}: ${errorMessage(error)}`);
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw Errors.configInvalid(`invalid JSON in ${file}: ${errorMessage(error)}`);
|
|
76
|
+
}
|
|
77
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
const detail = result.error.issues
|
|
80
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
81
|
+
.join("; ");
|
|
82
|
+
throw Errors.configInvalid(detail);
|
|
83
|
+
}
|
|
84
|
+
return result.data;
|
|
85
|
+
}
|
|
86
|
+
export function saveConfig(config, home) {
|
|
87
|
+
const dir = configDir(home);
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
const file = configPath(home);
|
|
90
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
91
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
92
|
+
fs.renameSync(tmp, file);
|
|
93
|
+
}
|
|
94
|
+
function errorMessage(error) {
|
|
95
|
+
return error instanceof Error ? error.message : String(error);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Set a dotted config key, e.g. `models.main glm-5.3` (spec §28).
|
|
99
|
+
* Existing numbers/booleans coerce the incoming string; result must re-validate.
|
|
100
|
+
*/
|
|
101
|
+
export function setConfigValue(config, dottedKey, rawValue) {
|
|
102
|
+
const keys = dottedKey.split(".");
|
|
103
|
+
if (keys.length === 0 || keys.some((k) => k.length === 0)) {
|
|
104
|
+
throw Errors.configInvalid(`invalid config key "${dottedKey}"`);
|
|
105
|
+
}
|
|
106
|
+
const draft = structuredClone(config);
|
|
107
|
+
let target = draft;
|
|
108
|
+
for (const key of keys.slice(0, -1)) {
|
|
109
|
+
const next = target[key];
|
|
110
|
+
if (typeof next !== "object" || next === null) {
|
|
111
|
+
throw Errors.configInvalid(`config key "${dottedKey}" does not exist`);
|
|
112
|
+
}
|
|
113
|
+
target = next;
|
|
114
|
+
}
|
|
115
|
+
const leafKey = keys[keys.length - 1];
|
|
116
|
+
if (!(leafKey in target)) {
|
|
117
|
+
throw Errors.configInvalid(`config key "${dottedKey}" does not exist`);
|
|
118
|
+
}
|
|
119
|
+
const current = target[leafKey];
|
|
120
|
+
let value = rawValue;
|
|
121
|
+
if (typeof current === "number") {
|
|
122
|
+
const numeric = Number(rawValue);
|
|
123
|
+
if (Number.isNaN(numeric)) {
|
|
124
|
+
throw Errors.configInvalid(`"${dottedKey}" expects a number, got "${rawValue}"`);
|
|
125
|
+
}
|
|
126
|
+
value = numeric;
|
|
127
|
+
}
|
|
128
|
+
else if (typeof current === "boolean") {
|
|
129
|
+
if (rawValue !== "true" && rawValue !== "false") {
|
|
130
|
+
throw Errors.configInvalid(`"${dottedKey}" expects true or false, got "${rawValue}"`);
|
|
131
|
+
}
|
|
132
|
+
value = rawValue === "true";
|
|
133
|
+
}
|
|
134
|
+
target[leafKey] = value;
|
|
135
|
+
const result = ConfigSchema.safeParse(draft);
|
|
136
|
+
if (!result.success) {
|
|
137
|
+
const detail = result.error.issues
|
|
138
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
139
|
+
.join("; ");
|
|
140
|
+
throw Errors.configInvalid(detail);
|
|
141
|
+
}
|
|
142
|
+
return result.data;
|
|
143
|
+
}
|
|
144
|
+
/** Config path for messages, independent of home override (used by status/doctor). */
|
|
145
|
+
export function describeConfigPath(home) {
|
|
146
|
+
return configPath(home);
|
|
147
|
+
}
|
|
148
|
+
export function configDirFor(home) {
|
|
149
|
+
return path.dirname(configPath(home));
|
|
150
|
+
}
|
package/dist/core/env.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Per-child-process timeout injected alongside the Z.ai env (spec §12). */
|
|
2
|
+
export const API_TIMEOUT_MS = "3000000";
|
|
3
|
+
/**
|
|
4
|
+
* Environment injected into the claude.exe child process only (spec §12).
|
|
5
|
+
* Never applied to the parent shell and never persisted globally.
|
|
6
|
+
*/
|
|
7
|
+
export function createGlmEnv(config, zaiKey, baseEnv = process.env) {
|
|
8
|
+
return {
|
|
9
|
+
...baseEnv,
|
|
10
|
+
ANTHROPIC_API_KEY: "",
|
|
11
|
+
ANTHROPIC_AUTH_TOKEN: zaiKey,
|
|
12
|
+
ANTHROPIC_BASE_URL: config.provider.anthropicBaseUrl,
|
|
13
|
+
API_TIMEOUT_MS,
|
|
14
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
15
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: config.models.main,
|
|
16
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: config.models.main,
|
|
17
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: config.models.fast,
|
|
18
|
+
// GLM models are not in Claude Code's model catalog; without this it
|
|
19
|
+
// enforces its assumed 200k context window on unknown models.
|
|
20
|
+
CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT: "1",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Standard exit codes (spec §35). */
|
|
2
|
+
export const ExitCode = {
|
|
3
|
+
Success: 0,
|
|
4
|
+
GenericFailure: 1,
|
|
5
|
+
InvalidArgs: 2,
|
|
6
|
+
ZaiKeyMissing: 10,
|
|
7
|
+
ConfigInvalid: 11,
|
|
8
|
+
ClaudeNotFound: 20,
|
|
9
|
+
CodexNotFound: 21,
|
|
10
|
+
ProjectRootNotFound: 30,
|
|
11
|
+
ManagedFileWriteFailed: 31,
|
|
12
|
+
ChildAgentFailed: 40,
|
|
13
|
+
UnsupportedPlatform: 50,
|
|
14
|
+
};
|
|
15
|
+
/** Base error for all expected failures. Printed as `ERROR [NAME]` (spec §36). */
|
|
16
|
+
export class GlmRouterError extends Error {
|
|
17
|
+
codeName;
|
|
18
|
+
hint;
|
|
19
|
+
exitCode;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
super(options.message);
|
|
22
|
+
this.name = `GlmRouterError:${options.name}`;
|
|
23
|
+
this.codeName = options.name;
|
|
24
|
+
this.hint = options.hint ?? [];
|
|
25
|
+
this.exitCode = options.exitCode;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const Errors = {
|
|
29
|
+
zaiKeyMissing: () => new GlmRouterError({
|
|
30
|
+
name: "ZAI_KEY_MISSING",
|
|
31
|
+
message: "ZAI_API_KEY was not found.",
|
|
32
|
+
hint: ["Run:", "", " glm-router key set"],
|
|
33
|
+
exitCode: ExitCode.ZaiKeyMissing,
|
|
34
|
+
}),
|
|
35
|
+
configInvalid: (detail) => new GlmRouterError({
|
|
36
|
+
name: "CONFIG_INVALID",
|
|
37
|
+
message: `Configuration is invalid: ${detail}`,
|
|
38
|
+
hint: [
|
|
39
|
+
"Fix or remove the config file:",
|
|
40
|
+
"",
|
|
41
|
+
" %USERPROFILE%\\.glm-coding-router\\config.json",
|
|
42
|
+
],
|
|
43
|
+
exitCode: ExitCode.ConfigInvalid,
|
|
44
|
+
}),
|
|
45
|
+
claudeNotFound: (detail) => new GlmRouterError({
|
|
46
|
+
name: "CLAUDE_NOT_FOUND",
|
|
47
|
+
message: detail ??
|
|
48
|
+
"Claude Code executable was not found in PATH.",
|
|
49
|
+
hint: [
|
|
50
|
+
"Expected:",
|
|
51
|
+
"",
|
|
52
|
+
" claude.exe",
|
|
53
|
+
"",
|
|
54
|
+
"Install Claude Code or set an override:",
|
|
55
|
+
"",
|
|
56
|
+
" glm-router config set claudePath C:\\path\\to\\claude.exe",
|
|
57
|
+
],
|
|
58
|
+
exitCode: ExitCode.ClaudeNotFound,
|
|
59
|
+
}),
|
|
60
|
+
codexNotFound: () => new GlmRouterError({
|
|
61
|
+
name: "CODEX_NOT_FOUND",
|
|
62
|
+
message: "Codex executable was not found in PATH.",
|
|
63
|
+
hint: ["Codex is optional; Claude-only setups are supported."],
|
|
64
|
+
exitCode: ExitCode.CodexNotFound,
|
|
65
|
+
}),
|
|
66
|
+
projectRootNotFound: () => new GlmRouterError({
|
|
67
|
+
name: "PROJECT_ROOT_NOT_FOUND",
|
|
68
|
+
message: "Could not determine the project root.",
|
|
69
|
+
exitCode: ExitCode.ProjectRootNotFound,
|
|
70
|
+
}),
|
|
71
|
+
managedFileWriteFailed: (file, cause) => new GlmRouterError({
|
|
72
|
+
name: "MANAGED_FILE_WRITE_FAILED",
|
|
73
|
+
message: `Failed to update ${file}: ${cause}`,
|
|
74
|
+
hint: ["The file was left unmodified."],
|
|
75
|
+
exitCode: ExitCode.ManagedFileWriteFailed,
|
|
76
|
+
}),
|
|
77
|
+
childAgentFailed: (cause) => new GlmRouterError({
|
|
78
|
+
name: "CHILD_AGENT_FAILED",
|
|
79
|
+
message: `The child agent process failed: ${cause}`,
|
|
80
|
+
exitCode: ExitCode.ChildAgentFailed,
|
|
81
|
+
}),
|
|
82
|
+
unsupportedPlatform: (platform) => new GlmRouterError({
|
|
83
|
+
name: "UNSUPPORTED_PLATFORM",
|
|
84
|
+
message: `This command requires Windows (detected: ${platform}).`,
|
|
85
|
+
hint: ["Linux and macOS support is planned for v0.2."],
|
|
86
|
+
exitCode: ExitCode.UnsupportedPlatform,
|
|
87
|
+
}),
|
|
88
|
+
promptRequired: (command = "glm-worker") => new GlmRouterError({
|
|
89
|
+
name: "PROMPT_REQUIRED",
|
|
90
|
+
message: "No task prompt was provided.",
|
|
91
|
+
hint: [
|
|
92
|
+
"Usage:",
|
|
93
|
+
"",
|
|
94
|
+
` ${command} "Implement validation and add tests"`,
|
|
95
|
+
` Get-Content task.md | ${command}`,
|
|
96
|
+
],
|
|
97
|
+
exitCode: ExitCode.InvalidArgs,
|
|
98
|
+
}),
|
|
99
|
+
managedBlockCorrupt: (file, cause) => new GlmRouterError({
|
|
100
|
+
name: "MANAGED_BLOCK_CORRUPT",
|
|
101
|
+
message: `Managed block in ${file} is malformed: ${cause}`,
|
|
102
|
+
hint: [
|
|
103
|
+
"Fix or remove the markers manually:",
|
|
104
|
+
"",
|
|
105
|
+
" <!-- glm-coding-router:start -->",
|
|
106
|
+
" ...",
|
|
107
|
+
" <!-- glm-coding-router:end -->",
|
|
108
|
+
"",
|
|
109
|
+
"The file was not modified.",
|
|
110
|
+
],
|
|
111
|
+
exitCode: ExitCode.ManagedFileWriteFailed,
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
/** Print a GlmRouterError in the spec §36 format. Never print secret values. */
|
|
115
|
+
export function formatGlmError(error) {
|
|
116
|
+
const lines = [`ERROR [${error.codeName}]`, "", error.message];
|
|
117
|
+
if (error.hint.length > 0) {
|
|
118
|
+
lines.push("", ...error.hint);
|
|
119
|
+
}
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const LEVEL_ORDER = {
|
|
2
|
+
error: 0,
|
|
3
|
+
warn: 1,
|
|
4
|
+
info: 2,
|
|
5
|
+
debug: 3,
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Replace every occurrence of a known secret with `[REDACTED]` (spec §37).
|
|
9
|
+
* Empty/missing values are ignored; any real secret value is redacted.
|
|
10
|
+
*/
|
|
11
|
+
export function redact(text, secrets) {
|
|
12
|
+
let output = text;
|
|
13
|
+
for (const secret of secrets) {
|
|
14
|
+
if (secret && secret.length > 0) {
|
|
15
|
+
output = output.split(secret).join("[REDACTED]");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
20
|
+
/** Minimal leveled logger honoring --quiet / --verbose. Writes to stderr so stdout stays parseable. */
|
|
21
|
+
export class Logger {
|
|
22
|
+
level;
|
|
23
|
+
quiet;
|
|
24
|
+
constructor(level = "info", quiet = false) {
|
|
25
|
+
this.level = level;
|
|
26
|
+
this.quiet = quiet;
|
|
27
|
+
}
|
|
28
|
+
setLevel(level, quiet = false) {
|
|
29
|
+
this.level = level;
|
|
30
|
+
this.quiet = quiet;
|
|
31
|
+
}
|
|
32
|
+
error(message) {
|
|
33
|
+
this.emit("error", message);
|
|
34
|
+
}
|
|
35
|
+
warn(message) {
|
|
36
|
+
this.emit("warn", message);
|
|
37
|
+
}
|
|
38
|
+
info(message) {
|
|
39
|
+
this.emit("info", message);
|
|
40
|
+
}
|
|
41
|
+
debug(message) {
|
|
42
|
+
this.emit("debug", message);
|
|
43
|
+
}
|
|
44
|
+
emit(level, message) {
|
|
45
|
+
if (this.quiet && level !== "error")
|
|
46
|
+
return;
|
|
47
|
+
if (LEVEL_ORDER[level] > LEVEL_ORDER[this.level])
|
|
48
|
+
return;
|
|
49
|
+
const prefix = level === "debug" ? "debug:" : "";
|
|
50
|
+
const line = prefix ? `${prefix} ${message}` : message;
|
|
51
|
+
process.stderr.write(line + "\n");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* True when this module is the entry point (node dist/bin/glm-worker.js).
|
|
6
|
+
* Robust against URL-encoded characters and symlinks.
|
|
7
|
+
*/
|
|
8
|
+
export function isMainModule(importMetaUrl) {
|
|
9
|
+
const entry = process.argv[1];
|
|
10
|
+
if (!entry)
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
const self = fs.realpathSync(fileURLToPath(importMetaUrl));
|
|
14
|
+
const invoked = fs.realpathSync(path.resolve(entry));
|
|
15
|
+
return self === invoked;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Config directory: %USERPROFILE%\.glm-coding-router (spec §13). */
|
|
4
|
+
export function configDir(home = os.homedir()) {
|
|
5
|
+
return path.join(home, ".glm-coding-router");
|
|
6
|
+
}
|
|
7
|
+
export function configPath(home = os.homedir()) {
|
|
8
|
+
return path.join(configDir(home), "config.json");
|
|
9
|
+
}
|
|
10
|
+
/** Local metadata tracking which project files this tool created entirely (spec §43). */
|
|
11
|
+
export function ownershipPath(home = os.homedir()) {
|
|
12
|
+
return path.join(configDir(home), "ownership.json");
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { Errors } from "./errors.js";
|
|
3
|
+
export function isWindows() {
|
|
4
|
+
return process.platform === "win32";
|
|
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;
|
|
10
|
+
}
|
|
11
|
+
const release = os.release();
|
|
12
|
+
const build = Number.parseInt(release.split(".")[2] ?? "0", 10);
|
|
13
|
+
return build >= 22000 ? "Windows 11" : "Windows 10";
|
|
14
|
+
}
|
|
15
|
+
/** Guard for commands that require Windows-specific machinery (PowerShell user env, etc.). */
|
|
16
|
+
export function assertWindows() {
|
|
17
|
+
if (!isWindows()) {
|
|
18
|
+
throw Errors.unsupportedPlatform(process.platform);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Errors } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Spawn a child agent process (spec §18, §32, §39):
|
|
5
|
+
* - argument array, never a shell string
|
|
6
|
+
* - inherits cwd and stdout/stderr
|
|
7
|
+
* - forwards SIGINT/SIGTERM so Ctrl+C reaches the child
|
|
8
|
+
* - resolves with the child's exit code (signals resolve to 1)
|
|
9
|
+
*/
|
|
10
|
+
export function spawnAgent(binPath, options) {
|
|
11
|
+
const { args, cwd, env, interactive = true } = options;
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let child;
|
|
14
|
+
try {
|
|
15
|
+
child = spawn(binPath, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
env,
|
|
18
|
+
stdio: interactive ? "inherit" : ["ignore", "inherit", "inherit"],
|
|
19
|
+
shell: false,
|
|
20
|
+
windowsHide: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const signals = ["SIGINT", "SIGTERM"];
|
|
28
|
+
const handlers = new Map();
|
|
29
|
+
for (const signal of signals) {
|
|
30
|
+
const handler = () => {
|
|
31
|
+
if (child.killed)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
child.kill(signal);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Child already gone; the exit event settles the promise.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
handlers.set(signal, handler);
|
|
41
|
+
process.on(signal, handler);
|
|
42
|
+
}
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
for (const [signal, handler] of handlers) {
|
|
45
|
+
process.removeListener(signal, handler);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
child.on("error", (error) => {
|
|
49
|
+
cleanup();
|
|
50
|
+
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
51
|
+
});
|
|
52
|
+
child.on("exit", (code) => {
|
|
53
|
+
cleanup();
|
|
54
|
+
resolve(code ?? 1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function errorMessage(error) {
|
|
59
|
+
return error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Errors } from "./errors.js";
|
|
2
|
+
/** Read all of stdin when it is not a TTY; resolve undefined otherwise (spec §40). */
|
|
3
|
+
export function readStdin() {
|
|
4
|
+
const stdin = process.stdin;
|
|
5
|
+
if (stdin.isTTY) {
|
|
6
|
+
return Promise.resolve(undefined);
|
|
7
|
+
}
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let data = "";
|
|
10
|
+
stdin.setEncoding("utf8");
|
|
11
|
+
stdin.on("data", (chunk) => {
|
|
12
|
+
data += chunk;
|
|
13
|
+
});
|
|
14
|
+
stdin.on("end", () => resolve(data.length > 0 ? data : undefined));
|
|
15
|
+
stdin.on("error", () => resolve(undefined));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the task prompt (spec §15, §40):
|
|
20
|
+
* stdin text → joined arguments → error
|
|
21
|
+
*/
|
|
22
|
+
export async function resolvePrompt(argv, readStdinFn = readStdin, command = "glm-worker") {
|
|
23
|
+
const stdinText = await readStdinFn();
|
|
24
|
+
if (stdinText !== undefined && stdinText.trim().length > 0) {
|
|
25
|
+
return stdinText;
|
|
26
|
+
}
|
|
27
|
+
const argText = argv.join(" ").trim();
|
|
28
|
+
if (argText.length > 0) {
|
|
29
|
+
return argText;
|
|
30
|
+
}
|
|
31
|
+
throw Errors.promptRequired(command);
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
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
|
+
/**
|
|
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.
|
|
12
|
+
*/
|
|
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
|
+
}
|
|
32
|
+
}
|
|
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
|
+
});
|
|
50
|
+
}
|
|
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
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the Z.ai key with the mandatory fallback order (spec §10):
|
|
65
|
+
* 1. process.env.ZAI_API_KEY
|
|
66
|
+
* 2. Windows User Environment
|
|
67
|
+
* 3. fail (undefined)
|
|
68
|
+
*
|
|
69
|
+
* The fallback exists because Orca terminals snapshot a stale environment and
|
|
70
|
+
* cannot see keys added after startup. Never cache the key to disk.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveZaiApiKey(options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const readUserEnv = options.readUserEnv ?? readWindowsUserEnv;
|
|
75
|
+
const fromProcess = env[ZAI_API_KEY_ENV];
|
|
76
|
+
if (fromProcess && fromProcess.trim()) {
|
|
77
|
+
return { key: fromProcess.trim(), source: "process-env" };
|
|
78
|
+
}
|
|
79
|
+
const fromUserEnv = readUserEnv(ZAI_API_KEY_ENV);
|
|
80
|
+
if (fromUserEnv && fromUserEnv.trim()) {
|
|
81
|
+
return { key: fromUserEnv.trim(), source: "windows-user-env" };
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { removeManagedFile, upsertManagedFile } from "../project/managed-file.js";
|
|
3
|
+
import { CLAUDE_MANAGED_BLOCK } from "../templates/claude-block.js";
|
|
4
|
+
export function claudeFilePath(projectRoot) {
|
|
5
|
+
return path.join(projectRoot, "CLAUDE.md");
|
|
6
|
+
}
|
|
7
|
+
/** Upsert the GLM delegation block into <root>\CLAUDE.md without overwriting user content (spec §19–21). */
|
|
8
|
+
export function installClaudeIntegration(projectRoot, options = {}) {
|
|
9
|
+
return upsertManagedFile({
|
|
10
|
+
file: claudeFilePath(projectRoot),
|
|
11
|
+
block: CLAUDE_MANAGED_BLOCK,
|
|
12
|
+
home: options.home,
|
|
13
|
+
dryRun: options.dryRun,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function removeClaudeIntegration(projectRoot, options = {}) {
|
|
17
|
+
return removeManagedFile(claudeFilePath(projectRoot), options.home, options.dryRun);
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { removeManagedFile, upsertManagedFile } from "../project/managed-file.js";
|
|
3
|
+
import { AGENTS_MANAGED_BLOCK } from "../templates/agents-block.js";
|
|
4
|
+
export function agentsFilePath(projectRoot) {
|
|
5
|
+
return path.join(projectRoot, "AGENTS.md");
|
|
6
|
+
}
|
|
7
|
+
/** Upsert the GLM delegation block into <root>\AGENTS.md for Codex (spec §23, §24). */
|
|
8
|
+
export function installCodexIntegration(projectRoot, options = {}) {
|
|
9
|
+
return upsertManagedFile({
|
|
10
|
+
file: agentsFilePath(projectRoot),
|
|
11
|
+
block: AGENTS_MANAGED_BLOCK,
|
|
12
|
+
home: options.home,
|
|
13
|
+
dryRun: options.dryRun,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function removeCodexIntegration(projectRoot, options = {}) {
|
|
17
|
+
return removeManagedFile(agentsFilePath(projectRoot), options.home, options.dryRun);
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../project/atomic-write.js";
|
|
4
|
+
import { GLM_DELEGATION_SKILL_MD, GLM_DELEGATION_SKILL_NAME, } from "../templates/glm-delegation-skill.js";
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export class CodexSkillInstaller {
|
|
11
|
+
codexHome;
|
|
12
|
+
constructor(home) {
|
|
13
|
+
this.codexHome = path.join(home, ".codex");
|
|
14
|
+
}
|
|
15
|
+
detect() {
|
|
16
|
+
if (!fs.existsSync(this.codexHome)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return { skillsDir: path.join(this.codexHome, "skills") };
|
|
20
|
+
}
|
|
21
|
+
install(skill) {
|
|
22
|
+
const skillDir = path.join(this.codexHome, "skills", skill.name);
|
|
23
|
+
atomicWriteFile(path.join(skillDir, "SKILL.md"), skill.content);
|
|
24
|
+
}
|
|
25
|
+
remove(name) {
|
|
26
|
+
const skillDir = path.join(this.codexHome, "skills", name);
|
|
27
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
isInstalled(name) {
|
|
30
|
+
return fs.existsSync(path.join(this.codexHome, "skills", name, "SKILL.md"));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function glmDelegationSkill() {
|
|
34
|
+
return { name: GLM_DELEGATION_SKILL_NAME, content: GLM_DELEGATION_SKILL_MD };
|
|
35
|
+
}
|