@zenera/cli 1.1.3 → 1.1.4

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.
@@ -1,120 +1,565 @@
1
- import { loadProject, readProjectConfig } from '@zenera/neo';
1
+ // ---------------------------------------------------------------------------
2
+ // zen models
3
+ //
4
+ // What this machine *can* use, as opposed to whether one particular project
5
+ // works. `zen check` answers the second question — it resolves a project,
6
+ // audits its credentials and asks every model it declares one real question.
7
+ // This command answers the first, and needs no project at all.
8
+ //
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.
13
+ // ---------------------------------------------------------------------------
14
+ import { createEmbedder, createModel, ModelRegistry } from '@zenera/neo';
2
15
  import { parse } from "../args.js";
3
- import { envNames, form, KeyStore, PROVIDERS } from "../keys.js";
4
- import { project as resolveProject } from "../resolve.js";
5
- import { bold, credentialError, cyan, dim, green, invalidError, json, note, red, table, writeAll, yellow, } from "../term.js";
6
- import { availableTools } from "../validate.js";
7
- const USAGE = 'zen models [--project <name|dir>]';
16
+ import { loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
17
+ import { ensureHome } from "../home.js";
18
+ import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
19
+ import { probeModel } from "../liveness.js";
20
+ import { ago, bold, credentialError, cyan, dim, green, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
21
+ const USAGE = 'zen models <providers|ls|search|show|test|pick> [ref] [options]';
8
22
  /**
9
- * Everything a run would resolve, resolved — and nothing called. Loading a
10
- * project constructs the model clients, so a config that names an impossible
11
- * provider or an agent that hands off to nobody fails here, in a command that
12
- * costs nothing, instead of three seconds into a run that costs money.
23
+ * The keyring is materialised here, and only here.
24
+ *
25
+ * The frame does not do it, so every command that needs a credential asks for
26
+ * one itself. Forgetting looks exactly like a missing key, which is the most
27
+ * expensive kind of bug this file could have.
13
28
  */
14
- export const models = {
15
- summary: 'Resolve providers and models and validate the config, calling nothing.',
16
- usage: USAGE,
17
- run: async (ctx) => {
18
- const { values } = parse(ctx.args, { project: { type: 'string' } }, USAGE);
19
- const found = await resolveProject({ cwd: ctx.cwd, project: values.project });
20
- const dir = found.dir;
21
- const store = await KeyStore.open();
22
- // Asked before materialising, because materialising is exactly what
23
- // erases the difference between "the environment had it" and "the
24
- // keyring supplied it".
25
- const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name])));
26
- store.materialize();
27
- let project;
28
- try {
29
- // The same tools a run would register. Without them every
30
- // `workspace:*` or `sandbox:*` selector in the config resolves
31
- // against an empty list and the project fails to load with
32
- // "no tools in group" — a report about this command, not about
33
- // the project.
34
- const { config } = readProjectConfig(dir);
35
- project = await loadProject(dir, { tools: availableTools(dir, config) });
29
+ async function credentials() {
30
+ ensureHome();
31
+ const store = await KeyStore.open();
32
+ // Asked before materialising, because materialising is what erases the
33
+ // difference between "the environment had it" and "the keyring supplied it".
34
+ const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((n) => process.env[n])));
35
+ store.materialize();
36
+ const usable = [
37
+ ...PROVIDERS.filter((p) => fromEnv.has(p)),
38
+ ...PROVIDERS.filter((p) => !fromEnv.has(p) && Boolean(store.active(p))),
39
+ ];
40
+ return { store, fromEnv, usable };
41
+ }
42
+ function source(where, provider) {
43
+ if (where.fromEnv.has(provider)) {
44
+ return 'environment';
45
+ }
46
+ return where.store.active(provider) ? 'keyring' : '';
47
+ }
48
+ /** The providers a subcommand should touch, given an optional `--provider`. */
49
+ function scope(where, only) {
50
+ if (!only) {
51
+ if (where.usable.length === 0) {
52
+ throw credentialError('no provider on this machine has a credential', 'try: zen key add openai');
36
53
  }
37
- catch (err) {
38
- const message = err instanceof Error ? err.message : String(err);
39
- // Constructing a client needs a credential, so a keyless machine
40
- // cannot get far enough to validate anything. Saying "invalid
41
- // project" there would be a lie about whose fault it is.
42
- if (!fromEnv.size && !PROVIDERS.some((p) => store.active(p))) {
43
- throw credentialError(message, 'add a key: zen key add openai');
54
+ return where.usable;
55
+ }
56
+ if (!isProvider(only)) {
57
+ throw usageError(`unknown provider "${only}"`, `known: ${PROVIDERS.join(', ')}`);
58
+ }
59
+ return [only];
60
+ }
61
+ const ROLE_OPTIONS = {
62
+ chat: { type: 'boolean' },
63
+ // Both spellings, because half the flags in this CLI read as a filter over
64
+ // a set and half as a choice of one, and nobody should have to remember
65
+ // which this is.
66
+ embedding: { type: 'boolean' },
67
+ embeddings: { type: 'boolean' },
68
+ images: { type: 'boolean' },
69
+ audio: { type: 'boolean' },
70
+ };
71
+ function rolesFrom(values) {
72
+ const roles = [];
73
+ if (values.chat) {
74
+ roles.push('chat');
75
+ }
76
+ if (values.embedding || values.embeddings) {
77
+ roles.push('embedding');
78
+ }
79
+ if (values.images) {
80
+ roles.push('image');
81
+ }
82
+ if (values.audio) {
83
+ roles.push('audio');
84
+ }
85
+ return roles;
86
+ }
87
+ const num = (value, what) => {
88
+ if (value === undefined) {
89
+ return undefined;
90
+ }
91
+ const n = Number(value);
92
+ if (!Number.isFinite(n) || n < 0) {
93
+ throw usageError(`${what} must be a number, got "${value}"`);
94
+ }
95
+ return n;
96
+ };
97
+ const ms = (n) => (n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(1)}s`);
98
+ const tokens = (n) => {
99
+ if (n === undefined) {
100
+ return '';
101
+ }
102
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
103
+ };
104
+ /** Freshness of a listing, said the way a person would ask about it. */
105
+ function freshness(cat) {
106
+ if (cat.origin === 'curated') {
107
+ return yellow('built-in list');
108
+ }
109
+ if (cat.origin === 'stale') {
110
+ return yellow(`stale, ${ago(cat.fetchedAt)}`);
111
+ }
112
+ return cat.origin === 'live' ? green('fetched now') : dim(`cached ${ago(cat.fetchedAt)}`);
113
+ }
114
+ const roleMark = (row) => row.roles.join('+');
115
+ function row(entry) {
116
+ return [
117
+ cyan(entry.ref),
118
+ dim(roleMark(entry)),
119
+ dim(tokens(entry.contextLength)),
120
+ dim(entry.pricing?.free ? 'free' : ''),
121
+ dim(entry.name ?? ''),
122
+ ];
123
+ }
124
+ /**
125
+ * Says how a listing was obtained whenever it was not the vendor's own word —
126
+ * on stderr, because it is narration about the answer rather than the answer.
127
+ */
128
+ function explain(cats) {
129
+ for (const cat of cats) {
130
+ if (cat.problem) {
131
+ note(`${yellow(cat.provider)} could not be listed: ${cat.problem.detail ?? 'no reason given'} ` +
132
+ dim(`(showing the ${cat.origin} list)`));
133
+ if (cat.problem.fix) {
134
+ note(dim(` ${cat.problem.fix}`));
44
135
  }
45
- throw invalidError(message, dir);
46
136
  }
47
- const agents = project.agents.map((a) => ({
48
- name: a.name,
49
- entry: a.name === project.entry,
50
- model: a.model?.id ?? project.config.model ?? null,
51
- tools: a.tools.map((t) => t.name),
52
- handoffs: a.handoffs,
53
- }));
54
- // Resolved through the registry, which proves the ref parses and the
55
- // provider exists without any call being made.
56
- const embeddings = Object.keys(project.config.embeddings ?? {})
57
- .concat(project.config.embedding && !project.config.embeddings?.[project.config.embedding]
58
- ? [project.config.embedding]
59
- : [])
60
- .map((name) => ({
61
- name,
62
- model: project.embedder(name)?.id ?? null,
63
- default: name === project.config.embedding,
64
- }));
65
- const credentials = PROVIDERS.map((p) => ({
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // providers
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * The overview, and deliberately an offline one: five listing round trips is
144
+ * the wrong price for the question "what have I got set up". `ls` fetches.
145
+ */
146
+ const providers = async (ctx, args) => {
147
+ const { values } = parse(args, { refresh: { type: 'boolean' } }, 'zen models providers [--refresh]');
148
+ const where = await credentials();
149
+ const cats = await loadCatalogs(PROVIDERS, {
150
+ offline: !values.refresh,
151
+ refresh: values.refresh,
152
+ });
153
+ const by = new Map(cats.map((c) => [c.provider, c]));
154
+ if (ctx.json) {
155
+ json(PROVIDERS.map((p) => ({
66
156
  provider: p,
67
- env: envNames(p).find((name) => process.env[name]) ?? form(p).env,
68
- source: fromEnv.has(p)
69
- ? 'environment'
70
- : store.active(p)
71
- ? 'keyring'
72
- : 'missing',
73
- }));
157
+ credential: source(where, p) || null,
158
+ env: form(p).env,
159
+ models: by.get(p)?.entries.length ?? 0,
160
+ origin: by.get(p)?.origin,
161
+ fetchedAt: by.get(p)?.fetchedAt,
162
+ })));
163
+ return;
164
+ }
165
+ writeAll(table(PROVIDERS.map((p) => {
166
+ const cat = by.get(p);
167
+ const cred = source(where, p);
168
+ const counted = `${cat.entries.length} model${cat.entries.length === 1 ? '' : 's'}`;
169
+ return [
170
+ cred ? cyan(p) : dim(p),
171
+ cred ? green(cred) : red('no credential'),
172
+ cred ? dim(counted) : dim(''),
173
+ cred ? freshness(cat) : dim(`zen key add ${p}`),
174
+ ];
175
+ })));
176
+ note('');
177
+ note(dim('zen models ls <provider> what one of them serves'));
178
+ note(dim('zen models pick --chat the first one that answers'));
179
+ };
180
+ // ---------------------------------------------------------------------------
181
+ // ls
182
+ // ---------------------------------------------------------------------------
183
+ const ls = async (ctx, args) => {
184
+ const { values, positionals } = parse(args, {
185
+ ...ROLE_OPTIONS,
186
+ refresh: { type: 'boolean' },
187
+ limit: { type: 'string' },
188
+ all: { type: 'boolean' },
189
+ }, 'zen models ls [provider] [--chat] [--embeddings] [--refresh] [--limit N] [--all]');
190
+ const where = await credentials();
191
+ const targets = scope(where, positionals[0]);
192
+ const roles = rolesFrom(values);
193
+ const limit = values.all ? Infinity : (num(values.limit, '--limit') ?? 40);
194
+ const bar = ctx.json ? undefined : progress();
195
+ bar?.update(dim(`listing ${targets.join(', ')} …`));
196
+ const cats = await loadCatalogs(targets, { refresh: values.refresh });
197
+ bar?.done();
198
+ const all = cats
199
+ .flatMap((c) => c.entries)
200
+ .filter((e) => matches(e, '', { roles }))
201
+ .sort((a, b) => a.ref.localeCompare(b.ref));
202
+ if (ctx.json) {
203
+ json({
204
+ models: all,
205
+ sources: cats.map(({ provider, origin, fetchedAt }) => ({
206
+ provider,
207
+ origin,
208
+ fetchedAt,
209
+ })),
210
+ });
211
+ return;
212
+ }
213
+ explain(cats);
214
+ if (all.length === 0) {
215
+ note(dim('nothing matched — try: zen models ls --refresh'));
216
+ return;
217
+ }
218
+ writeAll(table(all.slice(0, limit).map(row)));
219
+ if (all.length > limit) {
220
+ note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
221
+ }
222
+ };
223
+ // ---------------------------------------------------------------------------
224
+ // search
225
+ // ---------------------------------------------------------------------------
226
+ const search = async (ctx, args) => {
227
+ const { values, positionals } = parse(args, {
228
+ ...ROLE_OPTIONS,
229
+ provider: { type: 'string' },
230
+ tools: { type: 'boolean' },
231
+ vision: { type: 'boolean' },
232
+ free: { type: 'boolean' },
233
+ 'min-context': { type: 'string' },
234
+ limit: { type: 'string' },
235
+ refresh: { type: 'boolean' },
236
+ }, 'zen models search <query> [--provider p] [--embeddings] [--tools] [--vision] [--free] [--min-context N] [--limit N]');
237
+ const query = positionals.join(' ').trim();
238
+ const where = await credentials();
239
+ const targets = scope(where, values.provider);
240
+ const limit = num(values.limit, '--limit') ?? 20;
241
+ const filters = {
242
+ roles: rolesFrom(values),
243
+ tools: values.tools,
244
+ vision: values.vision,
245
+ free: values.free,
246
+ minContext: num(values['min-context'], '--min-context'),
247
+ };
248
+ const bar = ctx.json ? undefined : progress();
249
+ bar?.update(dim(`searching ${targets.join(', ')} …`));
250
+ const cats = await loadCatalogs(targets, { refresh: values.refresh });
251
+ bar?.done();
252
+ const hits = cats
253
+ .flatMap((c) => c.entries)
254
+ .filter((e) => matches(e, query, filters))
255
+ // Cheapest first when price is known: search is usually the step before
256
+ // picking one, and the free ones are what most people are looking for.
257
+ .sort((a, b) => Number(b.pricing?.free ?? false) - Number(a.pricing?.free ?? false) ||
258
+ a.ref.localeCompare(b.ref));
259
+ if (ctx.json) {
260
+ json({ query, matched: hits.length, models: hits.slice(0, limit) });
261
+ return;
262
+ }
263
+ explain(cats);
264
+ if (hits.length === 0) {
265
+ note(dim(`nothing matched "${query}" — try fewer words, or --refresh`));
266
+ return;
267
+ }
268
+ writeAll(table(hits.slice(0, limit).map(row)));
269
+ if (hits.length > limit) {
270
+ note(dim(`${hits.length - limit} more — raise --limit, or add a word`));
271
+ }
272
+ note(dim('zen models test <ref> ask one of them whether it answers'));
273
+ };
274
+ // ---------------------------------------------------------------------------
275
+ // show
276
+ // ---------------------------------------------------------------------------
277
+ /**
278
+ * Splits a ref without building anything. `ModelRegistry.parse` is the same
279
+ * splitter the runtime uses, so a ref that reads here reads there — and a typo
280
+ * is a usage error rather than a credential one.
281
+ */
282
+ function split(ref) {
283
+ const registry = new ModelRegistry();
284
+ let spec;
285
+ try {
286
+ spec = registry.parse(ref);
287
+ }
288
+ catch (err) {
289
+ throw usageError(err instanceof Error ? err.message.split('\n')[0] : `bad reference "${ref}"`, 'expected provider:model — see: zen models ls');
290
+ }
291
+ const provider = spec.provider ?? registry.defaultProvider;
292
+ if (!isProvider(provider)) {
293
+ throw usageError(`"${provider}" is not a provider this command knows`, `known: ${PROVIDERS.join(', ')}`);
294
+ }
295
+ return { provider, id: spec.model };
296
+ }
297
+ const show = async (ctx, args) => {
298
+ const { values, positionals } = parse(args, { refresh: { type: 'boolean' } }, 'zen models show <provider:model> [--refresh]');
299
+ const ref = positionals[0];
300
+ if (!ref) {
301
+ throw usageError('which model?', 'see: zen models ls');
302
+ }
303
+ const { provider, id } = split(ref);
304
+ const cat = await loadCatalog(provider, { refresh: values.refresh });
305
+ const found = cat.entries.find((e) => e.id === id);
306
+ if (ctx.json) {
307
+ json(found ?? { ref: `${provider}:${id}`, provider, id, known: false });
308
+ return;
309
+ }
310
+ explain([cat]);
311
+ if (!found) {
312
+ note(`${yellow('not listed')} ${provider} does not advertise ${bold(id)}`);
313
+ note(dim(`it may still work — try: zen models test ${provider}:${id}`));
314
+ note(dim(`or look: zen models search ${id} --provider ${provider}`));
315
+ return;
316
+ }
317
+ const rows = [
318
+ [dim('ref'), cyan(found.ref)],
319
+ [dim('roles'), found.roles.join(', ')],
320
+ ];
321
+ const add = (label, value) => {
322
+ if (value) {
323
+ rows.push([dim(label), value]);
324
+ }
325
+ };
326
+ add('name', found.name);
327
+ add('context', found.contextLength ? `${found.contextLength.toLocaleString()} tokens` : undefined);
328
+ add('max output', found.maxOutputTokens ? `${found.maxOutputTokens.toLocaleString()} tokens` : undefined);
329
+ add('dimensions', found.dimensions ? String(found.dimensions) : undefined);
330
+ add('input', found.modalities?.input?.join(', '));
331
+ add('output', found.modalities?.output?.join(', '));
332
+ add('supports', Object.entries(found.supports ?? {})
333
+ .filter(([, on]) => on)
334
+ .map(([k]) => k)
335
+ .join(', '));
336
+ add('pricing', found.pricing?.free
337
+ ? 'free'
338
+ : found.pricing?.prompt
339
+ ? `$${found.pricing.prompt}/token in, $${found.pricing.completion ?? '?'}/token out`
340
+ : undefined);
341
+ add('released', found.created);
342
+ add('source', found.source === 'live' ? `${provider}, ${freshness(cat)}` : 'built-in list');
343
+ writeAll(table(rows));
344
+ if (found.description) {
345
+ note('');
346
+ note(dim(found.description));
347
+ }
348
+ };
349
+ // ---------------------------------------------------------------------------
350
+ // test
351
+ // ---------------------------------------------------------------------------
352
+ /**
353
+ * Which role to ask in. An explicit flag wins; then what the provider says the
354
+ * model is for; then the id, which is the last resort and the only one that can
355
+ * be wrong.
356
+ */
357
+ async function roleOf(provider, id, forced) {
358
+ if (forced) {
359
+ return forced;
360
+ }
361
+ // Offline: this is one lookup on the way to a real call, and it must not
362
+ // add a listing round trip to every `test`.
363
+ const cat = await loadCatalog(provider, { offline: true });
364
+ const known = cat.entries.find((e) => e.id === id);
365
+ if (known?.roles.includes('embedding') && !known.roles.includes('chat')) {
366
+ return 'embedding';
367
+ }
368
+ if (known?.roles.includes('chat')) {
369
+ return 'chat';
370
+ }
371
+ return /embed/.test(id) ? 'embedding' : 'chat';
372
+ }
373
+ function target(ref, provider, id, role) {
374
+ if (role === 'embedding') {
375
+ return { ref, kind: 'embedding', embedder: createEmbedder({ provider, model: id }) };
376
+ }
377
+ if (role !== 'chat') {
378
+ throw usageError(`${ref} is ${role}-only, and there is no way to ask it a question from here`, 'zen models test only exercises chat and embedding models');
379
+ }
380
+ // 16 tokens is enough for "ok" and not enough to matter. Anthropic requires
381
+ // a cap at all, so this is not merely thrift.
382
+ return { ref, kind: 'model', model: createModel({ provider, model: id, maxTokens: 16 }) };
383
+ }
384
+ function verdict(probe) {
385
+ switch (probe.check.state) {
386
+ case 'live':
387
+ return `${green('answers')} ${dim(ms(probe.ms))}`;
388
+ case 'blocked':
389
+ return yellow('blocked');
390
+ case 'dead':
391
+ return red('refused');
392
+ default:
393
+ return dim('no answer');
394
+ }
395
+ }
396
+ const test = async (ctx, args) => {
397
+ const { values, positionals } = parse(args, ROLE_OPTIONS, 'zen models test <provider:model> … [--chat|--embedding]');
398
+ if (positionals.length === 0) {
399
+ throw usageError('which model?', 'see: zen models ls');
400
+ }
401
+ const forced = rolesFrom(values)[0];
402
+ // Every ref is split before anything is built, so a typo in the third one
403
+ // does not arrive after two billable calls.
404
+ const parsed = positionals.map((ref) => ({ ref, ...split(ref) }));
405
+ await credentials();
406
+ const probes = [];
407
+ const bar = ctx.json ? undefined : progress();
408
+ for (const { ref, provider, id } of parsed) {
409
+ bar?.update(dim(`asking ${ref} …`));
410
+ probes.push(await probeModel(target(ref, provider, id, await roleOf(provider, id, forced))));
411
+ }
412
+ bar?.done();
413
+ if (ctx.json) {
414
+ json(probes);
415
+ }
416
+ else {
417
+ writeAll(table(probes.map((p) => [
418
+ cyan(p.ref),
419
+ verdict(p),
420
+ dim(p.dimensions ? `${p.dimensions} dims` : ''),
421
+ dim(p.check.detail ?? ''),
422
+ ])));
423
+ for (const p of probes) {
424
+ if (p.check.fix) {
425
+ note(dim(`${p.ref}: ${p.check.fix}`));
426
+ }
427
+ }
428
+ }
429
+ const failed = probes.filter((p) => p.check.state !== 'live');
430
+ if (failed.length > 0) {
431
+ // The suggestion follows what was actually asked, not what was flagged:
432
+ // being told to `pick --chat` after an embedder was refused is how a
433
+ // recovery path stops being one.
434
+ const role = failed.every((p) => p.kind === 'embedding') ? '--embedding' : '--chat';
435
+ throw credentialError(`${failed.length} of ${probes.length} did not answer`, `find one that does: zen models pick ${role}`);
436
+ }
437
+ };
438
+ // ---------------------------------------------------------------------------
439
+ // pick
440
+ // ---------------------------------------------------------------------------
441
+ /**
442
+ * The recovery path, and the reason this command exists.
443
+ *
444
+ * Candidates are tried one at a time and the walk stops at the first that
445
+ * answers. Sequential on purpose: the goal is *one* working ref, and firing
446
+ * eight billable calls to find it is the wrong trade — especially for the
447
+ * caller most likely to be running this, which is an agent that has just been
448
+ * refused and is looking for somewhere else to go.
449
+ */
450
+ const pick = async (ctx, args) => {
451
+ const { values } = parse(args, { ...ROLE_OPTIONS, provider: { type: 'string' }, limit: { type: 'string' } }, 'zen models pick --chat|--embedding [--provider p] [--limit N]');
452
+ const roles = rolesFrom(values);
453
+ if (roles.length !== 1 || (roles[0] !== 'chat' && roles[0] !== 'embedding')) {
454
+ throw usageError('which kind of model?', 'pass --chat or --embedding');
455
+ }
456
+ const role = roles[0];
457
+ const where = await credentials();
458
+ const targets = scope(where, values.provider);
459
+ const cap = num(values.limit, '--limit') ?? 8;
460
+ const candidates = targets
461
+ .flatMap((p) => PREFERRED[p][role].map((id) => ({ provider: p, id, ref: `${p}:${id}` })))
462
+ .slice(0, cap);
463
+ if (candidates.length === 0) {
464
+ throw credentialError(`no ${role} model is known for ${targets.join(', ')}`, 'try: zen models pick --provider openai');
465
+ }
466
+ const tried = [];
467
+ const bar = ctx.json ? undefined : progress();
468
+ for (const candidate of candidates) {
469
+ bar?.update(dim(`trying ${candidate.ref} …`));
470
+ const probe = await probeModel(target(candidate.ref, candidate.provider, candidate.id, role));
471
+ tried.push(probe);
472
+ if (probe.check.state !== 'live') {
473
+ continue;
474
+ }
475
+ bar?.done();
74
476
  if (ctx.json) {
75
477
  json({
76
- project: found.name,
77
- source: project.source,
78
- providers: project.models.names(),
79
- agents,
80
- embeddings,
81
- credentials,
478
+ ref: probe.ref,
479
+ provider: candidate.provider,
480
+ model: candidate.id,
481
+ role,
482
+ ...(probe.dimensions ? { dimensions: probe.dimensions } : {}),
483
+ ms: probe.ms,
484
+ tried: tried.map(({ ref, check }) => ({ ref, ...check })),
82
485
  });
83
486
  return;
84
487
  }
85
- note(`${bold(found.name)} ${dim(project.source)}`);
86
- note('');
87
- note(bold('Agents'));
88
- writeAll(table(agents.map((a) => [
89
- ` ${a.entry ? green('→') : ' '} ${a.name}`,
90
- cyan(a.model ?? dim('inherited')),
91
- dim(a.tools.length ? a.tools.join(' ') : 'no tools'),
92
- dim(a.handoffs.length ? `→ ${a.handoffs.join(', ')}` : ''),
93
- ])));
94
- if (embeddings.length) {
95
- note('');
96
- note(bold('Embeddings'));
97
- writeAll(table(embeddings.map((e) => [
98
- ` ${e.default ? green('→') : ' '} ${e.name}`,
99
- cyan(e.model ?? dim('unresolved')),
100
- ])));
488
+ for (const t of tried.slice(0, -1)) {
489
+ note(`${dim(t.ref)} ${verdict(t)} ${dim(t.check.detail ?? '')}`);
101
490
  }
102
- note('');
103
- note(bold('Credentials'));
104
- writeAll(table(credentials.map((c) => [
105
- ` ${c.provider}`,
106
- dim(c.env),
107
- c.source === 'missing' ? red('missing') : green(c.source),
491
+ note(`${green('works')} ${bold(probe.ref)} ${dim(ms(probe.ms))}`);
492
+ // stdout, alone and unstyled, so `$(zen models pick --embedding)` is
493
+ // the ref and nothing else.
494
+ write(probe.ref);
495
+ return;
496
+ }
497
+ bar?.done();
498
+ if (ctx.json) {
499
+ json({ ref: null, role, tried: tried.map(({ ref, check }) => ({ ref, ...check })) });
500
+ }
501
+ else {
502
+ writeAll(table(tried.map((t) => [
503
+ dim(t.ref),
504
+ verdict(t),
505
+ dim(t.check.fix ?? t.check.detail ?? ''),
108
506
  ])));
109
- const declared = project.models.names();
110
- if (declared.length) {
111
- note('');
112
- note(`${bold('Providers')} ${dim(declared.join(', '))}`);
507
+ }
508
+ throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
509
+ };
510
+ // ---------------------------------------------------------------------------
511
+ // Dispatch
512
+ // ---------------------------------------------------------------------------
513
+ const SUBS = {
514
+ providers,
515
+ ls,
516
+ list: ls,
517
+ search: search,
518
+ find: search,
519
+ show: show,
520
+ test,
521
+ check: test,
522
+ pick,
523
+ };
524
+ export const models = {
525
+ summary: 'What this machine can use: list, search, test and pick models.',
526
+ usage: USAGE,
527
+ details: [
528
+ 'Answers "what can I use", using the credentials already on this',
529
+ 'machine. `zen check` answers the other question — whether one',
530
+ 'particular project works — and needs a project to do it.',
531
+ '',
532
+ 'Listings come from the providers themselves and are cached for a day',
533
+ 'in ~/.zenera/neo/catalog. When a provider cannot be asked, the last',
534
+ 'listing is used and said to be stale; only if there was never one does',
535
+ 'a short built-in list stand in.',
536
+ '',
537
+ ' zen models Providers, credentials and counts.',
538
+ ' zen models <provider> Short for `ls <provider>`.',
539
+ ' zen models ls [provider] Everything it serves.',
540
+ ' zen models search <query> Narrow it — --tools, --vision, --free.',
541
+ ' zen models show <ref> One model, in full.',
542
+ ' zen models test <ref> … Ask it one real question.',
543
+ ' zen models pick --embedding The first ref that answers, on stdout.',
544
+ ],
545
+ run: async (ctx) => {
546
+ const [name, ...rest] = ctx.args;
547
+ if (!name) {
548
+ await providers(ctx, []);
549
+ return;
550
+ }
551
+ const sub = SUBS[name];
552
+ if (sub) {
553
+ await sub(ctx, rest);
554
+ return;
113
555
  }
114
- if (credentials.every((c) => c.source === 'missing')) {
115
- note('');
116
- note(yellow('nothing can be reached — add a key: zen key add openai'));
556
+ // `zen models openai` is the thing people type, and it means `ls`.
557
+ // Safe because no provider is named after a subcommand.
558
+ if (isProvider(name)) {
559
+ await ls(ctx, ctx.args);
560
+ return;
117
561
  }
562
+ throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
118
563
  },
119
564
  };
120
565
  //# sourceMappingURL=models.js.map