@0xgeorgemathew/zcode 0.3.1 → 0.4.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/lib/isolation.js CHANGED
@@ -1,93 +1,71 @@
1
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
2
+ // Scratch-HOME isolation (DESIGN §6, reworked in 0.4): the engine runs with
3
+ // HOME pointed at a directory we own so the live ~/.zcode — which the
4
4
  // desktop app is actively using — is never written by us, and the user's
5
5
  // OAuth credentials.json is never copied.
6
6
  //
7
- // Invariant (§6.4): zcode performs no writes to the live ~/.zcode. The
8
- // fallback ladder NEVER re-adds credentials.json.
7
+ // 0.4: one engine HOME per zcode session (<root>/sessions/<id>/home) instead
8
+ // of one shared HOME. The shared HOME was rm -rf'd and rebuilt whenever a
9
+ // volatile desktop file (agents-state.json, bot-config.v3.json, …) changed
10
+ // mtime — which deleted the engine's session database under every other
11
+ // session and made --session resume 404 — and per-run MCP/skills overlays
12
+ // rewrote files that concurrent runs were reading. Per-session HOMEs give
13
+ // each session its own engine DB, config and skills; config files are
14
+ // refreshed in place on every invocation and engine state (cli/db,
15
+ // cli/agents, rollout, logs) is never deleted.
16
+ //
17
+ // Invariant (§6.4): zcode performs no writes to the live ~/.zcode, and
18
+ // credentials.json is never copied.
9
19
  const fs = require('node:fs');
10
20
  const path = require('node:path');
11
21
  const os = require('node:os');
12
- const crypto = require('node:crypto');
13
22
 
14
- const LIVE_ZCODE = path.join(os.homedir(), '.zcode');
23
+ // The desktop app's live directory. ZAI_CODE_LIVE_DIR points elsewhere
24
+ // (e.g. a second desktop profile, or a fixture in tests).
25
+ function liveDir(env = process.env) {
26
+ return env.ZAI_CODE_LIVE_DIR || path.join(os.homedir(), '.zcode');
27
+ }
28
+
29
+ const LIVE_ZCODE = liveDir({});
15
30
  const LIVE_V2 = path.join(LIVE_ZCODE, 'v2');
16
31
 
17
32
  // v2 top-level entries we deliberately never copy.
18
33
  const V2_EXCLUDE_FILES = new Set(['credentials.json', '.DS_Store']);
19
34
  function isV2Excluded(name) {
20
- return (
21
- V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite')
22
- );
35
+ return V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite');
23
36
  }
24
37
 
25
- const keySha256 = (key) =>
26
- crypto.createHash('sha256').update(key).digest('hex');
38
+ // Root of zcode's own state. MUST NOT be ~/.zcode: that is the desktop
39
+ // app's live directory (0.3.0/0.3.1 regression — the package rename made
40
+ // rootDir collide with it). Ours is ~/.zcode-cli.
41
+ function rootDir(env = process.env) {
42
+ return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode-cli');
43
+ }
27
44
 
28
- // --- source inventory -------------------------------------------------------
45
+ // --- small fs helpers ---------------------------------------------------------
29
46
 
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'));
47
+ function writeJson600(file, value) {
48
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
49
+ fs.chmodSync(file, 0o600);
50
+ }
51
+
52
+ function readJson(file) {
51
53
  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
- }
54
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
59
55
  } catch {
60
- /* no skills dir */
56
+ return null;
61
57
  }
58
+ }
59
+
60
+ function listDir(dir) {
62
61
  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
- }
62
+ return fs.readdirSync(dir, { withFileTypes: true });
70
63
  } catch {
71
- /* no agents dir */
64
+ return [];
72
65
  }
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
66
  }
84
67
 
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
- }
68
+ // --- provider/key rewriting -----------------------------------------------------
91
69
 
92
70
  // Prune to exactly the zai-api rule and swap in the package key (§6.2 item 2).
93
71
  function rewriteProviderConfig(target, key) {
@@ -101,32 +79,16 @@ function rewriteProviderConfig(target, key) {
101
79
  }
102
80
  const zaiRules = rules.filter((r) => r && r.providerId === 'zai-api');
103
81
  if (zaiRules.length === 0) {
104
- throw new Error(
105
- `no zai-api rule found in provider_config.json at ${target}`
106
- );
82
+ throw new Error(`no zai-api rule found in provider_config.json at ${target}`);
107
83
  }
108
84
  const rule = zaiRules[0];
109
85
  rule.config = rule.config || {};
110
86
  rule.config.group = rule.config.group || 'standard-personal';
111
- rule.config.access = {
112
- type: 'api-key',
113
- apiKey: key,
114
- };
87
+ rule.config.access = { type: 'api-key', apiKey: key };
115
88
  parsed.config.providerConfigRules.providerRules = [rule];
116
89
  writeJson600(target, parsed);
117
90
  }
