@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
@@ -7,18 +7,18 @@
7
7
  // This command answers the first, and needs no project at all.
8
8
  //
9
9
  // The split matters most when something breaks. `zen check` says "this
10
- // embedder was refused"; `zen models pick --embedding` says "use this one
11
- // instead", and prints a ref on stdout that can be pasted straight back into
12
- // agents.yaml — or read by an agent doing the pasting.
10
+ // embedder was refused"; `zen models ls --embeddings` and `zen models test`
11
+ // find one that answers, as a ref that can be pasted straight back into
12
+ // agents.yaml.
13
13
  // ---------------------------------------------------------------------------
14
- import { createEmbedder, ModelRegistry } from '@zenera/neo';
14
+ import { createEmbedder, GATEWAY_NAME, gatewayOf, ModelRegistry } from '@zenera/neo';
15
15
  import { parse } from "../args.js";
16
- import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, PREFERRED, routerBalance, } from "../catalog.js";
16
+ import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, routerBalance, } from "../catalog.js";
17
17
  import { ensureHome } from "../home.js";
18
18
  import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
19
19
  import { probeChat, probeModel } from "../liveness.js";
20
20
  import { ago, bold, CliError, credentialError, cyan, dim, EXIT, green, isInteractive, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
- const USAGE = 'zen models <providers|ls|search|show|test|pick|browse> [ref] [options]';
21
+ const USAGE = 'zen models <providers|ls|search|show|test|browse> [ref] [options]';
22
22
  /**
23
23
  * The keyring is materialised here, and only here.
24
24
  *
@@ -33,6 +33,10 @@ async function credentials() {
33
33
  // difference between "the environment had it" and "the keyring supplied it".
34
34
  const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((n) => process.env[n])));
35
35
  store.materialize();
36
+ const gateway = gatewayOf();
37
+ if (gateway) {
38
+ return { store, fromEnv, usable: [...PROVIDERS], gateway };
39
+ }
36
40
  const usable = [
37
41
  ...PROVIDERS.filter((p) => fromEnv.has(p)),
38
42
  ...PROVIDERS.filter((p) => !fromEnv.has(p) && Boolean(store.active(p))),
@@ -40,6 +44,9 @@ async function credentials() {
40
44
  return { store, fromEnv, usable };
41
45
  }
42
46
  function source(where, provider) {
47
+ if (where.gateway) {
48
+ return GATEWAY_NAME;
49
+ }
43
50
  if (where.fromEnv.has(provider)) {
44
51
  return 'environment';
45
52
  }
@@ -101,10 +108,22 @@ const tokens = (n) => {
101
108
  }
102
109
  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
103
110
  };
111
+ /**
112
+ * Said before any listing in a gateway mode, on stderr: the same command lists
113
+ * something else entirely there, and nothing in a row of model ids shows it.
114
+ */
115
+ function announce(ctx, where) {
116
+ if (!where.gateway || ctx.json) {
117
+ return;
118
+ }
119
+ note(`${cyan(`⇄ ${GATEWAY_NAME} mode`)} ${dim('only what')} ${where.gateway.baseURL} ` +
120
+ dim('serves; zen key mode direct to list the vendors'));
121
+ note('');
122
+ }
104
123
  /** Freshness of a listing, said the way a person would ask about it. */
105
124
  function freshness(cat) {
106
125
  if (cat.origin === 'curated') {
107
- return yellow('built-in list');
126
+ return cat.via ? yellow('not listed yet') : yellow('built-in list');
108
127
  }
109
128
  if (cat.origin === 'stale') {
110
129
  return yellow(`stale, ${ago(cat.fetchedAt)}`);
@@ -163,8 +182,9 @@ function explain(cats) {
163
182
  const providers = async (ctx, args) => {
164
183
  const { values } = parse(args, { refresh: { type: 'boolean' } }, 'zen models providers [--refresh]');
165
184
  const where = await credentials();
185
+ // A gateway lists every provider in one request, so its overview can afford to ask.
166
186
  const cats = await loadCatalogs(PROVIDERS, {
167
- offline: !values.refresh,
187
+ offline: !values.refresh && !where.gateway,
168
188
  refresh: values.refresh,
169
189
  });
170
190
  const by = new Map(cats.map((c) => [c.provider, c]));
@@ -176,9 +196,11 @@ const providers = async (ctx, args) => {
176
196
  models: by.get(p)?.entries.length ?? 0,
177
197
  origin: by.get(p)?.origin,
178
198
  fetchedAt: by.get(p)?.fetchedAt,
199
+ ...(where.gateway ? { gateway: where.gateway.baseURL } : {}),
179
200
  })));
180
201
  return;
181
202
  }
203
+ announce(ctx, where);
182
204
  writeAll(table(PROVIDERS.map((p) => {
183
205
  const cat = by.get(p);
184
206
  const cred = source(where, p);
@@ -193,7 +215,6 @@ const providers = async (ctx, args) => {
193
215
  note('');
194
216
  note(dim('zen models ls <provider> what one of them serves'));
195
217
  note(dim('zen models browse walk them with the arrow keys'));
196
- note(dim('zen models pick --chat the first one that answers'));
197
218
  };
198
219
  // ---------------------------------------------------------------------------
199
220
  // ls
@@ -225,9 +246,11 @@ const ls = async (ctx, args) => {
225
246
  origin,
226
247
  fetchedAt,
227
248
  })),
249
+ ...(where.gateway ? { gateway: where.gateway.baseURL } : {}),
228
250
  });
229
251
  return;
230
252
  }
