@zenera/cli 1.1.2 → 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.
Files changed (59) hide show
  1. package/README.md +228 -31
  2. package/dist/audit.d.ts +13 -8
  3. package/dist/audit.js +21 -24
  4. package/dist/catalog.d.ts +111 -0
  5. package/dist/catalog.js +439 -0
  6. package/dist/commands/check.js +72 -17
  7. package/dist/commands/index.d.ts +2 -2
  8. package/dist/commands/index.js +3 -2
  9. package/dist/commands/init.js +71 -11
  10. package/dist/commands/key.js +144 -36
  11. package/dist/commands/models.d.ts +0 -6
  12. package/dist/commands/models.js +546 -101
  13. package/dist/commands/open.js +2 -2
  14. package/dist/commands/run.js +3 -0
  15. package/dist/engine.d.ts +2 -0
  16. package/dist/engine.js +1 -0
  17. package/dist/home.d.ts +2 -0
  18. package/dist/home.js +2 -0
  19. package/dist/keys.d.ts +104 -13
  20. package/dist/keys.js +175 -34
  21. package/dist/lib.d.ts +2 -1
  22. package/dist/lib.js +2 -1
  23. package/dist/liveness.d.ts +48 -6
  24. package/dist/liveness.js +268 -28
  25. package/dist/sandbox.d.ts +2 -0
  26. package/dist/sandbox.js +58 -7
  27. package/dist/scaffold.d.ts +21 -21
  28. package/dist/scaffold.js +132 -204
  29. package/dist/validate.d.ts +17 -1
  30. package/dist/validate.js +100 -10
  31. package/package.json +2 -18
  32. package/templates/{.github → editor/.github}/copilot-instructions.md +37 -9
  33. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  34. package/templates/editor/.github/skills/zen-cli/SKILL.md +77 -0
  35. package/templates/editor/.github/skills/zen-cli/references/check.md +88 -0
  36. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  37. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  38. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  39. package/templates/editor/.github/skills/zen-cli/references/keys.md +119 -0
  40. package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
  41. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  42. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  43. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  44. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  45. package/templates/editor/.vscode/settings.json +6 -0
  46. package/templates/parts/exa.yaml.tmpl +5 -0
  47. package/templates/parts/model.yaml.tmpl +4 -0
  48. package/templates/parts/models.yaml.tmpl +10 -0
  49. package/templates/project/INSTRUCTIONS.md +7 -0
  50. package/templates/project/SPECIFICATION.md +6 -0
  51. package/templates/project/agents/prompts/default.md +15 -0
  52. package/templates/project/agents.yaml.tmpl +44 -0
  53. package/templates/project/assets/README.md +12 -0
  54. package/templates/project/gitignore +9 -0
  55. package/templates/{sandbox → project/sandbox}/Dockerfile +2 -0
  56. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  57. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  58. /package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +0 -0
  59. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