118
91
 
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
92
  function synthesizedProviderConfig(key) {
131
93
  return {
132
94
  schemaVersion: 1,
@@ -147,146 +109,240 @@ function synthesizedProviderConfig(key) {
147
109
  };
148
110
  }
149
111
 
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 });
112
+ // --- skills -------------------------------------------------------------------
156
113
 
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);
114
+ // A --skills dir is either one skill (<dir>/SKILL.md) or a skills root
115
+ // (<dir>/<name>/SKILL.md). Returns [{name, dir}] of copyable skills.
116
+ function discoverSkillDirs(dir) {
117
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
118
+ return [{ name: path.basename(path.resolve(dir)), dir }];
119
+ }
120
+ return listDir(dir)
121
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
122
+ .map((e) => ({ name: e.name, dir: path.join(dir, e.name) }));
123
+ }
124
+
125
+ // --- MCP ----------------------------------------------------------------------
126
+
127
+ // The live desktop MCP servers, read-only, honoring `enabled`.
128
+ function liveMcpServers(live = LIVE_ZCODE) {
129
+ const cfg = readJson(path.join(live, 'cli', 'config.json'));
130
+ const servers = cfg && cfg.mcp && cfg.mcp.servers;
131
+ if (!servers || typeof servers !== 'object') return {};
132
+ const out = {};
133
+ for (const [name, def] of Object.entries(servers)) {
134
+ if (!def || typeof def !== 'object' || def.enabled === false) continue;
135
+ out[name] = def;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ // Effective set for one invocation. Hermetic by default: ONLY the caller's
141
+ // --mcp servers. --mcp-inherit pulls in the desktop's enabled servers as a
142
+ // base (specs still win by name). Hermetic default is deliberate: the
143
+ // desktop set includes OAuth servers that can never complete headless and
144
+ // stall the engine's connect pool.
145
+ function effectiveMcpServers({ inherit = false, mcpSpecs = [], live = LIVE_ZCODE } = {}) {
146
+ const servers = inherit ? liveMcpServers(live) : {};
147
+ for (const spec of mcpSpecs) {
148
+ for (const [name, def] of Object.entries(spec)) servers[name] = def;
149
+ }
150
+ return servers;
151
+ }
152
+
153
+ // --- shared caches ------------------------------------------------------------
154
+
155
+ // Engine-populated caches that are expensive to recreate per session (the
156
+ // plugin marketplace cache is ~30 MB) are shared through symlinks into
157
+ // <root>/shared. A pre-0.4 shared HOME donates its caches once.
158
+ const SHARED_CACHES = [
159
+ { name: 'plugins', rel: path.join('.zcode', 'cli', 'plugins') },
160
+ { name: 'runtime', rel: path.join('.zcode', 'v2', 'runtime') },
161
+ ];
162
+
163
+ function linkSharedCaches(home, root) {
164
+ for (const cache of SHARED_CACHES) {
165
+ const shared = path.join(root, 'shared', cache.name);
166
+ if (!fs.existsSync(shared)) {
167
+ const legacy = path.join(root, 'home', cache.rel);
168
+ fs.mkdirSync(path.dirname(shared), { recursive: true, mode: 0o700 });
169
+ try {
170
+ if (fs.existsSync(legacy) && !fs.lstatSync(legacy).isSymbolicLink()) {
171
+ fs.renameSync(legacy, shared);
172
+ }
173
+ } catch {
174
+ /* fall through to an empty cache */
171
175
  }
172
- copied++;
176
+ fs.mkdirSync(shared, { recursive: true, mode: 0o700 });
177
+ }
178
+ const target = path.join(home, cache.rel);
179
+ if (fs.existsSync(target) || isDanglingLink(target)) continue;
180
+ fs.mkdirSync(path.dirname(target), { recursive: true });
181
+ try {
182
+ fs.symlinkSync(shared, target, 'dir');
183
+ } catch {
184
+ /* engine recreates the cache itself */
173
185
  }
186
+ }
187
+ }
188
+
189
+ function isDanglingLink(p) {
190
+ try {
191
+ return fs.lstatSync(p).isSymbolicLink() && !fs.existsSync(p);
174
192
  } catch {
175
- /* no live v2 at all */
193
+ return false;
176
194
  }
195
+ }
177
196
 
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);
197
+ // --- build ------------------------------------------------------------------
198
+
199
+ // Creates or refreshes one engine HOME. Config is (re)written from the live
200
+ // sources + this invocation's flags; engine state under the HOME (cli/db,
201
+ // cli/agents, cli/rollout, logs, memories) is never touched, so a session
202
+ // stays resumable across invocations. Returns facts for reporting.
203
+ function buildEngineHome({
204
+ home,
205
+ root,
206
+ key,
207
+ live = LIVE_ZCODE,
208
+ mcpSpecs = [],
209
+ mcpInherit = false,
210
+ skillsDirs = [],
211
+ noSkills = false,
212
+ }) {
213
+ const zdir = path.join(home, '.zcode');
214
+ const v2 = path.join(zdir, 'v2');
215
+ const cliDir = path.join(zdir, 'cli');
216
+ fs.mkdirSync(v2, { recursive: true, mode: 0o700 });
217
+ fs.mkdirSync(cliDir, { recursive: true, mode: 0o700 });
218
+ fs.chmodSync(home, 0o700);
219
+
220
+ // v2: top-level files only, no credentials.json, no sqlite (§6.2 item 1).
221
+ const liveV2 = path.join(live, 'v2');
222
+ const v2Files = listDir(liveV2).filter((e) => e.isFile() && !isV2Excluded(e.name));
223
+ for (const entry of v2Files) {
224
+ fs.copyFileSync(path.join(liveV2, entry.name), path.join(v2, entry.name));
184
225
  }
226
+ const providerConfig = path.join(v2, 'provider_config.json');
227
+ const mode = v2Files.some((e) => e.name === 'provider_config.json')
228
+ ? 'copied-v2-top-level'
229
+ : 'synthesized';
230
+ if (mode === 'synthesized') writeJson600(providerConfig, synthesizedProviderConfig(key));
231
+ else rewriteProviderConfig(providerConfig, key);
185
232
 
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);
233
+ // cli/config.json: live copy (builtin:zai key swapped) + this run's MCP set.
234
+ const cfg = readJson(path.join(live, 'cli', 'config.json')) || {};
235
+ const zai = cfg.provider && cfg.provider['builtin:zai'];
236
+ if (zai) {
237
+ zai.options = zai.options || {};
238
+ zai.options.apiKey = key;
190
239
  }
240
+ const servers = effectiveMcpServers({ inherit: mcpInherit, mcpSpecs, live });
241
+ cfg.mcp = { ...(cfg.mcp || {}), servers };
242
+ writeJson600(path.join(cliDir, 'config.json'), cfg);
191
243
 
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
- }
244
+ // Agent profiles + AGENTS.md: mirror the live set exactly.
245
+ const agentsDir = path.join(zdir, 'agents');
246
+ fs.rmSync(agentsDir, { recursive: true, force: true });
247
+ for (const entry of listDir(path.join(live, 'agents'))) {
248
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
249
+ fs.mkdirSync(agentsDir, { recursive: true });
250
+ fs.copyFileSync(path.join(live, 'agents', entry.name), path.join(agentsDir, entry.name));
199
251
  }
