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.
- package/README.md +419 -415
- package/dist/bin/glm-worker.js +37 -4
- package/dist/commands/doctor.js +21 -5
- package/dist/commands/init.js +5 -3
- package/dist/commands/key.js +46 -9
- package/dist/commands/uninstall.js +2 -1
- package/dist/core/config.js +31 -2
- package/dist/core/errors.js +33 -10
- package/dist/core/platform.js +37 -8
- package/dist/core/profile.js +6 -1
- package/dist/core/user-env.js +181 -0
- package/dist/core/zai-key.js +15 -59
- package/package.json +1 -1
package/dist/bin/glm-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/commands/doctor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
88
|
-
|
|
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({
|
package/dist/commands/key.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
import prompts from "prompts";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
77
|
+
process.stderr.write(formatGlmError(error) + "\n");
|
|
48
78
|
return error.exitCode;
|
|
49
79
|
}
|
|
50
|
-
const sourceLabel = resolved.source === "process-env"
|
|
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
|
-
|
|
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(
|
|
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");
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/core/errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
hint: ["Linux
|
|
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({
|
package/dist/core/platform.js
CHANGED
|
@@ -3,18 +3,47 @@ import { Errors } from "./errors.js";
|
|
|
3
3
|
export function isWindows() {
|
|
4
4
|
return process.platform === "win32";
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/dist/core/profile.js
CHANGED
|
@@ -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: {
|
|
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
|
+
}
|