@0xgeorgemathew/zcode 0.3.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 +239 -0
- package/bin/zcode.js +239 -0
- package/lib/appserver.js +432 -0
- package/lib/bootstrap.js +89 -0
- package/lib/commands/agent.js +203 -0
- package/lib/commands/doctor.js +118 -0
- package/lib/commands/login.js +48 -0
- package/lib/commands/mcp-list.js +20 -0
- package/lib/commands/run.js +126 -0
- package/lib/engine.js +139 -0
- package/lib/isolation.js +313 -0
- package/lib/key.js +51 -0
- package/lib/models.js +53 -0
- package/lib/overlay.js +138 -0
- package/lib/ui.js +105 -0
- package/package.json +41 -0
package/lib/isolation.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Scratch-HOME isolation (DESIGN §6): the engine runs with HOME pointed at a
|
|
3
|
+
// copy we own (~/.zcode/home, mode 700) so the live ~/.zcode — which the
|
|
4
|
+
// desktop app is actively using — is never written by us, and the user's
|
|
5
|
+
// OAuth credentials.json is never copied.
|
|
6
|
+
//
|
|
7
|
+
// Invariant (§6.4): zcode performs no writes to the live ~/.zcode. The
|
|
8
|
+
// fallback ladder NEVER re-adds credentials.json.
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
|
|
14
|
+
const LIVE_ZCODE = path.join(os.homedir(), '.zcode');
|
|
15
|
+
const LIVE_V2 = path.join(LIVE_ZCODE, 'v2');
|
|
16
|
+
|
|
17
|
+
// v2 top-level entries we deliberately never copy.
|
|
18
|
+
const V2_EXCLUDE_FILES = new Set(['credentials.json', '.DS_Store']);
|
|
19
|
+
function isV2Excluded(name) {
|
|
20
|
+
return (
|
|
21
|
+
V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite')
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const keySha256 = (key) =>
|
|
26
|
+
crypto.createHash('sha256').update(key).digest('hex');
|
|
27
|
+
|
|
28
|
+
// --- source inventory -------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
// Files the scratch copy depends on: every source path + mtimeMs. A change
|
|
31
|
+
// in the set, an mtime, or the key fingerprint invalidates the stamp.
|
|
32
|
+
function sourceInventory() {
|
|
33
|
+
const sources = [];
|
|
34
|
+
const pushFile = (abs) => {
|
|
35
|
+
try {
|
|
36
|
+
sources.push({ file: abs, mtimeMs: fs.statSync(abs).mtimeMs });
|
|
37
|
+
} catch {
|
|
38
|
+
/* source vanished — not copied */
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
for (const entry of fs.readdirSync(LIVE_V2, { withFileTypes: true })) {
|
|
43
|
+
if (!entry.isFile() || isV2Excluded(entry.name)) continue;
|
|
44
|
+
pushFile(path.join(LIVE_V2, entry.name));
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
/* no live v2 — synthesized path */
|
|
48
|
+
}
|
|
49
|
+
pushFile(path.join(LIVE_ZCODE, 'cli', 'config.json'));
|
|
50
|
+
pushFile(path.join(LIVE_ZCODE, 'AGENTS.md'));
|
|
51
|
+
try {
|
|
52
|
+
for (const entry of fs.readdirSync(path.join(LIVE_ZCODE, 'skills'), {
|
|
53
|
+
withFileTypes: true,
|
|
54
|
+
})) {
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
pushFile(path.join(LIVE_ZCODE, 'skills', entry.name, 'SKILL.md'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* no skills dir */
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
for (const entry of fs.readdirSync(path.join(LIVE_ZCODE, 'agents'), {
|
|
64
|
+
withFileTypes: true,
|
|
65
|
+
})) {
|
|
66
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
67
|
+
pushFile(path.join(LIVE_ZCODE, 'agents', entry.name));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* no agents dir */
|
|
72
|
+
}
|
|
73
|
+
return sources;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- build ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function writeJson600(file, value) {
|
|
79
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', {
|
|
80
|
+
mode: 0o600,
|
|
81
|
+
});
|
|
82
|
+
fs.chmodSync(file, 0o600);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function copyFileTo(src, destDir) {
|
|
86
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
87
|
+
const dest = path.join(destDir, path.basename(src));
|
|
88
|
+
fs.copyFileSync(src, dest);
|
|
89
|
+
return dest;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Prune to exactly the zai-api rule and swap in the package key (§6.2 item 2).
|
|
93
|
+
function rewriteProviderConfig(target, key) {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
95
|
+
const rules =
|
|
96
|
+
parsed.config &&
|
|
97
|
+
parsed.config.providerConfigRules &&
|
|
98
|
+
parsed.config.providerConfigRules.providerRules;
|
|
99
|
+
if (!Array.isArray(rules)) {
|
|
100
|
+
throw new Error(`unexpected provider_config.json shape at ${target}`);
|
|
101
|
+
}
|
|
102
|
+
const zaiRules = rules.filter((r) => r && r.providerId === 'zai-api');
|
|
103
|
+
if (zaiRules.length === 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`no zai-api rule found in provider_config.json at ${target}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const rule = zaiRules[0];
|
|
109
|
+
rule.config = rule.config || {};
|
|
110
|
+
rule.config.group = rule.config.group || 'standard-personal';
|
|
111
|
+
rule.config.access = {
|
|
112
|
+
type: 'api-key',
|
|
113
|
+
apiKey: key,
|
|
114
|
+
};
|
|
115
|
+
parsed.config.providerConfigRules.providerRules = [rule];
|
|
116
|
+
writeJson600(target, parsed);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Overwrite builtin:zai apiKey with the package key (§6.2 item 3).
|
|
120
|
+
function rewriteCliConfig(target, key) {
|
|
121
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
122
|
+
const zai = parsed.provider && parsed.provider['builtin:zai'];
|
|
123
|
+
if (zai) {
|
|
124
|
+
zai.options = zai.options || {};
|
|
125
|
+
zai.options.apiKey = key;
|
|
126
|
+
}
|
|
127
|
+
writeJson600(target, parsed);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function synthesizedProviderConfig(key) {
|
|
131
|
+
return {
|
|
132
|
+
schemaVersion: 1,
|
|
133
|
+
config: {
|
|
134
|
+
providerConfigRules: {
|
|
135
|
+
providerRules: [
|
|
136
|
+
{
|
|
137
|
+
providerId: 'zai-api',
|
|
138
|
+
templateId: 'zai-api',
|
|
139
|
+
config: {
|
|
140
|
+
group: 'standard-personal',
|
|
141
|
+
access: { type: 'api-key', apiKey: key },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tier (a), the review-proven recipe: top-level v2 files, no directories, no
|
|
151
|
+
// credentials.json, no sqlite. Fresh machines (no live v2) synthesize instead.
|
|
152
|
+
function buildScratch(home, key) {
|
|
153
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
154
|
+
const scratchV2 = path.join(home, '.zcode', 'v2');
|
|
155
|
+
fs.mkdirSync(scratchV2, { recursive: true });
|
|
156
|
+
|
|
157
|
+
let mode = 'synthesized';
|
|
158
|
+
let copied = 0;
|
|
159
|
+
try {
|
|
160
|
+
const entries = fs.readdirSync(LIVE_V2, { withFileTypes: true });
|
|
161
|
+
const files = entries.filter(
|
|
162
|
+
(e) => e.isFile() && !isV2Excluded(e.name)
|
|
163
|
+
);
|
|
164
|
+
if (files.length > 0 || fs.existsSync(path.join(LIVE_V2, 'provider_config.json'))) {
|
|
165
|
+
mode = 'copied-v2-top-level';
|
|
166
|
+
}
|
|
167
|
+
for (const entry of files) {
|
|
168
|
+
const dest = copyFileTo(path.join(LIVE_V2, entry.name), scratchV2);
|
|
169
|
+
if (entry.name === 'provider_config.json') {
|
|
170
|
+
rewriteProviderConfig(dest, key);
|
|
171
|
+
}
|
|
172
|
+
copied++;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
/* no live v2 at all */
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const scratchProviderConfig = path.join(scratchV2, 'provider_config.json');
|
|
179
|
+
if (!fs.existsSync(scratchProviderConfig)) {
|
|
180
|
+
writeJson600(scratchProviderConfig, synthesizedProviderConfig(key));
|
|
181
|
+
} else {
|
|
182
|
+
// Ensure key swap even when the copy step found an unexpected shape.
|
|
183
|
+
rewriteProviderConfig(scratchProviderConfig, key);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const liveCliConfig = path.join(LIVE_ZCODE, 'cli', 'config.json');
|
|
187
|
+
if (fs.existsSync(liveCliConfig)) {
|
|
188
|
+
const dest = copyFileTo(liveCliConfig, path.join(home, '.zcode', 'cli'));
|
|
189
|
+
rewriteCliConfig(dest, key);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const liveAgents = path.join(LIVE_ZCODE, 'agents');
|
|
193
|
+
if (fs.existsSync(liveAgents)) {
|
|
194
|
+
for (const entry of fs.readdirSync(liveAgents, { withFileTypes: true })) {
|
|
195
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
196
|
+
copyFileTo(path.join(liveAgents, entry.name), path.join(home, '.zcode', 'agents'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const liveAgentsMd = path.join(LIVE_ZCODE, 'AGENTS.md');
|
|
201
|
+
if (fs.existsSync(liveAgentsMd)) {
|
|
202
|
+
copyFileTo(liveAgentsMd, path.join(home, '.zcode'));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Skills: copy each <name>/SKILL.md dir from the live user scope so the
|
|
206
|
+
// engine (running under this scratch HOME) sees the same skills as the
|
|
207
|
+
// desktop. Project skills (<cwd>/.zcode/skills) need no copy — the engine
|
|
208
|
+
// reads them from the real workspace.
|
|
209
|
+
let skillsCopied = 0;
|
|
210
|
+
const liveSkills = path.join(LIVE_ZCODE, 'skills');
|
|
211
|
+
if (fs.existsSync(liveSkills)) {
|
|
212
|
+
for (const entry of fs.readdirSync(liveSkills, { withFileTypes: true })) {
|
|
213
|
+
if (!entry.isDirectory()) continue;
|
|
214
|
+
if (!fs.existsSync(path.join(liveSkills, entry.name, 'SKILL.md'))) continue;
|
|
215
|
+
fs.cpSync(
|
|
216
|
+
path.join(liveSkills, entry.name),
|
|
217
|
+
path.join(home, '.zcode', 'skills', entry.name),
|
|
218
|
+
{ recursive: true }
|
|
219
|
+
);
|
|
220
|
+
skillsCopied++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { mode, copied, skillsCopied };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- public API -------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function rootDir(env = process.env) {
|
|
230
|
+
return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Ensures the scratch HOME exists and is current. Returns
|
|
234
|
+
// {root, home, mode, built, stamp}.
|
|
235
|
+
function ensureIsolation(key, { fresh = false, env = process.env } = {}) {
|
|
236
|
+
const root = rootDir(env);
|
|
237
|
+
fs.mkdirSync(root, { recursive: true });
|
|
238
|
+
fs.chmodSync(root, 0o700);
|
|
239
|
+
const home = path.join(root, 'home');
|
|
240
|
+
const stampFile = path.join(root, 'build-stamp.json');
|
|
241
|
+
|
|
242
|
+
const sources = sourceInventory();
|
|
243
|
+
const fingerprint = keySha256(key);
|
|
244
|
+
const desired = {
|
|
245
|
+
version: 1,
|
|
246
|
+
keySha256: fingerprint,
|
|
247
|
+
sources,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (!fresh && fs.existsSync(home) && fs.existsSync(stampFile)) {
|
|
251
|
+
try {
|
|
252
|
+
const stamp = JSON.parse(fs.readFileSync(stampFile, 'utf8'));
|
|
253
|
+
const sameSources =
|
|
254
|
+
Array.isArray(stamp.sources) &&
|
|
255
|
+
stamp.sources.length === desired.sources.length &&
|
|
256
|
+
stamp.sources.every(
|
|
257
|
+
(s, i) =>
|
|
258
|
+
s.file === desired.sources[i].file && s.mtimeMs === desired.sources[i].mtimeMs
|
|
259
|
+
);
|
|
260
|
+
if (stamp.keySha256 === fingerprint && sameSources) {
|
|
261
|
+
return {
|
|
262
|
+
root,
|
|
263
|
+
home,
|
|
264
|
+
mode: stamp.mode,
|
|
265
|
+
built: false,
|
|
266
|
+
stamp,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
/* corrupt stamp — rebuild */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { mode, copied, skillsCopied } = buildScratch(home, key);
|
|
275
|
+
fs.chmodSync(home, 0o700);
|
|
276
|
+
const stamp = {
|
|
277
|
+
...desired,
|
|
278
|
+
mode,
|
|
279
|
+
copiedFiles: copied,
|
|
280
|
+
skillsCopied,
|
|
281
|
+
builtAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
writeJson600(stampFile, stamp);
|
|
284
|
+
return { root, home, mode, built: true, stamp };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Env for the engine child process (§6.3): HOME=scratch, pinned builtin
|
|
288
|
+
// config, and the uncontrolled overrides deleted. cwd is untouched here —
|
|
289
|
+
// the caller sets it to the user's real workspace.
|
|
290
|
+
function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
|
|
291
|
+
const childEnv = { ...parentEnv };
|
|
292
|
+
childEnv.HOME = home;
|
|
293
|
+
childEnv.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = builtinProviderConfig;
|
|
294
|
+
for (const name of [
|
|
295
|
+
'ZCODE_STORAGE_DIR',
|
|
296
|
+
'ZCODE_DATA_BASE_DIR',
|
|
297
|
+
'ZCODE_PERSONAL_PROVIDER_CONFIG_FILE',
|
|
298
|
+
'ANTHROPIC_API_KEY',
|
|
299
|
+
'ANTHROPIC_BASE_URL',
|
|
300
|
+
]) {
|
|
301
|
+
delete childEnv[name];
|
|
302
|
+
}
|
|
303
|
+
return childEnv;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = {
|
|
307
|
+
ensureIsolation,
|
|
308
|
+
engineEnv,
|
|
309
|
+
rootDir,
|
|
310
|
+
keySha256,
|
|
311
|
+
LIVE_ZCODE,
|
|
312
|
+
LIVE_V2,
|
|
313
|
+
};
|
package/lib/key.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// API key resolution (DESIGN §5): ZAI_API_KEY env → ~/.zcode/credentials.json
|
|
3
|
+
// ({"apiKey":"…"}, mode 600, written by `zcode login`). The key is never
|
|
4
|
+
// printed, logged, or echoed by anything in this package.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
|
|
9
|
+
// Root of zcode's own state (scratch HOME lives under <root>/home).
|
|
10
|
+
function rootDir(env = process.env) {
|
|
11
|
+
return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function credentialsPath(env = process.env) {
|
|
15
|
+
return path.join(rootDir(env), 'credentials.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class NoKeyError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('No API key configured. Run "zcode login" or set ZAI_API_KEY.');
|
|
21
|
+
this.code = 'ZAI_NO_KEY';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reads {"apiKey": ...} from the credentials file; returns '' when absent.
|
|
26
|
+
function readCredentialsKey(file) {
|
|
27
|
+
let text;
|
|
28
|
+
try {
|
|
29
|
+
text = fs.readFileSync(file, 'utf8');
|
|
30
|
+
} catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(text);
|
|
35
|
+
return typeof parsed.apiKey === 'string' ? parsed.apiKey : '';
|
|
36
|
+
} catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Sources, in order: ZAI_API_KEY (non-empty) → credentials.json.
|
|
42
|
+
// Throws NoKeyError when neither holds a key.
|
|
43
|
+
function resolveKey(env = process.env) {
|
|
44
|
+
const fromEnv = (env.ZAI_API_KEY || '').trim();
|
|
45
|
+
if (fromEnv) return fromEnv;
|
|
46
|
+
const fromFile = readCredentialsKey(credentialsPath(env));
|
|
47
|
+
if (fromFile) return fromFile;
|
|
48
|
+
throw new NoKeyError();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { resolveKey, credentialsPath, rootDir, NoKeyError };
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The ONLY place model ids are chosen (DESIGN §7). The wrapper constructs
|
|
3
|
+
// every engine request itself, so nothing outside this allowlist can reach
|
|
4
|
+
// the wire.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MODEL = 'GLM-5.3-Flash';
|
|
7
|
+
|
|
8
|
+
// Case-insensitive aliases → official ids (exact ids pass through verbatim;
|
|
9
|
+
// they are exactly "GLM-5.3" / "GLM-5.3-Flash").
|
|
10
|
+
const ALIASES = new Map([
|
|
11
|
+
['glm-5.3', 'GLM-5.3'],
|
|
12
|
+
['5.3', 'GLM-5.3'],
|
|
13
|
+
['53', 'GLM-5.3'],
|
|
14
|
+
['glm-5.3-flash', 'GLM-5.3-Flash'],
|
|
15
|
+
['5.3-flash', 'GLM-5.3-Flash'],
|
|
16
|
+
['flash', 'GLM-5.3-Flash'],
|
|
17
|
+
['53f', 'GLM-5.3-Flash'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const SUPPORTED = ['glm-5.3', 'glm-5.3-flash'];
|
|
21
|
+
|
|
22
|
+
// Effort enum per the bundle's schema (DESIGN §8). `max` is the default and
|
|
23
|
+
// the only guaranteed-portable choice across both models.
|
|
24
|
+
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
25
|
+
|
|
26
|
+
function normalizeEffort(input) {
|
|
27
|
+
const raw = String(input ?? '').trim().toLowerCase();
|
|
28
|
+
return EFFORT_LEVELS.includes(raw) ? raw : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns the official model id, or null when input is not allowed.
|
|
32
|
+
function normalizeModel(input) {
|
|
33
|
+
const raw = String(input ?? '').trim();
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
return ALIASES.get(raw.toLowerCase()) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unknownModelMessage(input) {
|
|
39
|
+
return (
|
|
40
|
+
`unknown model "${input}". zcode supports only ` +
|
|
41
|
+
`${SUPPORTED[0]} and ${SUPPORTED[1]} ` +
|
|
42
|
+
`(official ids: GLM-5.3, GLM-5.3-Flash).`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
DEFAULT_MODEL,
|
|
48
|
+
normalizeModel,
|
|
49
|
+
unknownModelMessage,
|
|
50
|
+
normalizeEffort,
|
|
51
|
+
EFFORT_LEVELS,
|
|
52
|
+
SUPPORTED,
|
|
53
|
+
};
|
package/lib/overlay.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-run overlays on the scratch HOME (DESIGN §6 extension): MCP servers
|
|
3
|
+
// and extra skills. Both are recomputed from read-only live sources plus the
|
|
4
|
+
// caller's flags on EVERY run, so the scratch never leaks a previous run's
|
|
5
|
+
// additions — the default state is always "what the desktop has".
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { LIVE_ZCODE } = require('./isolation.js');
|
|
9
|
+
|
|
10
|
+
// --- MCP ---------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
// Base servers: the live desktop config, read-only, honoring `enabled`.
|
|
13
|
+
function liveMcpServers() {
|
|
14
|
+
try {
|
|
15
|
+
const cfg = JSON.parse(
|
|
16
|
+
fs.readFileSync(path.join(LIVE_ZCODE, 'cli', 'config.json'), 'utf8')
|
|
17
|
+
);
|
|
18
|
+
const servers = cfg && cfg.mcp && cfg.mcp.servers;
|
|
19
|
+
if (!servers || typeof servers !== 'object') return {};
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
22
|
+
if (!def || typeof def !== 'object') continue;
|
|
23
|
+
if (def.enabled === false) continue;
|
|
24
|
+
out[name] = def;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Effective set for one run. Hermetic by default: ONLY the caller's --mcp
|
|
33
|
+
// servers. --mcp-inherit pulls in the desktop's enabled servers as a base
|
|
34
|
+
// (specs still win by name). Hermetic default is deliberate: the desktop
|
|
35
|
+
// set includes OAuth servers that can never complete headless and stall
|
|
36
|
+
// the engine's connect pool, starving later servers nondeterministically.
|
|
37
|
+
function effectiveMcpServers({ inherit = false, mcpSpecs = [] } = {}) {
|
|
38
|
+
const servers = inherit ? liveMcpServers() : {};
|
|
39
|
+
for (const spec of mcpSpecs) {
|
|
40
|
+
for (const [name, def] of Object.entries(spec)) {
|
|
41
|
+
servers[name] = def;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return servers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Writes the effective set into the scratch cli config so the engine child
|
|
48
|
+
// picks it up at session start. Creates a minimal config when the scratch
|
|
49
|
+
// has none (fresh-machine synthesized path).
|
|
50
|
+
function applyMcpOverlay({ home, inherit = false, mcpSpecs = [] }) {
|
|
51
|
+
const servers = effectiveMcpServers({ inherit, mcpSpecs });
|
|
52
|
+
// Names the caller explicitly added — the CLI waits for these to settle
|
|
53
|
+
// (connected or failed) before sending the prompt, so an explicit server
|
|
54
|
+
// is never lost to a slow stdio spawn. Inherited servers are best-effort.
|
|
55
|
+
const added = mcpSpecs.flatMap((spec) => Object.keys(spec));
|
|
56
|
+
const configPath = path.join(home, '.zcode', 'cli', 'config.json');
|
|
57
|
+
let cfg = {};
|
|
58
|
+
try {
|
|
59
|
+
cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
60
|
+
if (!cfg || typeof cfg !== 'object') cfg = {};
|
|
61
|
+
} catch {
|
|
62
|
+
cfg = {};
|
|
63
|
+
}
|
|
64
|
+
cfg.mcp = { ...(cfg.mcp || {}), servers };
|
|
65
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
66
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
67
|
+
fs.chmodSync(configPath, 0o600);
|
|
68
|
+
return { servers, count: Object.keys(servers).length, added };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- skills ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const MANIFEST = 'injected-skills.json';
|
|
74
|
+
|
|
75
|
+
function readManifest(rootDir) {
|
|
76
|
+
try {
|
|
77
|
+
const m = JSON.parse(fs.readFileSync(path.join(rootDir, MANIFEST), 'utf8'));
|
|
78
|
+
return Array.isArray(m.skills) ? m : { skills: [] };
|
|
79
|
+
} catch {
|
|
80
|
+
return { skills: [] };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A --skills dir is either one skill (<dir>/SKILL.md) or a skills root
|
|
85
|
+
// (<dir>/<name>/SKILL.md). Returns [{name, dir}] of copyable skills.
|
|
86
|
+
function discoverSkillDirs(dir) {
|
|
87
|
+
const found = [];
|
|
88
|
+
if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
|
|
89
|
+
found.push({ name: path.basename(path.resolve(dir)), dir });
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
if (!entry.isDirectory()) continue;
|
|
94
|
+
if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) {
|
|
95
|
+
found.push({ name: entry.name, dir: path.join(dir, entry.name) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Removes skills injected by a previous run, copies this run's extras in,
|
|
102
|
+
// records them in the manifest. --no-skills empties the scratch skills dir
|
|
103
|
+
// entirely (per-run hermetic mode).
|
|
104
|
+
function applySkillsOverlay({ rootDir, home, skillsDirs = [], noSkills = false }) {
|
|
105
|
+
const scratchSkills = path.join(home, '.zcode', 'skills');
|
|
106
|
+
const previous = readManifest(rootDir);
|
|
107
|
+
for (const name of previous.skills) {
|
|
108
|
+
fs.rmSync(path.join(scratchSkills, name), { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (noSkills) {
|
|
112
|
+
fs.rmSync(scratchSkills, { recursive: true, force: true });
|
|
113
|
+
fs.mkdirSync(scratchSkills, { recursive: true });
|
|
114
|
+
fs.writeFileSync(
|
|
115
|
+
path.join(rootDir, MANIFEST),
|
|
116
|
+
JSON.stringify({ skills: [] }, null, 2) + '\n',
|
|
117
|
+
{ mode: 0o600 }
|
|
118
|
+
);
|
|
119
|
+
return { injected: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const injected = [];
|
|
123
|
+
for (const dir of skillsDirs) {
|
|
124
|
+
for (const { name, dir: skillDir } of discoverSkillDirs(dir)) {
|
|
125
|
+
fs.rmSync(path.join(scratchSkills, name), { recursive: true, force: true });
|
|
126
|
+
fs.cpSync(skillDir, path.join(scratchSkills, name), { recursive: true });
|
|
127
|
+
injected.push(name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
fs.writeFileSync(
|
|
131
|
+
path.join(rootDir, MANIFEST),
|
|
132
|
+
JSON.stringify({ skills: injected }, null, 2) + '\n',
|
|
133
|
+
{ mode: 0o600 }
|
|
134
|
+
);
|
|
135
|
+
return { injected };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { effectiveMcpServers, applyMcpOverlay, applySkillsOverlay, discoverSkillDirs };
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Plain-mode terminal UI (lib/ui.js): one place for the "live feed" an
|
|
3
|
+
// operator watches on stderr while stdout carries only the answer.
|
|
4
|
+
//
|
|
5
|
+
// Visual contract (deliberate, terminal-native):
|
|
6
|
+
// thinking │ dimmed, every line prefixed "│ " - a quote block, clearly
|
|
7
|
+
// not the answer
|
|
8
|
+
// tool calls → started / ✓ ok / ✗ error, dimmed, one line each
|
|
9
|
+
// mcp status ◇ connected / ✗ failed, dimmed
|
|
10
|
+
// footer one dimmed status line (model, effort, tool calls, time,
|
|
11
|
+
// session) - the session id is there so a plain-mode caller
|
|
12
|
+
// can pass it to --session without re-running with --json
|
|
13
|
+
//
|
|
14
|
+
// stdout is NEVER touched here. --quiet silences the whole feed.
|
|
15
|
+
|
|
16
|
+
function makePlainReporter({ quiet = false } = {}) {
|
|
17
|
+
const tty = process.stderr.isTTY;
|
|
18
|
+
const dim = (t) => (tty ? `\x1b[2m${t}\x1b[0m` : t);
|
|
19
|
+
|
|
20
|
+
let thinkingOpen = false; // a thinking block is being streamed
|
|
21
|
+
let atLineStart = true; // no "│ " written for this line yet
|
|
22
|
+
|
|
23
|
+
const closeThinking = () => {
|
|
24
|
+
if (thinkingOpen) {
|
|
25
|
+
thinkingOpen = false;
|
|
26
|
+
atLineStart = true;
|
|
27
|
+
process.stderr.write('\n');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const writeThinking = (delta) => {
|
|
32
|
+
if (!thinkingOpen) {
|
|
33
|
+
thinkingOpen = true;
|
|
34
|
+
atLineStart = true;
|
|
35
|
+
}
|
|
36
|
+
for (const ch of delta) {
|
|
37
|
+
if (atLineStart) {
|
|
38
|
+
process.stderr.write(dim('│ '));
|
|
39
|
+
atLineStart = false;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '\n') {
|
|
42
|
+
process.stderr.write('\n');
|
|
43
|
+
atLineStart = true;
|
|
44
|
+
} else {
|
|
45
|
+
process.stderr.write(dim(ch));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const toolLine = (name, phase) => {
|
|
51
|
+
if (phase === 'started' || phase === 'scheduled') {
|
|
52
|
+
process.stderr.write(dim(`→ ${name} started\n`));
|
|
53
|
+
} else if (phase === 'result') {
|
|
54
|
+
process.stderr.write(dim(`✓ ${name} ok\n`));
|
|
55
|
+
} else if (phase === 'error') {
|
|
56
|
+
process.stderr.write(dim(`✗ ${name} error\n`));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const mcpLine = (ev) => {
|
|
61
|
+
if (ev.status === 'connected') {
|
|
62
|
+
process.stderr.write(dim(`◇ mcp ${ev.name} connected\n`));
|
|
63
|
+
} else if (ev.status === 'failed' || ev.status === 'timeout') {
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
dim(`✗ mcp ${ev.name} ${ev.status}${ev.error ? ` (${ev.error})` : ''}\n`)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const onEvent = (ev) => {
|
|
71
|
+
if (quiet) return;
|
|
72
|
+
if (ev.type === 'stream' && ev.kind === 'reasoning_delta' && ev.delta) {
|
|
73
|
+
writeThinking(ev.delta);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
closeThinking();
|
|
77
|
+
if (ev.type === 'stream' && ev.kind === 'text_delta' && ev.delta) {
|
|
78
|
+
return; // the answer streams to stdout, not here
|
|
79
|
+
}
|
|
80
|
+
if (ev.type === 'tool' && ev.name) {
|
|
81
|
+
toolLine(ev.name, ev.phase);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (ev.type === 'mcp') {
|
|
85
|
+
mcpLine(ev);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const footer = ({ model, effort, toolCalls, durationMs, sessionId }) => {
|
|
90
|
+
if (quiet) return;
|
|
91
|
+
closeThinking();
|
|
92
|
+
const parts = [
|
|
93
|
+
model,
|
|
94
|
+
`effort ${effort}`,
|
|
95
|
+
typeof toolCalls === 'number' ? `${toolCalls} tool call${toolCalls === 1 ? '' : 's'}` : null,
|
|
96
|
+
`${(durationMs / 1000).toFixed(1)}s`,
|
|
97
|
+
sessionId ? sessionId.slice(0, 16) : null,
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
process.stderr.write(dim(` ${parts.join(' · ')}\n`));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { onEvent, closeThinking, footer };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { makePlainReporter };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@0xgeorgemathew/zcode",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Headless GLM-5.3 / GLM-5.3-Flash coding CLI for agents: streaming thinking, subagents, MCP servers, skills, session continuity. Wraps the ZCode engine. Zero dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"bin": {
|
|
8
|
+
"zcode": "bin/zcode.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"lib",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test --test-concurrency=1 \"test/**/*.test.js\"",
|
|
22
|
+
"prepublishOnly": "npm test",
|
|
23
|
+
"accept:flash": "node scripts/accept.js flash",
|
|
24
|
+
"accept:subagent": "node scripts/accept.js subagent",
|
|
25
|
+
"accept:53": "node scripts/accept.js glm53"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"glm",
|
|
29
|
+
"glm-5.3",
|
|
30
|
+
"zai",
|
|
31
|
+
"zcode",
|
|
32
|
+
"cli",
|
|
33
|
+
"coding-agent",
|
|
34
|
+
"agent",
|
|
35
|
+
"llm",
|
|
36
|
+
"mcp",
|
|
37
|
+
"subagent",
|
|
38
|
+
"headless",
|
|
39
|
+
"ndjson"
|
|
40
|
+
]
|
|
41
|
+
}
|