@zenera/cli 1.1.0 → 1.1.3

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.
Files changed (61) hide show
  1. package/README.md +88 -11
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.js +14 -22
  4. package/dist/commands/check.js +79 -19
  5. package/dist/commands/init.js +71 -11
  6. package/dist/commands/key.js +126 -36
  7. package/dist/commands/models.js +3 -3
  8. package/dist/commands/open.js +2 -2
  9. package/dist/commands/run.js +3 -0
  10. package/dist/commands/sandbox.js +226 -22
  11. package/dist/engine.d.ts +3 -1
  12. package/dist/engine.js +10 -2
  13. package/dist/image.d.ts +16 -0
  14. package/dist/image.js +85 -0
  15. package/dist/keys.d.ts +95 -12
  16. package/dist/keys.js +175 -34
  17. package/dist/lib.d.ts +2 -2
  18. package/dist/lib.js +2 -2
  19. package/dist/liveness.d.ts +16 -6
  20. package/dist/liveness.js +74 -23
  21. package/dist/main.js +0 -0
  22. package/dist/podman.d.ts +57 -1
  23. package/dist/podman.js +177 -12
  24. package/dist/projects.d.ts +18 -0
  25. package/dist/projects.js +60 -1
  26. package/dist/sandbox.d.ts +14 -1
  27. package/dist/sandbox.js +88 -8
  28. package/dist/scaffold.d.ts +21 -15
  29. package/dist/scaffold.js +133 -167
  30. package/dist/term.d.ts +2 -0
  31. package/dist/term.js +14 -0
  32. package/dist/validate.d.ts +20 -3
  33. package/dist/validate.js +309 -14
  34. package/package.json +2 -18
  35. package/templates/{.github → editor/.github}/copilot-instructions.md +161 -48
  36. package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +13 -6
  37. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  38. package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
  39. package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  41. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  42. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  43. package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
  44. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  45. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  47. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  48. package/templates/editor/.vscode/settings.json +6 -0
  49. package/templates/parts/exa.yaml.tmpl +5 -0
  50. package/templates/parts/model.yaml.tmpl +4 -0
  51. package/templates/parts/models.yaml.tmpl +10 -0
  52. package/templates/project/INSTRUCTIONS.md +7 -0
  53. package/templates/project/SPECIFICATION.md +6 -0
  54. package/templates/project/agents/prompts/default.md +15 -0
  55. package/templates/project/agents.yaml.tmpl +44 -0
  56. package/templates/project/assets/README.md +12 -0
  57. package/templates/project/gitignore +9 -0
  58. package/templates/project/sandbox/Dockerfile +21 -0
  59. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  60. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  61. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/podman.js CHANGED
@@ -1,10 +1,14 @@
1
- import { platform } from 'node:os';
2
1
  import { runProcess, SandboxError } from '@zenera/neo';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { homedir, platform } from 'node:os';
4
+ import { join } from 'node:path';
3
5
  import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
4
6
  const DEFAULT_MACHINE_CPUS = 2;
5
7
  const DEFAULT_MACHINE_MEMORY = 2048;
6
8
  /** Starting a virtual machine and pulling an image are both slow on purpose. */
7
9
  const SLOW_MS = 600_000;
10
+ /** ...and a build from a cold cache is slower than either. */
11
+ const BUILD_MS = 900_000;
8
12
  // One process asks once. Several agents starting containers in the same run
9
13
  // must not each decide to boot a virtual machine.
10
14
  const settled = new Map();
@@ -62,7 +66,10 @@ async function preflight(opts) {
62
66
  }
63
67
  // 4. The image, so the first command is not a five-minute pull that looks
64
68
  // like a hung model.