253
+ announce(ctx, where);
231
254
  explain(cats);
232
255
  if (all.length === 0) {
233
256
  note(dim('nothing matched — try: zen models ls --refresh'));
@@ -237,7 +260,7 @@ const ls = async (ctx, args) => {
237
260
  if (all.length > limit) {
238
261
  note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
239
262
  }
240
- if (all.some(isPartner)) {
263
+ if (!where.gateway && all.some(isPartner)) {
241
264
  note(dim('partner models are billed through Vertex and must be enabled in Model Garden first: https://console.cloud.google.com/vertex-ai/model-garden'));
242
265
  }
243
266
  };
@@ -281,6 +304,7 @@ const search = async (ctx, args) => {
281
304
  json({ query, matched: hits.length, models: hits.slice(0, limit) });
282
305
  return;
283
306
  }
307
+ announce(ctx, where);
284
308
  explain(cats);
285
309
  if (hits.length === 0) {
286
310
  note(dim(`nothing matched "${query}" — try fewer words, or --refresh`));
@@ -322,15 +346,17 @@ const show = async (ctx, args) => {
322
346
  throw usageError('which model?', 'see: zen models ls');
323
347
  }
324
348
  const { provider, id } = split(ref);
349
+ const where = await credentials();
325
350
  const cat = await loadCatalog(provider, { refresh: values.refresh });
326
351
  const found = cat.entries.find((e) => e.id === id);
327
352
  if (ctx.json) {
328
353
  json(found ?? { ref: `${provider}:${id}`, provider, id, known: false });
329
354
  return;
330
355
  }
356
+ announce(ctx, where);
331
357
  explain([cat]);
332
358
  if (!found) {
333
- note(`${yellow('not listed')} ${provider} does not advertise ${bold(id)}`);
359
+ note(`${yellow('not listed')} ${where.gateway ? GATEWAY_NAME : provider} does not advertise ${bold(id)}`);
334
360
  note(dim(`it may still work — try: zen models test ${provider}:${id}`));
335
361
  note(dim(`or look: zen models search ${id} --provider ${provider}`));
336
362
  return;
@@ -360,7 +386,11 @@ const show = async (ctx, args) => {
360
386
  ? `$${found.pricing.prompt}/token in, $${found.pricing.completion ?? '?'}/token out`
361
387
  : undefined);
362
388
  add('released', found.created);
363
- add('source', found.source === 'live' ? `${provider}, ${freshness(cat)}` : 'built-in list');
389
+ add('source', found.source !== 'live'
390
+ ? 'built-in list'
391
+ : cat.via
392
+ ? `${cat.via.name} (${provider}), ${freshness(cat)}`
393
+ : `${provider}, ${freshness(cat)}`);
364
394
  writeAll(table(rows));
365
395
  if (found.description) {
366
396
  note('');
@@ -456,6 +486,7 @@ const test = async (ctx, args) => {
456
486
  json(probes);
457
487
  }
458
488
  else {
489
+ announce(ctx, where);
459
490
  writeAll(table(probes.map((p) => [
460
491
  cyan(p.ref),
461
492
  verdict(p),
@@ -469,90 +500,16 @@ const test = async (ctx, args) => {
469
500
  }
470
501
  // A Vertex refusal is about a model *in a project*, and the project is
471
502
  // the half of that nobody typed.
472
- if (parsed.some((p) => p.provider === 'vertex')) {
503
+ if (!where.gateway && parsed.some((p) => p.provider === 'vertex')) {
473
504
  note(dim(vertexAt(where)));
474
505
  }
475
506
  }
476
507
  const failed = probes.filter((p) => p.check.state !== 'live');
477
508
  if (failed.length > 0) {
478
- // The suggestion follows what was actually asked, not what was flagged:
479
- // being told to `pick --chat` after an embedder was refused is how a
480
- // recovery path stops being one.
481
- const role = failed.every((p) => p.kind === 'embedding') ? '--embedding' : '--chat';
482
- throw credentialError(`${failed.length} of ${probes.length} did not answer`, `find one that does: zen models pick ${role}`);
483
- }
484
- };
485
- // ---------------------------------------------------------------------------
486
- // pick
487
- // ---------------------------------------------------------------------------
488
- /**
489
- * The recovery path, and the reason this command exists.
490
- *
491
- * Candidates are tried one at a time and the walk stops at the first that
492
- * answers. Sequential on purpose: the goal is *one* working ref, and firing
493
- * eight billable calls to find it is the wrong trade — especially for the
494
- * caller most likely to be running this, which is an agent that has just been
495
- * refused and is looking for somewhere else to go.
496
- */
497
- const pick = async (ctx, args) => {
498
- const { values } = parse(args, { ...ROLE_OPTIONS, provider: { type: 'string' }, limit: { type: 'string' } }, 'zen models pick --chat|--embedding [--provider p] [--limit N]');
499
- const roles = rolesFrom(values);
500
- if (roles.length !== 1 || (roles[0] !== 'chat' && roles[0] !== 'embedding')) {
501
- throw usageError('which kind of model?', 'pass --chat or --embedding');
502
- }
503
- const role = roles[0];
504
- const where = await credentials();
505
- const targets = scope(where, values.provider);
506
- const cap = num(values.limit, '--limit') ?? 8;
507
- const candidates = targets
508
- .flatMap((p) => PREFERRED[p][role].map((id) => ({ provider: p, id, ref: `${p}:${id}` })))
509
- .slice(0, cap);
510
- if (candidates.length === 0) {
511
- throw credentialError(`no ${role} model is known for ${targets.join(', ')}`, 'try: zen models pick --provider openai');
512
- }
513
- const tried = [];
514
- const bar = ctx.json ? undefined : progress();
515
- for (const candidate of candidates) {
516
- bar?.update(dim(`trying ${candidate.ref} …`));
517
- const probe = await probeModel(target(candidate.ref, candidate.provider, candidate.id, role));
518
- tried.push(probe);
519
- if (probe.check.state !== 'live') {
520
- continue;
521
- }
522
- bar?.done();
523
- if (ctx.json) {
524
- json({
525
- ref: probe.ref,
526
- provider: candidate.provider,
527
- model: candidate.id,
528
- role,
529
- ...(probe.dimensions ? { dimensions: probe.dimensions } : {}),
530
- ms: probe.ms,
531
- tried: tried.map(({ ref, check }) => ({ ref, ...check })),
532
- });
533
- return;
534
- }
535
- for (const t of tried.slice(0, -1)) {
536
- note(`${dim(t.ref)} ${verdict(t)} ${dim(t.check.detail ?? '')}`);
537
- }
538
- note(`${green('works')} ${bold(probe.ref)} ${dim(ms(probe.ms))}`);
539
- // stdout, alone and unstyled, so `$(zen models pick --embedding)` is
540
- // the ref and nothing else.
541
- write(probe.ref);
542
- return;
543
- }
544
- bar?.done();
545
- if (ctx.json) {
546
- json({ ref: null, role, tried: tried.map(({ ref, check }) => ({ ref, ...check })) });
547
- }
548
- else {
549
- writeAll(table(tried.map((t) => [
550
- dim(t.ref),
551
- verdict(t),
552
- dim(t.check.fix ?? t.check.detail ?? ''),
553
- ])));
509
+ // The suggestion follows what was actually asked, not what was flagged.
510
+ const role = failed.every((p) => p.kind === 'embedding') ? '--embeddings' : '--chat';
511
+ throw credentialError(`${failed.length} of ${probes.length} did not answer`, `see what else there is: zen models ls ${role}`);
554
512
  }
555
- throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
556
513
  };
557
514
  // ---------------------------------------------------------------------------
558
515
  // browse
@@ -562,7 +519,7 @@ const pick = async (ctx, args) => {
562
519
  * one model in full, with a key to ask it a question and a key to take it.
563
520
  *
564
521
  * The interface is drawn on stderr, so a pick reaches stdout alone and
565
- * `$(zen models browse)` is the ref, the same contract `pick` keeps.
522
+ * `$(zen models browse)` is the ref.
566
523
  */
567
524
  const browse = async (ctx, args) => {
568
525
  const { values, positionals } = parse(args, { refresh: { type: 'boolean' }, theme: { type: 'string' } }, 'zen models browse [provider] [--refresh] [--theme dark|light|auto]');
@@ -571,7 +528,7 @@ const browse = async (ctx, args) => {
571
528
  throw usageError(`unknown provider "${initial}"`, `known: ${PROVIDERS.join(', ')}`);
572
529
  }
573
530
  if (ctx.json || !isInteractive()) {
574
- throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json, or zen models pick --chat');
531
+ throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json');
575
532
  }
576
533
  const chosen = await browseModels({
577
534
  initial,
@@ -593,7 +550,7 @@ export async function browseModels(request = {}) {
593
550
  const where = await credentials();
594
551
  // Offline, like `providers`: the overview should not cost five listings.
595
552
  // A provider is fetched when it is opened.
596
- const cats = await loadCatalogs(PROVIDERS, { offline: true });
553
+ const cats = await loadCatalogs(PROVIDERS, { offline: !where.gateway });
597
554
  const { browse: draw } = await import("../tui/models.js");
598
555
  return await draw({
599
556
  providers: cats.map((c) => ({
@@ -606,8 +563,11 @@ export async function browseModels(request = {}) {
606
563
  initial: request.initial,
607
564
  role: request.role,
608
565
  theme: request.theme,
566
+ ...(where.gateway ? { gateway: { name: GATEWAY_NAME, url: where.gateway.baseURL } } : {}),
609
567
  load: (provider, refresh) => loadCatalog(provider, { refresh: refresh || request.refresh }),
610
- balance: async (provider) => provider === 'openrouter' && source(where, provider) ? routerBalance() : undefined,
568
+ balance: async (provider) => provider === 'openrouter' && !where.gateway && source(where, provider)
569
+ ? routerBalance()
570
+ : undefined,
611
571
  test: async (entry) => {
612
572
  const role = entry.roles.includes('chat')
613
573
  ? 'chat'
@@ -637,12 +597,11 @@ const SUBS = {
637
597
  show: show,
638
598
  test,
639
599
  check: test,
640
- pick,
641
600
  browse,
642
601
  ui: browse,
643
602
  };
644
603
  export const models = {
645
- summary: 'What this machine can use: list, search, test and pick models.',
604
+ summary: 'What this machine can use: list, search, test and browse models.',
646
605
  usage: USAGE,
647
606
  banner: { head: 'Zenera', accent: 'Models', subtitle: 'Model Catalog', hue: 'dusk' },
648
607
  details: [
@@ -661,7 +620,6 @@ export const models = {
661
620
  ' zen models search <query> Narrow it — --tools, --vision, --free.',
662
621
  ' zen models show <ref> One model, in full.',
663
622
  ' zen models test <ref> … Ask it one real question.',
664
- ' zen models pick --embedding The first ref that answers, on stdout.',
665
623
  ' zen models browse [provider] Walk providers and models with the',
666
624
  ' arrow keys; t asks one, p prints its ref.',
667
625
  ],
@@ -682,7 +640,7 @@ export const models = {
682
640
  await ls(ctx, ctx.args);
683
641
  return;
684
642
  }
685
- throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
643
+ throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
686
644
  },
687
645
  };
688
646
  //# sourceMappingURL=models.js.map
@@ -0,0 +1,16 @@
1
+ declare const SHELLS: readonly ["zsh", "bash", "fish"];
2
+ export type Shell = (typeof SHELLS)[number];
3
+ export declare function shellOf(value: string): Shell | undefined;
4
+ /** The primary startup file for an interactive shell. */
5
+ export declare function rcFile(shell: Shell, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
6
+ /** macOS needs both login and non-login Bash startup files. */
7
+ export declare function rcFiles(shell: Shell, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string[];
8
+ /** Guard the loader so uninstalling zen does not break shell startup. */
9
+ export declare function loader(shell: Shell): string;
10
+ /** Whether `file` already loads the script; a commented-out line does not count. */
11
+ export declare function installed(shell: Shell, file: string): boolean;
12
+ /** Appends the loader to `file`, unless it is already there. */
13
+ export declare function install(shell: Shell, file: string): 'added' | 'present';
14
+ export declare function autoInstall(env?: NodeJS.ProcessEnv): void;
15
+ export {};
16
+ //# sourceMappingURL=completion-install.d.ts.map
@@ -0,0 +1,85 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+ const SHELLS = ['zsh', 'bash', 'fish'];
5
+ export function shellOf(value) {
6
+ const name = basename(value);
7
+ return SHELLS.find((shell) => shell === name);
8
+ }
9
+ /** The primary startup file for an interactive shell. */
10
+ export function rcFile(shell, env = process.env, platform = process.platform) {
11
+ const home = env.HOME || homedir();
12
+ switch (shell) {
13
+ case 'zsh':
14
+ return join(env.ZDOTDIR || home, '.zshrc');
15
+ case 'bash':
16
+ if (platform === 'darwin') {
17
+ // Login Bash reads only the first existing profile in this order.
18
+ return (['.bash_profile', '.bash_login', '.profile']
19
+ .map((name) => join(home, name))
20
+ .find((file) => existsSync(file)) ?? join(home, '.bash_profile'));
21
+ }
22
+ return join(home, '.bashrc');
23
+ case 'fish':
24
+ return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'fish', 'config.fish');
25
+ }
26
+ }
27
+ /** macOS needs both login and non-login Bash startup files. */
28
+ export function rcFiles(shell, env = process.env, platform = process.platform) {
29
+ const primary = rcFile(shell, env, platform);
30
+ return shell === 'bash' && platform === 'darwin'
31
+ ? [primary, join(env.HOME || homedir(), '.bashrc')]
32
+ : [primary];
33
+ }
34
+ /** Guard the loader so uninstalling zen does not break shell startup. */
35
+ export function loader(shell) {
36
+ return shell === 'fish'
37
+ ? 'if type -q zen; zen completion fish | source; end'
38
+ : `if command -v zen >/dev/null 2>&1; then eval "$(zen completion ${shell})"; fi`;
39
+ }
40
+ /** Whether `file` already loads the script; a commented-out line does not count. */
41
+ export function installed(shell, file) {
42
+ if (!existsSync(file)) {
43
+ return false;
44
+ }
45
+ const loads = new RegExp(`\\b(zen|zn|zenera)\\s+completion\\s+${shell}\\b`);
46
+ return readFileSync(file, 'utf8')
47
+ .split(/\r?\n/)
48
+ .some((line) => !line.trimStart().startsWith('#') && loads.test(line));
49
+ }
50
+ /** Appends the loader to `file`, unless it is already there. */
51
+ export function install(shell, file) {
52
+ if (installed(shell, file)) {
53
+ return 'present';
54
+ }
55
+ const have = existsSync(file) ? readFileSync(file, 'utf8') : '';
56
+ mkdirSync(dirname(file), { recursive: true });
57
+ const gap = have === '' || have.endsWith('\n\n') ? '' : have.endsWith('\n') ? '\n' : '\n\n';
58
+ appendFileSync(file, `${gap}# zen tab completion\n${loader(shell)}\n`);
59
+ return 'added';
60
+ }
61
+ export function autoInstall(env = process.env) {
62
+ // Local dependencies, npx and workspace installs must not edit shell startup.
63
+ if (env.npm_config_global !== 'true') {
64
+ return;
65
+ }
66
+ if (process.getuid?.() === 0 || env.SUDO_UID || env.SUDO_USER) {
67
+ console.error('zen: skipping automatic completion setup under root/sudo. Run zen completion --install as your normal user.');
68
+ return;
69
+ }
70
+ if (process.platform === 'win32' || (env.CI && env.CI !== 'false')) {
71
+ console.error('zen: skipping automatic completion setup on Windows or in CI. Run zen completion --install in a supported shell.');
72
+ return;
73
+ }
74
+ const shell = shellOf(env.SHELL ?? '');
75
+ if (!shell) {
76
+ console.error('zen: cannot detect a supported shell. Run zen completion <zsh|bash|fish> --install.');
77
+ return;
78
+ }
79
+ for (const file of rcFiles(shell, env)) {
80
+ const result = install(shell, file);
81
+ console.error(`zen: completion ${result === 'added' ? 'added to' : 'already set up in'} ${file}.`);
82
+ }
83
+ console.error('zen: open a new terminal to use tab completion.');
84
+ }
85
+ //# sourceMappingURL=completion-install.js.map
@@ -0,0 +1,9 @@
1
+ import { type Candidate } from './candidates.ts';
2
+ /**
3
+ * `words` is everything typed after the program's name, the word being
4
+ * completed last — an empty string when the cursor follows a space.
5
+ */
6
+ export declare function complete(words: readonly string[], cwd: string): Promise<Candidate[]>;
7
+ /** The wire format. Whitespace in a description would break a line in two. */
8
+ export declare function render(found: readonly Candidate[]): string;
9
+ //# sourceMappingURL=completion.d.ts.map
@@ -0,0 +1,93 @@
1
+ import { resolve } from 'node:path';
2
+ import { matching, projectNames } from "./candidates.js";
3
+ import { ALIASES, COMMANDS, EXTERNAL } from "./commands/index.js";
4
+ import { completeMeta } from "./commands/meta.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Shell completion
7
+ //
8
+ // The shell's own glue (`zen completion zsh|bash|fish`) is deliberately dumb:
9
+ // it hands `zen __complete` the words typed so far, the one being completed
10
+ // last, and shows whatever comes back. Everything that needs to know what a
11
+ // project is, or which prompts it holds, is answered here, once, for all three
12
+ // shells.
13
+ //
14
+ // One candidate per line, `value<TAB>description`. Nothing at all means "no
15
+ // opinion", and the shell falls back to completing file names — which is the
16
+ // right answer for `-C <dir>`, for `--events <file>`, and for the files that
17
+ // follow a stored prompt.
18
+ // ---------------------------------------------------------------------------
19
+ /** Flags the frame owns, offered where a command name would be. */
20
+ const GLOBAL_FLAGS = [
21
+ { value: '--help', description: 'Print help.' },
22
+ { value: '--version', description: 'Print the version.' },
23
+ { value: '--json', description: 'Machine-readable output.' },
24
+ { value: '--directory', description: 'Act as if run in <dir>.' },
25
+ ];
26
+ /** Commands whose first bare word is a project, not a subcommand. */
27
+ const TAKES_PROJECT = new Set(['run', 'open', 'check', 'export']);
28
+ /** `zen key <ls|add|rm>` → `ls`, `add`, `rm`; a usage line is the only list there is. */
29
+ function subcommands(usage) {
30
+ const group = /[<[]([a-z][a-z-]*(?:\|[a-z][a-z-]*)+)[>\]]/.exec(usage);
31
+ return group ? group[1].split('|') : [];
32
+ }
33
+ /**
34
+ * `words` is everything typed after the program's name, the word being
35
+ * completed last — an empty string when the cursor follows a space.
36
+ */
37
+ export async function complete(words, cwd) {
38
+ const cur = words.at(-1) ?? '';
39
+ const before = [];
40
+ let at = cwd;
41
+ for (let i = 0; i < words.length - 1; i++) {
42
+ const w = words[i];
43
+ if (w === '-C' || w === '--directory') {
44
+ // The directory itself is being typed: that is the shell's to complete.
45
+ if (i + 1 === words.length - 1) {
46
+ return [];
47
+ }
48
+ at = resolve(cwd, words[++i]);
49
+ }
50
+ else if (w !== '--json' && w !== '--help' && w !== '-h') {
51
+ before.push(w);
52
+ }
53
+ }
54
+ if (before.length === 0) {
55
+ if (cur.startsWith('-')) {
56
+ return matching(GLOBAL_FLAGS, cur);
57
+ }
58
+ return matching([
59
+ ...Object.entries(COMMANDS).map(([value, c]) => ({
60
+ value,
61
+ description: c.summary,
62
+ })),
63
+ ...Object.entries(EXTERNAL).map(([value, e]) => ({
64
+ value,
65
+ description: e.summary,
66
+ })),
67
+ { value: 'help', description: 'Help for a command.' },
68
+ ], cur);
69
+ }
70
+ const name = ALIASES[before[0]] ?? before[0];
71
+ const args = before.slice(1);
72
+ if (name === 'help') {
73
+ return args.length === 0 ? await complete([cur], at) : [];
74
+ }
75
+ if (name === 'meta') {
76
+ return await completeMeta(args, cur, at);
77
+ }
78
+ if (cur.startsWith('-') || args.some((a) => !a.startsWith('-'))) {
79
+ return [];
80
+ }
81
+ if (TAKES_PROJECT.has(name)) {
82
+ return matching(await projectNames(), cur);
83
+ }
84
+ const usage = COMMANDS[name]?.usage ?? EXTERNAL[name]?.usage;
85
+ return matching(subcommands(usage ?? '').map((value) => ({ value })), cur);
86
+ }
87
+ /** The wire format. Whitespace in a description would break a line in two. */
88
+ export function render(found) {
89
+ return found
90
+ .map((c) => c.description ? `${c.value}\t${c.description.replace(/\s+/g, ' ').trim()}` : c.value)
91
+ .join('\n');
92
+ }
93
+ //# sourceMappingURL=completion.js.map
package/dist/keys.d.ts CHANGED
@@ -14,9 +14,19 @@ export type Provider = (typeof PROVIDERS)[number];
14
14
  */
15
15
  export declare const SERVICES: readonly ["exa"];
16
16
  export type Service = (typeof SERVICES)[number];
17
+ /**
18
+ * Somewhere every model can be reached through, with one key. Its own list for
19
+ * the same reason as `SERVICES`: it is neither a vendor a model comes from nor
20
+ * a tool, and a gateway entry only counts while the keyring is in its mode.
21
+ */
22
+ export declare const GATEWAYS: readonly ["bifrost"];
23
+ export type GatewayOwner = (typeof GATEWAYS)[number];
24
+ /** `direct` calls each vendor with its own key; `bifrost` sends everything through the gateway. */
25
+ export type KeyMode = 'direct' | GatewayOwner;
26
+ export declare const MODES: readonly KeyMode[];
17
27
  /** Anything the keyring can hold a credential for. */
18
- export declare const OWNERS: readonly ["openai", "anthropic", "google", "vertex", "openrouter", "exa"];
19
- export type KeyOwner = Provider | Service;
28
+ export declare const OWNERS: readonly ["openai", "anthropic", "google", "vertex", "openrouter", "exa", "bifrost"];
29
+ export type KeyOwner = Provider | Service | GatewayOwner;
20
30
  /**
21
31
  * One way a credential can arrive.
22
32
  *
@@ -34,8 +44,8 @@ export interface CredentialForm {
34
44
  where: string;
35
45
  }
36
46
  interface ProviderShape {
37
- /** whether this is somewhere a model lives, or something a tool calls */
38
- kind: 'model' | 'service';
47
+ /** whether this is somewhere a model lives, something a tool calls, or a way to every model */
48
+ kind: 'model' | 'service' | 'gateway';
39
49
  label: string;
40
50
  /** the ways in, first being the one this provider is usually reached by */
41
51
  forms: [CredentialForm, ...CredentialForm[]];
@@ -64,6 +74,7 @@ export declare const FILE_ENVS: string[];
64
74
  export declare function envOf(entry: Pick<KeyEntry, 'provider' | 'holds' | 'env'>): string;
65
75
  export declare function isProvider(name: string): name is Provider;
66
76
  export declare function isOwner(name: string): name is KeyOwner;
77
+ export declare function isGateway(name: string): name is GatewayOwner;
67
78
  export declare function assertOwner(name: string): KeyOwner;
68
79
  /**
69
80
  * `blocked` is the credential being fine and the *account* not: an api switched
@@ -92,6 +103,8 @@ export interface KeyEntry {
92
103
  /** vertex, file-shaped only: what the library would otherwise have to be told twice */
93
104
  project?: string;
94
105
  location?: string;
106
+ /** gateway only: where it listens */
107
+ url?: string;
95
108
  addedAt: string;
96
109
  check?: KeyCheck;
97
110
  }
@@ -122,6 +135,8 @@ export interface KeyMeta {
122
135
  project?: string;
123
136
  /** GCP region, or `global` */
124
137
  location?: string;
138
+ /** gateway base url */
139
+ url?: string;
125
140
  }
126
141
  export declare class KeyStore {
127
142
  #private;
@@ -149,6 +164,11 @@ export declare class KeyStore {
149
164
  remove(provider: KeyOwner, name: string): boolean;
150
165
  use(provider: KeyOwner, name: string): KeyEntry;
151
166
  record(entry: KeyEntry, check: KeyCheck): void;
167
+ /** A `BIFROST_BASE_URL` in the environment wins over the keyring, as it does for the runtime. */
168
+ get mode(): KeyMode;
169
+ get modeFrom(): 'env' | 'keyring';
170
+ /** Refuses a gateway mode with no gateway to send anything to. */
171
+ setMode(mode: KeyMode): void;
152
172
  save(): void;
153
173
  /**
154
174
  * Absolute path behind a file-shaped entry. Stored entries name a file in
@@ -175,6 +195,11 @@ export declare class KeyStore {
175
195
  *
176
196
  * Services are included by default, because a tool reads its key from the
177
197
  * environment for exactly the same reason a model adapter does.
198
+ *
199
+ * In a gateway mode the gateway's key replaces every vendor's, so those are
200
+ * withheld: nothing would read them, and the sandbox would be handed keys
201
+ * the run never needed. In `direct` mode it is the gateway that is withheld,
202
+ * since its url alone is what switches the library over.
178
203
  */
179
204
  environment(only?: KeyOwner[]): Record<string, string>;
180
205
  /** Applies `environment()` to this process. Returns what it set. */