200
- const liveAgentsMd = path.join(LIVE_ZCODE, 'AGENTS.md');
201
- if (fs.existsSync(liveAgentsMd)) {
202
- copyFileTo(liveAgentsMd, path.join(home, '.zcode'));
252
+ const agentsMd = path.join(zdir, 'AGENTS.md');
253
+ if (fs.existsSync(path.join(live, 'AGENTS.md'))) {
254
+ fs.copyFileSync(path.join(live, 'AGENTS.md'), agentsMd);
255
+ } else {
256
+ fs.rmSync(agentsMd, { force: true });
203
257
  }
204
258
 
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.
259
+ // Skills: live user skills (unless --no-skills) + this run's --skills.
260
+ // Project skills (<cwd>/.zcode/skills) need no copy — the engine reads
261
+ // them from the real workspace.
262
+ const skillsDir = path.join(zdir, 'skills');
263
+ fs.rmSync(skillsDir, { recursive: true, force: true });
264
+ fs.mkdirSync(skillsDir, { recursive: true });
209
265
  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 })) {
266
+ if (!noSkills) {
267
+ const liveSkills = path.join(live, 'skills');
268
+ for (const entry of listDir(liveSkills)) {
213
269
  if (!entry.isDirectory()) continue;
214
270
  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
- );
271
+ fs.cpSync(path.join(liveSkills, entry.name), path.join(skillsDir, entry.name), {
272
+ recursive: true,
273
+ });
220
274
  skillsCopied++;
221
275
  }
222
276
  }
277
+ const skillsInjected = [];
278
+ if (!noSkills) {
279
+ for (const dir of skillsDirs) {
280
+ for (const { name, dir: skillDir } of discoverSkillDirs(dir)) {
281
+ fs.rmSync(path.join(skillsDir, name), { recursive: true, force: true });
282
+ fs.cpSync(skillDir, path.join(skillsDir, name), { recursive: true });
283
+ skillsInjected.push(name);
284
+ }
285
+ }
286
+ }
223
287
 
