@zenera/cli 1.1.27 → 1.1.29

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 (51) hide show
  1. package/README.md +127 -75
  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 +142 -11
  13. package/dist/commands/meta.d.ts +3 -0
  14. package/dist/commands/meta.js +235 -62
  15. package/dist/commands/models.d.ts +12 -0
  16. package/dist/commands/models.js +79 -112
  17. package/dist/completion-install.d.ts +16 -0
  18. package/dist/completion-install.js +85 -0
  19. package/dist/completion.d.ts +9 -0
  20. package/dist/completion.js +93 -0
  21. package/dist/keys.d.ts +27 -4
  22. package/dist/keys.js +63 -3
  23. package/dist/liveness.js +73 -3
  24. package/dist/main.js +22 -0
  25. package/dist/meta/model.d.ts +1 -1
  26. package/dist/meta/model.js +1 -1
  27. package/dist/postinstall.d.ts +2 -0
  28. package/dist/postinstall.js +10 -0
  29. package/dist/sandbox.js +4 -3
  30. package/dist/term.d.ts +2 -0
  31. package/dist/term.js +2 -0
  32. package/dist/tui/links.d.ts +2 -0
  33. package/dist/tui/links.js +31 -7
  34. package/dist/tui/models.d.ts +8 -1
  35. package/dist/tui/models.js +25 -18
  36. package/dist/validate.js +2 -2
  37. package/package.json +8 -6
  38. package/postinstall.mjs +7 -0
  39. package/templates/completions/zen.bash +16 -0
  40. package/templates/completions/zen.fish +22 -0
  41. package/templates/completions/zen.zsh +28 -0
  42. package/templates/editor/.github/copilot-instructions.md +5 -4
  43. package/templates/editor/.github/prompts/spec-edit.prompt.md +250 -0
  44. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +5 -0
  45. package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -2
  46. package/templates/editor/.github/skills/zen-cli/references/keys.md +27 -2
  47. package/templates/editor/.github/skills/zen-cli/references/models.md +22 -23
  48. package/dist/meta/finetune/guard.d.ts +0 -29
  49. package/dist/meta/finetune/guard.js +0 -104
  50. package/dist/meta.d.ts +0 -291
  51. package/dist/meta.js +0 -981
package/dist/keys.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EXA_API_KEY_ENV } from '@zenera/neo';
1
+ import { EXA_API_KEY_ENV, GATEWAY_KEY_ENV, GATEWAY_URL_ENV } from '@zenera/neo';
2
2
  import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
