@zenera/cli 1.1.16 → 1.1.18

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 (71) hide show
  1. package/README.md +110 -19
  2. package/dist/banner.d.ts +19 -0
  3. package/dist/banner.js +12 -9
  4. package/dist/catalog.js +19 -20
  5. package/dist/command.d.ts +3 -0
  6. package/dist/commands/check.js +5 -1
  7. package/dist/commands/index.js +4 -1
  8. package/dist/commands/init.js +42 -7
  9. package/dist/commands/inspect.js +77 -13
  10. package/dist/commands/key.js +49 -1
  11. package/dist/commands/memory.js +16 -3
  12. package/dist/commands/meta.d.ts +3 -0
  13. package/dist/commands/meta.js +596 -0
  14. package/dist/commands/run.js +64 -35
  15. package/dist/engine.d.ts +15 -0
  16. package/dist/engine.js +26 -1
  17. package/dist/env.d.ts +20 -0
  18. package/dist/env.js +65 -0
  19. package/dist/home.d.ts +2 -0
  20. package/dist/home.js +2 -0
  21. package/dist/keys.d.ts +2 -0
  22. package/dist/keys.js +2 -0
  23. package/dist/main.js +1 -1
  24. package/dist/meta.d.ts +221 -0
  25. package/dist/meta.js +695 -0
  26. package/dist/podman.js +64 -2
  27. package/dist/projects.d.ts +9 -1
  28. package/dist/projects.js +19 -2
  29. package/dist/resolve.js +7 -1
  30. package/dist/sandbox.d.ts +11 -0
  31. package/dist/sandbox.js +24 -4
  32. package/dist/scaffold.d.ts +6 -0
  33. package/dist/scaffold.js +56 -15
  34. package/dist/session.d.ts +10 -0
  35. package/dist/session.js +19 -1
  36. package/dist/term.d.ts +7 -0
  37. package/dist/term.js +8 -1
  38. package/dist/tui/app.js +33 -5
  39. package/dist/tui/wrap.d.ts +1 -1
  40. package/dist/tui/wrap.js +1 -1
  41. package/dist/validate.js +57 -15
  42. package/package.json +3 -2
  43. package/templates/editor/.github/copilot-instructions.md +313 -94
  44. package/templates/editor/.github/prompts/project-review.prompt.md +74 -0
  45. package/templates/editor/.github/prompts/spec-apply-feedback.prompt.md +142 -0
  46. package/templates/editor/.github/prompts/spec-sync-project.prompt.md +392 -0
  47. package/templates/editor/.github/skills/zen-cli/references/check.md +6 -3
  48. package/templates/editor/.github/skills/zen-cli/references/frame.md +5 -3
  49. package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
  50. package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
  51. package/templates/editor/.github/skills/zen-cli/references/projects.md +19 -2
  52. package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
  53. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +6 -0
  54. package/templates/editor/.github/skills/zen-memory/SKILL.md +153 -33
  55. package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +115 -0
  56. package/templates/editor/.github/skills/zen-review/SKILL.md +123 -0
  57. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +119 -0
  58. package/templates/editor/.github/skills/zen-review/scripts/review.sh +104 -0
  59. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +197 -0
  60. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +200 -0
  61. package/templates/editor/.vscode/settings.json +44 -4
  62. package/templates/parts/memory-terms.yaml.tmpl +16 -0
  63. package/templates/parts/memory.yaml.tmpl +12 -0
  64. package/templates/project/SPECIFICATION.md.tmpl +32 -2
  65. package/templates/project/agents/memory-instructions.md +278 -0
  66. package/templates/project/agents.yaml.tmpl +12 -3
  67. package/templates/project/env +28 -0
  68. package/templates/project/gitignore +4 -4
  69. package/templates/editor/.github/prompts/review-project.prompt.md +0 -36
  70. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +0 -205
  71. /package/templates/project/{INSTRUCTIONS.md → agents/instructions.md} +0 -0
