@zenera/cli 1.1.10 → 1.1.12

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 (65) hide show
  1. package/README.md +69 -15
  2. package/dist/banner.js +69 -47
  3. package/dist/cache.d.ts +98 -0
  4. package/dist/cache.js +301 -0
  5. package/dist/catalog.d.ts +3 -0
  6. package/dist/catalog.js +35 -11
  7. package/dist/commands/cache.d.ts +7 -0
  8. package/dist/commands/cache.js +245 -0
  9. package/dist/commands/check.js +6 -3
  10. package/dist/commands/index.js +5 -0
  11. package/dist/commands/init.js +8 -4
  12. package/dist/commands/key.js +68 -14
  13. package/dist/commands/memory.d.ts +3 -0
  14. package/dist/commands/memory.js +375 -0
  15. package/dist/commands/models.js +17 -1
  16. package/dist/commands/run.js +11 -3
  17. package/dist/commands/sandbox.js +70 -23
  18. package/dist/engine.js +14 -9
  19. package/dist/home.d.ts +2 -2
  20. package/dist/home.js +2 -2
  21. package/dist/keys.d.ts +22 -0
  22. package/dist/keys.js +105 -2
  23. package/dist/lib.d.ts +1 -0
  24. package/dist/lib.js +1 -0
  25. package/dist/liveness.js +11 -0
  26. package/dist/main.js +9 -0
  27. package/dist/podman.js +43 -9
  28. package/dist/projects.js +10 -2
  29. package/dist/resolve.d.ts +4 -0
  30. package/dist/resolve.js +43 -17
  31. package/dist/scaffold.js +22 -1
  32. package/dist/term.d.ts +23 -2
  33. package/dist/term.js +215 -8
  34. package/dist/tui/app.d.ts +10 -0
  35. package/dist/tui/app.js +478 -54
  36. package/dist/tui/theme.d.ts +13 -2
  37. package/dist/tui/theme.js +26 -13
  38. package/dist/tui/wrap.d.ts +92 -0
  39. package/dist/tui/wrap.js +147 -2
  40. package/dist/validate.d.ts +2 -0
  41. package/dist/validate.js +87 -2
  42. package/package.json +2 -2
  43. package/templates/editor/.github/copilot-instructions.md +200 -55
  44. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  45. package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
  46. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
  47. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
  48. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  49. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
  51. package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
  52. package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
  53. package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
  54. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  55. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +4 -4
  56. package/templates/editor/.vscode/settings.json +4 -2
  57. package/templates/parts/exa.prompt.md.tmpl +4 -0
  58. package/templates/parts/exa.spec.md.tmpl +2 -0
  59. package/templates/project/INSTRUCTIONS.md +8 -0
  60. package/templates/project/SPECIFICATION.md.tmpl +47 -0
  61. package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
  62. package/templates/project/agents.yaml.tmpl +4 -0
  63. package/templates/project/gitignore +7 -1
  64. package/templates/project/scripts/_setup.sh +101 -0
  65. package/templates/project/SPECIFICATION.md +0 -6
@@ -104,13 +104,14 @@ export const run = {
104
104
  await start(engine, {
105
105
  readOnly: Boolean(values['read-only']),
106
106
  theme: values.theme,
107
+ started: { created: where.created, freshWorkspace: where.freshWorkspace },
107
108
  });
108
109
  return;
109
110
  }
110
111
  if (!prompt) {
111
112
  throw usageError('nothing to ask', 'give a prompt, or pipe one in');
112
113
  }
113
- await once(engine, prompt, values, ctx.json, ctx.cwd);
114
+ await once(engine, prompt, values, ctx.json, ctx.cwd, where);
114
115
  }