65
- if (opts.image) {
69
+ if (opts.build) {
70
+ await build(engine, opts.build, run, opts.rebuild);
71
+ }
72
+ else if (opts.image) {
66
73
  const present = await call(['image', 'exists', opts.image], 30_000);
67
74
  if (present.code !== 0) {
68
75
  note(dim(`pulling ${opts.image} — this happens once`));
@@ -158,6 +165,52 @@ function parseMachines(stdout) {
158
165
  }
159
166
  }
160
167
  // ---------------------------------------------------------------------------
168
+ // The image
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Builds the project's Dockerfile under its tag.
172
+ *
173
+ * Run every time rather than skipped when the tag already exists, because the
174
+ * tag is a hash of the Dockerfile and its context and the engine's layer cache
175
+ * is a hash of the same thing plus the base image. Asking it is cheap, and it
176
+ * is the only thing that notices when `FROM node:24` starts meaning a different
177
+ * node:24.
178
+ */
179
+ /**
180
+ * A build that ran and failed, rather than a host that could not be asked.
181
+ *
182
+ * The difference is invisible at the command line — both stop the run and
183
+ * print why — but it decides everything for `zen check`: a Dockerfile that
184
+ * does not build is a broken project, and a laptop without podman on it is
185
+ * not. Only one of them is an error.
186
+ */
187
+ export class BuildError extends CliError {
188
+ }
189
+ /**
190
+ * Builds the project's Dockerfile under its tag, unless that tag is already on
191
+ * disk.
192
+ *
193
+ * Skipping is safe here in a way it would not be for an ordinary tag, because
194
+ * this one is a hash of the Dockerfile and its context: the image existing
195
+ * *means* the content is unchanged. What it does not cover is a moved base
196
+ * image — `podman build` defaults to `--pull=missing` and reuses whatever
197
+ * `FROM node:24` resolved to last time — so `zen sandbox pull` forces the
198
+ * build, and `--pull` is where that would be fixed if it ever needs to be.
199
+ */
200
+ async function build(engine, spec, run, force = false) {
201
+ if (!force) {
202
+ const present = await run(engine, ['image', 'exists', spec.tag], { timeoutMs: 30_000 });
203
+ if (present.code === 0) {
204
+ return;
205
+ }
206
+ }
207
+ note(dim(`building ${spec.tag} from ${spec.dockerfile}`));
208
+ const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
209
+ if (built.code !== 0) {
210
+ throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
161
214
  // Reporting
162
215
  // ---------------------------------------------------------------------------
163
216
  /** What `zn sandbox status` prints. Changes nothing, and never throws. */
@@ -210,19 +263,41 @@ function safeMachines(stdout) {
210
263
  * *stopped* container behind, and a listing that only showed running ones
211
264
  * would say nothing is there while the disk says otherwise.
212
265
  */
213
- export async function ownedContainers(engine = 'podman', exec = runProcess) {
214
- const res = await exec(engine, ['ps', '--all', '--filter', 'label=zenera=1', '--format', '{{.Names}}\t{{.State}}'], { timeoutMs: 30_000 }).catch(() => undefined);
266
+ export async function ownedContainers(engine = 'podman', exec = runProcess, opts = {}) {
267
+ const args = ['ps', '--all', '--filter', 'label=zenera=1', '--format', 'json'];
268
+ // Asked for by name only: podman works a size out by diffing the layer,
269
+ // which costs more than everything else `status` does put together.
270
+ if (opts.sizes) {
271
+ args.push('--size');
272
+ }
273
+ const res = await exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
215
274
  if (!res || res.code !== 0) {
216
275
  return [];
217
276
  }
218
- return res.stdout
219
- .split('\n')
220
- .map((l) => l.trim())
221
- .filter(Boolean)
222
- .map((line) => {
223
- const [name, state] = line.split('\t');
224
- return { name, state: state?.trim() || 'unknown' };
225
- });
277
+ return parseContainers(res.stdout);
278
+ }
279
+ /** Newest first, which is the order someone reading a list of them wants. */
280
+ function parseContainers(stdout) {
281
+ let raw;
282
+ try {
283
+ raw = JSON.parse(stdout.trim() || '[]');
284
+ }
285
+ catch {
286
+ return [];
287
+ }
288
+ if (!Array.isArray(raw)) {
289
+ return [];
290
+ }
291
+ return raw
292
+ .map((c) => ({
293
+ name: c.Names?.[0] ?? '',
294
+ state: c.State?.trim() || 'unknown',
295
+ key: c.Labels?.['zenera.key'],
296
+ createdAt: c.Created ? new Date(c.Created * 1000).toISOString() : undefined,
297
+ size: c.Size?.rwSize,
298
+ }))
299
+ .filter((c) => c.name)
300
+ .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
226
301
  }
227
302
  export async function removeContainers(names, engine = 'podman', exec = runProcess) {
228
303
  if (names.length === 0) {
@@ -230,6 +305,91 @@ export async function removeContainers(names, engine = 'podman', exec = runProce
230
305
  }
231
306
  await exec(engine, ['rm', '--force', '--volumes', ...names], { timeoutMs: 120_000 });
232
307
  }
308
+ /** What the engine is using. Never throws: a disk report is not worth a crash. */
309
+ export async function engineDisk(engine = 'podman', exec = runProcess) {
310
+ const call = (args) => exec(engine, args, { timeoutMs: 120_000 }).catch(() => undefined);
311
+ const res = await call(['system', 'df', '--format', 'json']);
312
+ if (!res || res.code !== 0) {
313
+ return undefined;
314
+ }
315
+ let raw;
316
+ try {
317
+ raw = JSON.parse(res.stdout.trim() || '[]');
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ const rows = Array.isArray(raw) ? raw : [];
323
+ const pick = (type) => {
324
+ const row = rows.find((r) => r.Type === type);
325
+ return {
326
+ count: row?.TotalCount ?? row?.Total ?? 0,
327
+ active: row?.Active ?? 0,
328
+ size: row?.RawSize ?? 0,
329
+ reclaimable: row?.RawReclaimable ?? 0,
330
+ };
331
+ };
332
+ const info = await call([
333
+ 'info',
334
+ '--format',
335
+ '{{.Store.GraphRootUsed}}\t{{.Store.GraphRootAllocated}}',
336
+ ]);
337
+ const [used, capacity] = (info?.code === 0 ? info.stdout.trim() : '').split('\t').map(Number);
338
+ return {
339
+ images: pick('Images'),
340
+ containers: pick('Containers'),
341
+ volumes: pick('Local Volumes'),
342
+ store: used > 0 && capacity > 0 ? { used, capacity } : undefined,
343
+ image: platform() === 'linux' ? undefined : await machineImage(engine, exec),
344
+ };
345
+ }
346
+ /**
347
+ * What the machine costs the host, which is not what it says it costs: the
348
+ * disk image is created sparse at its full size, so only its allocated blocks
349
+ * are real, and blocks freed inside the machine are not handed back until
350
+ * something trims them. That is why this can exceed the machine's own `used`.
351
+ *
352
+ * The path is not something podman will tell us — `machine inspect` stopped
353
+ * carrying it — so this is the documented default location and nothing is
354
+ * reported when the file is not there.
355
+ */
356
+ async function machineImage(engine, exec) {
357
+ const listed = await exec(engine, ['machine', 'list', '--format', 'json'], {
358
+ timeoutMs: 30_000,
359
+ }).catch(() => undefined);
360
+ if (!listed || listed.code !== 0) {
361
+ return undefined;
362
+ }
363
+ const machines = safeMachines(listed.stdout);
364
+ const name = (machines.find((m) => m.Default) ?? machines[0])?.Name;
365
+ if (!name) {
366
+ return undefined;
367
+ }
368
+ const base = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'containers', 'podman', 'machine');
369
+ for (const provider of children(base)) {
370
+ for (const file of children(join(base, provider))) {
371
+ if (!file.startsWith(name) || !/\.(raw|qcow2|img)$/.test(file)) {
372
+ continue;
373
+ }
374
+ const path = join(base, provider, file);
375
+ try {
376
+ return { name, path, allocated: statSync(path).blocks * 512 };
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ }
382
+ }
383
+ return undefined;
384
+ }
385
+ function children(dir) {
386
+ try {
387
+ return readdirSync(dir);
388
+ }
389
+ catch {
390
+ return [];
391
+ }
392
+ }
233
393
  // ---------------------------------------------------------------------------
234
394
  /**
235
395
  * Long steps print as they go. A five-minute pull with no output is
@@ -248,6 +408,11 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
248
408
  function first(res) {
249
409
  return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
250
410
  }
411
+ /** A build says what went wrong on its last line, not its first. */
412
+ function last(res) {
413
+ const lines = (res.stderr.trim() || res.stdout.trim()).split('\n').filter((l) => l.trim());
414
+ return lines.slice(-2).join(' — ');
415
+ }
251
416
  function sandboxError(message, hint) {
252
417
  return new CliError(message, EXIT.sandbox, hint);
253
418
  }
@@ -1,3 +1,4 @@
1
+ import { type ProjectConfig, type SandboxMount } from '@zenera/neo';
1
2
  /** Whether a directory is a project: whether the loader has something to read. */
2
3
  export declare function isProjectDir(dir: string): boolean;
3
4
  /** A project resolved on disk. */
@@ -6,6 +7,16 @@ export interface Project {
6
7
  /** what it is called: the registry's name, or the directory's own */
7
8
  name: string;
8
9
  }
10
+ /**
11
+ * The trees a run mounts besides the workspace itself.
12
+ *
13
+ * One array, handed to the file tools and to the container both, because the
14
+ * two have to agree on the name: a path `run_command` prints has to be a path
15
+ * `read_file` takes. Everything here is read-only — material an agent consults
16
+ * and does not edit — and the paths are resolved, because the podman machine on
17
+ * macOS shares the real path or nothing.
18
+ */
19
+ export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
9
20
  /** Walks up from `start` looking for a project configuration. */
10
21
  export declare function findUp(start: string): Promise<Project | undefined>;
11
22
  export declare function openDir(dir: string): Promise<Project>;
@@ -59,6 +70,13 @@ export declare const sessionsDir: (projectDir: string) => string;
59
70
  /** Session ids, oldest first. Only well-formed stamps count as sessions. */
60
71
  export declare function sessionIds(projectDir: string): string[];
61
72
  export declare function runIds(sessionDir: string): string[];
73
+ /**
74
+ * What a tree occupies, in allocated blocks rather than in bytes, because a
75
+ * sparse file costs what it was given and not what it claims. Symlinks are
76
+ * counted and never followed: a link out of the tree is not part of it, and
77
+ * one that points back in would otherwise be counted twice — or forever.
78
+ */
79
+ export declare function dirSize(dir: string): number;
62
80
  /** True when a session's lock names a process that is still alive. */
63
81
  export declare function isBusy(sessionDir: string): boolean;
64
82
  /**
package/dist/projects.js CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { ASSETS_MOUNT, assetsDir, skillDirs, skillMounts, SKILLS_MOUNT, } from '@zenera/neo';
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
3
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
4
  import { paths, readJson, writeJson } from "./home.js";
4
5
  import { isStamp } from "./ids.js";
@@ -25,6 +26,29 @@ const CONFIG_NAMES = ['agents.yaml', 'agents.yml', 'agents/agents.yaml', 'agents
25
26
  export function isProjectDir(dir) {
26
27
  return CONFIG_NAMES.some((name) => existsSync(join(dir, name)));
27
28
  }
29
+ /**
30
+ * The trees a run mounts besides the workspace itself.
31
+ *
32
+ * One array, handed to the file tools and to the container both, because the
33
+ * two have to agree on the name: a path `run_command` prints has to be a path
34
+ * `read_file` takes. Everything here is read-only — material an agent consults
35
+ * and does not edit — and the paths are resolved, because the podman machine on
36
+ * macOS shares the real path or nothing.
37
+ */
38
+ export function projectMounts(root, config) {
39
+ const mounts = [];
40
+ const assets = assetsDir(root, config);
41
+ if (assets) {
42
+ mounts.push({ host: realpathSync(assets), at: ASSETS_MOUNT, readOnly: true });
43
+ }
44
+ // The catalog goes in whole, at fixed names, before anything is loaded: a
45
+ // container's mounts are decided when it is created, so a skill folder
46
+ // cannot be added at the moment `skill_load` asks for it.
47
+ for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
48
+ mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
49
+ }
50
+ return mounts;
51
+ }
28
52
  // ---------------------------------------------------------------------------
29
53
  // The project directory
30
54
  // ---------------------------------------------------------------------------
@@ -201,6 +225,41 @@ function stampedChildren(dir) {
201
225
  .map((e) => e.name)
202
226
  .sort();
203
227
  }
228
+ /**
229
+ * What a tree occupies, in allocated blocks rather than in bytes, because a
230
+ * sparse file costs what it was given and not what it claims. Symlinks are
231
+ * counted and never followed: a link out of the tree is not part of it, and
232
+ * one that points back in would otherwise be counted twice — or forever.
233
+ */
234
+ export function dirSize(dir) {
235
+ let total = 0;
236
+ const stack = [dir];
237
+ while (stack.length > 0) {
238
+ const at = stack.pop();
239
+ let entries;
240
+ try {
241
+ entries = readdirSync(at, { withFileTypes: true });
242
+ }
243
+ catch {
244
+ continue;
245
+ }
246
+ for (const entry of entries) {
247
+ const path = join(at, entry.name);
248
+ if (entry.isDirectory()) {
249
+ stack.push(path);
250
+ continue;
251
+ }
252
+ try {
253
+ total += lstatSync(path).blocks * 512;
254
+ }
255
+ catch {
256
+ // Gone between the listing and the stat: a live session is
257
+ // writing, which is normal and not worth failing a report for.
258
+ }
259
+ }
260
+ }
261
+ return total;
262
+ }
204
263
  /** True when a session's lock names a process that is still alive. */
205
264
  export function isBusy(sessionDir) {
206
265
  const path = join(sessionDir, '.lock');
package/dist/sandbox.d.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxSpec } from '@zenera/neo';
1
+ import { SandboxPool, type AgentProject, type ProjectConfig, type SandboxMount, type SandboxSpec } from '@zenera/neo';
2
+ import { type ResolvedBuild } from './image.ts';
2
3
  import type { SessionPaths } from './session.ts';
3
4
  export interface SandboxSetup {
4
5
  pool: SandboxPool;
5
6
  /** the resolved base spec, for `zn sandbox status` and the image pre-warm */
6
7
  spec: SandboxSpec;
7
8
  image: string;
9
+ /** the Dockerfile behind `image`, when it came from one */
10
+ build?: ResolvedBuild;
8
11
  /** host side of the container's `$HOME`, created only if it is ever needed */
9
12
  home: string;
10
13
  }
@@ -12,9 +15,19 @@ export interface SandboxInputs {
12
15
  config: ProjectConfig;
13
16
  session: SessionPaths;
14
17
  workspace: string;
18
+ /** the project directory, which `build:` paths are relative to */
19
+ root: string;
15
20
  readOnly?: boolean;
21
+ /**
22
+ * Trees to mount besides the workspace and the home — the project's assets
23
+ * and its skill catalog. The same array goes to the file tools, so a path
24
+ * a command prints is a path `read_file` accepts.
25
+ */
26
+ mounts?: readonly SandboxMount[];
16
27
  /** `--image` */
17
28
  image?: string;
29
+ /** `--no-keys`: refuse to forward credentials, whatever the config says */
30
+ keys?: boolean;
18
31
  }
19
32
  export declare function buildSandbox(opts: SandboxInputs): SandboxSetup;
20
33
  /**
package/dist/sandbox.js CHANGED
@@ -1,6 +1,8 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { resolveBuild } from "./image.js";
5
+ import { credentials } from "./keys.js";
4
6
  import { ensurePodmanReady } from "./podman.js";
5
7
  import { warn } from "./term.js";
6
8
  // ---------------------------------------------------------------------------
@@ -22,15 +24,75 @@ import { warn } from "./term.js";
22
24
  // ---------------------------------------------------------------------------
23
25
  /** Where the persistent home lives, inside the container. */
24
26
  const HOME = '/home/agent';
27
+ /** Where a credential *file* is mounted, inside the container. */
28
+ const KEY_MOUNT = '/run/zenera/keys';
29
+ /**
30
+ * The credentials the run is using, in the form a container takes.
31
+ *
32
+ * Two shapes, two mechanisms. A secret is forwarded by *name* — podman reads
33
+ * the value from its own environment, so nothing is written into an argv that
34
+ * `ps` will show. A service-account file has to be present as a file, so the
35
+ * one file is bind-mounted read-only and the variable is rewritten to where it
36
+ * landed; mounting the directory it came from would drag in whatever else the
37
+ * user keeps beside it.
38
+ *
39
+ * This is the part of the design that gives something away, and it should be
40
+ * said plainly: the sandbox runs code the model wrote, and a key it can read
41
+ * is a key it can send. `keys: false`, or `network: none`, is the answer for a
42
+ * project where that is not an acceptable trade.
43
+ */
44
+ function forwarded() {
45
+ const secrets = [];
46
+ const env = {};
47
+ const mounts = [];
48
+ for (const cred of credentials()) {
49
+ if (cred.holds === 'file') {
50
+ const at = `${KEY_MOUNT}/${basename(cred.value)}`;
51
+ mounts.push({ host: cred.value, at, readOnly: true });
52
+ env[cred.env] = at;
53
+ continue;
54
+ }
55
+ secrets.push(cred.env);
56
+ }
57
+ // Not secrets, and useless without one: a service account says which
58
+ // project it belongs to but never which region to call.
59
+ for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION']) {
60
+ const value = process.env[name];
61
+ if (value) {
62
+ env[name] = value;
63
+ }
64
+ }
65
+ return { secrets, env, mounts };
66
+ }
25
67
  export function buildSandbox(opts) {
26
- const base = { ...(opts.config.sandbox ?? {}), ...(opts.image ? { image: opts.image } : {}) };
68
+ // An explicit --image is an answer, so there is nothing left to build.
69
+ const build = opts.image ? undefined : resolveBuild(opts.root, opts.config.sandbox);
70
+ const base = {
71
+ ...(opts.config.sandbox ?? {}),
72
+ ...((opts.image ?? build?.tag) ? { image: opts.image ?? build?.tag } : {}),
73
+ ...(opts.keys === false ? { keys: false } : {}),
74
+ };
27
75
  const home = join(opts.session.data, 'sandbox', 'home');
28
- const mounts = [{ host: home, at: HOME }];
29
- const spec = toSpec(base, { HOME });
76
+ const mounts = [{ host: home, at: HOME }, ...(opts.mounts ?? [])];
77
+ const keys = base.keys === false ? undefined : forwarded();
78
+ if (keys) {
79
+ mounts.push(...keys.mounts);
80
+ }
81
+ // Skills and assets are mounted read-only, and a python script run from a
82
+ // read-only directory fails on writing its own `__pycache__` — a confusing
83
+ // error about a file nobody asked for.
84
+ const extra = { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(keys?.env ?? {}) };
85
+ const spec = toSpec(base, extra, keys?.secrets);
30
86
  const agents = {};
31
87
  for (const agent of opts.config.agents) {
32
88
  if (agent.sandbox) {
33
- agents[agent.name] = toSpec({ ...base, ...agent.sandbox }, { HOME });
89
+ const merged = merge(base, agent.sandbox);
90
+ // An agent that opts out gets neither the variables nor the mount,
91
+ // but the mount is on the pool and cannot be taken back per agent —
92
+ // so the variable is what actually decides, and without it the file
93
+ // sitting there is not a credential anything will look for.
94
+ const own = merged.keys === false ? undefined : keys;
95
+ agents[agent.name] = toSpec(merged, { HOME, PYTHONDONTWRITEBYTECODE: '1', ...(own?.env ?? {}) }, own?.secrets);
34
96
  }
35
97
  }
36
98
  const pool = new SandboxPool({
@@ -41,14 +103,30 @@ export function buildSandbox(opts) {
41
103
  readOnly: opts.readOnly,
42
104
  mounts,
43
105
  });
44
- return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, home };
106
+ return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
107
+ }
108
+ /**
109
+ * An agent's overrides on the project's block. `image` and `build` answer the
110
+ * same question, so naming either one drops the other: a plain spread would
111
+ * leave an agent's `image` sitting next to the project's `build`, and the
112
+ * schema forbids exactly that combination when it is written down.
113
+ */
114
+ function merge(base, agent) {
115
+ const merged = { ...base, ...agent };
116
+ if (agent.image !== undefined) {
117
+ delete merged.build;
118
+ }
119
+ if (agent.build !== undefined) {
120
+ delete merged.image;
121
+ }
122
+ return merged;
45
123
  }
46
124
  /**
47
125
  * Config names a variable; this reads it. A name that is not set on this host
48
126
  * is simply not forwarded — an empty string in the container is a different
49
127
  * thing from an absent one, and tools test for absence.
50
128
  */
51
- function toSpec(config, extra) {
129
+ function toSpec(config, extra, secrets) {
52
130
  const env = { ...extra };
53
131
  for (const name of config.env ?? []) {
54
132
  const value = process.env[name];
@@ -66,6 +144,7 @@ function toSpec(config, extra) {
66
144
  user: config.user,
67
145
  persist: config.persist,
68
146
  env,
147
+ secrets,
69
148
  };
70
149
  }
71
150
  /**
@@ -87,6 +166,7 @@ export async function preflight(setup, yes) {
87
166
  mkdirSync(setup.home, { recursive: true });
88
167
  await ensurePodmanReady({
89
168
  image: setup.image,
169
+ build: setup.build,
90
170
  cpus: setup.spec.cpus,
91
171
  memory: setup.spec.memory,
92
172
  yes,
@@ -1,16 +1,11 @@
1
1
  /**
2
- * Writes `.vscode/settings.json` under `dir`, replacing what is there. The file
3
- * is ours: it says how the editor is to treat a directory the agents write
4
- * into, and a stale copy of that answer is worse than none. Returns the
5
- * relative path.
2
+ * Writes the editor's files under `dir`, replacing what is there. They are
3
+ * ours: they say how the editor is to treat a directory the agents write into,
4
+ * and they describe the file formats of the version of `zen` in hand, so the
5
+ * current answer is the only one worth having and a stale one is worse than
6
+ * none. Returns the relative paths written.
6
7
  */
7
- export declare function editorSettings(dir: string): string;
8
- /**
9
- * Writes the `.github/` tree under `dir`, replacing what is there — it
10
- * describes the file formats of the version of `zen` in hand, so the current
11
- * one is the only one worth having. Returns the relative paths written.
12
- */
13
- export declare function copilotInstructions(dir: string): string[];
8
+ export declare function editorFiles(dir: string): string[];
14
9
  export interface ScaffoldOptions {
15
10
  /** the project directory */
16
11
  dir: string;
@@ -20,10 +15,21 @@ export interface ScaffoldOptions {
20
15
  /** give the default agent `exa:*` — set when a key for it is on hand */
21
16
  web?: boolean;
22
17
  }
18
+ export interface Scaffolded {
19
+ /** the project's own files, in the order they were written */
20
+ files: string[];
21
+ /** `.vscode/` and `.github/` — written alongside, and nobody's to edit */
22
+ editor: string[];
23
+ }
23
24
  /**
24
- * Writes a project. Never overwrites the project's own files: the caller
25
- * decides whether it may. The editor files are the exceptionthey are ours,
26
- * and are replaced.
25
+ * Writes a project. Never overwrites the project's own files a second `init`
26
+ * over a directory fills in what is missing and leaves the rest alone but the
27
+ * editor files are ours, and are replaced.
28
+ *
29
+ * The two are returned apart because they are read differently: the project's
30
+ * files are the thing that was just made, and worth listing; the editor's are
31
+ * plumbing for a tool that may not even be installed, and listing them buries
32
+ * the first set under twice as many lines about the second.
27
33
  */
28
- export declare function scaffold(opts: ScaffoldOptions): string[];
34
+ export declare function scaffold(opts: ScaffoldOptions): Scaffolded;
29
35
  //# sourceMappingURL=scaffold.d.ts.map