@zenera/cli 1.1.28 → 1.1.30

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 (43) hide show
  1. package/README.md +73 -24
  2. package/dist/audit.d.ts +2 -2
  3. package/dist/audit.js +11 -4
  4. package/dist/candidates.d.ts +9 -0
  5. package/dist/candidates.js +17 -0
  6. package/dist/catalog.d.ts +10 -5
  7. package/dist/catalog.js +93 -12
  8. package/dist/commands/completion.d.ts +9 -0
  9. package/dist/commands/completion.js +70 -0
  10. package/dist/commands/index.js +2 -0
  11. package/dist/commands/init.js +1 -1
  12. package/dist/commands/key.js +181 -17
  13. package/dist/commands/meta.d.ts +3 -0
  14. package/dist/commands/meta.js +134 -29
  15. package/dist/commands/models.js +56 -98
  16. package/dist/completion-install.d.ts +16 -0
  17. package/dist/completion-install.js +85 -0
  18. package/dist/completion.d.ts +9 -0
  19. package/dist/completion.js +93 -0
  20. package/dist/keys.d.ts +29 -4
  21. package/dist/keys.js +67 -3
  22. package/dist/liveness.js +73 -3
  23. package/dist/main.js +22 -0
  24. package/dist/postinstall.d.ts +2 -0
  25. package/dist/postinstall.js +10 -0
  26. package/dist/sandbox.js +4 -3
  27. package/dist/term.d.ts +2 -0
  28. package/dist/term.js +2 -0
  29. package/dist/tui/links.d.ts +2 -0
  30. package/dist/tui/links.js +31 -7
  31. package/dist/tui/models.d.ts +5 -0
  32. package/dist/tui/models.js +21 -14
  33. package/dist/validate.js +2 -2
  34. package/package.json +8 -6
  35. package/postinstall.mjs +7 -0
  36. package/templates/completions/zen.bash +16 -0
  37. package/templates/completions/zen.fish +22 -0
  38. package/templates/completions/zen.zsh +28 -0
  39. package/templates/editor/.github/copilot-instructions.md +5 -4
  40. package/templates/editor/.github/prompts/spec-edit.prompt.md +250 -0
  41. package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -2
  42. package/templates/editor/.github/skills/zen-cli/references/keys.md +29 -2
  43. package/templates/editor/.github/skills/zen-cli/references/models.md +22 -23
@@ -1,10 +1,11 @@
1
+ import { GATEWAY_URL_ENV } from '@zenera/neo';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { parse } from "../args.js";
3
4
  import { ensureHome } from "../home.js";
4
- import { ambient, ambientId, assertNotEmpty, checkRegion, describe, envNames, envOf, formOf, keyId, KeyStore, mask, OWNERS, parseRef, SHAPES, } from "../keys.js";
5
+ import { ambient, ambientId, assertNotEmpty, checkRegion, describe, envNames, envOf, formOf, GATEWAYS, isGateway, keyId, KeyStore, mask, MODES, OWNERS, parseRef, SHAPES, } from "../keys.js";
5
6
  import { probe, probeAll } from "../liveness.js";