115
116
  finally {
116
117
  await engine.close();
@@ -120,7 +121,7 @@ export const run = {
120
121
  // ---------------------------------------------------------------------------
121
122
  // One shot
122
123
  // ---------------------------------------------------------------------------
123
- async function once(engine, prompt, values, asJson, cwd) {
124
+ async function once(engine, prompt, values, asJson, cwd, where) {
124
125
  const narrator = new Narrator({
125
126
  quiet: Boolean(values.quiet) || asJson,
126
127
  live: Boolean(process.stderr.isTTY),
@@ -131,7 +132,14 @@ async function once(engine, prompt, values, asJson, cwd) {
131
132
  const onInterrupt = () => stopping.abort();
132
133
  process.once('SIGINT', onInterrupt);
133
134
  if (!values.quiet && !asJson) {
134
- note(`${bold(engine.name)} ${dim(engine.session.id)} ` + dim(display(engine.workspace, cwd)));
135
+ // Two questions were just answered, possibly without being asked. Say
136
+ // which way they went: a run that quietly resumed the wrong session, or
137
+ // wrote into the directory you were standing in, is only explainable
138
+ // afterwards, and this is the one line where it is cheap to say.
139
+ note(`${bold(engine.name)} ${dim(where.created ? 'new session' : 'continuing')} ` +
140
+ `${dim(engine.session.id)}`);
141
+ note(dim(`${where.freshWorkspace ? 'new directory' : 'workspace'} ` +
142
+ `${display(engine.workspace, cwd)}`));
135
143
  }
136
144
  let outcome;
137
145
  try {
@@ -2,8 +2,7 @@ import { readProjectConfig } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
3
  import { resolveBuild } from "../image.js";
4
4
  import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
5
- import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
6
- import { project as findProject } from "../resolve.js";
5
+ import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
7
6
  import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
8
7
  const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
9
8
  /** Enough to see the pattern; the rest are a number. */
@@ -29,9 +28,13 @@ export const sandbox = {
29
28
  ' clean Remove every container this CLI created.',
30
29
  ' disk What the engine and every known project occupy.',
31
30
  '',
32
- ' --project <name|dir> Which project the image comes from.',
31
+ ' --project <name|dir> Which project the image and containers belong to.',
33
32
  ' --image <ref> Use this image instead of the project\u2019s.',
34
33
  '',
34
+ 'Without --project this is the project you are standing in, and standing',
35
+ 'nowhere is an answer: `status` then reports the engine and every container',
36
+ 'on the machine, since that is all there is to say.',
37
+ '',
35
38
  'None of this is required. A run does all of it on its own, the first',
36
39
  'time an agent that can reach a shell is about to start one.',
37
40
  ],
@@ -47,18 +50,21 @@ export const sandbox = {
47
50
  if (positionals.length > 1) {
48
51
  throw usageError('one subcommand at a time', USAGE);
49
52
  }
50
- // `clean` and `disk` are machine-wide questions, so asking which
51
- // project they mean would be asking something they do not use.
53
+ // `clean` and `disk` are machine-wide questions, so looking for a
54
+ // project they do not use would be reading something for nothing.
52
55
  const scoped = what === 'status' || what === 'up' || what === 'pull';
53
- const found = values.image || !scoped ? undefined : await projectSandbox(ctx.cwd, values);
56
+ const found = scoped ? await projectSandbox(ctx.cwd, values) : undefined;
54
57
  const image = values.image ?? found?.image;
55
- const build = found?.build;
58
+ const build = values.image ? undefined : found?.build;
56
59
  switch (what) {
57
60
  case 'status':
58
- return status(image, build, ctx.json);
61
+ return status(found, image, ctx.json);
59
62
  case 'up':
60
63
  return up(image, build, ctx.json, ctx.json);
61
64
  case 'pull':
65
+ if (!image) {
66
+ throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
67
+ }
62
68
  return up(image, build, true, ctx.json, true);
63
69
  case 'clean':
64
70
  return clean(ctx.json);
@@ -68,27 +74,51 @@ export const sandbox = {
68
74
  },
69
75
  };
70
76
  /**
71
- * The project's image, when there is a project to ask. `zen sandbox status`
72
- * run from anywhere at all is still a useful thing, so failing to find one is
73
- * not a failure — it just means there is no image to report on. Notably this
74
- * does *not* go through `target`: reading a setting must not create a session.
77
+ * Which project this is about: the flag, or the directory the command was run
78
+ * in. It deliberately never asks. `zen sandbox status` run from anywhere at
79
+ * all is a useful thing, so standing outside every project is an answer — the
80
+ * engine is still there to report on — and a question with a list of every
81
+ * project on the machine is not one a *reading* has any business asking.
82
+ *
83
+ * Notably it also does not go through `target`: reading a setting must not
84
+ * create a session.
75
85
  */
76
86
  async function projectSandbox(cwd, values) {
87
+ const found = values.project ? await openProject(values.project) : await currentProject(cwd);
88
+ if (!found) {
89
+ return undefined;
90
+ }
77
91
  try {
78
- const found = await findProject({ cwd, project: values.project, yes: true });
79
92
  const { root, config } = readProjectConfig(found.dir);
80
93
  const build = resolveBuild(root, config.sandbox);
81
- return { image: build?.tag ?? config.sandbox?.image, build };
94
+ return {
95
+ dir: found.dir,
96
+ name: found.name,
97
+ image: build?.tag ?? config.sandbox?.image,
98
+ build,
99
+ };
82
100
  }
83
101
  catch {
84
- return undefined;
102
+ // A project whose configuration does not read is `zen check`'s to
103
+ // report; here it only means there is no image to name.
104
+ return { dir: found.dir, name: found.name };
85
105
  }
86
106
  }
87
- async function status(image, build, asJson) {
107
+ async function status(project, image, asJson) {
108
+ const build = project?.build;
88
109
  const found = await podmanStatus({ image });
89
- const containers = found.ready ? await ownedContainers(found.engine) : [];
110
+ const all = found.ready ? await ownedContainers(found.engine) : [];
111
+ // A container belongs to a session, and a session belongs to a project, so
112
+ // a report about one project must not list another's — or the faker's,
113
+ // which wears the same label and belongs to no project at all.
114
+ const containers = project ? ofProject(project.dir, all) : all;
90
115
  if (asJson) {
91
- json({ ...found, dockerfile: build?.dockerfile ?? null, containers });
116
+ json({
117
+ ...found,
118
+ project: project?.name ?? null,
119
+ dockerfile: build?.dockerfile ?? null,
120
+ containers,
121
+ });
92
122
  return;
93
123
  }
94
124
  const mark = (ok) => (ok ? green('ok') : red('no'));
@@ -98,18 +128,31 @@ async function status(image, build, asJson) {
98
128
  write(`${bold('machine')} ${found.machine.name} ${state}`);
99
129
  }
100
130
  write(`${bold('responds')} ${mark(found.ready)}`);
131
+ if (project) {
132
+ write(`${bold('project')} ${project.name} ${dim(project.dir)}`);
133
+ }
101
134
  if (found.image) {
102
135
  write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
103
136
  }
104
137
  if (build) {
105
138
  write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
106
139
  }
107
- writeAll(containerLines(containers));
140
+ writeAll(containerLines(containers, project?.name));
108
141
  if (!found.installed || !found.ready) {
109
142
  note('');
110
143
  note(dim('run `zen sandbox up` to fix what can be fixed.'));
111
144
  }
112
145
  }
146
+ /**
147
+ * The containers this project's sessions made. A container carries the session
148
+ * id that made it in a label, and a session id is a directory name under the
149
+ * project — the same attribution `disk` does for every project at once, so the
150
+ * two cannot disagree.
151
+ */
152
+ function ofProject(dir, containers) {
153
+ const sessions = new Set(sessionIds(dir));
154
+ return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
155
+ }
113
156
  /**
114
157
  * One per line rather than one long line, because there is normally more than
115
158
  * one and the interesting part — how old, and whether anything is still up —
@@ -117,11 +160,15 @@ async function status(image, build, asJson) {
117
160
  *
118
161
  * The trailing note is there because the count surprises people: a container
119
162
  * is per *session*, not per project, and `persist: true` is what leaves the
120
- * stopped ones behind.
163
+ * stopped ones behind. `scope` names the project these belong to, and saying
164
+ * so is half the answer — the other half is that there are more elsewhere.
121
165
  */
122
- function containerLines(containers) {
166
+ function containerLines(containers, scope) {
167
+ const tail = scope
168
+ ? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
169
+ : 'one per session, kept by `persist: true` — see: zen sandbox disk';
123
170
  if (containers.length === 0) {
124
- return [`${bold('containers')} ${dim('none')}`];
171
+ return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
125
172
  }
126
173
  const running = containers.filter((c) => c.state === 'running').length;
127
174
  const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
@@ -138,7 +185,7 @@ function containerLines(containers) {
138
185
  head,
139
186
  ...table(rows),
140
187
  ...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
141
- `${INDENT}${dim('one per session, kept by `persist: true` — see: zen sandbox disk')}`,
188
+ `${INDENT}${dim(tail)}`,
142
189
  ];
143
190
  }
144
191
  async function up(image, build, yes, asJson, rebuild = false) {
package/dist/engine.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
1
+ import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
@@ -27,7 +27,6 @@ export async function open(opts) {
27
27
  const meta = await readSessionMeta(opts.session);
28
28
  const workspace = resolve(meta.workspace);
29
29
  const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
30
- const memory = [new FileMemoryStore({ dir: opts.session.memory, id: 'file' })];
31
30
  // The config is read before the project is loaded because the sandbox is
32
31
  // configured by it and the tools have to exist before selectors can be
33
32
  // resolved against them. They are always registered, whether or not anyone
@@ -35,6 +34,7 @@ export async function open(opts) {
35
34
  // fails to load with "unknown tool", which would be a lie.
36
35
  let sandbox;
37
36
  let project;
37
+ let files;
38
38
  try {
39
39
  const { root, config } = readProjectConfig(opts.project.dir);
40
40
  // Assets and the skill catalog are mounted for both, under one name.
@@ -49,17 +49,21 @@ export async function open(opts) {
49
49
  keys: opts.keys,
50
50
  mounts,
51
51
  });
52
+ const workspaceOptions = {
53
+ root: workspace,
54
+ readOnly: opts.readOnly,
55
+ mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
56
+ mounts,
57
+ };
58
+ // The same view the file tools have, so a path the agent just wrote
59
+ // resolves the same way when it asks memory to keep a copy.
60
+ files = new Workspace(workspaceOptions);
52
61
  project = await loadProject(opts.project.dir, {
53
62
  tools: [
54
63
  // Both groups are pointed at one directory, so both are told the
55
64
  // one name for it: whatever `run_command` prints a path as,
56
65
  // `read_file` accepts.
57
- ...workspaceTools({
58
- root: workspace,
59
- readOnly: opts.readOnly,
60
- mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
61
- mounts,
62
- }),
66
+ ...workspaceTools(workspaceOptions),
63
67
  ...sandboxTools(sandbox.pool),
64
68
  // Registered whether or not a key exists: the credential is
65
69
  // read when a tool is called, so a project that names `exa:*`
@@ -69,7 +73,7 @@ export async function open(opts) {
69
73
  ],
70
74
  skillsAt: SKILLS_MOUNT,
71
75
  payloads,
72
- memory,
76
+ resolveFile: (path) => files.within(path),
73
77
  });
74
78
  }
75
79
  catch (err) {
@@ -105,6 +109,7 @@ export async function open(opts) {
105
109
  lock,
106
110
  close: async () => {
107
111
  await teardown(sandbox.pool);
112
+ project.close();
108
113
  lock.release();
109
114
  },
110
115
  };
package/dist/home.d.ts CHANGED
@@ -5,8 +5,8 @@ export declare const paths: {
5
5
  keys: () => string;
6
6
  keyDir: () => string;
7
7
  faker: () => string;
8
- /** cached model listings, one file per provider — public data, not secrets */
9
- catalog: () => string;
8
+ /** work already done and worth keeping: vectors, parses, model listings */
9
+ cache: () => string;
10
10
  };
11
11
  /** Creates a directory owner-only, and leaves an existing one's mode alone. */
12
12
  export declare function ensureDir(dir: string, mode?: number): string;
package/dist/home.js CHANGED
@@ -26,8 +26,8 @@ export const paths = {
26
26
  keys: () => join(home(), 'keys.json'),
27
27
  keyDir: () => join(home(), 'keys'),
28
28
  faker: () => join(home(), 'faker'),
29
- /** cached model listings, one file per provider — public data, not secrets */
30
- catalog: () => join(home(), 'catalog'),
29
+ /** work already done and worth keeping: vectors, parses, model listings */
30
+ cache: () => join(home(), 'cache'),
31
31
  };
32
32
  /** Creates a directory owner-only, and leaves an existing one's mode alone. */
33
33
  export function ensureDir(dir, mode = DIR_MODE) {
package/dist/keys.d.ts CHANGED
@@ -103,6 +103,17 @@ export declare function parseRef(ref: string): {
103
103
  provider: KeyOwner;
104
104
  name?: string;
105
105
  };
106
+ /** Which of a provider's forms a raw value is, decided by the value itself. */
107
+ export declare function formOf(provider: KeyOwner, raw: string): CredentialForm;
108
+ /**
109
+ * Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
110
+ * or `us-central` with its digit missing — because those are 404s at the first
111
+ * call, a long way from the command that caused them. Returns false for a
112
+ * well-formed name that is merely unrecognised, which the caller reports and
113
+ * stores anyway: Google adds regions, and a list compiled here would be wrong
114
+ * before it was old.
115
+ */
116
+ export declare function checkRegion(region: string): boolean;
106
117
  /** What a credential cannot say about itself. */
107
118
  export interface KeyMeta {
108
119
  /** GCP project id, for a Vertex service account */
@@ -144,6 +155,17 @@ export declare class KeyStore {
144
155
  fileOf(entry: KeyEntry): string;
145
156
  /** The plaintext an entry stands for — the only way out of the store. */
146
157
  reveal(entry: KeyEntry): string;
158
+ /**
159
+ * Which GCP project a Vertex entry addresses, and how that was decided —
160
+ * the same order the library resolves it in, so what is shown is what will
161
+ * be called. A service-account file states its own project, and nothing was
162
+ * ever stored for it, so an entry with no `project` is not an entry with no
163
+ * project: it is one whose project is only knowable by reading the file.
164
+ */
165
+ projectOf(entry: KeyEntry): {
166
+ id: string;
167
+ from: 'env' | 'stored' | 'file';
168
+ } | undefined;
147
169
  /**
148
170
  * What the library would see. Real environment variables win, so CI,
149
171
  * `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
package/dist/keys.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EXA_API_KEY_ENV } from '@zenera/neo';
2
- import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
2
+ import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
@@ -169,7 +169,7 @@ export function parseRef(ref) {
169
169
  return { provider, name };
170
170
  }
171
171
  /** Which of a provider's forms a raw value is, decided by the value itself. */
172
- function formOf(provider, raw) {
172
+ export function formOf(provider, raw) {
173
173
  const { forms } = SHAPES[provider];
174
174
  if (forms.length === 1) {
175
175
  return forms[0];
@@ -177,6 +177,71 @@ function formOf(provider, raw) {
177
177
  const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
178
178
  return forms.find((f) => f.holds === wanted) ?? forms[0];
179
179
  }
180
+ const REGION = /^[a-z]+-[a-z]+[0-9]+$/;
181
+ const REGIONS = new Set([
182
+ 'africa-south1',
183
+ 'asia-east1',
184
+ 'asia-east2',
185
+ 'asia-northeast1',
186
+ 'asia-northeast2',
187
+ 'asia-northeast3',
188
+ 'asia-south1',
189
+ 'asia-south2',
190
+ 'asia-southeast1',
191
+ 'asia-southeast2',
192
+ 'asia-southeast3',
193
+ 'australia-southeast1',
194
+ 'australia-southeast2',
195
+ 'europe-central2',
196
+ 'europe-north1',
197
+ 'europe-southwest1',
198
+ 'europe-west1',
199
+ 'europe-west2',
200
+ 'europe-west3',
201
+ 'europe-west4',
202
+ 'europe-west6',
203
+ 'europe-west8',
204
+ 'europe-west9',
205
+ 'europe-west10',
206
+ 'europe-west12',
207
+ 'me-central1',
208
+ 'me-central2',
209
+ 'me-west1',
210
+ 'northamerica-northeast1',
211
+ 'northamerica-northeast2',
212
+ 'northamerica-south1',
213
+ 'southamerica-east1',
214
+ 'southamerica-west1',
215
+ 'us-central1',
216
+ 'us-east1',
217
+ 'us-east4',
218
+ 'us-east5',
219
+ 'us-south1',
220
+ 'us-west1',
221
+ 'us-west2',
222
+ 'us-west3',
223
+ 'us-west4',
224
+ ]);
225
+ /**
226
+ * Endpoints that route across regions rather than naming one. `us` and `eu` keep
227
+ * processing inside their territory; `global` does not promise that, and takes
228
+ * whatever capacity is free.
229
+ */
230
+ const ENDPOINTS = new Set(['global', 'us', 'eu']);
231
+ /**
232
+ * Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
233
+ * or `us-central` with its digit missing — because those are 404s at the first
234
+ * call, a long way from the command that caused them. Returns false for a
235
+ * well-formed name that is merely unrecognised, which the caller reports and
236
+ * stores anyway: Google adds regions, and a list compiled here would be wrong
237
+ * before it was old.
238
+ */
239
+ export function checkRegion(region) {
240
+ if (!ENDPOINTS.has(region) && !REGION.test(region)) {
241
+ throw usageError(`"${region}" is not a region`, 'give a region such as us-central1, or the endpoints global, us or eu');
242
+ }
243
+ return ENDPOINTS.has(region) || REGIONS.has(region);
244
+ }
180
245
  // ---------------------------------------------------------------------------
181
246
  // Store
182
247
  // ---------------------------------------------------------------------------
@@ -300,6 +365,30 @@ export class KeyStore {
300
365
  reveal(entry) {
301
366
  return entry.holds === 'file' ? this.fileOf(entry) : entry.value;
302
367
  }
368
+ /**
369
+ * Which GCP project a Vertex entry addresses, and how that was decided —
370
+ * the same order the library resolves it in, so what is shown is what will
371
+ * be called. A service-account file states its own project, and nothing was
372
+ * ever stored for it, so an entry with no `project` is not an entry with no
373
+ * project: it is one whose project is only knowable by reading the file.
374
+ */
375
+ projectOf(entry) {
376
+ if (entry.provider !== 'vertex') {
377
+ return undefined;
378
+ }
379
+ const fromEnv = process.env.GOOGLE_CLOUD_PROJECT;
380
+ if (fromEnv) {
381
+ return { id: fromEnv, from: 'env' };
382
+ }
383
+ if (entry.project) {
384
+ return { id: entry.project, from: 'stored' };
385
+ }
386
+ if (entry.holds !== 'file') {
387
+ return undefined;
388
+ }
389
+ const id = projectIdOf(this.fileOf(entry));
390
+ return id ? { id, from: 'file' } : undefined;
391
+ }
303
392
  /**
304
393
  * What the library would see. Real environment variables win, so CI,
305
394
  * `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
@@ -350,6 +439,20 @@ export class KeyStore {
350
439
  return target;
351
440
  }
352
441
  }
442
+ /**
443
+ * `project_id` out of a service-account file. Express-mode keys, gcloud user
444
+ * ADC and metadata credentials carry none, so finding nothing is an answer.
445
+ */
446
+ function projectIdOf(path) {
447
+ try {
448
+ const key = JSON.parse(readFileSync(path, 'utf8'));
449
+ const id = key.project_id;
450
+ return typeof id === 'string' && id.trim() ? id.trim() : undefined;
451
+ }
452
+ catch {
453
+ return undefined;
454
+ }
455
+ }
353
456
  // ---------------------------------------------------------------------------
354
457
  // Display
355
458
  // ---------------------------------------------------------------------------
package/dist/lib.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { extract, invokedAs, one, parse, split, type Parsed, type Split } from './args.ts';
2
2
  export { printBanner, type BannerText } from './banner.ts';
3
+ export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, type CacheEntry, type CacheItem, type CacheKind, type CacheOptions, type CacheStore, type Swept, } from './cache.ts';
3
4
  export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, type Catalog, type CatalogEntry, type CatalogOptions, type Filters, type Role, } from './catalog.ts';
4
5
  export type { Command, Context } from './command.ts';
5
6
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
package/dist/lib.js CHANGED
@@ -23,6 +23,7 @@
23
23
  // ---------------------------------------------------------------------------
24
24
  export { extract, invokedAs, one, parse, split } from "./args.js";
25
25
  export { printBanner } from "./banner.js";
26
+ export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, } from "./cache.js";
26
27
  export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, } from "./catalog.js";
27
28
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
28
29
  export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
package/dist/liveness.js CHANGED
@@ -107,6 +107,17 @@ export function classify(err) {
107
107
  if (UNREACHED.some((needle) => haystack.includes(needle))) {
108
108
  return { state: 'unknown', at, detail: 'could not reach the provider' };
109
109
  }
110
+ // A json api that answers in html was never reached: the hostname was
111
+ // wrong. Vertex builds its hostname out of the location, so a misspelt
112
+ // region lands here — as `<!DOCTYPE html>`, which explains nothing.
113
+ if (haystack.includes('<!doctype html') || haystack.includes('<html')) {
114
+ return {
115
+ state: 'unknown',
116
+ at,
117
+ detail: 'a web page came back, not the api — the endpoint does not exist',
118
+ fix: 'check the location: vertex builds its hostname from it',
119
+ };
120
+ }
110
121
  // 429 means the credential authenticated and then got rate limited, which
111
122
  // is a live key having a bad day.
112
123
  if (status === 429) {
package/dist/main.js CHANGED
@@ -151,5 +151,14 @@ async function usage(name) {
151
151
  write(` -C, --directory <d> ${dim('Act as if run in <d>.')}`);
152
152
  write(`\n${dim(`Start with ${cyan(`${NAME} init`)}, then ${cyan(`${NAME} run`)}.`)}`);
153
153
  }
154
+ // `zen memory ls | head` closes the pipe while we are still writing to it, and
155
+ // an unhandled EPIPE turns that ordinary shell idiom into a stack trace. The
156
+ // reader having left is not our error, so we stop quietly.
157
+ process.stdout.on('error', (err) => {
158
+ if (err.code !== 'EPIPE') {
159
+ throw err;
160
+ }
161
+ process.exit(0);
162
+ });
154
163
  process.exitCode = await main(process.argv.slice(2));
155
164
  //# sourceMappingURL=main.js.map
package/dist/podman.js CHANGED
@@ -2,7 +2,7 @@ import { runProcess, SandboxError } from '@zenera/neo';
2
2
  import { readdirSync, statSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
5
+ import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
6
6
  const DEFAULT_MACHINE_CPUS = 2;
7
7
  const DEFAULT_MACHINE_MEMORY = 2048;
8
8
  /** Starting a virtual machine and pulling an image are both slow on purpose. */
@@ -73,7 +73,7 @@ async function preflight(opts) {
73
73
  const present = await call(['image', 'exists', opts.image], 30_000);
74
74
  if (present.code !== 0) {
75
75
  note(dim(`pulling ${opts.image} — this happens once`));
76
- const pulled = await stream(engine, ['pull', opts.image], run);
76
+ const pulled = await stream(engine, ['pull', opts.image], run, 'pulling');
77
77
  if (pulled.code !== 0) {
78
78
  throw sandboxError(`could not pull ${opts.image}`, first(pulled) || 'check the image name and your network');
79
79
  }
@@ -99,7 +99,7 @@ async function install(engine, opts, run) {
99
99
  throw sandboxError('podman is not installed', how);
100
100
  }
101
101
  note(dim('installing podman — this takes a few minutes'));
102
- const done = await stream('brew', ['install', 'podman'], run);
102
+ const done = await stream('brew', ['install', 'podman'], run, 'installing podman');
103
103
  if (done.code !== 0) {
104
104
  throw sandboxError('could not install podman', first(done) || how);
105
105
  }
@@ -127,7 +127,7 @@ async function machine(engine, call, opts, run) {
127
127
  const cpus = String(opts.cpus ?? DEFAULT_MACHINE_CPUS);
128
128
  const memory = String(opts.memory ?? DEFAULT_MACHINE_MEMORY);
129
129
  note(dim(`initialising the podman machine (${cpus} cpus, ${memory} MiB) — once per host`));
130
- const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run);
130
+ const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run, 'initialising the machine');
131
131
  if (created.code !== 0) {
132
132
  throw sandboxError('could not create the podman machine', first(created));
133
133
  }
@@ -143,7 +143,7 @@ async function machine(engine, call, opts, run) {
143
143
  args.push(chosen.Name);
144
144
  }
145
145
  note(dim('starting the podman machine'));
146
- const started = await stream(engine, args, run, SLOW_MS);
146
+ const started = await stream(engine, args, run, 'starting the machine', SLOW_MS);
147
147
  if (started.code !== 0 && !/already running/i.test(started.stderr)) {
148
148
  throw sandboxError('could not start the podman machine', first(started));
149
149
  }
@@ -197,8 +197,8 @@ async function build(engine, spec, run, force = false) {
197
197
  }
198
198
  note(dim(`${force ? 'rebuilding' : 'building'} the sandbox image from ${spec.dockerfile}`));
199
199
  note(dim(' the agent runs its commands inside a container, so the image is needed first'));
200
- note(dim(' this takes a few minutes; later runs reuse it until the Dockerfile changes'));
201
- const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
200
+ note(dim(' this takes a few minutes; later runs reuse it'));
201
+ const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, 'building the sandbox image', BUILD_MS);
202
202
  if (built.code !== 0) {
203
203
  throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
204
204
  }
@@ -384,13 +384,20 @@ function children(dir) {
384
384
  }
385
385
  }
386
386
  // ---------------------------------------------------------------------------
387
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
388
+ const FRAME_MS = 100;
387
389
  /**
388
390
  * Long steps print as they go. A five-minute pull with no output is
389
391
  * indistinguishable from a hang, and the one thing worse than waiting is not
390
392
  * knowing whether you are waiting.
393
+ *
394
+ * The engine's own output is buffered until it exits, so while the step runs
395
+ * the only honest thing to show is that it is still running: a spinner and a
396
+ * clock, both of which have to move without any event to move them. Off
397
+ * without a terminal — a CI log wants one line per fact, not ten a second.
391
398
  */
392
- async function stream(bin, args, run, timeoutMs = SLOW_MS) {
393
- const res = await run(bin, args, { timeoutMs });
399
+ async function stream(bin, args, run, label, timeoutMs = SLOW_MS) {
400
+ const res = await ticking(label, run(bin, args, { timeoutMs }));
394
401
  for (const line of res.stderr.split('\n').slice(-3)) {
395
402
  if (line.trim()) {
396
403
  note(dim(` ${line.trim()}`));
@@ -398,6 +405,33 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
398
405
  }
399
406
  return res;
400
407
  }
408
+ async function ticking(label, work) {
409
+ if (!process.stderr.isTTY) {
410
+ return work;
411
+ }
412
+ const bar = progress();
413
+ const startedAt = Date.now();
414
+ let frame = 0;
415
+ const timer = setInterval(() => {
416
+ const spin = SPINNER[frame++ % SPINNER.length];
417
+ bar.update(dim(` ${spin} ${label} ${elapsed(Date.now() - startedAt)}`));
418
+ }, FRAME_MS);
419
+ timer.unref();
420
+ try {
421
+ return await work;
422
+ }
423
+ finally {
424
+ clearInterval(timer);
425
+ bar.done();
426
+ }
427
+ }
428
+ function elapsed(ms) {
429
+ const total = Math.floor(ms / 1000);
430
+ const seconds = total % 60;
431
+ return total < 60
432
+ ? `${seconds}s`
433
+ : `${Math.floor(total / 60)}m ${String(seconds).padStart(2, '0')}s`;
434
+ }
401
435
  function first(res) {
402
436
  return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
403
437
  }