@zenera/cli 1.1.9 → 1.1.11

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 (45) hide show
  1. package/README.md +49 -15
  2. package/dist/cache.d.ts +98 -0
  3. package/dist/cache.js +301 -0
  4. package/dist/catalog.d.ts +3 -0
  5. package/dist/catalog.js +35 -11
  6. package/dist/commands/cache.d.ts +7 -0
  7. package/dist/commands/cache.js +245 -0
  8. package/dist/commands/check.js +6 -3
  9. package/dist/commands/index.js +3 -1
  10. package/dist/commands/key.js +68 -14
  11. package/dist/commands/models.js +17 -1
  12. package/dist/commands/run.js +11 -3
  13. package/dist/commands/sandbox.js +70 -23
  14. package/dist/history.d.ts +18 -0
  15. package/dist/history.js +93 -0
  16. package/dist/home.d.ts +2 -2
  17. package/dist/home.js +2 -2
  18. package/dist/keys.d.ts +22 -0
  19. package/dist/keys.js +105 -2
  20. package/dist/lib.d.ts +1 -0
  21. package/dist/lib.js +1 -0
  22. package/dist/liveness.js +11 -0
  23. package/dist/resolve.d.ts +4 -0
  24. package/dist/resolve.js +43 -17
  25. package/dist/term.d.ts +25 -2
  26. package/dist/term.js +224 -10
  27. package/dist/tui/app.d.ts +10 -0
  28. package/dist/tui/app.js +542 -58
  29. package/dist/tui/theme.d.ts +6 -2
  30. package/dist/tui/theme.js +14 -8
  31. package/dist/tui/wrap.d.ts +92 -0
  32. package/dist/tui/wrap.js +147 -2
  33. package/dist/validate.d.ts +2 -0
  34. package/dist/validate.js +87 -2
  35. package/package.json +2 -2
  36. package/templates/editor/.github/copilot-instructions.md +50 -13
  37. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  38. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
  39. package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  41. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  42. package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
  43. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  44. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
  45. package/templates/editor/.vscode/settings.json +1 -1
package/dist/term.d.ts CHANGED
@@ -59,9 +59,32 @@ export interface Choice<T> {
59
59
  label: string;
60
60
  detail?: string;
61
61
  value: T;
62
+ /** Listed and picked by this instead of its position, e.g. `0` for an escape hatch. */
63
+ key?: string;
62
64
  }
63
- /** A numbered list. The pretty picker is the TUI's; this is the fallback. */
64
- export declare function choose<T>(title: string, choices: readonly Choice<T>[]): Promise<T>;
65
+ export interface ChooseOptions {
66
+ /**
67
+ * The choice Enter takes, as an index. It is marked `*`, because a default
68
+ * nobody can see is a default nobody uses.
69
+ */
70
+ initial?: number;
71
+ /** How many rows stay on screen at once; anything beyond them scrolls. */
72
+ window?: number;
73
+ }
74
+ /**
75
+ * A list, arrow-driven where the terminal allows it and numbered where it does
76
+ * not. Both forms agree on the two things that matter: every row has a stable
77
+ * number you can type, and one of them is the default Enter takes.
78
+ */
79
+ export declare function choose<T>(title: string, choices: readonly Choice<T>[], options?: ChooseOptions): Promise<T>;
80
+ /** A chunk of raw input, split into one string per keystroke. */
81
+ export declare function keysIn(data: string): Generator<string>;
82
+ /**
83
+ * Cut to a visible width, carrying the style codes over. Styling is invisible
84
+ * to the terminal's column count but not to `String.length`, so a naive slice
85
+ * either cuts too early or leaves a colour turned on.
86
+ */
87
+ export declare function cut(s: string, max: number): string;
65
88
  /** Piped input, or undefined when stdin is a terminal (i.e. nobody piped). */
66
89
  export declare function readStdin(): Promise<string | undefined>;