package/dist/meta.js ADDED
@@ -0,0 +1,695 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { createWriteStream, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createInterface } from 'node:readline';
5
+ import { invokedAs } from "./args.js";
6
+ import { paths, readJson, writeJson } from "./home.js";
7
+ import { envOf } from "./keys.js";
8
+ import { credentialError, cut, cyan, dim, note, pad, plain, red, styled, usageError, write, yellow, } from "./term.js";
9
+ import { answerWidth, segmentsOf, wrap } from "./tui/wrap.js";
10
+ export const PROMPT_DIR = '.github/prompts';
11
+ const PROMPT_SUFFIX = '.prompt.md';
12
+ // ---------------------------------------------------------------------------
13
+ // The model
14
+ // ---------------------------------------------------------------------------
15
+ /** The variable a project's `.env` names it with — a zen ref, not a wire id. */
16
+ export const MODEL_ENV = 'ZENERA_META_MODEL';
17
+ /** Where the answer is looked for, best first. */
18
+ export const ORDER = [
19
+ 'flag',
20
+ 'shell',
21
+ 'env',
22
+ 'store',
23
+ 'project',
24
+ 'default',
25
+ ];
26
+ export function chooseModel(sources) {
27
+ const at = {
28
+ flag: sources.flag,
29
+ // `.env` only fills gaps, so an answer that survived it came from the shell
30
+ shell: sources.shell,
31
+ env: sources.shell ? undefined : sources.env,
32
+ store: sources.stored,
33
+ project: sources.project,
34
+ default: sources.fallback,
35
+ };
36
+ for (const from of ORDER) {
37
+ const ref = at[from]?.trim();
38
+ if (ref) {
39
+ return { ref, from };
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ export const SOURCE_LABELS = {
45
+ flag: '--model',
46
+ shell: `${MODEL_ENV} (shell)`,
47
+ env: `${MODEL_ENV} (.env)`,
48
+ store: 'zen meta model',
49
+ project: 'agents.yaml model:',
50
+ default: 'recommended',
51
+ };
52
+ // ---------------------------------------------------------------------------
53
+ // What to run when nobody said
54
+ //
55
+ // A first `zen meta` should work on a machine that has a key and nothing else,
56
+ // so the last resort is the best model the keys on hand can buy rather than an
57
+ // error. Deep tier deliberately: this agent is pointed at the project itself,
58
+ // and a cheap model reading a specification is a false economy.
59
+ // ---------------------------------------------------------------------------
60
+ /** First of each row is the default; the rest are what `--pick` offers beside it. */
61
+ export const RECOMMENDED = {
62
+ openai: ['gpt-5.6-sol', 'gpt-5.6-terra'],
63
+ anthropic: ['claude-opus-5', 'claude-sonnet-5'],
64
+ vertex: ['gemini-3.8-flash', 'gemini-3.5-flash-lite'],
65
+ google: ['gemini-3.8-flash', 'gemini-3.5-flash-lite'],
66
+ openrouter: ['anthropic/claude-opus-5', 'openai/gpt-5.6-sol'],
67
+ };
68
+ /** Which provider is tried first when several are held. */
69
+ const PREFERENCE = ['anthropic', 'openai', 'vertex', 'google', 'openrouter'];
70
+ /** The recommended ref for the first provider with a key, or for `only`. */
71
+ export function defaultRef(store, only) {
72
+ for (const provider of only ? [only] : PREFERENCE) {
73
+ const id = RECOMMENDED[provider]?.[0];
74
+ if (id && store.active(provider)) {
75
+ return `${provider}/${id}`;
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+ const EMPTY = { version: 1 };
81
+ export const readMeta = () => readJson(paths.meta(), EMPTY);
82
+ export function writeMeta(file) {
83
+ writeJson(paths.meta(), file);
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Refs
87
+ // ---------------------------------------------------------------------------
88
+ const PROVIDER_NAMES = new Set(['openai', 'anthropic', 'google', 'vertex', 'openrouter']);
89
+ /**
90
+ * `vertex/gemini-3.8-flash` splits; `gpt-5.6-sol` does not. Only a known provider
91
+ * counts as a prefix, because `google/gemini-3.8-flash` is also a perfectly good
92
+ * bare id on an endpoint that speaks publisher names.
93
+ *
94
+ * The colon form is the library's own — `openai:gpt-5.6-sol`, and with an api
95
+ * selector `openai/responses:gpt-5.6-sol` — which is what arrives when the ref
96
+ * came from `agents.yaml`.
97
+ */
98
+ export function splitRef(ref) {
99
+ const colon = ref.indexOf(':');
100
+ if (colon > 0) {
101
+ const head = ref.slice(0, colon).split('/')[0];
102
+ if (PROVIDER_NAMES.has(head)) {
103
+ return { provider: head, id: ref.slice(colon + 1) };
104
+ }
105
+ }
106
+ const slash = ref.indexOf('/');
107
+ if (slash < 0) {
108
+ return { id: ref };
109
+ }
110
+ const head = ref.slice(0, slash);
111
+ return PROVIDER_NAMES.has(head)
112
+ ? { provider: head, id: ref.slice(slash + 1) }
113
+ : { id: ref };
114
+ }
115
+ /**
116
+ * The provider a misspelt prefix was reaching for, if that is what it is.
117
+ *
118
+ * `vertes/gemini-3.8-flash` is shaped exactly like the OpenRouter id
119
+ * `meta-llama/llama-4`, so an unknown prefix cannot be an error on its own —
120
+ * only one close enough to a real provider name to be a slip of the hand.
121
+ */
122
+ export function misspelledProvider(ref) {
123
+ const slash = ref.indexOf('/');
124
+ if (slash <= 0) {
125
+ return undefined;
126
+ }
127
+ const head = ref.slice(0, slash).toLowerCase();
128
+ if (PROVIDER_NAMES.has(head)) {
129
+ return undefined;
130
+ }
131
+ for (const name of PROVIDER_NAMES) {
132
+ // Two, so that a transposition — which Levenshtein counts twice — reads
133
+ // as the typo it is. No provider name is short enough for that to be loose.
134
+ if (distance(head, name) <= 2) {
135
+ return name;
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ /** Levenshtein, one row at a time. Both operands are a word long. */
141
+ function distance(a, b) {
142
+ if (Math.abs(a.length - b.length) > 2) {
143
+ return 3;
144
+ }
145
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
146
+ for (let i = 1; i <= a.length; i++) {
147
+ const next = [i];
148
+ for (let j = 1; j <= b.length; j++) {
149
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
150
+ next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + cost);
151
+ }
152
+ row = next;
153
+ }
154
+ return row[b.length];
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Wiring
158
+ // ---------------------------------------------------------------------------
159
+ const TYPES = {
160
+ openai: 'openai',
161
+ anthropic: 'anthropic',
162
+ google: 'openai',
163
+ vertex: 'openai',
164
+ openrouter: 'openai',
165
+ };
166
+ const BASE_URLS = {
167
+ openai: 'https://api.openai.com/v1',
168
+ anthropic: 'https://api.anthropic.com',
169
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai/',
170
+ openrouter: 'https://openrouter.ai/api/v1',
171
+ };
172
+ /**
173
+ * Copilot offers its tools as OpenAI *custom* tools, which the completions API
174
+ * rejects outright — `400 Invalid value: 'custom'`. Only the responses API
175
+ * takes them, and only the reasoning models serve it, so the wire API follows
176
+ * the model rather than being a flag nobody would know to set.
177
+ */
178
+ export function wireApi(provider, id) {
179
+ return provider === 'openai' && /^(gpt-5|o[34])/.test(id) ? 'responses' : undefined;
180
+ }
181
+ /**
182
+ * Turns a zen key entry and a model id into copilot's environment.
183
+ *
184
+ * `COPILOT_PROVIDER_BASE_URL` is what activates BYOK at all, so every provider
185
+ * gets one — including OpenAI, whose url copilot would otherwise have reached
186
+ * through a GitHub account we are deliberately not using.
187
+ */
188
+ export function wire(store, entry, id) {
189
+ const provider = entry.provider;
190
+ const env = {};
191
+ const secret = [];
192
+ const warnings = [];
193
+ env.COPILOT_PROVIDER_TYPE = TYPES[provider];
194
+ env.COPILOT_MODEL = id;
195
+ if (provider === 'vertex') {
196
+ const project = store.projectOf(entry)?.id;
197
+ if (!project) {
198
+ throw credentialError(`no GCP project on ${entry.provider}/${entry.name}`, 'set one: zen key add vertex --project <id>');
199
+ }
200
+ const location = entry.location?.trim() || 'global';
201
+ if (location === 'global') {
202
+ warnings.push('location `global` adds about ten seconds of cold start');
203
+ }
204
+ env.COPILOT_PROVIDER_BASE_URL = `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/endpoints/openapi`;
205
+ // Vertex speaks publisher names on the wire and knows nothing of zen's
206
+ // provider prefix; the bare id stays as the model *id* so copilot can
207
+ // still match its catalogue for token limits and tool support.
208
+ env.COPILOT_PROVIDER_WIRE_MODEL = id.includes('/') ? id : `google/${id}`;
209
+ env.COPILOT_PROVIDER_MODEL_ID = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id;
210
+ }
211
+ else {
212
+ const url = BASE_URLS[provider];
213
+ if (!url) {
214
+ throw credentialError(`no BYOK endpoint known for ${provider}`);
215
+ }
216
+ env.COPILOT_PROVIDER_BASE_URL = url;
217
+ }
218
+ const api = wireApi(provider, id);
219
+ if (api) {
220
+ env.COPILOT_PROVIDER_WIRE_API = api;
221
+ }
222
+ if (entry.holds === 'file') {
223
+ // A Google access token is good for an hour and a session is not, so
224
+ // copilot is told how to mint one rather than handed one that will be
225
+ // stale by the time it matters.
226
+ env.COPILOT_PROVIDER_API_KEY_COMMAND = `${invokedAs('zen')} key token ${entry.provider}`;
227
+ env[envOf(entry)] = store.fileOf(entry);
228
+ }
229
+ else {
230
+ env.COPILOT_PROVIDER_API_KEY = store.reveal(entry);
231
+ secret.push('COPILOT_PROVIDER_API_KEY');
232
+ }
233
+ return { provider, model: env.COPILOT_PROVIDER_WIRE_MODEL ?? id, env, secret, warnings };
234
+ }
235
+ /** Values a `--dry-run` may print. A key is four characters and an apology. */
236
+ export function masked(env, secret) {
237
+ return Object.entries(env)
238
+ .sort(([a], [b]) => a.localeCompare(b))
239
+ .map(([name, value]) => `${name}=${secret.includes(name) ? '••••' : value}`);
240
+ }
241
+ // ---------------------------------------------------------------------------
242
+ // Stored prompts
243
+ //
244
+ // Copilot reads `AGENTS.md`, `.github/skills/` and `.github/agents/`, but it
245
+ // has no notion of `.github/prompts/*.prompt.md` — the files an editor offers
246
+ // as slash commands. Reading one and handing over its body is the whole of
247
+ // `zen meta run`, and it is why that subcommand exists.
248
+ // ---------------------------------------------------------------------------
249
+ /** `/project-review`, `project-review`, `project-review.prompt.md` or a path. */
250
+ export function promptPath(dir, name) {
251
+ const bare = name.replace(/^\//, '');
252
+ if (bare.includes('/') || bare.endsWith('.md')) {
253
+ return `${dir}/${bare}`;
254
+ }
255
+ return `${dir}/${PROMPT_DIR}/${bare}${PROMPT_SUFFIX}`;
256
+ }
257
+ /**
258
+ * Frontmatter is the editor's business — `mode`, `tools`, `description` — and
259
+ * none of it means anything to copilot, so only the body is sent. `description`
260
+ * is kept for the one line of narration that says which prompt is running.
261
+ */
262
+ export function readPrompt(path, name, text) {
263
+ let body = text;
264
+ let description;
265
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
266
+ if (match) {
267
+ body = text.slice(match[0].length);
268
+ const found = /^description:\s*(.+)$/m.exec(match[1]);
269
+ description = found?.[1].trim().replace(/^['"]|['"]$/g, '');
270
+ }
271
+ return { name, path, description, body: body.trim() };
272
+ }
273
+ export async function loadPrompt(dir, name) {
274
+ const path = promptPath(dir, name);
275
+ if (!existsSync(path)) {
276
+ const known = await listPrompts(dir);
277
+ throw usageError(`no prompt named ${name.replace(/^\//, '')}`, known.length > 0
278
+ ? `try: ${known.map((p) => `/${p}`).join(', ')}`
279
+ : `put one in ${PROMPT_DIR}/`);
280
+ }
281
+ return readPrompt(path, name.replace(/^\//, ''), await readFile(path, 'utf8'));
282
+ }
283
+ export async function listPrompts(dir) {
284
+ const { readdir } = await import('node:fs/promises');
285
+ try {
286
+ const names = await readdir(`${dir}/${PROMPT_DIR}`);
287
+ return names
288
+ .filter((n) => n.endsWith(PROMPT_SUFFIX))
289
+ .map((n) => n.slice(0, -PROMPT_SUFFIX.length))
290
+ .sort();
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ }
296
+ /**
297
+ * A `copilot` already installed is used as it is; otherwise `npx` fetches one.
298
+ * Note that finding the name on `PATH` is not the same as finding the CLI —
299
+ * an editor may have put a shim there that installs it on first run — so this
300
+ * only decides how to launch, never whether it will work.
301
+ */
302
+ export function locate(has = (cmd) => onPath(cmd)) {
303
+ return has('copilot')
304
+ ? { command: 'copilot', args: [], from: 'path' }
305
+ : { command: 'npx', args: ['--yes', '@github/copilot'], from: 'npx' };
306
+ }
307
+ function onPath(cmd) {
308
+ const dirs = (process.env.PATH ?? '').split(':').filter(Boolean);
309
+ return dirs.some((d) => existsSync(`${d}/${cmd}`));
310
+ }
311
+ export const terminalSink = {
312
+ answer: (text) => write(text),
313
+ narrate: (line) => note(line),
314
+ warn: (line) => note(red(line)),
315
+ };
316
+ /** Rows of narration the window keeps on screen. */
317
+ export const WINDOW_ROWS = 8;
318
+ const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
319
+ /** How often the border is redrawn — the wave's frame, not the clock's. */
320
+ export const FRAME_MS = 80;
321
+ // Dimmest first, crest last. Written as escapes rather than `styleText` because
322
+ // a named colour has no ramp, which is also why `styled()` has to be asked.
323
+ const WAVE = [
324
+ '\u001b[0;38;5;24m',
325
+ '\u001b[0;38;5;31m',
326
+ '\u001b[0;38;5;38m',
327
+ '\u001b[0;38;5;45m',
328
+ '\u001b[0;38;5;51m',
329
+ '\u001b[1;38;5;159m',
330
+ '\u001b[1;38;5;231m',
331
+ ];
332
+ const RESET = '\u001b[0m';
333
+ /** Cells each tone of the tail covers, so the crest reads as a band, not a dot. */
334
+ const WAVE_TAIL = 2;
335
+ /** Cells the crest travels per frame — a lap of a wide window in a few seconds. */
336
+ export const WAVE_STEP = 3;
337
+ /**
338
+ * How brightly one cell of the border is lit, given where the crest has got to.
339
+ *
340
+ * `position` counts clockwise from the top left corner, so one index walks the
341
+ * whole ring and the wave rounds the corners without a seam. Everything the
342
+ * tail has passed sits at the dimmest tone, which is the border's resting
343
+ * colour: the ring is always drawn, only the crest moves.
344
+ */
345
+ export function toneAt(position, head, span) {
346
+ const behind = (((head - position) % span) + span) % span;
347
+ const step = Math.floor(behind / WAVE_TAIL);
348
+ return step < WAVE.length ? WAVE.length - 1 - step : 0;
349
+ }
350
+ /**
351
+ * A run of border cells, coloured by the wave and emitting an escape only where
352
+ * the tone changes — a frame of the whole ring is a handful of them.
353
+ *
354
+ * `from` is the ring position of the first cell and `step` which way round the
355
+ * ring the cells run, because the bottom rule is printed left to right while
356
+ * the ring travels it right to left.
357
+ */
358
+ function lit(cells, from, step, head, span) {
359
+ let out = '';
360
+ let tone = -1;
361
+ for (let i = 0; i < cells.length; i++) {
362
+ const next = toneAt(from + i * step, head, span);
363
+ if (next !== tone) {
364
+ out += WAVE[next];
365
+ tone = next;
366
+ }
367
+ out += cells[i];
368
+ }
369
+ return `${out}${RESET}`;
370
+ }
371
+ /**
372
+ * The bottom rule as cells, with the status label set into it near the left.
373
+ *
374
+ * Waiting belongs to the frame rather than to the narration: a row of its own
375
+ * costs a step of what the agent is doing every time, and it settles just
376
+ * under the last line, which is where the eye already is. Near the left, where
377
+ * reading starts, and not so near that it displaces the corner.
378
+ */
379
+ export function footerCells(label, width) {
380
+ const room = width - 6;
381
+ const text = label && room > 0 ? [...label].slice(0, room) : [];
382
+ const cells = text.length > 0 ? [BOX.bl, BOX.h, ' ', ...text, ' '] : [BOX.bl];
383
+ while (cells.length < width - 1) {
384
+ cells.push(BOX.h);
385
+ }
386
+ return [...cells, BOX.br];
387
+ }
388
+ /**
389
+ * Narration held to a fixed number of rows that rewrite themselves in place.
390
+ *
391
+ * A long run is hundreds of tool calls, and printing each as its own line
392
+ * scrolls the question, the model line and every warning off the top of the
393
+ * screen — by the end the terminal holds a transcript nobody asked for and the
394
+ * answer is somewhere in the middle of it. Only the last few steps say what it
395
+ * is doing now, which is the only thing narration is for; the rest is in the
396
+ * session copilot recorded.
397
+ *
398
+ * Warnings are not narration: they leave the window and stay on the screen.
399
+ *
400
+ * Without a terminal there is nothing to rewrite over, so every line is its
401
+ * own — which is what a CI log wants anyway.
402
+ */
403
+ export function windowSink(rows = WINDOW_ROWS) {
404
+ if (!process.stderr.isTTY) {
405
+ return terminalSink;
406
+ }
407
+ const kept = [];
408
+ const colour = styled();
409
+ let label = '';
410
+ let head = 0;
411
+ let painted = 0;
412
+ // The frame must stay under the viewport: a block taller than the screen
413
+ // scrolls its own top away, and then the cursor-up erase falls short and
414
+ // strands a copy of every repaint. The two border rows count towards it.
415
+ const height = () => Math.max(1, Math.min(rows, (process.stderr.rows ?? 24) - 4));
416
+ const columns = () => Math.max(20, (process.stderr.columns ?? 80) - 1);
417
+ const innerOf = () => columns() - 4;
418
+ const erase = () => {
419
+ if (painted > 0) {
420
+ process.stderr.write(`\u001b[${painted}A\u001b[0J`);
421
+ painted = 0;
422
+ }
423
+ };
424
+ /** The whole frame, border lit by the wave, every row exactly one row. */
425
+ const frameOf = (show, inner) => {
426
+ const width = inner + 4;
427
+ // Clockwise from the top left: across the top, down the right, back
428
+ // along the bottom, up the left. One ring, so one index addresses it.
429
+ const span = 2 * width + 2 * show.length;
430
+ const top = [BOX.tl, ...Array(width - 2).fill(BOX.h), BOX.tr];
431
+ const bottom = footerCells(label, width);
432
+ const edge = (position) => colour ? lit([BOX.v], position, 1, head, span) : cyan(BOX.v);
433
+ return [
434
+ colour ? lit(top, 0, 1, head, span) : cyan(top.join('')),
435
+ ...show.map((line, r) => `${edge(span - 1 - r)} ${pad(cut(line, inner), inner)} ${edge(width + r)}`),
436
+ colour
437
+ ? lit(bottom, 2 * width + show.length - 1, -1, head, span)
438
+ : cyan(bottom.join('')),
439
+ ];
440
+ };
441
+ const paint = () => {
442
+ const show = kept.slice(-height());
443
+ if (show.length === 0 && label === '') {
444
+ erase();
445
+ return;
446
+ }
447
+ // The wave wants a frame to run round before there is anything to say.
448
+ const lines = frameOf(show.length > 0 ? show : [''], innerOf());
449
+ if (painted === lines.length) {
450
+ // Erasing before a repaint is what flickers; overwriting in place
451
+ // does not, and at frame rate the whole border has to be redrawn.
452
+ process.stderr.write(`\u001b[${painted}A${lines.map((l) => `\r${l}\u001b[0K\n`).join('')}`);
453
+ return;
454
+ }
455
+ erase();
456
+ process.stderr.write(lines.map((l) => `${l}\n`).join(''));
457
+ painted = lines.length;
458
+ };
459
+ return {
460
+ answer: (text) => {
461
+ erase();
462
+ write(text);
463
+ },
464
+ narrate: (line) => {
465
+ kept.push(...line.split('\n'));
466
+ if (kept.length > height() * 4) {
467
+ kept.splice(0, kept.length - height());
468
+ }
469
+ paint();
470
+ },
471
+ warn: (line) => {
472
+ erase();
473
+ note(red(line));
474
+ paint();
475
+ },
476
+ status: (line) => {
477
+ label = line;
478
+ head += WAVE_STEP;
479
+ paint();
480
+ },
481
+ close: erase,
482
+ };
483
+ }
484
+ /**
485
+ * The answer in the same box `zen run` draws it in: bounded width, rounded
486
+ * rule, fenced blocks kept as they were written.
487
+ *
488
+ * Redirected output gets the text and nothing else — `zen meta run ... > out.md`
489
+ * is meant to produce a file you can read, not one with a border down its side.
490
+ */
491
+ export function answerBox(text, columns = process.stdout.columns ?? 80) {
492
+ if (!process.stdout.isTTY) {
493
+ return text.split('\n');
494
+ }
495
+ const outer = answerWidth(columns);
496
+ const inner = outer - 4;
497
+ const body = [];
498
+ for (const segment of segmentsOf(text)) {
499
+ if (segment.code) {
500
+ // Indentation is the meaning of a fenced block, so it is cut rather
501
+ // than reflowed.
502
+ body.push(dim(`\u250c\u2500${segment.title ? ` ${segment.title}` : ''}`));
503
+ body.push(...segment.lines.map((l) => `${dim('\u2502')} ${cut(l, inner - 2)}`));
504
+ body.push(dim('\u2514\u2500'));
505
+ }
506
+ else {
507
+ body.push(...wrap(segment.lines.join('\n'), inner));
508
+ }
509
+ }
510
+ const rule = BOX.h.repeat(outer - 2);
511
+ return [
512
+ '',
513
+ dim(`${BOX.tl}${rule}${BOX.tr}`),
514
+ ...body.map((l) => `${dim(BOX.v)} ${pad(l, inner)} ${dim(BOX.v)}`),
515
+ dim(`${BOX.bl}${rule}${BOX.br}`),
516
+ '',
517
+ ];
518
+ }
519
+ /** `<project>/.tmp/logs/meta.<when>.log`, opened for append. */
520
+ export function openLog(dir, now = new Date()) {
521
+ const two = (n) => String(n).padStart(2, '0');
522
+ const stamp = `${now.getFullYear()}${two(now.getMonth() + 1)}${two(now.getDate())}` +
523
+ `${two(now.getHours())}${two(now.getMinutes())}${two(now.getSeconds())}`;
524
+ const folder = `${dir}/.tmp/logs`;
525
+ mkdirSync(folder, { recursive: true });
526
+ const path = `${folder}/meta.${stamp}.log`;
527
+ const answerPath = `${folder}/meta.${stamp}.md`;
528
+ const file = createWriteStream(path, { flags: 'a' });
529
+ return {
530
+ path,
531
+ answerPath,
532
+ line: (text) => {
533
+ file.write(`${plain(text)}\n`);
534
+ },
535
+ saveAnswer: (text) => {
536
+ writeFileSync(answerPath, text.endsWith('\n') ? text : `${text}\n`);
537
+ },
538
+ close: () => {
539
+ file.end();
540
+ },
541
+ };
542
+ }
543
+ /** Everything the sink is told, kept by the log too — and the detail only there. */
544
+ function tee(sink, log) {
545
+ return {
546
+ answer: (text) => sink.answer(text),
547
+ narrate: (line) => {
548
+ log.line(line);
549
+ sink.narrate(line);
550
+ },
551
+ detail: (line) => log.line(line),
552
+ warn: (line) => {
553
+ log.line(line);
554
+ sink.warn(line);
555
+ },
556
+ status: (line) => sink.status?.(line),
557
+ close: () => sink.close?.(),
558
+ };
559
+ }
560
+ /**
561
+ * Folds one event into the outcome and says what, if anything, to show for it.
562
+ *
563
+ * `assistant.message` carries both the running commentary and the final word;
564
+ * what separates them is `toolRequests`, so the last message that asked for no
565
+ * tool is the answer and the rest are narration.
566
+ */
567
+ export function absorb(event, out, sink, width) {
568
+ const data = (event.data ?? {});
569
+ switch (event.type) {
570
+ case 'assistant.message': {
571
+ const content = String(data.content ?? '').trim();
572
+ const requests = Array.isArray(data.toolRequests) ? data.toolRequests : [];
573
+ if (requests.length === 0) {
574
+ out.answer = content;
575
+ }
576
+ else if (content) {
577
+ sink.narrate(cut(content.replace(/\s+/g, ' '), width));
578
+ }
579
+ break;
580
+ }
581
+ case 'tool.execution_start': {
582
+ const args = (data.arguments ?? {});
583
+ const what = String(args.command ?? args.description ?? data.toolName ?? '').trim();
584
+ // Step-by-step belongs in the log: on screen it is a wall of `$`
585
+ // saying less than the one line of commentary above it.
586
+ sink.detail?.(`$ ${what.replace(/\s+/g, ' ')}`);
587
+ break;
588
+ }
589
+ case 'tool.execution_complete': {
590
+ if (data.success === false) {
591
+ sink.detail?.(` ${String(data.toolName ?? 'the tool')} failed`);
592
+ }
593
+ break;
594
+ }
595
+ case 'session.tools_updated': {
596
+ if (data.model) {
597
+ sink.detail?.(`model ${String(data.model)}`);
598
+ }
599
+ break;
600
+ }
601
+ case 'session.error': {
602
+ sink.warn(String(data.message ?? 'the session failed'));
603
+ break;
604
+ }
605
+ case 'model.call_failure': {
606
+ const status = data.statusCode ? ` (${String(data.statusCode)})` : '';
607
+ sink.warn(`${cut(String(data.errorMessage ?? 'the model call failed'), width)}${status}`);
608
+ break;
609
+ }
610
+ case 'result': {
611
+ out.exitCode = Number(event.exitCode ?? 0);
612
+ out.sessionId = event.sessionId;
613
+ out.usage = event.usage;
614
+ break;
615
+ }
616
+ default:
617
+ break;
618
+ }
619
+ out.events.push(event);
620
+ }
621
+ export async function launch(opts) {
622
+ const shown = opts.sink ?? windowSink();
623
+ const sink = opts.log ? tee(shown, opts.log) : shown;
624
+ const width = opts.width ?? Math.max(40, (process.stderr.columns ?? 100) - 4);
625
+ const start = opts.spawn ?? nodeSpawn;
626
+ const out = { answer: '', exitCode: 0, events: [] };
627
+ const child = start(opts.binary.command, [...opts.binary.args, ...opts.args], {
628
+ cwd: opts.cwd,
629
+ env: { ...process.env, ...opts.env },
630
+ stdio: ['ignore', 'pipe', 'pipe'],
631
+ shell: false,
632
+ });
633
+ const stop = (signal) => () => {
634
+ child.kill(signal);
635
+ };
636
+ const onInt = stop('SIGINT');
637
+ const onTerm = stop('SIGTERM');
638
+ process.on('SIGINT', onInt);
639
+ process.on('SIGTERM', onTerm);
640
+ // The first model call says nothing for as long as it takes, and silence
641
+ // after the model line is indistinguishable from a hang.
642
+ const began = Date.now();
643
+ const beat = setInterval(() => {
644
+ const seconds = Math.round((Date.now() - began) / 1000);
645
+ sink.status?.(`working: ${seconds}s`);
646
+ }, FRAME_MS);
647
+ beat.unref();
648
+ child.stderr?.on('data', (chunk) => {
649
+ const text = chunk.toString().trimEnd();
650
+ if (text) {
651
+ sink.narrate(dim(text));
652
+ }
653
+ });
654
+ try {
655
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
656
+ for await (const line of lines) {
657
+ if (!line.trim()) {
658
+ continue;
659
+ }
660
+ let event;
661
+ try {
662
+ event = JSON.parse(line);
663
+ }
664
+ catch {
665
+ // Not ours to interpret, but losing it is worse than showing it.
666
+ sink.narrate(line);
667
+ continue;
668
+ }
669
+ absorb(event, out, sink, width);
670
+ }
671
+ const code = await new Promise((resolve) => {
672
+ child.on('close', (value) => resolve(value ?? 0));
673
+ });
674
+ if (out.exitCode === 0 && code !== 0) {
675
+ out.exitCode = code;
676
+ }
677
+ }
678
+ finally {
679
+ clearInterval(beat);
680
+ process.off('SIGINT', onInt);
681
+ process.off('SIGTERM', onTerm);
682
+ // The answer is written by the caller, so the window has to be gone first.
683
+ sink.close?.();
684
+ }
685
+ return out;
686
+ }
687
+ /** Non-fatal, said once, because a competing file is a silent override. */
688
+ export function providersWarning() {
689
+ const configured = process.env.COPILOT_PROVIDERS_CONFIG;
690
+ const path = configured ?? `${process.env.HOME ?? ''}/.copilot/providers.json`;
691
+ return existsSync(path)
692
+ ? yellow(`${path} may override the provider zen just wired`)
693
+ : undefined;
694
+ }
695
+ //# sourceMappingURL=meta.js.map