6
- import { ago, ask, askSecret, bold, confirm, credentialError, cyan, dim, green, isInteractive, json, note, progress, readStdin, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
- const USAGE = 'zen key <ls|add|use|check|rm|show|env> [ref] [options]';
7
+ import { ago, ask, askSecret, badge, bold, confirm, credentialError, cyan, dim, green, isInteractive, json, note, progress, readStdin, red, table, usageError, warn, write, writeAll, yellow, } from "../term.js";
8
+ const USAGE = 'zen key <ls|add|use|mode|check|rm|show|env> [ref] [options]';
8
9
  // ---------------------------------------------------------------------------
9
10
  // Display
10
11
  // ---------------------------------------------------------------------------
@@ -32,6 +33,9 @@ const ORIGIN = {
32
33
  * the environment supplied either, because then the stored one is not in play.
33
34
  */
34
35
  function placed(store, entry) {
36
+ if (entry.url) {
37
+ return dim(entry.url);
38
+ }
35
39
  if (entry.provider !== 'vertex' || entry.holds !== 'file') {
36
40
  return '';
37
41
  }
@@ -50,25 +54,52 @@ function placed(store, entry) {
50
54
  * intent — it can be listed and probed, not chosen or forgotten.
51
55
  */
52
56
  function asEntry(cred) {
57
+ const url = isGateway(cred.provider) ? envUrl() : undefined;
53
58
  return {
54
59
  provider: cred.provider,
55
60
  name: cred.env ? `$${cred.env}` : 'adc',
56
61
  holds: cred.holds,
57
62
  value: cred.value,
58
63
  env: cred.env,
64
+ ...(url ? { url } : {}),
59
65
  addedAt: new Date().toISOString(),
60
66
  };
61
67
  }
68
+ const envUrl = () => process.env[GATEWAY_URL_ENV]?.trim() || undefined;
69
+ /** Where a gateway mode sends runs, and with which key: the environment beats the keyring. */
70
+ function route(store, mode) {
71
+ const entry = store.active(mode);
72
+ const shell = envNames(mode).find((name) => process.env[name]);
73
+ return {
74
+ url: envUrl() ?? entry?.url,
75
+ key: shell ? `$${shell}` : entry ? keyId(entry) : undefined,
76
+ };
77
+ }
78
+ const FROM_ENV = `set by $${GATEWAY_URL_ENV} in the environment`;
62
79
  function rows(store, borrowed) {
63
80
  const out = [
64
- [bold(''), bold('KEY'), bold('VALUE'), bold('GCP'), bold('STATE'), bold('CHECKED')],
81
+ [bold(''), bold('KEY'), bold('VALUE'), bold('WHERE'), bold('STATE'), bold('CHECKED')],
65
82
  ];
66
83
  for (const provider of OWNERS) {
84
+ // Held but not in play under the current mode: drawn grey, so the live set stands out.
85
+ const idle = unused(store, provider);
67
86
  for (const entry of store.for(provider)) {
68
87
  const shadow = envNames(provider).find((name) => process.env[name]);
88
+ if (idle) {
89
+ out.push([
90
+ dim('-'),
91
+ dim(keyId(entry)),
92
+ dim(describe(store, entry)),
93
+ dim(entry.url ?? ''),
94
+ dim(entry.check?.state ?? 'unchecked'),
95
+ dim(`not used in ${store.mode} mode`),
96
+ ]);
97
+ continue;
98
+ }
99
+ const gateway = isGateway(provider);
69
100
  out.push([
70
101
  store.isActive(entry) ? green('*') : ' ',
71
- keyId(entry),
102
+ gateway && store.isActive(entry) ? bold(cyan(keyId(entry))) : keyId(entry),
72
103
  dim(describe(store, entry)),
73
104
  placed(store, entry),
74
105
  state(entry),
@@ -78,17 +109,49 @@ function rows(store, borrowed) {
78
109
  }
79
110
  for (const [cred, entry] of borrowed.filter(([c]) => c.provider === provider)) {
80
111
  out.push([
81
- dim('~'),
112
+ dim(idle ? '-' : '~'),
82
113
  dim(ambientId(cred)),
83
114
  dim(describe(store, entry)),
84
- placed(store, entry),
85
- state(entry),
86
- dim(cred.env ? 'from the environment' : 'from gcloud'),
115
+ idle ? '' : placed(store, entry),
116
+ idle ? dim(entry.check?.state ?? 'unchecked') : state(entry),
117
+ dim(idle
118
+ ? `not used in ${store.mode} mode`
119
+ : cred.env
120
+ ? 'from the environment'
121
+ : 'from gcloud'),
87
122
  ]);
88
123
  }
89
124
  }
90
125
  return table(out);
91
126
  }
127
+ /** A vendor key under a gateway mode, or a gateway key under any other. */
128
+ function unused(store, provider) {
129
+ if (isGateway(provider)) {
130
+ return store.mode !== provider;
131
+ }
132
+ return SHAPES[provider].kind === 'model' && store.mode !== 'direct';
133
+ }
134
+ /**
135
+ * Above the table, because it decides which half of the table matters. Said
136
+ * only once a gateway key exists: before that there is no choice to report.
137
+ */
138
+ function modeLine(store) {
139
+ const mode = store.mode;
140
+ if (mode === 'direct') {
141
+ if (GATEWAYS.some((g) => store.active(g))) {
142
+ note(`${bold('mode')} ${cyan('direct')} ${dim('— each provider with its own key; zen key mode bifrost to send every model through the gateway')}`);
143
+ note('');
144
+ }
145
+ return;
146
+ }
147
+ const { url, key } = route(store, mode);
148
+ note(`${cyan(badge(`⇄ ${mode.toUpperCase()} MODE`))} ${bold(url ?? 'no url stored')}`);
149
+ note(dim(`every model goes through the gateway with ${key ?? 'no key'}; vendor keys are not used — ` +
150
+ (store.modeFrom === 'env'
151
+ ? `${FROM_ENV}, unset it to stop`
152
+ : 'zen key mode direct to stop')));
153
+ note('');
154
+ }
92
155
  /**
93
156
  * Checking is a network round trip per key, so it says which ones have come
94
157
  * back. Silence for ten seconds is indistinguishable from a hang.
@@ -123,6 +186,7 @@ const ls = async (ctx, args) => {
123
186
  }
124
187
  if (ctx.json) {
125
188
  json({
189
+ mode: store.mode,
126
190
  keys: store.entries.map((e) => ({
127
191
  ...e,
128
192
  value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
@@ -140,13 +204,17 @@ const ls = async (ctx, args) => {
140
204
  });
141
205
  return;
142
206
  }
143
- if (store.entries.length === 0 && borrowed.length === 0) {
207
+ if (store.entries.length === 0 && borrowed.length === 0 && store.mode === 'direct') {
144
208
  note('the keyring is empty');
145
209
  note(dim('add one: zen key add openai'));
146
210
  return;
147
211
  }
212
+ modeLine(store);
148
213
  writeAll(rows(store, borrowed));
149
- const missing = OWNERS.filter((p) => !store.active(p) && !borrowed.some(([c]) => c.provider === p));
214
+ const missing = OWNERS.filter((p) => !store.active(p) &&
215
+ !borrowed.some(([c]) => c.provider === p) &&
216
+ (!isGateway(p) || store.mode === p) &&
217
+ (store.mode === 'direct' || SHAPES[p].kind !== 'model'));
150
218
  if (missing.length) {
151
219
  note('');
152
220
  note(dim(`no key for: ${missing.join(', ')}`));
@@ -164,8 +232,9 @@ const add = async (ctx, args) => {
164
232
  // Prefixed because every other command's --project is a Zenera one.
165
233
  'gcp-project': { type: 'string' },
166
234
  'gcp-location': { type: 'string' },
235
+ url: { type: 'string' },
167
236
  }, 'zen key add <provider>[/name] [--name <name>] [--gcp-project <id>] ' +
168
- '[--gcp-location <region>] [--no-check]');
237
+ '[--gcp-location <region>] [--url <gateway url>] [--no-check]');
169
238
  const ref = positionals[0];
170
239
  if (!ref) {
171
240
  throw usageError('which provider?', `one of: ${OWNERS.join(', ')}`);
@@ -177,6 +246,7 @@ const add = async (ctx, args) => {
177
246
  if ((values['gcp-project'] || values['gcp-location']) && provider !== 'vertex') {
178
247
  throw usageError(`--gcp-project and --gcp-location mean nothing to ${shape.label}`, 'they configure a Vertex service account');
179
248
  }
249
+ const url = gatewayUrl(provider, values.url);
180
250
  // Checked before the prompt, so a typo does not cost you pasting the key.
181
251
  const region = values['gcp-location'];
182
252
  if (region !== undefined && !checkRegion(region)) {
@@ -208,6 +278,7 @@ const add = async (ctx, args) => {
208
278
  const entry = store.add(provider, name, raw, {
209
279
  project: values['gcp-project'],
210
280
  location: values['gcp-location'],
281
+ url,
211
282
  });
212
283
  // Verified before it is trusted, but stored either way: a key that cannot
213
284
  // be checked right now — offline, behind a proxy — is not a key that is
@@ -249,6 +320,9 @@ const add = async (ctx, args) => {
249
320
  if (process.env[envOf(entry)]) {
250
321
  note(yellow(`$${envOf(entry)} is set and will win over this`));
251
322
  }
323
+ if (isGateway(provider) && store.mode !== provider) {
324
+ note(dim(`not in use yet — send every model through it with: zen key mode ${provider}`));
325
+ }
252
326
  if (provider === 'vertex' && entry.holds === 'file' && !entry.project) {
253
327
  const found = store.projectOf(entry);
254
328
  note(dim(found
@@ -256,6 +330,36 @@ const add = async (ctx, args) => {
256
330
  : 'no --gcp-project given, and the key file names no project_id'));
257
331
  }
258
332
  };
333
+ /**
334
+ * A gateway is nothing without its address, and nothing else has one. Only
335
+ * http(s) is taken: the url ends up as an SDK base url, where anything else
336
+ * fails three frames down.
337
+ */
338
+ function gatewayUrl(provider, raw) {
339
+ if (!isGateway(provider)) {
340
+ if (raw !== undefined) {
341
+ throw usageError(`--url means nothing to ${SHAPES[provider].label}`, 'it locates a gateway');
342
+ }
343
+ return undefined;
344
+ }
345
+ if (!raw) {
346
+ throw usageError(`${SHAPES[provider].label} needs --url`, `e.g. zen key add ${provider} --url https://bifrost.example.com`);
347
+ }
348
+ let url;
349
+ try {
350
+ url = new URL(raw);
351
+ }
352
+ catch {
353
+ throw usageError(`"${raw}" is not a url`);
354
+ }
355
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
356
+ throw usageError(`"${raw}" is not an http(s) url`);
357
+ }
358
+ if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
359
+ note(yellow('plain http: the virtual key will cross the network unencrypted'));
360
+ }
361
+ return url.href.replace(/\/+$/, '');
362
+ }
259
363
  /**
260
364
  * A provider with two forms cannot ask for one of them. It asks with the echo
261
365
  * off, because one of the two answers is a secret and there is no way to know
@@ -294,11 +398,53 @@ const use = async (ctx, args) => {
294
398
  }
295
399
  note(`${green('using')} ${bold(keyId(entry))} for ${SHAPES[provider].label}`);
296
400
  };
401
+ /** Bare, it says which mode a run would use; with a word, it switches. */
402
+ const mode = async (ctx, args) => {
403
+ const { positionals } = parse(args, {}, `zen key mode [${MODES.join('|')}]`);
404
+ const wanted = positionals[0];
405
+ const store = await KeyStore.open();
406
+ if (wanted !== undefined) {
407
+ if (!MODES.includes(wanted)) {
408
+ throw usageError(`unknown mode "${wanted}"`, `one of: ${MODES.join(', ')}`);
409
+ }
410
+ // Already on through the environment: storing it as well needs a gateway key of its own.
411
+ const viaEnv = store.modeFrom === 'env' && wanted === store.mode;
412
+ if (!viaEnv || store.active(store.mode)?.url) {
413
+ store.setMode(wanted);
414
+ store.save();
415
+ }
416
+ if (store.modeFrom === 'env' && wanted !== store.mode) {
417
+ warn(`saved, but ${FROM_ENV} wins: unset it to call vendors directly`);
418
+ }
419
+ }
420
+ const now = store.mode;
421
+ const from = store.modeFrom;
422
+ const { url, key } = now === 'direct' ? {} : route(store, now);
423
+ if (ctx.json) {
424
+ json({ mode: now, from, ...(key ? { key } : {}), ...(url ? { url } : {}) });
425
+ return;
426
+ }
427
+ if (now === 'direct') {
428
+ note(`${cyan('direct')} ${dim('— each provider is called with its own key')}`);
429
+ return;
430
+ }
431
+ note(`${cyan(now)} ${dim('— every model goes through')} ${url ?? 'no url stored'} ` +
432
+ dim(`with ${key ?? 'no key'}`));
433
+ if (from === 'env') {
434
+ note(dim(FROM_ENV));
435
+ }
436
+ };
297
437
  const check = async (ctx, args) => {
298
438
  const { positionals } = parse(args, {}, 'zen key check [provider[/name]]');
299
439
  const store = await KeyStore.open();
300
- assertNotEmpty(store);
301
- const targets = positionals[0] ? select(store, positionals[0]) : store.entries;
440
+ // Checked like stored keys, never recorded: `store.record` finds no entry for them.
441
+ const borrowed = ambient(store).map(asEntry);
442
+ const targets = positionals[0]
443
+ ? select(store, positionals[0], borrowed)
444
+ : [...store.entries, ...borrowed];
445
+ if (targets.length === 0) {
446
+ assertNotEmpty(store);
447
+ }
302
448
  const checks = await checkAll(ctx, store, targets);
303
449
  for (const [entry, result] of checks) {
304
450
  store.record(entry, result);
@@ -327,12 +473,13 @@ const check = async (ctx, args) => {
327
473
  throw credentialError('at least one key authenticated but cannot be used');
328
474
  }
329
475
  };
330
- function select(store, ref) {
476
+ function select(store, ref, borrowed = []) {
331
477
  const { provider, name } = parseRef(ref);
332
478
  if (!name) {
333
- return store.for(provider);
479
+ return [...store.for(provider), ...borrowed.filter((e) => e.provider === provider)];
334
480
  }
335
- const entry = store.find(provider, name);
481
+ const entry = store.find(provider, name) ??
482
+ borrowed.find((e) => e.provider === provider && e.name === name);
336
483
  if (!entry) {
337
484
  throw usageError(`no key ${ref}`, 'see: zen key ls');
338
485
  }
@@ -404,6 +551,7 @@ const show = async (ctx, args) => {
404
551
  ? [[dim('gcp project'), `${project.id} ${dim(ORIGIN[project.from])}`]]
405
552
  : []),
406
553
  ...(entry.location ? [[dim('gcp location'), entry.location]] : []),
554
+ ...(entry.url ? [[dim('url'), entry.url]] : []),
407
555
  [dim('state'), state(entry)],
408
556
  ...(entry.check?.fix ? [[dim('fix'), entry.check.fix]] : []),
409
557
  [dim('added'), ago(entry.addedAt)],
@@ -424,11 +572,18 @@ const env = async (ctx, args) => {
424
572
  // Ignore what is already exported: the point is to produce the exports.
425
573
  const vars = {};
426
574
  for (const provider of only ?? OWNERS) {
575
+ // Unasked-for, a gateway is exported only in its own mode, where it is the switch.
576
+ if (!only && isGateway(provider) && store.mode !== provider) {
577
+ continue;
578
+ }
427
579
  const entry = store.active(provider);
428
580
  if (!entry) {
429
581
  continue;
430
582
  }
431
583
  vars[envOf(entry)] = store.reveal(entry);
584
+ if (entry.url) {
585
+ vars[GATEWAY_URL_ENV] = entry.url;
586
+ }
432
587
  // A service account says which project it belongs to but not which
433
588
  // region to call, and a shell that has the file and not the region is
434
589
  // still a shell that cannot reach a model.
@@ -498,6 +653,7 @@ const SUBS = {
498
653
  list: ls,
499
654
  add,
500
655
  use,
656
+ mode,
501
657
  check,
502
658
  rm,
503
659
  remove: rm,
@@ -516,14 +672,22 @@ export const key = {
516
672
  '',
517
673
  'Model providers: openai, anthropic, google, vertex, openrouter.',
518
674
  'Services the tools call: exa.',
675
+ `Gateways every model can go through: ${GATEWAYS.join(', ')}.`,
519
676
  '',
520
677
  'Vertex takes either shape: a service-account JSON file, which wants',
521
678
  '--gcp-project and --gcp-location too, or an express-mode API key, which',
522
679
  'wants neither. Which one you gave is read off the value.',
523
680
  '',
681
+ 'A gateway holds one virtual key and its --url. In its mode every model',
682
+ 'goes through it, each in its own vendor protocol, and agents.yaml does',
683
+ 'not change: vertex:gemini-3.5-flash reaches <url>/genai as',
684
+ 'vertex/gemini-3.5-flash. Vendor keys are withheld from runs meanwhile.',
685
+ '',
524
686
  ' zen key ls [--check] Everything stored, and its state.',
525
687
  ' zen key add <provider>[/name] Read a key from stdin, or ask for it.',
688
+ ' zen key add bifrost --url <url> A gateway key, and where the gateway is.',
526
689
  ' zen key use <provider>/<name> Choose which one a run uses.',
690
+ ' zen key mode [direct|bifrost] Call vendors directly, or via the gateway.',
527
691
  ' zen key check [provider[/name]] Ask the provider whether it still works.',
528
692
  ' zen key rm <provider>/<name> Forget one.',
529
693
  ' zen key show <ref> [--reveal] Masked by default.',
@@ -1,3 +1,6 @@
1
+ import { type Candidate } from '../candidates.ts';
1
2
  import type { Command } from '../command.ts';
2
3
  export declare const meta: Command;
4
+ /** `args` is what came after `meta`, without the word being completed. */
5
+ export declare function completeMeta(args: readonly string[], cur: string, cwd: string): Promise<Candidate[]>;
3
6
  //# sourceMappingURL=meta.d.ts.map
@@ -5,10 +5,11 @@ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { parse } from "../args.js";
8
+ import { matching, projectNames } from "../candidates.js";
8
9
  import { loadProjectEnv } from "../env.js";
9
10
  import { copilotEventLog } from "../eventlog.js";
10
11
  import { ensureHome } from "../home.js";
11
- import { assertOwner, KeyStore, PROVIDERS } from "../keys.js";
12
+ import { assertOwner, isProvider, KeyStore, PROVIDERS } from "../keys.js";
12
13
  import { probeChat, probeModel } from "../liveness.js";
13
14
  import { answerBox, chooseModel, DATASET_DETAILS, DATASET_SUMMARY, DATASET_USAGE, datasetProjectFlag, DEFAULT_RESUMES, defaultRef, editorFiles, FINETUNE_DETAILS, FINETUNE_SUMMARY, FINETUNE_USAGE, finetuneProjectFlag, lastSession, launch, listPrompts, loadPrompt, locate, masked, misspelledProvider, MODEL_ENV, openLog, ORDER, PROMPT_DIR, providersWarning, readMeta, recordSession, RESUME_PROMPT, resumeDelayMs, runDataset, runFinetune, sameModel, sessionModel, sessionTotals, SOURCE_LABELS, startVertexRelay, tailSpans, Tally, terminalSink, transient, wire, writeMeta, } from "../meta/index.js";
14
15
  import { splitRef } from "../modelref.js";
@@ -57,6 +58,32 @@ function page(p) {
57
58
  write(line ? ` ${line}` : '');
58
59
  }
59
60
  }
61
+ /** Kept apart from `run` so that shell completion offers exactly what is parsed. */
62
+ const OPTIONS = {
63
+ project: { type: 'string' },
64
+ provider: { type: 'string' },
65
+ model: { type: 'string' },
66
+ prompt: { type: 'string', short: 'p' },
67
+ 'prompt-file': { type: 'string' },
68
+ agent: { type: 'string' },
69
+ effort: { type: 'string' },
70
+ share: { type: 'string' },
71
+ events: { type: 'string' },
72
+ resume: { type: 'string' },
73
+ continue: { type: 'boolean' },
74
+ 'session-id': { type: 'string' },
75
+ retries: { type: 'string' },
76
+ 'allow-all': { type: 'boolean' },
77
+ 'allow-tool': { type: 'string' },
78
+ ask: { type: 'boolean' },
79
+ 'add-dir': { type: 'string', multiple: true },
80
+ 'dry-run': { type: 'boolean' },
81
+ local: { type: 'boolean' },
82
+ pick: { type: 'boolean' },
83
+ clear: { type: 'boolean' },
84
+ force: { type: 'boolean' },
85
+ 'no-refresh': { type: 'boolean' },
86
+ };
60
87
  const RUN_FORMS = [
61
88
  ' zen meta run [project] "<question>" ask it something',
62
89
  ' zen meta run [project] /<name> [words] run a stored prompt',
@@ -65,7 +92,8 @@ const RUN_FORMS = [
65
92
  const RUN_ARGUMENTS = [
66
93
  'Arguments:',
67
94
  ' [project] Name of a project. Default: the one you are in.',
68
- ' [prompt] Your question, in quotes. Also --prompt, or piped in.',
95
+ ' [prompt] Your question, in quotes. Or --prompt, or piped in when neither',
96
+ ' it nor --prompt-file is given.',
69
97
  ' /<name> A prompt file in .github/prompts/<name>.prompt.md.',
70
98
  ' [words] Appended to that prompt as a final line.',
71
99
  ];
@@ -74,7 +102,10 @@ const RUN_OPTIONS = [
74
102
  ' --project <name|dir> Which project to work in. The agent is rooted there.',
75
103
  ' --model <ref> Model for this run, e.g. vertex/gemini-3.8-flash.',
76
104
  ' --provider <name> Force the key to use. Default: the model ref says.',
77
- ' --prompt-file <file> Append this file to the prompt, after any words.',
105
+ ' -p, --prompt <text> The question, in place of [prompt].',
106
+ ' Ignored with /<name>: put words after it instead.',
107
+ ' --prompt-file <file> Added after the question - or after /<name> and its',
108
+ ' words - past a blank line. Alone, it is the question.',
78
109
  ' --agent <name> A .github/agents/<name>.agent.md.',
79
110
  ' --effort <level> none, minimal, low, medium, high, xhigh or max.',
80
111
  ' --allow-tool <list> Only these: read, shell(git:*), write(README.md).',
@@ -182,31 +213,7 @@ export const meta = {
182
213
  });
183
214
  return await run({ args, json: ctx.json, cwd: ctx.cwd }, found);
184
215
  }
185
- const { values, positionals } = parse(ctx.args, {
186
- project: { type: 'string' },
187
- provider: { type: 'string' },
188
- model: { type: 'string' },
189
- prompt: { type: 'string', short: 'p' },
190
- 'prompt-file': { type: 'string' },
191
- agent: { type: 'string' },
192
- effort: { type: 'string' },
193
- share: { type: 'string' },
194
- events: { type: 'string' },
195
- resume: { type: 'string' },
196
- continue: { type: 'boolean' },
197
- 'session-id': { type: 'string' },
198
- retries: { type: 'string' },
199
- 'allow-all': { type: 'boolean' },
200
- 'allow-tool': { type: 'string' },
201
- ask: { type: 'boolean' },
202
- 'add-dir': { type: 'string', multiple: true },
203
- 'dry-run': { type: 'boolean' },
204
- local: { type: 'boolean' },
205
- pick: { type: 'boolean' },
206
- clear: { type: 'boolean' },
207
- force: { type: 'boolean' },
208
- 'no-refresh': { type: 'boolean' },
209
- }, USAGE);
216
+ const { values, positionals } = parse(ctx.args, OPTIONS, USAGE);
210
217
  // `zen meta acme run` is typed at least as often as `zen meta run acme`,
211
218
  // and read literally it asks the agent the one-word question "run" —
212
219
  // a whole model call spent on a typo, with nothing on screen to say so.
@@ -388,6 +395,104 @@ async function pickPrompt(ctx, project) {
388
395
  return await choose('Which prompt?', names.map((n) => ({ label: `/${n}`, value: n })));
389
396
  }
390
397
  // ---------------------------------------------------------------------------
398
+ // Completing it
399
+ //
400
+ // `zen meta run acme /<Tab>` is where a terminal can show what an editor's menu
401
+ // shows. It reads the line the way `stored` does — a leading slash is a prompt,
402
+ // a bare word that names a project is the project — and it only ever reads:
403
+ // the editor files are not refreshed for the sake of a Tab.
404
+ // ---------------------------------------------------------------------------
405
+ async function findProject(name) {
406
+ try {
407
+ return await Projects.find(name);
408
+ }
409
+ catch {
410
+ return undefined;
411
+ }
412
+ }
413
+ async function promptsOf(project) {
414
+ if (!project) {
415
+ return [];
416
+ }
417
+ const names = await listPrompts(project.dir);
418
+ return await Promise.all(names.map(async (name) => ({
419
+ value: `/${name}`,
420
+ description: (await loadPrompt(project.dir, name).catch(() => undefined))?.description,
421
+ })));
422
+ }
423
+ /** `args` is what came after `meta`, without the word being completed. */
424
+ export async function completeMeta(args, cur, cwd) {
425
+ const words = [];
426
+ let flagged;
427
+ let waiting;
428
+ for (const arg of args) {
429
+ if (waiting) {
430
+ if (waiting === 'project') {
431
+ flagged = arg;
432
+ }
433
+ waiting = undefined;
434
+ }
435
+ else if (arg.startsWith('--')) {
436
+ const [flag, value] = arg.slice(2).split(/=(.*)/s);
437
+ if (value !== undefined) {
438
+ flagged = flag === 'project' ? value : flagged;
439
+ }
440
+ else if (OPTIONS[flag]?.type === 'string') {
441
+ waiting = flag;
442
+ }
443
+ }
444
+ else if (arg === '-p') {
445
+ waiting = 'prompt';
446
+ }
447
+ else if (!arg.startsWith('-')) {
448
+ words.push(arg);
449
+ }
450
+ }
451
+ // A flag's value is the flag's to name, and most of them are free text or a
452
+ // path — which is the shell's.
453
+ if (waiting === 'provider') {
454
+ return matching(PROVIDERS.map((value) => ({ value })), cur);
455
+ }
456
+ if (waiting === 'project') {
457
+ return matching(await projectNames(), cur);
458
+ }
459
+ if (waiting) {
460
+ return [];
461
+ }
462
+ const at = words.findIndex((w) => VERBS.has(w));
463
+ const named = at === 1 ? words[0] : undefined;
464
+ const verb = at === 0 || (named && (await findProject(named))) ? words[at] : undefined;
465
+ if (cur.startsWith('-')) {
466
+ return verb === 'dataset' || verb === 'finetune'
467
+ ? []
468
+ : matching(Object.keys(OPTIONS).map((flag) => ({ value: `--${flag}` })), cur);
469
+ }
470
+ if (!verb) {
471
+ const verbs = [...VERBS].map((value) => ({ value }));
472
+ if (words.length === 0) {
473
+ return matching([...verbs, ...(await projectNames())], cur);
474
+ }
475
+ return words.length === 1 && (await findProject(words[0])) ? matching(verbs, cur) : [];
476
+ }
477
+ const rest = words.slice(at + 1);
478
+ if (verb === 'prompts' || verb === 'resume') {
479
+ return flagged || named || rest.length > 0 ? [] : matching(await projectNames(), cur);
480
+ }
481
+ if (verb !== 'run' || rest.some((w) => w.startsWith('/'))) {
482
+ // Past the prompt's name, what follows is files and words.
483
+ return [];
484
+ }
485
+ const given = flagged ?? named ?? rest[0];
486
+ if (given === undefined) {
487
+ const here = await Projects.current(cwd).catch(() => undefined);
488
+ return matching([...(await promptsOf(here)), ...(await projectNames())], cur);
489
+ }
490
+ const project = await findProject(given);
491
+ const fromRest = !flagged && !named;
492
+ const free = rest.length - (fromRest && project ? 1 : 0);
493
+ return free > 0 ? [] : matching(await promptsOf(project), cur);
494
+ }
495
+ // ---------------------------------------------------------------------------
391
496
  // zen meta prompts [project]
392
497
  //
393
498
  // A slash command is only discoverable inside an editor, where a menu drops
@@ -789,7 +894,7 @@ function count(text, flag) {
789
894
  }
790
895
  function asProvider(name) {
791
896
  const owner = assertOwner(name);
792
- if (owner === 'exa') {
897
+ if (!isProvider(owner)) {
793
898
  throw usageError(`${name} is not a model provider`);
794
899
  }
795
900
  return owner;