67
90
  export declare function ago(iso: string | undefined): string;
package/dist/term.js CHANGED
@@ -173,8 +173,14 @@ export async function confirm(question, fallback = false) {
173
173
  }
174
174
  return /^y(es)?$/i.test(answer);
175
175
  }
176
- /** A numbered list. The pretty picker is the TUI's; this is the fallback. */
177
- export async function choose(title, choices) {
176
+ /** Rows kept on screen when the caller does not say. */
177
+ const WINDOW = 10;
178
+ /**
179
+ * A list, arrow-driven where the terminal allows it and numbered where it does
180
+ * not. Both forms agree on the two things that matter: every row has a stable
181
+ * number you can type, and one of them is the default Enter takes.
182
+ */
183
+ export async function choose(title, choices, options = {}) {
178
184
  if (choices.length === 0) {
179
185
  throw usageError(`nothing to choose from: ${title}`);
180
186
  }
@@ -182,19 +188,227 @@ export async function choose(title, choices) {
182
188
  return choices[0].value;
183
189
  }
184
190
  requireTty(title, 'the matching flag');
191
+ const initial = Math.min(Math.max(options.initial ?? 0, 0), choices.length - 1);
192
+ // A keyed choice is out of the sequence, so it does not consume a number.
193
+ let seq = 0;
194
+ const keys = choices.map((c) => c.key ?? String(++seq));
195
+ // A terminal that cannot move its own cursor gets the list printed once and
196
+ // answers with a number; anything else gets the arrows.
197
+ const dumb = typeof process.stdin.setRawMode !== 'function' ||
198
+ (process.env['TERM'] ?? 'dumb') === 'dumb';
199
+ if (dumb) {
200
+ return await numbered(title, choices, keys, initial);
201
+ }
202
+ return await pick(title, choices, keys, initial, options.window ?? WINDOW);
203
+ }
204
+ /** The rows themselves, column-aligned once and then reused every frame. */
205
+ function listOf(choices, keys, initial) {
206
+ // A key that is not where counting would have put it is the one thing on
207
+ // the row you have to be told, so it is the one that takes a colour.
208
+ return table(choices.map((c, i) => [
209
+ `${i === initial ? cyan('*') : ' '} ` +
210
+ (c.key === undefined ? dim(`${keys[i]}.`) : cyan(`${keys[i]}.`)),
211
+ c.label,
212
+ dim(c.detail ?? ''),
213
+ ]));
214
+ }
215
+ /** The fallback: print everything, read a number. No raw mode, no repainting. */
216
+ async function numbered(title, choices, keys, initial) {
185
217
  note(bold(title));
186
- const rows = choices.map((c, i) => [` ${dim(`${i + 1}.`)}`, c.label, dim(c.detail ?? '')]);
187
- for (const line of table(rows)) {
188
- note(line);
218
+ for (const line of listOf(choices, keys, initial)) {
219
+ note(` ${line}`);
189
220
  }
221
+ const extra = keys.filter((_, i) => choices[i].key !== undefined);
222
+ const counted = keys.length - extra.length;
223
+ const hint = `Enter a number between 1 and ${counted}` +
224
+ (extra.length ? `, or ${extra.join(' / ')}` : '') +
225
+ '.';
190
226
  for (;;) {
191
- const answer = await ask('Choose', '1');
192
- const n = Number(answer);
193
- if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
194
- return choices[n - 1].value;
227
+ const answer = await ask('Choose', keys[initial]);
228
+ const at = keys.indexOf(answer);
229
+ if (at >= 0) {
230
+ return choices[at].value;
231
+ }
232
+ note(dim(hint));
233
+ }
234
+ }
235
+ /**
236
+ * The arrow-driven form. A long list is shown through a window rather than all
237
+ * at once: twenty-five sessions printed in full push the question itself off
238
+ * the screen, and the question is the part being answered.
239
+ *
240
+ * The frame is a fixed number of lines so it can be erased by counting them
241
+ * back, which is also why every line is cut to the terminal's width — a row
242
+ * that wraps is two rows the erase does not know about. When it is over the
243
+ * whole frame is replaced by the one line saying what was chosen.
244
+ */
245
+ async function pick(title, choices, keys, initial, window) {
246
+ const out = process.stderr;
247
+ const stdin = process.stdin;
248
+ const rows = listOf(choices, keys, initial);
249
+ const shown = Math.max(1, Math.min(window, choices.length, (out.rows ?? 24) - 3));
250
+ const height = shown + 2;
251
+ let at = initial;
252
+ let top = Math.min(Math.max(0, at - shown + 1), Math.max(0, choices.length - shown));
253
+ let typed = '';
254
+ let painted = false;
255
+ let done = false;
256
+ const columns = Math.max(20, (out.columns ?? 80) - 1);
257
+ const paint = () => {
258
+ if (painted) {
259
+ out.write(`\u001b[${height}A`);
260
+ }
261
+ const lines = [bold(title)];
262
+ for (let i = top; i < top + shown; i++) {
263
+ lines.push(`${i === at ? cyan('\u276f') : ' '} ${rows[i]}`);
264
+ }
265
+ lines.push(dim(' \u2191\u2193 move \u00b7 enter choose \u00b7 esc cancel' +
266
+ (typed ? ` \u00b7 ${typed}` : '') +
267
+ (choices.length > shown ? ` ${at + 1}/${choices.length}` : '')));
268
+ for (const line of lines) {
269
+ out.write(`\u001b[2K${cut(line, columns)}\n`);
270
+ }
271
+ painted = true;
272
+ };
273
+ const goto = (i) => {
274
+ at = Math.min(Math.max(i, 0), choices.length - 1);
275
+ top = Math.min(Math.max(top, at - shown + 1), at);
276
+ paint();
277
+ };
278
+ const move = (delta) => {
279
+ typed = '';
280
+ goto(at + delta);
281
+ };
282
+ return await new Promise((settle, reject) => {
283
+ const wasRaw = stdin.isRaw;
284
+ stdin.setRawMode(true);
285
+ stdin.resume();
286
+ stdin.setEncoding('utf8');
287
+ out.write('\u001b[?25l');
288
+ const close = (summary) => {
289
+ stdin.off('data', onData);
290
+ stdin.setRawMode(Boolean(wasRaw));
291
+ if (!wasRaw) {
292
+ stdin.pause();
293
+ }
294
+ // The list has served its purpose; the answer has not.
295
+ out.write(`\u001b[${height}A\u001b[0J\u001b[?25h`);
296
+ out.write(`${cut(`${bold(title)} ${cyan('\u276f')} ${summary}`, columns)}\n`);
297
+ };
298
+ const jump = (digit) => {
299
+ const next = typed + digit;
300
+ const to = keys.indexOf(next);
301
+ typed = to >= 0 ? next : digit;
302
+ const found = to >= 0 ? to : keys.indexOf(digit);
303
+ goto(found >= 0 ? found : at);
304
+ };
305
+ const onData = (data) => {
306
+ // One read can carry several keystrokes — held keys, a paste, or a
307
+ // fast typist — so it is split into keys before any of it is acted
308
+ // on. Reading the chunk as one key loses everything after the first.
309
+ for (const key of keysIn(data)) {
310
+ if (done) {
311
+ return;
312
+ }
313
+ act(key);
314
+ }
315
+ };
316
+ const act = (key) => {
317
+ switch (key) {
318
+ case '\r':
319
+ case '\n': {
320
+ const picked = choices[at];
321
+ done = true;
322
+ close(picked.detail ? `${picked.label} ${dim(picked.detail)}` : picked.label);
323
+ settle(picked.value);
324
+ return;
325
+ }
326
+ case '\u0003':
327
+ case '\u001b':
328
+ case 'q':
329
+ done = true;
330
+ close(dim('cancelled'));
331
+ reject(usageError('cancelled'));
332
+ return;
333
+ case '\u001b[A':
334
+ case '\u001bOA':
335
+ case 'k':
336
+ return move(-1);
337
+ case '\u001b[B':
338
+ case '\u001bOB':
339
+ case 'j':
340
+ return move(1);
341
+ case '\u001b[5~':
342
+ return move(-shown);
343
+ case '\u001b[6~':
344
+ return move(shown);
345
+ case '\u001b[H':
346
+ case '\u001b[1~':
347
+ return move(-choices.length);
348
+ case '\u001b[F':
349
+ case '\u001b[4~':
350
+ return move(choices.length);
351
+ case '\u007f':
352
+ case '\u0008':
353
+ typed = '';
354
+ return paint();
355
+ default:
356
+ // Typing digits is how a two-digit row is reached without
357
+ // twelve keystrokes, so they accumulate until one matches.
358
+ if (key >= '0' && key <= '9') {
359
+ jump(key);
360
+ }
361
+ }
362
+ };
363
+ stdin.on('data', onData);
364
+ paint();
365
+ });
366
+ }
367
+ /** Escape sequences a keyboard sends: CSI (`ESC [ … final`) and SS3 (`ESC O x`). */
368
+ const SEQUENCE = /^\u001b(?:\[[0-9;]*[A-Za-z~]|O[A-Za-z])/;
369
+ /** A chunk of raw input, split into one string per keystroke. */
370
+ export function* keysIn(data) {
371
+ let i = 0;
372
+ while (i < data.length) {
373
+ if (data[i] === '\u001b') {
374
+ const found = SEQUENCE.exec(data.slice(i));
375
+ if (found) {
376
+ yield found[0];
377
+ i += found[0].length;
378
+ continue;
379
+ }
380
+ }
381
+ yield data[i++];
382
+ }
383
+ }
384
+ /**
385
+ * Cut to a visible width, carrying the style codes over. Styling is invisible
386
+ * to the terminal's column count but not to `String.length`, so a naive slice
387
+ * either cuts too early or leaves a colour turned on.
388
+ */
389
+ export function cut(s, max) {
390
+ if (width(s).length <= max) {
391
+ return s;
392
+ }
393
+ let out = '';
394
+ let seen = 0;
395
+ for (let i = 0; i < s.length;) {
396
+ if (s[i] === '\u001b') {
397
+ const end = s.indexOf('m', i);
398
+ if (end < 0) {
399
+ break;
400
+ }
401
+ out += s.slice(i, end + 1);
402
+ i = end + 1;
403
+ continue;
404
+ }
405
+ if (seen === max - 1) {
406
+ break;
195
407
  }
196
- note(dim(`Enter a number between 1 and ${choices.length}.`));
408
+ out += s[i++];
409
+ seen++;
197
410
  }
411
+ return `${out}\u2026\u001b[0m`;
198
412
  }
199
413
  // ---------------------------------------------------------------------------
200
414
  // stdin
package/dist/tui/app.d.ts CHANGED
@@ -3,6 +3,16 @@ export interface AppOptions {
3
3
  readOnly: boolean;
4
4
  /** `dark`, `light` or `auto`. Unset means `auto`. */
5
5
  theme?: string;
6
+ /**
7
+ * How this session was arrived at. The two questions before the first
8
+ * frame can be answered with two keystrokes, so the header repeats which
9
+ * way they went — a run that resumed when you meant to start over should
10
+ * say so before the first turn, not after it.
11
+ */
12
+ started?: {
13
+ created: boolean;
14
+ freshWorkspace: boolean;
15
+ };
6
16
  }
7
17
  export declare function start(engine: Engine.Engine, options: AppOptions): Promise<void>;
8
18
  //# sourceMappingURL=app.d.ts.map