@@ -31,8 +31,15 @@ export const PROVIDERS = ['openai', 'anthropic', 'google', 'vertex', 'openrouter
31
31
  * few places that must tell them apart are made to say which they mean.
32
32
  */
33
33
  export const SERVICES = ['exa'];
34
+ /**
35
+ * Somewhere every model can be reached through, with one key. Its own list for
36
+ * the same reason as `SERVICES`: it is neither a vendor a model comes from nor
37
+ * a tool, and a gateway entry only counts while the keyring is in its mode.
38
+ */
39
+ export const GATEWAYS = ['bifrost'];
40
+ export const MODES = ['direct', ...GATEWAYS];
34
41
  /** Anything the keyring can hold a credential for. */
35
- export const OWNERS = [...PROVIDERS, ...SERVICES];
42
+ export const OWNERS = [...PROVIDERS, ...SERVICES, ...GATEWAYS];
36
43
  /**
37
44
  * Vertex is the odd one, and it is odd twice.
38
45
  *
@@ -115,6 +122,17 @@ export const SHAPES = {
115
122
  },
116
123
  ],
117
124
  },
125
+ bifrost: {
126
+ kind: 'gateway',
127
+ label: 'Bifrost',
128
+ forms: [
129
+ {
130
+ holds: 'secret',
131
+ env: GATEWAY_KEY_ENV,
132
+ where: 'a virtual key (sk-bf-…) from your Bifrost administrator',
133
+ },
134
+ ],
135
+ },
118
136
  };
119
137
  /**
120
138
  * The form a provider is usually reached by — for the questions that have to
@@ -144,6 +162,9 @@ export function isProvider(name) {
144
162
  export function isOwner(name) {
145
163
  return OWNERS.includes(name);
146
164
  }
165
+ export function isGateway(name) {
166
+ return GATEWAYS.includes(name);
167
+ }
147
168
  export function assertOwner(name) {
148
169
  if (!isOwner(name)) {
149
170
  throw usageError(`unknown provider "${name}"`, `known providers: ${OWNERS.join(', ')}`);
@@ -256,7 +277,13 @@ export class KeyStore {
256
277
  const path = paths.keys();
257
278
  assertPrivate(path);
258
279
  const file = await readJson(path, EMPTY);
259
- return new KeyStore({ ...EMPTY, ...file });
280
+ // Fresh containers: a missing file hands back EMPTY itself, which `add` would mutate.
281
+ return new KeyStore({
282
+ ...EMPTY,
283
+ ...file,
284
+ entries: [...(file.entries ?? [])],
285
+ active: { ...file.active },
286
+ });
260
287
  }
261
288
  get entries() {
262
289
  return this.#file.entries;
@@ -311,6 +338,7 @@ export class KeyStore {
311
338
  // be a way to build the combination the service refuses.
312
339
  ...(form.holds === 'file' && meta.project ? { project: meta.project } : {}),
313
340
  ...(form.holds === 'file' && meta.location ? { location: meta.location } : {}),
341
+ ...(isGateway(provider) && meta.url ? { url: meta.url } : {}),
314
342
  addedAt: new Date().toISOString(),
315
343
  };
316
344
  const at = this.#file.entries.findIndex((e) => e.provider === provider && e.name === name);
@@ -352,6 +380,21 @@ export class KeyStore {
352
380
  hit.check = check;
353
381
  }
354
382
  }
383
+ get mode() {
384
+ return this.#file.mode ?? 'direct';
385
+ }
386
+ /** Refuses a gateway mode with no gateway to send anything to. */
387
+ setMode(mode) {
388
+ if (mode !== 'direct' && !this.active(mode)?.url) {
389
+ throw usageError(`no ${SHAPES[mode].label} key to switch to`, `add one first: zen key add ${mode} --url <gateway url>`);
390
+ }
391
+ if (mode === 'direct') {
392
+ delete this.#file.mode;
393
+ }
394
+ else {
395
+ this.#file.mode = mode;
396
+ }
397
+ }
355
398
  save() {
356
399
  ensureDir(paths.home());
357
400
  writeJson(paths.keys(), this.#file);
@@ -398,14 +441,28 @@ export class KeyStore {
398
441
  *
399
442
  * Services are included by default, because a tool reads its key from the
400
443
  * environment for exactly the same reason a model adapter does.
444
+ *
445
+ * In a gateway mode the gateway's key replaces every vendor's, so those are
446
+ * withheld: nothing would read them, and the sandbox would be handed keys
447
+ * the run never needed. In `direct` mode it is the gateway that is withheld,
448
+ * since its url alone is what switches the library over.
401
449
  */
402
450
  environment(only) {
403
451
  const env = {};
452
+ const mode = this.mode;
404
453
  for (const provider of only ?? OWNERS) {
454
+ const kind = SHAPES[provider].kind;
455
+ if (kind === 'gateway' ? provider !== mode : kind === 'model' && mode !== 'direct') {
456
+ continue;
457
+ }
405
458
  const entry = this.active(provider);
406
459
  if (!entry) {
407
460
  continue;
408
461
  }
462
+ // The mode is the switch, so the url goes out even when the shell supplies the key.
463
+ if (entry.url && !process.env[GATEWAY_URL_ENV]) {
464
+ env[GATEWAY_URL_ENV] = entry.url;
465
+ }
409
466
  // Either variable being set means this provider is already answered
410
467
  // for, so the stored alternative must not be exported alongside it.
411
468
  if (envNames(provider).some((name) => process.env[name])) {
@@ -542,6 +599,9 @@ export function credentials() {
542
599
  * one clear error here for an obscure one on the first turn.
543
600
  */
544
601
  export function assertUsable(store) {
602
+ if (process.env[GATEWAY_URL_ENV]) {
603
+ return;
604
+ }
545
605
  const reachable = PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name]) || store.active(p) !== undefined);
546
606
  if (reachable.length === 0) {
547
607
  throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
package/dist/liveness.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createModel, EXA_BASE_URL, isUnfunded, ModelRegistry, text, vertexRoute, } from '@zenera/neo';
1
+ import { createModel, EXA_BASE_URL, GATEWAY_KEY_HEADER, isUnfunded, ModelRegistry, text, vertexRoute, } from '@zenera/neo';
2
2
  import { envOf, SHAPES, } from "./keys.js";
3
3
  // ---------------------------------------------------------------------------
4
4
  // Liveness
@@ -201,6 +201,9 @@ export async function probe(store, entry) {
201
201
  if (shape.kind === 'service') {
202
202
  return probeService(entry.provider, store.reveal(entry));
203
203
  }
204
+ if (shape.kind === 'gateway') {
205
+ return probeGateway(entry.url, store.reveal(entry));
206
+ }
204
207
  const provider = entry.provider;
205
208
  const credential = store.reveal(entry);
206
209
  // What the entry holds, not what its provider usually holds: a Vertex
@@ -302,6 +305,57 @@ async function probeService(service, key) {
302
305
  return classify(err);
303
306
  }
304
307
  }
308
+ /**
309
+ * Bifrost's model listing is scoped to the virtual key that asks, so it is the
310
+ * cheap call that looks at the key. Its refusals carry their reason as a
311
+ * top-level `type` beside `error.message`, and two of them are about the
312
+ * key's allowance rather than the key.
313
+ */
314
+ async function probeGateway(url, key) {
315
+ const at = new Date().toISOString();
316
+ if (!url) {
317
+ return { state: 'unknown', at, detail: 'no gateway url stored with this key' };
318
+ }
319
+ try {
320
+ const res = await fetch(`${url.replace(/\/+$/, '')}/v1/models`, {
321
+ headers: { authorization: `Bearer ${key}`, [GATEWAY_KEY_HEADER]: key },
322
+ signal: AbortSignal.timeout(DEADLINE_MS),
323
+ });
324
+ if (res.ok) {
325
+ return { state: 'live', at };
326
+ }
327
+ const { type, message } = await refusal(res);
328
+ const detail = `${res.status} ${message}`;
329
+ if (res.status === 402 || type === 'budget_exceeded') {
330
+ return {
331
+ state: 'blocked',
332
+ at,
333
+ detail,
334
+ fix: "raise the virtual key's budget in Bifrost",
335
+ };
336
+ }
337
+ if (res.status === 401 || res.status === 403) {
338
+ return { state: 'dead', at, detail };
339
+ }
340
+ if (res.status === 404 || /<html/i.test(message)) {
341
+ return { state: 'unknown', at, detail, fix: 'check the url: is it the gateway root?' };
342
+ }
343
+ return { state: 'unknown', at, detail };
344
+ }
345
+ catch (err) {
346
+ return classify(err);
347
+ }
348
+ }
349
+ async function refusal(res) {
350
+ const raw = await res.text().catch(() => '');
351
+ try {
352
+ const body = JSON.parse(raw);
353
+ return { type: body.type, message: firstLine(body.error?.message ?? res.statusText) };
354
+ }
355
+ catch {
356
+ return { message: firstLine(raw || res.statusText) };
357
+ }
358
+ }
305
359
  /** The vendor's own sentence about a refusal, when the body carries one. */
306
360
  async function said(res) {
307
361
  try {
@@ -389,6 +443,11 @@ const UNSERVED = [
389
443
  'no access',
390
444
  ];
391
445
  const targetId = (target) => target.kind === 'model' ? target.model.id : target.embedder.id;
446
+ const PROBE_TOOL = {
447
+ name: 'noop',
448
+ description: 'Does nothing. Do not call it.',
449
+ parameters: { type: 'object', properties: {} },
450
+ };
392
451
  /**
393
452
  * A chat model sized for a probe: 16 output tokens is enough for "ok", and
394
453
  * Anthropic requires a cap at all. Only the adapters that read `maxTokens` are
@@ -415,6 +474,10 @@ function classifyModel(err, target, ms) {
415
474
  if (err instanceof Deadline || e?.name === 'AbortError' || e?.name === 'TimeoutError') {
416
475
  return { state: 'unknown', at, detail: `no answer in ${MODEL_DEADLINE_MS / 1000}s` };
417
476
  }
477
+ // OpenAI refuses tools alongside reasoning on chat completions, and says so.
478
+ if (target.kind === 'model' && haystack.includes('/v1/responses')) {
479
+ return { state: 'blocked', at, detail: firstLine(message), fix: responsesApi(target) };
480
+ }
418
481
  if (UNSERVED.some((needle) => haystack.includes(needle))) {
419
482
  return { state: 'blocked', at, detail: firstLine(message), fix: instead(target) };
420
483
  }
@@ -438,6 +501,10 @@ function limited(target) {
438
501
  }
439
502
  return `${blame} — retry later`;
440
503
  }
504
+ function responsesApi(target) {
505
+ const provider = target.ref.includes(':') ? target.ref.split(':')[0].split('/')[0] : 'openai';
506
+ return `use the responses API: \`api: responses\` on the model, or the ref ${provider}/responses:${targetId(target)}`;
507
+ }
441
508
  /** The command that finds something this account can actually use. */
442
509
  function instead(target) {
443
510
  const provider = target.ref.includes(':') ? target.ref.split(':')[0] : undefined;
@@ -446,7 +513,7 @@ function instead(target) {
446
513
  }
447
514
  return provider
448
515
  ? `zen models ls ${provider}`
449
- : `zen models pick --${target.kind === 'embedding' ? 'embedding' : 'chat'}`;
516
+ : `zen models ls --${target.kind === 'embedding' ? 'embeddings' : 'chat'}`;
450
517
  }
451
518
  /**
452
519
  * The smallest real call the model can be asked for. It costs a handful of
@@ -456,6 +523,9 @@ function instead(target) {
456
523
  * An embedding answers with its width, which is worth carrying back: a model
457
524
  * that serves the wrong number of dimensions is not interchangeable with the
458
525
  * one an index was built on.
526
+ *
527
+ * A chat model is offered one tool, because every agent run offers tools and
528
+ * some refusals (reasoning plus tools on chat completions) only happen then.
459
529
  */
460
530
  async function askModel(target, signal) {
461
531
  if (target.kind === 'embedding') {
@@ -465,7 +535,7 @@ async function askModel(target, signal) {
465
535
  await target.model.generate({
466
536
  system: 'Reply with the single word: ok',
467
537
  messages: [{ role: 'user', content: [text('ping')] }],
468
- tools: [],
538
+ tools: [PROBE_TOOL],
469
539
  signal,
470
540
  });
471
541
  return undefined;
package/dist/main.js CHANGED
@@ -32,7 +32,29 @@ const GLOBAL = {
32
32
  // not what it does.
33
33
  directory: { type: 'string', short: 'C' },
34
34
  };
35
+ /**
36
+ * The shell's half of tab completion calls this, hidden from help. It answers
37
+ * before anything else, because a banner or a window title on a Tab press would
38
+ * be printed into the line being edited — and it never fails loudly for the
39
+ * same reason: no answer is a valid answer, and the shell completes files.
40
+ */
41
+ async function completing(words) {
42
+ try {
43
+ const { complete, render } = await import("./completion.js");
44
+ const found = render(await complete(words, process.cwd()));
45
+ if (found) {
46
+ process.stdout.write(`${found}\n`);
47
+ }
48
+ }
49
+ catch {
50
+ // Nothing to offer.
51
+ }
52
+ return EXIT.ok;
53
+ }
35
54
  async function main(argv) {
55
+ if (argv[0] === '__complete') {
56
+ return await completing(argv.slice(1));
57
+ }
36
58
  const parts = split(argv);
37
59
  const { rest, global } = extract(parts.after);
38
60
  let values;
@@ -23,7 +23,7 @@ export interface ModelSources {
23
23
  export declare const ORDER: readonly ModelSource[];
24
24
  export declare function chooseModel(sources: ModelSources): ModelChoice | undefined;
25
25
  export declare const SOURCE_LABELS: Record<ModelSource, string>;
26
- /** First of each row is the default; the rest are what `--pick` offers beside it. */
26
+ /** First of each row is the default; the rest are the next best. */
27
27
  export declare const RECOMMENDED: Partial<Record<Provider, readonly string[]>>;
28
28
  /** The recommended ref for the first provider with a key, or for `only`. */
29
29
  export declare function defaultRef(store: KeyStore, only?: Provider): string | undefined;
@@ -47,7 +47,7 @@ export const SOURCE_LABELS = {
47
47
  // error. Deep tier deliberately: this agent is pointed at the project itself,
48
48
  // and a cheap model reading a specification is a false economy.
49
49
  // ---------------------------------------------------------------------------
50
- /** First of each row is the default; the rest are what `--pick` offers beside it. */
50
+ /** First of each row is the default; the rest are the next best. */
51
51
  export const RECOMMENDED = {
52
52
  openai: ['gpt-5.6-sol', 'gpt-5.6-terra'],
53
53
  anthropic: ['claude-opus-5', 'claude-sonnet-5'],
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=postinstall.d.ts.map
@@ -0,0 +1,10 @@
1
+ import { autoInstall } from "./completion-install.js";
2
+ try {
3
+ autoInstall();
4
+ }
5
+ catch (error) {
6
+ // Optional shell setup must not prevent installing the CLI.
7
+ console.error(`zen: automatic completion setup failed: ${error instanceof Error ? error.message : String(error)}`);
8
+ console.error('zen: run zen completion --install to retry.');
9
+ }
10
+ //# sourceMappingURL=postinstall.js.map
package/dist/sandbox.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_SANDBOX_IMAGE, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
1
+ import { DEFAULT_SANDBOX_IMAGE, GATEWAY_URL_ENV, SANDBOX_GROUP, SandboxPool, } from '@zenera/neo';
2
2
  import { mkdirSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { resolveBuild } from "./image.js";
@@ -55,8 +55,9 @@ function forwarded() {
55
55
  secrets.push(cred.env);
56
56
  }
57
57
  // Not secrets, and useless without one: a service account says which
58
- // project it belongs to but never which region to call.
59
- for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION']) {
58
+ // project it belongs to but never which region to call, and a gateway key
59
+ // says nothing of where the gateway is.
60
+ for (const name of ['GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', GATEWAY_URL_ENV]) {
60
61
  const value = process.env[name];
61
62
  if (value) {
62
63
  env[name] = value;
package/dist/term.d.ts CHANGED
@@ -42,6 +42,8 @@ export declare const red: (s: string) => string;
42
42
  export declare const green: (s: string) => string;
43
43
  export declare const yellow: (s: string) => string;
44
44
  export declare const cyan: (s: string) => string;
45
+ /** A label meant to be seen first: reverse video, padded so it reads as a tag. */
46
+ export declare const badge: (s: string) => string;
45
47
  /**
46
48
  * Whether anything should be coloured at all — `styleText`'s own answer, asked
47
49
  * by the places that write escape codes directly and so cannot inherit it.
package/dist/term.js CHANGED
@@ -112,6 +112,8 @@ export const red = (s) => styleText('red', s);
112
112
  export const green = (s) => styleText('green', s);
113
113
  export const yellow = (s) => styleText('yellow', s);
114
114
  export const cyan = (s) => styleText('cyan', s);
115
+ /** A label meant to be seen first: reverse video, padded so it reads as a tag. */
116
+ export const badge = (s) => styleText(['inverse', 'bold'], ` ${s} `);
115
117
  /**
116
118
  * Whether anything should be coloured at all — `styleText`'s own answer, asked
117
119
  * by the places that write escape codes directly and so cannot inherit it.
@@ -5,6 +5,8 @@ export interface Mounts {
5
5
  names: readonly string[];
6
6
  /** the host path an agent path names, or nothing when it is in no tree */
7
7
  resolve(path: string): string | undefined;
8
+ /** the host file a workspace-relative path names, only when that file exists */
9
+ file(path: string): string | undefined;
8
10
  }
9
11
  /** Exactly the trees the file tools reach, under the same containment rule. */
10
12
  export declare function mountsOf(files: Workspace): Mounts;
package/dist/tui/links.js CHANGED
@@ -1,16 +1,22 @@
1
+ import { statSync } from 'node:fs';
1
2
  import { pathToFileURL } from 'node:url';
2
3
  /** Exactly the trees the file tools reach, under the same containment rule. */
3
4
  export function mountsOf(files) {
4
5
  const names = [...(files.mount ? [files.mount] : []), ...files.mounts.map((m) => m.at)];
6
+ const resolve = (path) => {
7
+ try {
8
+ return files.within(path);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
5
14
  return {
6
15
  names: names.sort((a, b) => b.length - a.length),
7
- resolve: (path) => {
8
- try {
9
- return files.within(path);
10
- }
11
- catch {
12
- return undefined;
13
- }
16
+ resolve,
17
+ file: (path) => {
18
+ const host = resolve(path);
19
+ return host && statSync(host, { throwIfNoEntry: false })?.isFile() ? host : undefined;
14
20
  },
15
21
  };
16
22
  }
@@ -42,6 +48,17 @@ export function mountUrl(token, mounts) {
42
48
  const host = mounts.resolve(path);
43
49
  return host ? pathToFileURL(host).href + fragment : undefined;
44
50
  }
51
+ /** `scripts/x.py`, `README.md` — no scheme, no leading slash, no spaces. */
52
+ const RELATIVE = /^[\w@+-][\w.@+-]*(?:\/[\w.@+-]+)*$/;
53
+ /** The host `file:` url of a workspace-relative path in code, when that file exists. */
54
+ function relativeUrl(text, mounts) {
55
+ // Without a `.` or `/` it is an identifier, not worth a stat.
56
+ if (!RELATIVE.test(text) || !/[./]/.test(text)) {
57
+ return undefined;
58
+ }
59
+ const host = mounts.file(text);
60
+ return host ? pathToFileURL(host).href : undefined;
61
+ }
45
62
  const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, (c) => `\\${c}`);
46
63
  /** Every spelling of a mounted path in running text. */
47
64
  function pattern(mounts) {
@@ -73,6 +90,13 @@ export function linkSpans(spans, mounts) {
73
90
  out.push(url ? { ...span, href: url } : span);
74
91
  continue;
75
92
  }
93
+ if (span.code) {
94
+ const url = relativeUrl(span.text, mounts);
95
+ if (url) {
96
+ out.push({ ...span, href: url });
97
+ continue;
98
+ }
99
+ }
76
100
  let from = 0;
77
101
  for (const { at, token } of matches(span.text, mounts)) {
78
102
  const url = mountUrl(token, mounts);
@@ -1,4 +1,4 @@
1
- import { type Balance, type Catalog, type CatalogEntry } from '../catalog.ts';
1
+ import { type Balance, type Catalog, type CatalogEntry, type Role } from '../catalog.ts';
2
2
  import type { Provider } from '../keys.ts';
3
3
  export interface ProviderRow {
4
4
  provider: Provider;
@@ -21,7 +21,14 @@ export interface BrowseOptions {
21
21
  providers: readonly ProviderRow[];
22
22
  /** opens straight into this provider's listing */
23
23
  initial?: Provider;
24
+ /** the role filter a listing opens on, when it has any such model */
25
+ role?: Role;
24
26
  theme?: string;
27
+ /** set in a gateway mode: every listing and test goes through it */
28
+ gateway?: {
29
+ name: string;
30
+ url: string;
31
+ };
25
32
  load(provider: Provider, refresh: boolean): Promise<Catalog>;
26
33
  /** what the account has left to spend, for providers that say */
27
34
  balance?(provider: Provider): Promise<Balance | undefined>;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // ---------------------------------------------------------------------------
3
3
  // zen models browse
4
4
  //
@@ -8,8 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  //
9
9
  // Drawn on STDERR, like the banner and every other piece of narration, so the
10
10
  // one thing that reaches stdout is the ref picked at the end. That is what
11
- // makes `ref=$(zen models browse)` work the way `zen models pick` does: the
12
- // person sees the interface, the shell sees the answer.
11
+ // makes `ref=$(zen models browse)` work: the person sees the interface, the
12
+ // shell sees the answer.
13
13
  //
14
14
  // Nothing here knows how to list or probe a model. Both arrive as callbacks
15
15
  // from commands/models.ts, which already owns credentials and the catalog, so
@@ -44,10 +44,10 @@ function account(b, short) {
44
44
  }
45
45
  return parts.join(' · ');
46
46
  }
47
- function freshness(origin, fetchedAt) {
47
+ function freshness(origin, fetchedAt, via) {
48
48
  switch (origin) {
49
49
  case 'curated':
50
- return 'built-in list';
50
+ return via ? 'not listed yet' : 'built-in list';
51
51
  case 'stale':
52
52
  return `stale, ${ago(fetchedAt)}`;
53
53
  case 'live':
@@ -84,6 +84,13 @@ function Row({ children, ...style }) {
84
84
  function Keys({ theme, text }) {
85
85
  return _jsx(Row, { color: theme.chrome.color, children: text });
86
86
  }
87
+ /** Rides on each screen's title row, so it costs the frame no height. */
88
+ function Via({ gateway, theme, }) {
89
+ if (!gateway) {
90
+ return null;
91
+ }
92
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { inverse: true, bold: true, color: theme.warn, children: ` ⇄ ${gateway.name} ` }), _jsx(Text, { color: theme.chrome.color, children: ` ${gateway.url}` })] }));
93
+ }
87
94
  function Browser({ options, theme, onPick }) {
88
95
  const { exit } = useApp();
89
96
  const { stdout } = useStdout();
@@ -103,7 +110,7 @@ function Browser({ options, theme, onPick }) {
103
110
  const [failure, setFailure] = useState();
104
111
  const [query, setQuery] = useState('');
105
112
  const [typing, setTyping] = useState(false);
106
- const [role, setRole] = useState(0);
113
+ const [role, setRole] = useState(options.role);
107
114
  const [selected, setSelected] = useState(0);
108
115
  const [verdicts, setVerdicts] = useState(new Map());
109
116
  const [balances, setBalances] = useState(new Map());
@@ -147,7 +154,7 @@ function Browser({ options, theme, onPick }) {
147
154
  undefined,
148
155
  ...ROLES.filter((r) => catalog?.entries.some((e) => e.roles.includes(r))),
149
156
  ], [catalog]);
150
- const only = present[role % present.length];
157
+ const only = present.includes(role) ? role : undefined;
151
158
  const rows = useMemo(() => (catalog?.entries ?? [])
152
159
  .filter((e) => matches(e, query, { roles: only ? [only] : [] }))
153
160
  .sort(byGroup), [catalog, query, only]);
@@ -159,7 +166,7 @@ function Browser({ options, theme, onPick }) {
159
166
  setScreen('models');
160
167
  setQuery('');
161
168
  setTyping(false);
162
- setRole(0);
169
+ setRole(options.role);
163
170
  setSelected(0);
164
171
  top.current = 0;
165
172
  if (!catalogs.has(p)) {
@@ -287,7 +294,7 @@ function Browser({ options, theme, onPick }) {
287
294
  setSelected(0);
288
295
  }
289
296
  else if (key.tab) {
290
- setRole((r) => (r + 1) % present.length);
297
+ setRole(present[(present.indexOf(only) + 1) % present.length]);
291
298
  setSelected(0);
292
299
  }
293
300
  else if ((key.return || key.rightArrow || input === 'l') && current) {
@@ -322,22 +329,22 @@ function Browser({ options, theme, onPick }) {
322
329
  const width = Math.max(20, size.columns - 1);
323
330
  if (screen === 'providers') {
324
331
  const nameW = Math.max(...options.providers.map((p) => p.provider.length)) + 2;
325
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Row, { bold: true, color: theme.accent, children: "Providers" }), _jsx(Text, { children: " " }), options.providers.map((p, i) => {
332
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "Providers" }), _jsx(Via, { gateway: options.gateway, theme: theme })] }), _jsx(Text, { children: " " }), options.providers.map((p, i) => {
326
333
  const here = i === at;
327
334
  const cat = catalogs.get(p.provider);
328
335
  const count = cat?.entries.length ?? p.models;
329
336
  const fresh = cat
330
- ? freshness(cat.origin, cat.fetchedAt)
331
- : freshness(p.origin, p.fetchedAt);
337
+ ? freshness(cat.origin, cat.fetchedAt, cat.via)
338
+ : freshness(p.origin, p.fetchedAt, options.gateway);
332
339
  const b = balances.get(p.provider);
333
340
  return (_jsxs(Row, { children: [_jsxs(Text, { color: here ? theme.accent : undefined, bold: here, children: [here ? '› ' : ' ', p.provider.padEnd(nameW)] }), p.credential ? (_jsx(Text, { color: theme.code.color, children: p.credential.padEnd(15) })) : (_jsx(Text, { color: theme.line.error.color, children: 'no credential'.padEnd(15) })), _jsxs(Text, { color: theme.chrome.color, children: [`${count} model${count === 1 ? '' : 's'}`.padEnd(13), p.credential ? fresh : `zen key add ${p.provider}`] }), b && typeof b !== 'string' ? (_jsx(Text, { color: theme.code.color, children: ` ${account(b, true)}` })) : null] }, p.provider));
334
341
  }), _jsx(Text, { children: " " }), _jsx(Keys, { theme: theme, text: "\u2191\u2193 move \u00B7 enter open \u00B7 q quit" })] }));
335
342
  }
336
343
  if (screen === 'detail' && current) {
337
- return (_jsx(Detail, { entry: current, catalog: catalog, verdict: verdicts.get(current.ref), theme: theme, width: width, rows: size.rows - 1 }));
344
+ return (_jsx(Detail, { entry: current, catalog: catalog, verdict: verdicts.get(current.ref), gateway: options.gateway, theme: theme, width: width, rows: size.rows - 1 }));
338
345
  }
339
346
  const header = catalog
340
- ? `${provider} · ${catalog.entries.length} models · ${freshness(catalog.origin, catalog.fetchedAt)}`
347
+ ? `${provider} · ${catalog.entries.length} models · ${freshness(catalog.origin, catalog.fetchedAt, catalog.via)}`
341
348
  : provider;
342
349
  const withMaker = rows.some((e) => e.publisher);
343
350
  const priced = rows.some((e) => e.pricing);
@@ -381,13 +388,13 @@ function Browser({ options, theme, onPick }) {
381
388
  }) }));
382
389
  }
383
390
  const preview = current ? [current.name, current.description].filter(Boolean).join(' — ') : '';
384
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: header }), _jsxs(Text, { color: theme.chrome.color, children: [' role: ', only ?? 'all', filter ? ' ' : ''] }), _jsx(Text, { color: theme.code.color, children: filter })] }), problem ? (_jsxs(Row, { color: theme.warn, children: ["could not be listed: ", problem.detail ?? 'no reason given'] })) : null, funds.map((line, i) => (_jsx(Row, { color: short ? theme.warn : theme.code.color, children: line }, `funds${i}`))), _jsx(Text, { children: " " }), _jsx(Row, { bold: true, color: theme.chrome.color, children: columns }), _jsx(Box, { flexDirection: "column", height: height, children: body }), _jsx(Row, { color: theme.chrome.color, children: clip(preview, width) || ' ' }), _jsx(Row, { color: theme.chrome.color, children: rows.length === 0
391
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: header }), _jsxs(Text, { color: theme.chrome.color, children: [' role: ', only ?? 'all', filter ? ' ' : ''] }), _jsx(Text, { color: theme.code.color, children: filter }), _jsx(Via, { gateway: options.gateway, theme: theme })] }), problem ? (_jsxs(Row, { color: theme.warn, children: ["could not be listed: ", problem.detail ?? 'no reason given'] })) : null, funds.map((line, i) => (_jsx(Row, { color: short ? theme.warn : theme.code.color, children: line }, `funds${i}`))), _jsx(Text, { children: " " }), _jsx(Row, { bold: true, color: theme.chrome.color, children: columns }), _jsx(Box, { flexDirection: "column", height: height, children: body }), _jsx(Row, { color: theme.chrome.color, children: clip(preview, width) || ' ' }), _jsx(Row, { color: theme.chrome.color, children: rows.length === 0
385
392
  ? ' '
386
393
  : `${index + 1} of ${rows.length}${rows.length < (catalog?.entries.length ?? 0) ? ` (${catalog.entries.length} in all)` : ''}` }), _jsx(Keys, { theme: theme, text: typing
387
394
  ? 'type to filter · enter keep · esc clear · ↑↓ move'
388
395
  : '↑↓ · / filter · tab role · ⏎ more · t test · p pick · r reload · esc back' })] }));
389
396
  }
