@zenera/cli 1.1.3 → 1.1.5
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.
- package/README.md +160 -40
- package/dist/audit.d.ts +5 -2
- package/dist/audit.js +7 -2
- package/dist/catalog.d.ts +111 -0
- package/dist/catalog.js +439 -0
- package/dist/commands/check.js +39 -11
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +4 -3
- package/dist/commands/key.js +18 -0
- package/dist/commands/models.d.ts +0 -6
- package/dist/commands/models.js +546 -101
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/keys.d.ts +9 -1
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.d.ts +32 -0
- package/dist/liveness.js +194 -5
- package/dist/scaffold.js +9 -0
- package/dist/validate.d.ts +17 -1
- package/dist/validate.js +97 -7
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +30 -3
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +130 -9
- package/templates/editor/.github/skills/zen-cli/SKILL.md +7 -4
- package/templates/editor/.github/skills/zen-cli/references/check.md +15 -19
- package/templates/editor/.github/skills/zen-cli/references/keys.md +5 -0
- package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +65 -3
- package/templates/project/sandbox/{Dockerfile → Dockerfile.tmpl} +3 -1
package/dist/catalog.js
ADDED
|
@@ -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
|
package/dist/commands/check.js
CHANGED
|
@@ -2,11 +2,12 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { basename, dirname, resolve } from 'node:path';
|
|
3
3
|
import { one, parse } from "../args.js";
|
|
4
4
|
import { KeyStore } from "../keys.js";
|
|
5
|
+
import { duration } from "../narrate.js";
|
|
5
6
|
import { Registry } from "../projects.js";
|
|
6
7
|
import { project as resolveProject } from "../resolve.js";
|
|
7
8
|
import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
9
|
import { validateProject, } from "../validate.js";
|
|
9
|
-
const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--strict] [--quiet]';
|
|
10
|
+
const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--no-models] [--strict] [--quiet]';
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// zen check
|
|
12
13
|
//
|
|
@@ -17,15 +18,19 @@ const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--str
|
|
|
17
18
|
// stable code and a fix, and the whole thing goes to stdout — it is the answer,
|
|
18
19
|
// not narration.
|
|
19
20
|
//
|
|
20
|
-
//
|
|
21
|
-
// the project's image is built and one command is executed in it,
|
|
22
|
-
// Dockerfile that does not build is a broken project and nothing
|
|
23
|
-
// building it says so. It happens against a temporary directory, the
|
|
24
|
-
// is removed on the way out, and `--no-sandbox` skips it
|
|
25
|
-
//
|
|
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.
|
|
26
31
|
// ---------------------------------------------------------------------------
|
|
27
32
|
export const check = {
|
|
28
|
-
summary: 'Validate agents.yaml and every file it names, and
|
|
33
|
+
summary: 'Validate agents.yaml and every file it names, and ask its models.',
|
|
29
34
|
usage: USAGE,
|
|
30
35
|
details: [
|
|
31
36
|
'Checks the whole project: the configuration parses and satisfies the',
|
|
@@ -35,9 +40,13 @@ export const check = {
|
|
|
35
40
|
'on this machine.',
|
|
36
41
|
'',
|
|
37
42
|
'It also builds the sandbox image and runs one command in it, against a',
|
|
38
|
-
'temporary directory rather than your workspace
|
|
39
|
-
'it
|
|
40
|
-
'
|
|
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.',
|
|
41
50
|
'',
|
|
42
51
|
'Unlike a run, it does not stop at the first problem — the report lists',
|
|
43
52
|
'everything it found, each with a code and the fix for it.',
|
|
@@ -52,6 +61,7 @@ export const check = {
|
|
|
52
61
|
const { values, positionals } = parse(ctx.args, {
|
|
53
62
|
project: { type: 'string' },
|
|
54
63
|
'no-sandbox': { type: 'boolean' },
|
|
64
|
+
'no-models': { type: 'boolean' },
|
|
55
65
|
strict: { type: 'boolean' },
|
|
56
66
|
quiet: { type: 'boolean' },
|
|
57
67
|
}, USAGE);
|
|
@@ -78,6 +88,10 @@ export const check = {
|
|
|
78
88
|
enabled: !values['no-sandbox'],
|
|
79
89
|
onProgress: (what) => bar.update(dim(what)),
|
|
80
90
|
},
|
|
91
|
+
models: {
|
|
92
|
+
enabled: !values['no-models'],
|
|
93
|
+
onProgress: (what) => bar.update(dim(what)),
|
|
94
|
+
},
|
|
81
95
|
});
|
|
82
96
|
bar.done();
|
|
83
97
|
if (ctx.json) {
|
|
@@ -188,6 +202,7 @@ function render(report) {
|
|
|
188
202
|
dim(m.provider ? `${m.provider} (${m.kind})` : red('unresolved')),
|
|
189
203
|
dim(m.env ?? ''),
|
|
190
204
|
credential(m.credential),
|
|
205
|
+
answer(m),
|
|
191
206
|
// Nothing consumes an embedding yet, so `usedBy` would
|
|
192
207
|
// always read "declared, unused" and say the wrong thing.
|
|
193
208
|
dim(m.role === 'embedding'
|
|
@@ -316,6 +331,19 @@ function credential(state) {
|
|
|
316
331
|
}
|
|
317
332
|
return state === 'rejected' ? red('rejected') : dim('unchecked');
|
|
318
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
|
+
}
|
|
319
347
|
function tallyLine(report) {
|
|
320
348
|
const { errors, warnings, notes } = report.counts;
|
|
321
349
|
return `${count(errors, 'error')}, ${count(warnings, 'warning')}, ${count(notes, 'note')}`;
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/commands/index.js
CHANGED
|
@@ -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
|
|
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',
|
|
@@ -48,7 +49,7 @@ export const EXTERNAL = {
|
|
|
48
49
|
rag: {
|
|
49
50
|
package: '@zenera/rag',
|
|
50
51
|
summary: 'Search an openapi/swagger document as a graph.',
|
|
51
|
-
usage: 'zen rag schema <index|search|show|stats> [spec...]',
|
|
52
|
+
usage: 'zen rag schema <index|search|list|grep|show|stats> [spec...]',
|
|
52
53
|
install: 'npm i -g @zenera/rag',
|
|
53
54
|
banner: { head: 'Zenera', accent: 'Rag', subtitle: 'Api Retrieval' },
|
|
54
55
|
},
|
package/dist/commands/key.js
CHANGED
|
@@ -11,6 +11,7 @@ const USAGE = 'zen key <ls|add|use|check|rm|show|env> [ref] [options]';
|
|
|
11
11
|
const MARK = {
|
|
12
12
|
live: green('live'),
|
|
13
13
|
dead: red('dead'),
|
|
14
|
+
blocked: yellow('blocked'),
|
|
14
15
|
unknown: dim('unknown'),
|
|
15
16
|
};
|
|
16
17
|
function state(entry) {
|
|
@@ -179,6 +180,12 @@ const add = async (ctx, args) => {
|
|
|
179
180
|
if (check.state === 'dead') {
|
|
180
181
|
note(`${red('rejected')} ${check.detail ?? 'the provider refused this key'}`);
|
|
181
182
|
}
|
|
183
|
+
else if (check.state === 'blocked') {
|
|
184
|
+
note(`${yellow('blocked')} ${check.detail ?? 'the account cannot use this key'}`);
|
|
185
|
+
if (check.fix) {
|
|
186
|
+
note(dim(` ${check.fix}`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
182
189
|
}
|
|
183
190
|
store.save();
|
|
184
191
|
if (ctx.json) {
|
|
@@ -256,9 +263,19 @@ const check = async (ctx, args) => {
|
|
|
256
263
|
MARK[result.state],
|
|
257
264
|
dim(result.detail ?? ''),
|
|
258
265
|
])));
|
|
266
|
+
for (const [entry, result] of checks) {
|
|
267
|
+
if (result.fix) {
|
|
268
|
+
note(dim(`${keyId(entry)}: ${result.fix}`));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
259
271
|
if (checks.some(([, r]) => r.state === 'dead')) {
|
|
260
272
|
throw credentialError('at least one key was refused');
|
|
261
273
|
}
|
|
274
|
+
// Separately, because the action is not the same one: these authenticated,
|
|
275
|
+
// and a replacement key would be refused for exactly the same reason.
|
|
276
|
+
if (checks.some(([, r]) => r.state === 'blocked')) {
|
|
277
|
+
throw credentialError('at least one key authenticated but cannot be used');
|
|
278
|
+
}
|
|
262
279
|
};
|
|
263
280
|
function select(store, ref) {
|
|
264
281
|
const { provider, name } = parseRef(ref);
|
|
@@ -334,6 +351,7 @@ const show = async (ctx, args) => {
|
|
|
334
351
|
...(entry.project ? [[dim('project'), entry.project]] : []),
|
|
335
352
|
...(entry.location ? [[dim('location'), entry.location]] : []),
|
|
336
353
|
[dim('state'), state(entry)],
|
|
354
|
+
...(entry.check?.fix ? [[dim('fix'), entry.check.fix]] : []),
|
|
337
355
|
[dim('added'), ago(entry.addedAt)],
|
|
338
356
|
]));
|
|
339
357
|
note(dim('--reveal prints the secret itself'));
|
|
@@ -1,9 +1,3 @@
|
|
|
1
1
|
import type { Command } from '../command.ts';
|
|
2
|
-
/**
|
|
3
|
-
* Everything a run would resolve, resolved — and nothing called. Loading a
|
|
4
|
-
* project constructs the model clients, so a config that names an impossible
|
|
5
|
-
* provider or an agent that hands off to nobody fails here, in a command that
|
|
6
|
-
* costs nothing, instead of three seconds into a run that costs money.
|
|
7
|
-
*/
|
|
8
2
|
export declare const models: Command;
|
|
9
3
|
//# sourceMappingURL=models.d.ts.map
|