@zenera/cli 1.1.28 → 1.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -24
- package/dist/audit.d.ts +2 -2
- package/dist/audit.js +11 -4
- package/dist/candidates.d.ts +9 -0
- package/dist/candidates.js +17 -0
- package/dist/catalog.d.ts +10 -5
- package/dist/catalog.js +93 -12
- package/dist/commands/completion.d.ts +9 -0
- package/dist/commands/completion.js +70 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/key.js +181 -17
- package/dist/commands/meta.d.ts +3 -0
- package/dist/commands/meta.js +134 -29
- package/dist/commands/models.js +56 -98
- package/dist/completion-install.d.ts +16 -0
- package/dist/completion-install.js +85 -0
- package/dist/completion.d.ts +9 -0
- package/dist/completion.js +93 -0
- package/dist/keys.d.ts +29 -4
- package/dist/keys.js +67 -3
- package/dist/liveness.js +73 -3
- package/dist/main.js +22 -0
- package/dist/postinstall.d.ts +2 -0
- package/dist/postinstall.js +10 -0
- package/dist/sandbox.js +4 -3
- package/dist/term.d.ts +2 -0
- package/dist/term.js +2 -0
- package/dist/tui/links.d.ts +2 -0
- package/dist/tui/links.js +31 -7
- package/dist/tui/models.d.ts +5 -0
- package/dist/tui/models.js +21 -14
- package/dist/validate.js +2 -2
- package/package.json +8 -6
- package/postinstall.mjs +7 -0
- package/templates/completions/zen.bash +16 -0
- package/templates/completions/zen.fish +22 -0
- package/templates/completions/zen.zsh +28 -0
- package/templates/editor/.github/copilot-instructions.md +5 -4
- package/templates/editor/.github/prompts/spec-edit.prompt.md +250 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/keys.md +29 -2
- package/templates/editor/.github/skills/zen-cli/references/models.md +22 -23
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
|
-
|
|
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,25 @@ export class KeyStore {
|
|
|
352
380
|
hit.check = check;
|
|
353
381
|
}
|
|
354
382
|
}
|
|
383
|
+
/** A `BIFROST_BASE_URL` in the environment wins over the keyring, as it does for the runtime. */
|
|
384
|
+
get mode() {
|
|
385
|
+
return this.modeFrom === 'env' ? 'bifrost' : (this.#file.mode ?? 'direct');
|
|
386
|
+
}
|
|
387
|
+
get modeFrom() {
|
|
388
|
+
return process.env[GATEWAY_URL_ENV]?.trim() ? 'env' : 'keyring';
|
|
389
|
+
}
|
|
390
|
+
/** Refuses a gateway mode with no gateway to send anything to. */
|
|
391
|
+
setMode(mode) {
|
|
392
|
+
if (mode !== 'direct' && !this.active(mode)?.url) {
|
|
393
|
+
throw usageError(`no ${SHAPES[mode].label} key to switch to`, `add one first: zen key add ${mode} --url <gateway url>`);
|
|
394
|
+
}
|
|
395
|
+
if (mode === 'direct') {
|
|
396
|
+
delete this.#file.mode;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
this.#file.mode = mode;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
355
402
|
save() {
|
|
356
403
|
ensureDir(paths.home());
|
|
357
404
|
writeJson(paths.keys(), this.#file);
|
|
@@ -398,14 +445,28 @@ export class KeyStore {
|
|
|
398
445
|
*
|
|
399
446
|
* Services are included by default, because a tool reads its key from the
|
|
400
447
|
* environment for exactly the same reason a model adapter does.
|
|
448
|
+
*
|
|
449
|
+
* In a gateway mode the gateway's key replaces every vendor's, so those are
|
|
450
|
+
* withheld: nothing would read them, and the sandbox would be handed keys
|
|
451
|
+
* the run never needed. In `direct` mode it is the gateway that is withheld,
|
|
452
|
+
* since its url alone is what switches the library over.
|
|
401
453
|
*/
|
|
402
454
|
environment(only) {
|
|
403
455
|
const env = {};
|
|
456
|
+
const mode = this.mode;
|
|
404
457
|
for (const provider of only ?? OWNERS) {
|
|
458
|
+
const kind = SHAPES[provider].kind;
|
|
459
|
+
if (kind === 'gateway' ? provider !== mode : kind === 'model' && mode !== 'direct') {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
405
462
|
const entry = this.active(provider);
|
|
406
463
|
if (!entry) {
|
|
407
464
|
continue;
|
|
408
465
|
}
|
|
466
|
+
// The mode is the switch, so the url goes out even when the shell supplies the key.
|
|
467
|
+
if (entry.url && !process.env[GATEWAY_URL_ENV]) {
|
|
468
|
+
env[GATEWAY_URL_ENV] = entry.url;
|
|
469
|
+
}
|
|
409
470
|
// Either variable being set means this provider is already answered
|
|
410
471
|
// for, so the stored alternative must not be exported alongside it.
|
|
411
472
|
if (envNames(provider).some((name) => process.env[name])) {
|
|
@@ -542,6 +603,9 @@ export function credentials() {
|
|
|
542
603
|
* one clear error here for an obscure one on the first turn.
|
|
543
604
|
*/
|
|
544
605
|
export function assertUsable(store) {
|
|
606
|
+
if (process.env[GATEWAY_URL_ENV]) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
545
609
|
const reachable = PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name]) || store.active(p) !== undefined);
|
|
546
610
|
if (reachable.length === 0) {
|
|
547
611
|
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
|
|
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;
|
|
@@ -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
|
-
|
|
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.
|
package/dist/tui/links.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
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);
|
package/dist/tui/models.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface BrowseOptions {
|
|
|
24
24
|
/** the role filter a listing opens on, when it has any such model */
|
|
25
25
|
role?: Role;
|
|
26
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
|
+
};
|
|
27
32
|
load(provider: Provider, refresh: boolean): Promise<Catalog>;
|
|
28
33
|
/** what the account has left to spend, for providers that say */
|
|
29
34
|
balance?(provider: Provider): Promise<Balance | undefined>;
|
package/dist/tui/models.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
|
12
|
-
//
|
|
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();
|
|
@@ -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: [
|
|
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: [
|
|
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
|
|
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
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.30",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"dist",
|
|
39
39
|
"!dist/**/*.map",
|
|
40
|
+
"postinstall.mjs",
|
|
40
41
|
"templates"
|
|
41
42
|
],
|
|
42
43
|
"engines": {
|
|
@@ -45,20 +46,21 @@
|
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "tsc -b",
|
|
47
48
|
"clean": "tsc -b --clean && rm -rf dist",
|
|
49
|
+
"postinstall": "node postinstall.mjs",
|
|
48
50
|
"prepack": "tsc -b"
|
|
49
51
|
},
|
|
50
52
|
"dependencies": {
|
|
51
53
|
"@anthropic-ai/sdk": "^0.120.0",
|
|
52
|
-
"@google/genai": "^2.
|
|
54
|
+
"@google/genai": "^2.30.0",
|
|
53
55
|
"@inkjs/ui": "^2.0.0",
|
|
54
|
-
"@openrouter/sdk": "^1.
|
|
55
|
-
"@zenera/neo": "^1.1.
|
|
56
|
-
"@zenera/view": "^1.1.
|
|
56
|
+
"@openrouter/sdk": "^1.4.25",
|
|
57
|
+
"@zenera/neo": "^1.1.30",
|
|
58
|
+
"@zenera/view": "^1.1.30",
|
|
57
59
|
"google-auth-library": "^10.0.0",
|
|
58
60
|
"ink": "^7.1.1",
|
|
59
61
|
"openai": "^6.0.0",
|
|
60
62
|
"react": "^19.2.8",
|
|
61
|
-
"yaml": "^2.9.
|
|
63
|
+
"yaml": "^2.9.1",
|
|
62
64
|
"yauzl": "^3.4.0",
|
|
63
65
|
"yazl": "^3.3.1"
|
|
64
66
|
},
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# zen completion for bash. Add to ~/.bashrc:
|
|
2
|
+
#
|
|
3
|
+
# eval "$(zen completion bash)"
|
|
4
|
+
#
|
|
5
|
+
# Everything it offers comes from `zen __complete`; when that has nothing to
|
|
6
|
+
# say, file names are completed instead (`-o default`).
|
|
7
|
+
_zen() {
|
|
8
|
+
local out value
|
|
9
|
+
COMPREPLY=()
|
|
10
|
+
out=$("${COMP_WORDS[0]}" __complete "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null) || return 0
|
|
11
|
+
while IFS=$'\t' read -r value _; do
|
|
12
|
+
[[ -n $value ]] && COMPREPLY+=("$value")
|
|
13
|
+
done <<< "$out"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
complete -o default -F _zen zen zn zenera
|