224
- return { mode, copied, skillsCopied };
288
+ if (root) linkSharedCaches(home, root);
289
+
290
+ return {
291
+ home,
292
+ mode,
293
+ copiedFiles: v2Files.length,
294
+ skillsCopied,
295
+ skillsInjected,
296
+ mcp: {
297
+ servers,
298
+ // Names the caller explicitly added — the CLI waits for these to
299
+ // settle before sending the prompt. Inherited servers are best-effort.
300
+ added: mcpSpecs.flatMap((spec) => Object.keys(spec)),
301
+ },
302
+ };
225
303
  }
226
304
 
227
- // --- public API -------------------------------------------------------------
305
+ // --- migration ----------------------------------------------------------------
228
306
 
229
- function rootDir(env = process.env) {
230
- return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode');
231
- }
307
+ // The only names pre-0.4 versions created at a root. migrateRoot moves
308
+ // exactly these out of legacy locations and never touches anything else.
309
+ const OUR_ARTIFACTS = ['credentials.json', 'build-stamp.json', 'injected-skills.json', 'home'];
232
310
 
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 } = {}) {
311
+ // 0.3.2 migration: vacate our artifacts from (a) the live ~/.zcode, where
312
+ // 0.3.0/0.3.1 wrongly wrote them, and (b) the pre-rename ~/.zai-code root,
313
+ // into the ~/.zcode-cli root. Idempotent; never writes or deletes anything
314
+ // else in the live directory.
315
+ function migrateRoot(env = process.env) {
236
316
  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)) {
317
+ try {
318
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
319
+ } catch {
320
+ return; // unwritable home — callers surface it
321
+ }
322
+ const sources = [liveDir(env), path.join(os.homedir(), '.zai-code')];
323
+ for (const dir of sources) {
324
+ let entries;
251
325
  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
- }
326
+ entries = new Set(fs.readdirSync(dir));
269
327
  } catch {
270
- /* corrupt stamp — rebuild */
328
+ continue;
329
+ }
330
+ for (const name of OUR_ARTIFACTS) {
331
+ if (!entries.has(name)) continue;
332
+ const src = path.join(dir, name);
333
+ const dest = path.join(root, name);
334
+ try {
335
+ if (fs.existsSync(dest)) fs.rmSync(src, { recursive: true, force: true });
336
+ else fs.renameSync(src, dest);
337
+ } catch {
338
+ /* keep going; a missing artifact just rebuilds */
339
+ }
271
340
  }
272
341
  }
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
342
  }
286
343
 
287
344
  // 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.
345
+ // config, and the uncontrolled overrides deleted. cwd is set by the caller.
290
346
  function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
291
347
  const childEnv = { ...parentEnv };
292
348
  childEnv.HOME = home;
@@ -304,10 +360,14 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
304
360
  }
305
361
 
306
362
  module.exports = {
307
- ensureIsolation,
363
+ buildEngineHome,
308
364
  engineEnv,
309
365
  rootDir,
310
- keySha256,
366
+ liveDir,
367
+ migrateRoot,
368
+ effectiveMcpServers,
369
+ liveMcpServers,
370
+ discoverSkillDirs,
311
371
  LIVE_ZCODE,
312
372
  LIVE_V2,
313
373
  };
package/lib/key.js CHANGED
@@ -1,15 +1,11 @@
1
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.
2
+ // API key resolution (DESIGN §5): ZAI_API_KEY env →
3
+ // ~/.zcode-cli/credentials.json ({"apiKey":"…"}, mode 600, written by
4
+ // `zcode login`). The key is never printed, logged, or echoed by anything
5
+ // in this package.
5
6
  const fs = require('node:fs');
6
7
  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
- }
8
+ const { migrateRoot, rootDir } = require('./isolation.js');
13
9
 
14
10
  function credentialsPath(env = process.env) {
15
11
  return path.join(rootDir(env), 'credentials.json');
@@ -41,6 +37,7 @@ function readCredentialsKey(file) {
41
37
  // Sources, in order: ZAI_API_KEY (non-empty) → credentials.json.
42
38
  // Throws NoKeyError when neither holds a key.
43
39
  function resolveKey(env = process.env) {
40
+ migrateRoot(env); // 0.3.2: vacate our artifacts out of the live ~/.zcode first
44
41
  const fromEnv = (env.ZAI_API_KEY || '').trim();
45
42
  if (fromEnv) return fromEnv;
46
43
  const fromFile = readCredentialsKey(credentialsPath(env));