@@ -0,0 +1,439 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The catalog
3
+ //
4
+ // What each provider will actually serve this machine, asked of the provider
5
+ // itself and remembered on disk.
6
+ //
7
+ // Note what is *not* here: any judgement about which model is good. The lists
8
+ // below are ordered by how likely a call is to succeed and how little it costs
9
+ // to find out, not by quality — a recovery path, not a recommender. The moment
10
+ // a file like this starts ranking intelligence it becomes a thing that has to
11
+ // be argued about every quarter, and the argument is not what anyone came for.
12
+ //
13
+ // Nor is there a single hard-coded table of every model. Vendors ship models
14
+ // faster than a release cycle, so the shipped table is a *fallback* and the
15
+ // answer is whatever the vendor said this morning. Every row carries its own
16
+ // `source` for exactly that reason: a curated guess must never be mistaken for
17
+ // the vendor's own word.
18
+ // ---------------------------------------------------------------------------
19
+ import { join } from 'node:path';
20
+ import { ModelRegistry } from '@zenera/neo';
21
+ import { paths, readJson, writeJson } from "./home.js";
22
+ import { PROVIDERS } from "./keys.js";
23
+ import { classify } from "./liveness.js";
24
+ /** A day. Model lists change on the scale of weeks; a stale row costs a retry. */
25
+ export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
26
+ const CACHE_VERSION = 1;
27
+ // ---------------------------------------------------------------------------
28
+ // The fallback table
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Enough to work with when the provider cannot be asked — offline, no
32
+ * credential, or a listing endpoint that is down. Deliberately short: this is
33
+ * the set worth typing, not the set that exists.
34
+ *
35
+ * Anthropic publishes no embeddings API at all, which is why it has no
36
+ * embedding row here and why the registry throws rather than guessing.
37
+ */
38
+ export const CURATED = {
39
+ openai: [
40
+ { id: 'gpt-4o-mini', roles: ['chat'], contextLength: 128_000 },
41
+ { id: 'gpt-4o', roles: ['chat'], contextLength: 128_000 },
42
+ { id: 'text-embedding-3-small', roles: ['embedding'], dimensions: 1536 },
43
+ { id: 'text-embedding-3-large', roles: ['embedding'], dimensions: 3072 },
44
+ ],
45
+ anthropic: [
46
+ { id: 'claude-haiku-4-5', roles: ['chat'], contextLength: 200_000 },
47
+ { id: 'claude-sonnet-4-5', roles: ['chat'], contextLength: 200_000 },
48
+ ],
49
+ google: [
50
+ { id: 'gemini-2.5-flash', roles: ['chat'], contextLength: 1_048_576 },
51
+ { id: 'gemini-2.5-pro', roles: ['chat'], contextLength: 1_048_576 },
52
+ { id: 'gemini-embedding-001', roles: ['embedding'], dimensions: 3072 },
53
+ ],
54
+ vertex: [
55
+ { id: 'gemini-2.5-flash', roles: ['chat'], contextLength: 1_048_576 },
56
+ { id: 'gemini-2.5-pro', roles: ['chat'], contextLength: 1_048_576 },
57
+ { id: 'gemini-embedding-001', roles: ['embedding'], dimensions: 3072 },
58
+ { id: 'text-embedding-005', roles: ['embedding'], dimensions: 768 },
59
+ ],
60
+ openrouter: [
61
+ { id: 'openai/gpt-4o-mini', roles: ['chat'] },
62
+ { id: 'anthropic/claude-haiku-4.5', roles: ['chat'] },
63
+ { id: 'openai/text-embedding-3-small', roles: ['embedding'], dimensions: 1536 },
64
+ ],
65
+ };
66
+ /**
67
+ * The order `zen models pick` walks, per provider and per role.
68
+ *
69
+ * Cheap and fast first. `pick` exists to answer "give me something that works"
70
+ * in one round trip where it can, and the small models answer soonest and cost
71
+ * least when the answer is thrown away — which it always is.
72
+ */
73
+ export const PREFERRED = {
74
+ openai: {
75
+ chat: ['gpt-4o-mini', 'gpt-4o'],
76
+ embedding: ['text-embedding-3-small', 'text-embedding-3-large'],
77
+ },
78
+ anthropic: {
79
+ chat: ['claude-haiku-4-5', 'claude-sonnet-4-5'],
80
+ embedding: [],
81
+ },
82
+ google: {
83
+ chat: ['gemini-2.5-flash', 'gemini-2.5-pro'],
84
+ embedding: ['gemini-embedding-001'],
85
+ },
86
+ vertex: {
87
+ chat: ['gemini-2.5-flash', 'gemini-2.5-pro'],
88
+ embedding: ['text-embedding-005', 'gemini-embedding-001'],
89
+ },
90
+ openrouter: {
91
+ chat: ['openai/gpt-4o-mini', 'anthropic/claude-haiku-4.5'],
92
+ embedding: ['openai/text-embedding-3-small'],
93
+ },
94
+ };
95
+ function curated(provider) {
96
+ return CURATED[provider].map((entry) => ({
97
+ ...entry,
98
+ ref: `${provider}:${entry.id}`,
99
+ provider,
100
+ source: 'curated',
101
+ }));
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Vendor adapters
105
+ //
106
+ // Each takes the client `ModelRegistry` already built — never a direct SDK
107
+ // import — so a missing optional dependency surfaces as the library's own
108
+ // "run: npm i openai" rather than a stack trace from this file.
109
+ //
110
+ // The SDK types are described structurally and locally. Four vendors' generated
111
+ // types would drag half of `zod` into a module whose whole job is to produce
112
+ // one flat row shape, and every field here is one the wire format has carried
113
+ // unchanged for years.
114
+ // ---------------------------------------------------------------------------
115
+ const iso = (value) => {
116
+ if (value === undefined || value === null) {
117
+ return undefined;
118
+ }
119
+ // OpenAI and OpenRouter both say unix seconds; Anthropic says a timestamp.
120
+ const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
121
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10);
122
+ };
123
+ /** Drops empty optionals so a cached row does not carry a dozen `undefined`s. */
124
+ function entry(provider, row) {
125
+ const clean = Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined && v !== null));
126
+ return { ...clean, ref: `${provider}:${row.id}`, provider, source: 'live' };
127
+ }
128
+ /**
129
+ * OpenAI's listing is three fields — id, created, owner — so the role has to be
130
+ * read off the id. Wrong on an id nobody has seen before, which is why the
131
+ * curated table is merged over the top and why `test` asks the model itself.
132
+ */
133
+ function openaiRole(id) {
134
+ if (/moderation/.test(id)) {
135
+ return undefined; // not reachable through any interface this CLI has
136
+ }
137
+ if (/embedding/.test(id)) {
138
+ return 'embedding';
139
+ }
140
+ // `dall-e-3`, `gpt-image-1` and `chatgpt-image-latest` share only the word.
141
+ if (/^dall-e|image/.test(id)) {
142
+ return 'image';
143
+ }
144
+ if (/(whisper|tts|transcribe|realtime|audio)/.test(id)) {
145
+ return 'audio';
146
+ }
147
+ return 'chat';
148
+ }
149
+ async function fromOpenAI(client, provider) {
150
+ const page = await client.models.list();
151
+ const rows = [];
152
+ for await (const model of page) {
153
+ const role = openaiRole(model.id);
154
+ if (role) {
155
+ rows.push(entry(provider, { id: model.id, roles: [role], created: iso(model.created) }));
156
+ }
157
+ }
158
+ return rows;
159
+ }
160
+ async function fromAnthropic(client, provider) {
161
+ const page = await client.models.list({ limit: 1000 });
162
+ const rows = [];
163
+ for await (const model of page) {
164
+ // Every model Anthropic lists is a chat model. Emitting an embedding
165
+ // row would be inventing an endpoint that does not exist.
166
+ rows.push(entry(provider, {
167
+ id: model.id,
168
+ roles: ['chat'],
169
+ name: model.display_name,
170
+ created: iso(model.created_at),
171
+ }));
172
+ }
173
+ return rows;
174
+ }
175
+ /**
176
+ * Google returns resource names — `models/gemini-2.5-flash`, or under Vertex
177
+ * `publishers/google/models/…`. The native API takes the bare tail, and the
178
+ * bare tail is what a ref has to hold, so the prefix comes off here.
179
+ */
180
+ const bareId = (name) => name.split('/').pop() ?? name;
181
+ /**
182
+ * Vertex lists the whole Model Garden — `alphafold3-request`, `bart-large-cnn`,
183
+ * `automl-e2e` — alongside the models the GenAI API will actually serve. The
184
+ * garden rows are deployment recipes, not model ids: they carry no
185
+ * `supportedActions` and no token limits, and asking one of them a question
186
+ * fails in a way no error message explains.
187
+ *
188
+ * So a row with no declared actions has to earn its place on the id. Generous
189
+ * on purpose — a new `gemini-4` must not need a release here — and it only
190
+ * applies where the backend told us nothing.
191
+ */
192
+ const GENERATIVE = /^(gemini|gemma|imagen|veo|text-embedding|text-multilingual|multimodalembedding|embedding)/;
193
+ async function fromGenAI(client, provider) {
194
+ // `queryBase: true` is not optional: without it the SDK lists *tuned*
195
+ // models, and an account with none looks like an account with no models —
196
+ // which is precisely the confusion this command exists to end.
197
+ const pager = await client.models.list({
198
+ config: { queryBase: true, pageSize: 200 },
199
+ });
200
+ const rows = [];
201
+ for await (const model of pager) {
202
+ if (!model.name) {
203
+ continue;
204
+ }
205
+ const id = bareId(model.name);
206
+ const actions = model.supportedActions ?? [];
207
+ const roles = [];
208
+ if (actions.includes('embedContent')) {
209
+ roles.push('embedding');
210
+ }
211
+ if (actions.includes('generateContent') || actions.includes('streamGenerateContent')) {
212
+ roles.push('chat');
213
+ }
214
+ if (actions.includes('predict') && /image|imagen/.test(id)) {
215
+ roles.push('image');
216
+ }
217
+ if (roles.length === 0) {
218
+ if (!GENERATIVE.test(id)) {
219
+ continue;
220
+ }
221
+ roles.push(/embedding/.test(id) ? 'embedding' : 'chat');
222
+ }
223
+ rows.push(entry(provider, {
224
+ id,
225
+ roles,
226
+ name: model.displayName,
227
+ description: model.description,
228
+ contextLength: model.inputTokenLimit,
229
+ maxOutputTokens: model.outputTokenLimit,
230
+ }));
231
+ }
232
+ return rows;
233
+ }
234
+ function routerRow(provider, model, roles) {
235
+ const input = model.architecture?.inputModalities ?? [];
236
+ const params = model.supportedParameters ?? [];
237
+ const prompt = model.pricing?.prompt;
238
+ return entry(provider, {
239
+ id: model.id,
240
+ roles,
241
+ name: model.name,
242
+ description: model.description,
243
+ contextLength: model.contextLength ?? undefined,
244
+ maxOutputTokens: model.topProvider?.maxCompletionTokens ?? undefined,
245
+ modalities: {
246
+ input,
247
+ output: model.architecture?.outputModalities ?? [],
248
+ },
249
+ supports: {
250
+ tools: params.includes('tools'),
251
+ reasoning: params.includes('reasoning') || params.includes('include_reasoning'),
252
+ vision: input.includes('image'),
253
+ },
254
+ pricing: {
255
+ prompt,
256
+ completion: model.pricing?.completion,
257
+ // OpenRouter writes a free model's price as the string "0".
258
+ free: prompt !== undefined && Number(prompt) === 0,
259
+ },
260
+ created: iso(model.created),
261
+ });
262
+ }
263
+ async function walk(pages) {
264
+ const all = [];
265
+ for await (const page of pages) {
266
+ all.push(...(page.result?.data ?? []));
267
+ }
268
+ return all;
269
+ }
270
+ async function fromRouter(client, provider) {
271
+ const router = client;
272
+ // Two endpoints, because OpenRouter routes embeddings separately and the
273
+ // chat listing does not mention them. Asked together: one slow provider
274
+ // should not cost twice the wall clock.
275
+ //
276
+ // Only the chat listing is allowed to fail the whole call. The embeddings
277
+ // endpoint is the newer of the two and the one a gateway is most likely not
278
+ // to proxy, and losing every chat model over it would be a poor trade.
279
+ const [chat, embedding] = await Promise.all([
280
+ walk(await router.models.list()),
281
+ (async () => {
282
+ try {
283
+ return await walk(await router.embeddings.listModels());
284
+ }
285
+ catch {
286
+ return [];
287
+ }
288
+ })(),
289
+ ]);
290
+ const rows = chat.map((m) => routerRow(provider, m, ['chat']));
291
+ const seen = new Set(rows.map((r) => r.id));
292
+ for (const m of embedding) {
293
+ if (!seen.has(m.id)) {
294
+ rows.push(routerRow(provider, m, ['embedding']));
295
+ }
296
+ }
297
+ return rows;
298
+ }
299
+ /**
300
+ * Asks one provider what it serves. Throws whatever the SDK throws.
301
+ *
302
+ * `client` is a seam, not a feature: the four adapters are the part most likely
303
+ * to break when a vendor reshapes a payload, and they are untestable if the
304
+ * only way to reach them is a credential and a network. It mirrors
305
+ * `ProviderSpec.client`, which exists in the library for the same reason.
306
+ */
307
+ export async function fetchCatalog(provider, client = new ModelRegistry().client(provider)) {
308
+ switch (provider) {
309
+ case 'openai':
310
+ return enrich(provider, await fromOpenAI(client, provider));
311
+ case 'anthropic':
312
+ return enrich(provider, await fromAnthropic(client, provider));
313
+ case 'google':
314
+ case 'vertex':
315
+ return enrich(provider, await fromGenAI(client, provider));
316
+ case 'openrouter':
317
+ return enrich(provider, await fromRouter(client, provider));
318
+ }
319
+ }
320
+ // ---------------------------------------------------------------------------
321
+ // Enrichment and cache
322
+ // ---------------------------------------------------------------------------
323
+ /**
324
+ * A live row wins on every field it filled in, and the curated table supplies
325
+ * the rest. That is how `text-embedding-3-small` gets its width from here while
326
+ * still being reported as the vendor's own row: OpenAI's listing carries no
327
+ * dimensions field at all, and a blank there would send someone to the docs.
328
+ */
329
+ function enrich(provider, live) {
330
+ const known = new Map(CURATED[provider].map((c) => [c.id, c]));
331
+ return live.map((row) => {
332
+ const extra = known.get(row.id);
333
+ if (!extra) {
334
+ return row;
335
+ }
336
+ return {
337
+ ...row,
338
+ roles: row.roles.length > 0 ? row.roles : [...extra.roles],
339
+ contextLength: row.contextLength ?? extra.contextLength,
340
+ dimensions: row.dimensions ?? extra.dimensions,
341
+ };
342
+ });
343
+ }
344
+ const cachePath = (provider) => join(paths.catalog(), `${provider}.json`);
345
+ async function readCache(provider) {
346
+ const file = await readJson(cachePath(provider), undefined);
347
+ return file?.version === CACHE_VERSION && Array.isArray(file.entries) ? file : undefined;
348
+ }
349
+ /**
350
+ * The listing for one provider, from the cheapest source that can answer.
351
+ *
352
+ * Fresh cache, else the provider, else a *stale* cache, else the curated table.
353
+ * Stale-before-curated is the important ordering: yesterday's real answer from
354
+ * this account beats today's guess about accounts in general, and a listing
355
+ * that failed because the wifi dropped should not silently shrink someone's
356
+ * model list to four rows.
357
+ */
358
+ export async function loadCatalog(provider, opts = {}) {
359
+ const cached = await readCache(provider);
360
+ const fresh = cached && Date.now() - new Date(cached.fetchedAt).getTime() < CATALOG_TTL_MS
361
+ ? cached
362
+ : undefined;
363
+ if (fresh && !opts.refresh) {
364
+ return { provider, entries: fresh.entries, origin: 'cache', fetchedAt: fresh.fetchedAt };
365
+ }
366
+ if (!opts.offline) {
367
+ try {
368
+ const entries = await fetchCatalog(provider);
369
+ const fetchedAt = new Date().toISOString();
370
+ writeJson(cachePath(provider), { version: CACHE_VERSION, provider, fetchedAt, entries },
371
+ // Public data, and readable so a human can look at what was cached.
372
+ 0o644);
373
+ return { provider, entries, origin: 'live', fetchedAt };
374
+ }
375
+ catch (err) {
376
+ const problem = classify(err);
377
+ if (cached) {
378
+ return {
379
+ provider,
380
+ entries: cached.entries,
381
+ origin: 'stale',
382
+ fetchedAt: cached.fetchedAt,
383
+ problem,
384
+ };
385
+ }
386
+ return {
387
+ provider,
388
+ entries: curated(provider),
389
+ origin: 'curated',
390
+ fetchedAt: new Date(0).toISOString(),
391
+ problem,
392
+ };
393
+ }
394
+ }
395
+ if (cached) {
396
+ return { provider, entries: cached.entries, origin: 'stale', fetchedAt: cached.fetchedAt };
397
+ }
398
+ return {
399
+ provider,
400
+ entries: curated(provider),
401
+ origin: 'curated',
402
+ fetchedAt: new Date(0).toISOString(),
403
+ };
404
+ }
405
+ /** Every provider asked at once — they are independent and mostly latency. */
406
+ export async function loadCatalogs(providers, opts = {}) {
407
+ return Promise.all(providers.map((p) => loadCatalog(p, opts)));
408
+ }
409
+ export function matches(row, query, filters = {}) {
410
+ if (filters.roles?.length && !filters.roles.some((r) => row.roles.includes(r))) {
411
+ return false;
412
+ }
413
+ if (filters.tools && !row.supports?.tools) {
414
+ return false;
415
+ }
416
+ if (filters.vision && !row.supports?.vision) {
417
+ return false;
418
+ }
419
+ if (filters.free && !row.pricing?.free) {
420
+ return false;
421
+ }
422
+ if (filters.minContext !== undefined && (row.contextLength ?? 0) < filters.minContext) {
423
+ return false;
424
+ }
425
+ if (!query) {
426
+ return true;
427
+ }
428
+ // Every whitespace-separated word must appear somewhere, so `claude haiku`
429
+ // narrows rather than widening the way an OR would.
430
+ const haystack = `${row.ref} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase();
431
+ return query
432
+ .toLowerCase()
433
+ .split(/\s+/)
434
+ .filter(Boolean)
435
+ .every((word) => haystack.includes(word));
436
+ }
437
+ /** The providers a search covers when none was named. */
438
+ export const catalogProviders = () => PROVIDERS;
439
+ //# sourceMappingURL=catalog.js.map
@@ -1,11 +1,13 @@
1
+ import { existsSync, statSync } from 'node:fs';
1
2
  import { basename, dirname, resolve } from 'node:path';
2
3
  import { one, parse } from "../args.js";
3
4
  import { KeyStore } from "../keys.js";
5
+ import { duration } from "../narrate.js";
4
6
  import { Registry } from "../projects.js";
5
7
  import { project as resolveProject } from "../resolve.js";
6
- import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, write, writeAll, yellow, } from "../term.js";
8
+ import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
7
9
  import { validateProject, } from "../validate.js";
8
- const USAGE = 'zen check [dir] [--project <name|dir>] [--no-sandbox] [--strict] [--quiet]';
10
+ const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--no-models] [--strict] [--quiet]';
9
11
  // ---------------------------------------------------------------------------
10
12
  // zen check
11
13
  //
@@ -16,15 +18,19 @@ const USAGE = 'zen check [dir] [--project <name|dir>] [--no-sandbox] [--strict]
16
18
  // stable code and a fix, and the whole thing goes to stdout — it is the answer,
17
19
  // not narration.
18
20
  //
19
- // Nothing is contacted or paid for. The one thing that *runs* is the sandbox:
20
- // the project's image is built and one command is executed in it, because a
21
- // Dockerfile that does not build is a broken project and nothing short of
22
- // building it says so. It happens against a temporary directory, the container
23
- // is removed on the way out, and `--no-sandbox` skips it so the report is
24
- // still worth having on the machine that has no container engine at all.
21
+ // Almost nothing is contacted or paid for. The two exceptions earn their keep.
22
+ // The sandbox: the project's image is built and one command is executed in it,
23
+ // because a Dockerfile that does not build is a broken project and nothing
24
+ // short of building it says so. It happens against a temporary directory, the
25
+ // container is removed on the way out, and `--no-sandbox` skips it. And the
26
+ // models: each one that has a credential is asked to answer once, because a key
27
+ // that authenticates says nothing about the id it is spent on, and a misspelt or
28
+ // retired model is invisible to every reading of the files. That costs a few
29
+ // tokens and `--no-models` skips it — so the report is still worth having on the
30
+ // machine that has no container engine and no key at all.
25
31
  // ---------------------------------------------------------------------------
26
32
  export const check = {
27
- summary: 'Validate agents.yaml and every file it names, and report in full.',
33
+ summary: 'Validate agents.yaml and every file it names, and ask its models.',
28
34
  usage: USAGE,
29
35
  details: [
30
36
  'Checks the whole project: the configuration parses and satisfies the',
@@ -34,13 +40,20 @@ export const check = {
34
40
  'on this machine.',
35
41
  '',
36
42
  'It also builds the sandbox image and runs one command in it, against a',
37
- 'temporary directory rather than your workspace. That is the only thing',
38
- 'it starts, and --no-sandbox skips it. No container engine is a warning,',
39
- 'not an error.',
43
+ 'temporary directory rather than your workspace, and --no-sandbox skips',
44
+ 'it. No container engine is a warning, not an error.',
45
+ '',
46
+ 'It also asks every model it holds a credential for to answer once — a few',
47
+ 'tokens apiece, and the only way to learn that a model id is misspelt,',
48
+ 'retired, or not granted to this account. A refusal is an error; a model',
49
+ 'that never answered is a warning. --no-models skips it.',
40
50
  '',
41
51
  'Unlike a run, it does not stop at the first problem — the report lists',
42
52
  'everything it found, each with a code and the fix for it.',
43
53
  '',
54
+ 'The argument is a directory if one is there and a registered project',
55
+ 'name otherwise; with neither, the project you are standing in.',
56
+ '',
44
57
  'Exit codes: 0 nothing wrong, 3 at least one error (or, with --strict,',
45
58
  'at least one warning). --quiet prints the findings and nothing else.',
46
59
  ],
@@ -48,15 +61,13 @@ export const check = {
48
61
  const { values, positionals } = parse(ctx.args, {
49
62
  project: { type: 'string' },
50
63
  'no-sandbox': { type: 'boolean' },
64
+ 'no-models': { type: 'boolean' },
51
65
  strict: { type: 'boolean' },
52
66
  quiet: { type: 'boolean' },
53
67
  }, USAGE);
54
- // A bare directory is accepted so an unregistered folder — a checkout,
55
- // a scaffold in progress — can be checked at all. `--project` goes
56
- // through the registry, like everywhere else.
57
- const here = one(positionals, 'directory', USAGE);
68
+ const here = one(positionals, 'project or directory', USAGE);
58
69
  const dir = here
59
- ? resolve(ctx.cwd, here)
70
+ ? await locate(ctx.cwd, here)
60
71
  : await resolveProject({ cwd: ctx.cwd, project: values.project }).then((p) => p.dir);
61
72
  // Being listed is the registry's answer, not the directory's, so it is
62
73
  // read here and handed to the check rather than looked up inside it.
@@ -77,6 +88,10 @@ export const check = {
77
88
  enabled: !values['no-sandbox'],
78
89
  onProgress: (what) => bar.update(dim(what)),
79
90
  },
91
+ models: {
92
+ enabled: !values['no-models'],
93
+ onProgress: (what) => bar.update(dim(what)),
94
+ },
80
95
  });
81
96
  bar.done();
82
97
  if (ctx.json) {
@@ -96,6 +111,32 @@ export const check = {
96
111
  }
97
112
  },
98
113
  };
114
+ /**
115
+ * What a bare argument means: a directory when one is there, a registered name
116
+ * otherwise. A directory is tried first, and it does not have to be a project
117
+ * yet — an unregistered folder, a checkout, a scaffold in progress is exactly
118
+ * what there is to check.
119
+ *
120
+ * A word that is neither is a usage error and stops here. The check itself
121
+ * would answer it too, but it would answer at the length of a full report, and
122
+ * a page of empty sections about a directory that does not exist buries the one
123
+ * line that matters: there is nothing by that name.
124
+ */
125
+ async function locate(cwd, arg) {
126
+ const at = resolve(cwd, arg);
127
+ if (existsSync(at) && statSync(at).isDirectory()) {
128
+ return at;
129
+ }
130
+ const entry = (await Registry.open()).find(arg);
131
+ if (!entry) {
132
+ throw usageError(`no project or directory named "${arg}"`, 'see what is registered: zen list');
133
+ }
134
+ const path = resolve(entry.path);
135
+ if (!existsSync(path)) {
136
+ throw usageError(`project "${entry.name}" is registered at ${path}, which is gone`, 'forget it: zen list --prune');
137
+ }
138
+ return path;
139
+ }
99
140
  // ---------------------------------------------------------------------------
100
141
  // Rendering
101
142
  // ---------------------------------------------------------------------------
@@ -161,6 +202,7 @@ function render(report) {
161
202
  dim(m.provider ? `${m.provider} (${m.kind})` : red('unresolved')),
162
203
  dim(m.env ?? ''),
163
204
  credential(m.credential),
205
+ answer(m),
164
206
  // Nothing consumes an embedding yet, so `usedBy` would
165
207
  // always read "declared, unused" and say the wrong thing.
166
208
  dim(m.role === 'embedding'
@@ -289,6 +331,19 @@ function credential(state) {
289
331
  }
290
332
  return state === 'rejected' ? red('rejected') : dim('unchecked');
291
333
  }
334
+ /** What the provider said when the model itself was asked, if it was. */
335
+ function answer(m) {
336
+ if (!m.check) {
337
+ return dim('not asked');
338
+ }
339
+ if (m.check.state === 'live') {
340
+ return `${green('answers')} ${dim(duration(m.check.ms))}`;
341
+ }
342
+ if (m.check.state === 'blocked') {
343
+ return yellow('blocked');
344
+ }
345
+ return m.check.state === 'dead' ? red('refused') : yellow('no answer');
346
+ }
292
347
  function tallyLine(report) {
293
348
  const { errors, warnings, notes } = report.counts;
294
349
  return `${count(errors, 'error')}, ${count(warnings, 'warning')}, ${count(notes, 'note')}`;
@@ -2,8 +2,8 @@ import type { BannerText } from '../banner.ts';
2
2
  import type { Command } from '../command.ts';
3
3
  /**
4
4
  * Insertion order is the order help prints in, and it is deliberate: the four
5
- * a new user needs first, then the two about credentials and models, then the
6
- * two that are only ever run on purpose.
5
+ * a new user needs first, then the credentials-and-models pair, then the two
6
+ * reports on a project, then the two that are only ever run on purpose.
7
7
  */
8
8
  export declare const COMMANDS: Record<string, Command>;
9
9
  /** Names that are not listed in help but still work. */
@@ -10,8 +10,8 @@ import { sandbox } from "./sandbox.js";
10
10
  import { version } from "./version.js";
11
11
  /**
12
12
  * Insertion order is the order help prints in, and it is deliberate: the four
13
- * a new user needs first, then the two about credentials and models, then the
14
- * two that are only ever run on purpose.
13
+ * a new user needs first, then the credentials-and-models pair, then the two
14
+ * reports on a project, then the two that are only ever run on purpose.
15
15
  */
16
16
  export const COMMANDS = {
17
17
  init,
@@ -30,6 +30,7 @@ export const ALIASES = {
30
30
  ls: 'list',
31
31
  new: 'init',
32
32
  keys: 'key',
33
+ model: 'models',
33
34
  validate: 'check',
34
35
  doctor: 'check',
35
36
  report: 'inspect',