390
- function Detail({ entry, catalog, verdict, theme, width, rows }) {
397
+ function Detail({ entry, catalog, verdict, gateway, theme, width, rows, }) {
391
398
  const facts = [];
392
399
  const add = (label, value) => {
393
400
  if (value) {
@@ -412,7 +419,7 @@ function Detail({ entry, catalog, verdict, theme, width, rows }) {
412
419
  : undefined);
413
420
  add('released', entry.created);
414
421
  add('source', entry.source === 'live' && catalog
415
- ? `${entry.provider}, ${freshness(catalog.origin, catalog.fetchedAt)}`
422
+ ? `${catalog.via ? `${catalog.via.name} (${entry.provider})` : entry.provider}, ${freshness(catalog.origin, catalog.fetchedAt, catalog.via)}`
416
423
  : 'built-in list');
417
424
  const v = said(verdict, theme);
418
425
  const reason = verdict && verdict !== 'asking'
@@ -428,7 +435,7 @@ function Detail({ entry, catalog, verdict, theme, width, rows }) {
428
435
  // whatever is left, and is cut rather than allowed to push the keys off.
429
436
  const room = Math.max(0, rows - facts.length - 6);
430
437
  const about = entry.description ? wrap(entry.description, width - 2).slice(0, room) : [];
431
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Row, { bold: true, color: theme.accent, children: entry.ref }), _jsx(Text, { children: " " }), facts.map(([label, value]) => (_jsxs(Row, { children: [_jsx(Text, { color: theme.chrome.color, children: ` ${label.padEnd(12)}` }), _jsx(Text, { children: value })] }, label))), _jsxs(Row, { children: [_jsxs(Text, { color: theme.chrome.color, children: [' ', 'asked'.padEnd(12)] }), v ? (_jsx(Text, { color: v.color, children: v.text })) : (_jsx(Text, { color: theme.chrome.color, children: "not yet \u2014 t to ask it one question" })), reason ? _jsx(Text, { color: v?.color, children: ` ${reason}` }) : null] }), about.length > 0 ? _jsx(Text, { children: " " }) : null, about.map((line, i) => (_jsx(Row, { color: theme.chrome.color, children: ` ${line}` }, i))), _jsx(Text, { children: " " }), _jsx(Keys, { theme: theme, text: "t test \u00B7 p pick (prints the ref) \u00B7 esc back \u00B7 q quit" })] }));
438
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Row, { children: [_jsx(Text, { bold: true, color: theme.accent, children: entry.ref }), _jsx(Via, { gateway: gateway, theme: theme })] }), _jsx(Text, { children: " " }), facts.map(([label, value]) => (_jsxs(Row, { children: [_jsx(Text, { color: theme.chrome.color, children: ` ${label.padEnd(12)}` }), _jsx(Text, { children: value })] }, label))), _jsxs(Row, { children: [_jsxs(Text, { color: theme.chrome.color, children: [' ', 'asked'.padEnd(12)] }), v ? (_jsx(Text, { color: v.color, children: v.text })) : (_jsx(Text, { color: theme.chrome.color, children: "not yet \u2014 t to ask it one question" })), reason ? _jsx(Text, { color: v?.color, children: ` ${reason}` }) : null] }), about.length > 0 ? _jsx(Text, { children: " " }) : null, about.map((line, i) => (_jsx(Row, { color: theme.chrome.color, children: ` ${line}` }, i))), _jsx(Text, { children: " " }), _jsx(Keys, { theme: theme, text: "t test \u00B7 p pick (prints the ref) \u00B7 esc back \u00B7 q quit" })] }));
432
439
  }
433
440
  /**
434
441
  * Runs the browser until the person picks a model or leaves. Resolves to the
package/dist/validate.js CHANGED
@@ -471,13 +471,13 @@ async function askModels(targets, opts, add) {
471
471
  }
472
472
  const where = named(report);
473
473
  if (probe.check.state === 'blocked') {
474
- const pick = report.role === 'embedding' ? '--embedding' : '--chat';
474
+ const list = report.role === 'embedding' ? '--embeddings' : '--chat';
475
475
  add({
476
476
  severity: 'error',
477
477
  code: `${report.role}.blocked`,
478
478
  where,
479
479
  message: `the credential was accepted and then refused: ${probe.check.detail ?? 'no reason given'}`,
480
- fix: `${probe.check.fix ?? 'resolve it in the vendor console'}, or find one that works: zen models pick ${pick}`,
480
+ fix: `${probe.check.fix ?? 'resolve it in the vendor console'}, or find one that works: zen models ls ${list}`,
481
481
  });
482
482
  return;
483
483
  }