@zenera/cli 1.1.28 → 1.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +142 -11
- 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 +27 -4
- package/dist/keys.js +63 -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 +27 -2
- package/templates/editor/.github/skills/zen-cli/references/models.md +22 -23
package/dist/commands/models.js
CHANGED
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
// This command answers the first, and needs no project at all.
|
|
8
8
|
//
|
|
9
9
|
// The split matters most when something breaks. `zen check` says "this
|
|
10
|
-
// embedder was refused"; `zen models
|
|
11
|
-
//
|
|
12
|
-
// agents.yaml
|
|
10
|
+
// embedder was refused"; `zen models ls --embeddings` and `zen models test`
|
|
11
|
+
// find one that answers, as a ref that can be pasted straight back into
|
|
12
|
+
// agents.yaml.
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
|
-
import { createEmbedder, ModelRegistry } from '@zenera/neo';
|
|
14
|
+
import { createEmbedder, GATEWAY_NAME, gatewayOf, ModelRegistry } from '@zenera/neo';
|
|
15
15
|
import { parse } from "../args.js";
|
|
16
|
-
import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches,
|
|
16
|
+
import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, routerBalance, } from "../catalog.js";
|
|
17
17
|
import { ensureHome } from "../home.js";
|
|
18
18
|
import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
|
|
19
19
|
import { probeChat, probeModel } from "../liveness.js";
|
|
20
20
|
import { ago, bold, CliError, credentialError, cyan, dim, EXIT, green, isInteractive, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
21
|
-
const USAGE = 'zen models <providers|ls|search|show|test|
|
|
21
|
+
const USAGE = 'zen models <providers|ls|search|show|test|browse> [ref] [options]';
|
|
22
22
|
/**
|
|
23
23
|
* The keyring is materialised here, and only here.
|
|
24
24
|
*
|
|
@@ -33,6 +33,10 @@ async function credentials() {
|
|
|
33
33
|
// difference between "the environment had it" and "the keyring supplied it".
|
|
34
34
|
const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((n) => process.env[n])));
|
|
35
35
|
store.materialize();
|
|
36
|
+
const gateway = gatewayOf();
|
|
37
|
+
if (gateway) {
|
|
38
|
+
return { store, fromEnv, usable: [...PROVIDERS], gateway };
|
|
39
|
+
}
|
|
36
40
|
const usable = [
|
|
37
41
|
...PROVIDERS.filter((p) => fromEnv.has(p)),
|
|
38
42
|
...PROVIDERS.filter((p) => !fromEnv.has(p) && Boolean(store.active(p))),
|
|
@@ -40,6 +44,9 @@ async function credentials() {
|
|
|
40
44
|
return { store, fromEnv, usable };
|
|
41
45
|
}
|
|
42
46
|
function source(where, provider) {
|
|
47
|
+
if (where.gateway) {
|
|
48
|
+
return GATEWAY_NAME;
|
|
49
|
+
}
|
|
43
50
|
if (where.fromEnv.has(provider)) {
|
|
44
51
|
return 'environment';
|
|
45
52
|
}
|
|
@@ -101,10 +108,22 @@ const tokens = (n) => {
|
|
|
101
108
|
}
|
|
102
109
|
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
103
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* Said before any listing in a gateway mode, on stderr: the same command lists
|
|
113
|
+
* something else entirely there, and nothing in a row of model ids shows it.
|
|
114
|
+
*/
|
|
115
|
+
function announce(ctx, where) {
|
|
116
|
+
if (!where.gateway || ctx.json) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
note(`${cyan(`⇄ ${GATEWAY_NAME} mode`)} ${dim('only what')} ${where.gateway.baseURL} ` +
|
|
120
|
+
dim('serves; zen key mode direct to list the vendors'));
|
|
121
|
+
note('');
|
|
122
|
+
}
|
|
104
123
|
/** Freshness of a listing, said the way a person would ask about it. */
|
|
105
124
|
function freshness(cat) {
|
|
106
125
|
if (cat.origin === 'curated') {
|
|
107
|
-
return yellow('built-in list');
|
|
126
|
+
return cat.via ? yellow('not listed yet') : yellow('built-in list');
|
|
108
127
|
}
|
|
109
128
|
if (cat.origin === 'stale') {
|
|
110
129
|
return yellow(`stale, ${ago(cat.fetchedAt)}`);
|
|
@@ -163,8 +182,9 @@ function explain(cats) {
|
|
|
163
182
|
const providers = async (ctx, args) => {
|
|
164
183
|
const { values } = parse(args, { refresh: { type: 'boolean' } }, 'zen models providers [--refresh]');
|
|
165
184
|
const where = await credentials();
|
|
185
|
+
// A gateway lists every provider in one request, so its overview can afford to ask.
|
|
166
186
|
const cats = await loadCatalogs(PROVIDERS, {
|
|
167
|
-
offline: !values.refresh,
|
|
187
|
+
offline: !values.refresh && !where.gateway,
|
|
168
188
|
refresh: values.refresh,
|
|
169
189
|
});
|
|
170
190
|
const by = new Map(cats.map((c) => [c.provider, c]));
|
|
@@ -176,9 +196,11 @@ const providers = async (ctx, args) => {
|
|
|
176
196
|
models: by.get(p)?.entries.length ?? 0,
|
|
177
197
|
origin: by.get(p)?.origin,
|
|
178
198
|
fetchedAt: by.get(p)?.fetchedAt,
|
|
199
|
+
...(where.gateway ? { gateway: where.gateway.baseURL } : {}),
|
|
179
200
|
})));
|
|
180
201
|
return;
|
|
181
202
|
}
|
|
203
|
+
announce(ctx, where);
|
|
182
204
|
writeAll(table(PROVIDERS.map((p) => {
|
|
183
205
|
const cat = by.get(p);
|
|
184
206
|
const cred = source(where, p);
|
|
@@ -193,7 +215,6 @@ const providers = async (ctx, args) => {
|
|
|
193
215
|
note('');
|
|
194
216
|
note(dim('zen models ls <provider> what one of them serves'));
|
|
195
217
|
note(dim('zen models browse walk them with the arrow keys'));
|
|
196
|
-
note(dim('zen models pick --chat the first one that answers'));
|
|
197
218
|
};
|
|
198
219
|
// ---------------------------------------------------------------------------
|
|
199
220
|
// ls
|
|
@@ -225,9 +246,11 @@ const ls = async (ctx, args) => {
|
|
|
225
246
|
origin,
|
|
226
247
|
fetchedAt,
|
|
227
248
|
})),
|
|
249
|
+
...(where.gateway ? { gateway: where.gateway.baseURL } : {}),
|
|
228
250
|
});
|
|
229
251
|
return;
|
|
230
252
|
}
|
|
253
|
+
announce(ctx, where);
|
|
231
254
|
explain(cats);
|
|
232
255
|
if (all.length === 0) {
|
|
233
256
|
note(dim('nothing matched — try: zen models ls --refresh'));
|
|
@@ -237,7 +260,7 @@ const ls = async (ctx, args) => {
|
|
|
237
260
|
if (all.length > limit) {
|
|
238
261
|
note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
|
|
239
262
|
}
|
|
240
|
-
if (all.some(isPartner)) {
|
|
263
|
+
if (!where.gateway && all.some(isPartner)) {
|
|
241
264
|
note(dim('partner models are billed through Vertex and must be enabled in Model Garden first: https://console.cloud.google.com/vertex-ai/model-garden'));
|
|
242
265
|
}
|
|
243
266
|
};
|
|
@@ -281,6 +304,7 @@ const search = async (ctx, args) => {
|
|
|
281
304
|
json({ query, matched: hits.length, models: hits.slice(0, limit) });
|
|
282
305
|
return;
|
|
283
306
|
}
|
|
307
|
+
announce(ctx, where);
|
|
284
308
|
explain(cats);
|
|
285
309
|
if (hits.length === 0) {
|
|
286
310
|
note(dim(`nothing matched "${query}" — try fewer words, or --refresh`));
|
|
@@ -322,15 +346,17 @@ const show = async (ctx, args) => {
|
|
|
322
346
|
throw usageError('which model?', 'see: zen models ls');
|
|
323
347
|
}
|
|
324
348
|
const { provider, id } = split(ref);
|
|
349
|
+
const where = await credentials();
|
|
325
350
|
const cat = await loadCatalog(provider, { refresh: values.refresh });
|
|
326
351
|
const found = cat.entries.find((e) => e.id === id);
|
|
327
352
|
if (ctx.json) {
|
|
328
353
|
json(found ?? { ref: `${provider}:${id}`, provider, id, known: false });
|
|
329
354
|
return;
|
|
330
355
|
}
|
|
356
|
+
announce(ctx, where);
|
|
331
357
|
explain([cat]);
|
|
332
358
|
if (!found) {
|
|
333
|
-
note(`${yellow('not listed')} ${provider} does not advertise ${bold(id)}`);
|
|
359
|
+
note(`${yellow('not listed')} ${where.gateway ? GATEWAY_NAME : provider} does not advertise ${bold(id)}`);
|
|
334
360
|
note(dim(`it may still work — try: zen models test ${provider}:${id}`));
|
|
335
361
|
note(dim(`or look: zen models search ${id} --provider ${provider}`));
|
|
336
362
|
return;
|
|
@@ -360,7 +386,11 @@ const show = async (ctx, args) => {
|
|
|
360
386
|
? `$${found.pricing.prompt}/token in, $${found.pricing.completion ?? '?'}/token out`
|
|
361
387
|
: undefined);
|
|
362
388
|
add('released', found.created);
|
|
363
|
-
add('source', found.source
|
|
389
|
+
add('source', found.source !== 'live'
|
|
390
|
+
? 'built-in list'
|
|
391
|
+
: cat.via
|
|
392
|
+
? `${cat.via.name} (${provider}), ${freshness(cat)}`
|
|
393
|
+
: `${provider}, ${freshness(cat)}`);
|
|
364
394
|
writeAll(table(rows));
|
|
365
395
|
if (found.description) {
|
|
366
396
|
note('');
|
|
@@ -456,6 +486,7 @@ const test = async (ctx, args) => {
|
|
|
456
486
|
json(probes);
|
|
457
487
|
}
|
|
458
488
|
else {
|
|
489
|
+
announce(ctx, where);
|
|
459
490
|
writeAll(table(probes.map((p) => [
|
|
460
491
|
cyan(p.ref),
|
|
461
492
|
verdict(p),
|
|
@@ -469,90 +500,16 @@ const test = async (ctx, args) => {
|
|
|
469
500
|
}
|
|
470
501
|
// A Vertex refusal is about a model *in a project*, and the project is
|
|
471
502
|
// the half of that nobody typed.
|
|
472
|
-
if (parsed.some((p) => p.provider === 'vertex')) {
|
|
503
|
+
if (!where.gateway && parsed.some((p) => p.provider === 'vertex')) {
|
|
473
504
|
note(dim(vertexAt(where)));
|
|
474
505
|
}
|
|
475
506
|
}
|
|
476
507
|
const failed = probes.filter((p) => p.check.state !== 'live');
|
|
477
508
|
if (failed.length > 0) {
|
|
478
|
-
// The suggestion follows what was actually asked, not what was flagged
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const role = failed.every((p) => p.kind === 'embedding') ? '--embedding' : '--chat';
|
|
482
|
-
throw credentialError(`${failed.length} of ${probes.length} did not answer`, `find one that does: zen models pick ${role}`);
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
// ---------------------------------------------------------------------------
|
|
486
|
-
// pick
|
|
487
|
-
// ---------------------------------------------------------------------------
|
|
488
|
-
/**
|
|
489
|
-
* The recovery path, and the reason this command exists.
|
|
490
|
-
*
|
|
491
|
-
* Candidates are tried one at a time and the walk stops at the first that
|
|
492
|
-
* answers. Sequential on purpose: the goal is *one* working ref, and firing
|
|
493
|
-
* eight billable calls to find it is the wrong trade — especially for the
|
|
494
|
-
* caller most likely to be running this, which is an agent that has just been
|
|
495
|
-
* refused and is looking for somewhere else to go.
|
|
496
|
-
*/
|
|
497
|
-
const pick = async (ctx, args) => {
|
|
498
|
-
const { values } = parse(args, { ...ROLE_OPTIONS, provider: { type: 'string' }, limit: { type: 'string' } }, 'zen models pick --chat|--embedding [--provider p] [--limit N]');
|
|
499
|
-
const roles = rolesFrom(values);
|
|
500
|
-
if (roles.length !== 1 || (roles[0] !== 'chat' && roles[0] !== 'embedding')) {
|
|
501
|
-
throw usageError('which kind of model?', 'pass --chat or --embedding');
|
|
502
|
-
}
|
|
503
|
-
const role = roles[0];
|
|
504
|
-
const where = await credentials();
|
|
505
|
-
const targets = scope(where, values.provider);
|
|
506
|
-
const cap = num(values.limit, '--limit') ?? 8;
|
|
507
|
-
const candidates = targets
|
|
508
|
-
.flatMap((p) => PREFERRED[p][role].map((id) => ({ provider: p, id, ref: `${p}:${id}` })))
|
|
509
|
-
.slice(0, cap);
|
|
510
|
-
if (candidates.length === 0) {
|
|
511
|
-
throw credentialError(`no ${role} model is known for ${targets.join(', ')}`, 'try: zen models pick --provider openai');
|
|
512
|
-
}
|
|
513
|
-
const tried = [];
|
|
514
|
-
const bar = ctx.json ? undefined : progress();
|
|
515
|
-
for (const candidate of candidates) {
|
|
516
|
-
bar?.update(dim(`trying ${candidate.ref} …`));
|
|
517
|
-
const probe = await probeModel(target(candidate.ref, candidate.provider, candidate.id, role));
|
|
518
|
-
tried.push(probe);
|
|
519
|
-
if (probe.check.state !== 'live') {
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
bar?.done();
|
|
523
|
-
if (ctx.json) {
|
|
524
|
-
json({
|
|
525
|
-
ref: probe.ref,
|
|
526
|
-
provider: candidate.provider,
|
|
527
|
-
model: candidate.id,
|
|
528
|
-
role,
|
|
529
|
-
...(probe.dimensions ? { dimensions: probe.dimensions } : {}),
|
|
530
|
-
ms: probe.ms,
|
|
531
|
-
tried: tried.map(({ ref, check }) => ({ ref, ...check })),
|
|
532
|
-
});
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
for (const t of tried.slice(0, -1)) {
|
|
536
|
-
note(`${dim(t.ref)} ${verdict(t)} ${dim(t.check.detail ?? '')}`);
|
|
537
|
-
}
|
|
538
|
-
note(`${green('works')} ${bold(probe.ref)} ${dim(ms(probe.ms))}`);
|
|
539
|
-
// stdout, alone and unstyled, so `$(zen models pick --embedding)` is
|
|
540
|
-
// the ref and nothing else.
|
|
541
|
-
write(probe.ref);
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
bar?.done();
|
|
545
|
-
if (ctx.json) {
|
|
546
|
-
json({ ref: null, role, tried: tried.map(({ ref, check }) => ({ ref, ...check })) });
|
|
547
|
-
}
|
|
548
|
-
else {
|
|
549
|
-
writeAll(table(tried.map((t) => [
|
|
550
|
-
dim(t.ref),
|
|
551
|
-
verdict(t),
|
|
552
|
-
dim(t.check.fix ?? t.check.detail ?? ''),
|
|
553
|
-
])));
|
|
509
|
+
// The suggestion follows what was actually asked, not what was flagged.
|
|
510
|
+
const role = failed.every((p) => p.kind === 'embedding') ? '--embeddings' : '--chat';
|
|
511
|
+
throw credentialError(`${failed.length} of ${probes.length} did not answer`, `see what else there is: zen models ls ${role}`);
|
|
554
512
|
}
|
|
555
|
-
throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
|
|
556
513
|
};
|
|
557
514
|
// ---------------------------------------------------------------------------
|
|
558
515
|
// browse
|
|
@@ -562,7 +519,7 @@ const pick = async (ctx, args) => {
|
|
|
562
519
|
* one model in full, with a key to ask it a question and a key to take it.
|
|
563
520
|
*
|
|
564
521
|
* The interface is drawn on stderr, so a pick reaches stdout alone and
|
|
565
|
-
* `$(zen models browse)` is the ref
|
|
522
|
+
* `$(zen models browse)` is the ref.
|
|
566
523
|
*/
|
|
567
524
|
const browse = async (ctx, args) => {
|
|
568
525
|
const { values, positionals } = parse(args, { refresh: { type: 'boolean' }, theme: { type: 'string' } }, 'zen models browse [provider] [--refresh] [--theme dark|light|auto]');
|
|
@@ -571,7 +528,7 @@ const browse = async (ctx, args) => {
|
|
|
571
528
|
throw usageError(`unknown provider "${initial}"`, `known: ${PROVIDERS.join(', ')}`);
|
|
572
529
|
}
|
|
573
530
|
if (ctx.json || !isInteractive()) {
|
|
574
|
-
throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json
|
|
531
|
+
throw usageError('browsing needs a person at a terminal', 'from a script: zen models ls --json');
|
|
575
532
|
}
|
|
576
533
|
const chosen = await browseModels({
|
|
577
534
|
initial,
|
|
@@ -593,7 +550,7 @@ export async function browseModels(request = {}) {
|
|
|
593
550
|
const where = await credentials();
|
|
594
551
|
// Offline, like `providers`: the overview should not cost five listings.
|
|
595
552
|
// A provider is fetched when it is opened.
|
|
596
|
-
const cats = await loadCatalogs(PROVIDERS, { offline:
|
|
553
|
+
const cats = await loadCatalogs(PROVIDERS, { offline: !where.gateway });
|
|
597
554
|
const { browse: draw } = await import("../tui/models.js");
|
|
598
555
|
return await draw({
|
|
599
556
|
providers: cats.map((c) => ({
|
|
@@ -606,8 +563,11 @@ export async function browseModels(request = {}) {
|
|
|
606
563
|
initial: request.initial,
|
|
607
564
|
role: request.role,
|
|
608
565
|
theme: request.theme,
|
|
566
|
+
...(where.gateway ? { gateway: { name: GATEWAY_NAME, url: where.gateway.baseURL } } : {}),
|
|
609
567
|
load: (provider, refresh) => loadCatalog(provider, { refresh: refresh || request.refresh }),
|
|
610
|
-
balance: async (provider) => provider === 'openrouter' && source(where, provider)
|
|
568
|
+
balance: async (provider) => provider === 'openrouter' && !where.gateway && source(where, provider)
|
|
569
|
+
? routerBalance()
|
|
570
|
+
: undefined,
|
|
611
571
|
test: async (entry) => {
|
|
612
572
|
const role = entry.roles.includes('chat')
|
|
613
573
|
? 'chat'
|
|
@@ -637,12 +597,11 @@ const SUBS = {
|
|
|
637
597
|
show: show,
|
|
638
598
|
test,
|
|
639
599
|
check: test,
|
|
640
|
-
pick,
|
|
641
600
|
browse,
|
|
642
601
|
ui: browse,
|
|
643
602
|
};
|
|
644
603
|
export const models = {
|
|
645
|
-
summary: 'What this machine can use: list, search, test and
|
|
604
|
+
summary: 'What this machine can use: list, search, test and browse models.',
|
|
646
605
|
usage: USAGE,
|
|
647
606
|
banner: { head: 'Zenera', accent: 'Models', subtitle: 'Model Catalog', hue: 'dusk' },
|
|
648
607
|
details: [
|
|
@@ -661,7 +620,6 @@ export const models = {
|
|
|
661
620
|
' zen models search <query> Narrow it — --tools, --vision, --free.',
|
|
662
621
|
' zen models show <ref> One model, in full.',
|
|
663
622
|
' zen models test <ref> … Ask it one real question.',
|
|
664
|
-
' zen models pick --embedding The first ref that answers, on stdout.',
|
|
665
623
|
' zen models browse [provider] Walk providers and models with the',
|
|
666
624
|
' arrow keys; t asks one, p prints its ref.',
|
|
667
625
|
],
|
|
@@ -682,7 +640,7 @@ export const models = {
|
|
|
682
640
|
await ls(ctx, ctx.args);
|
|
683
641
|
return;
|
|
684
642
|
}
|
|
685
|
-
throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test,
|
|
643
|
+
throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, browse')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
|
|
686
644
|
},
|
|
687
645
|
};
|
|
688
646
|
//# sourceMappingURL=models.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare const SHELLS: readonly ["zsh", "bash", "fish"];
|
|
2
|
+
export type Shell = (typeof SHELLS)[number];
|
|
3
|
+
export declare function shellOf(value: string): Shell | undefined;
|
|
4
|
+
/** The primary startup file for an interactive shell. */
|
|
5
|
+
export declare function rcFile(shell: Shell, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
6
|
+
/** macOS needs both login and non-login Bash startup files. */
|
|
7
|
+
export declare function rcFiles(shell: Shell, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string[];
|
|
8
|
+
/** Guard the loader so uninstalling zen does not break shell startup. */
|
|
9
|
+
export declare function loader(shell: Shell): string;
|
|
10
|
+
/** Whether `file` already loads the script; a commented-out line does not count. */
|
|
11
|
+
export declare function installed(shell: Shell, file: string): boolean;
|
|
12
|
+
/** Appends the loader to `file`, unless it is already there. */
|
|
13
|
+
export declare function install(shell: Shell, file: string): 'added' | 'present';
|
|
14
|
+
export declare function autoInstall(env?: NodeJS.ProcessEnv): void;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=completion-install.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
const SHELLS = ['zsh', 'bash', 'fish'];
|
|
5
|
+
export function shellOf(value) {
|
|
6
|
+
const name = basename(value);
|
|
7
|
+
return SHELLS.find((shell) => shell === name);
|
|
8
|
+
}
|
|
9
|
+
/** The primary startup file for an interactive shell. */
|
|
10
|
+
export function rcFile(shell, env = process.env, platform = process.platform) {
|
|
11
|
+
const home = env.HOME || homedir();
|
|
12
|
+
switch (shell) {
|
|
13
|
+
case 'zsh':
|
|
14
|
+
return join(env.ZDOTDIR || home, '.zshrc');
|
|
15
|
+
case 'bash':
|
|
16
|
+
if (platform === 'darwin') {
|
|
17
|
+
// Login Bash reads only the first existing profile in this order.
|
|
18
|
+
return (['.bash_profile', '.bash_login', '.profile']
|
|
19
|
+
.map((name) => join(home, name))
|
|
20
|
+
.find((file) => existsSync(file)) ?? join(home, '.bash_profile'));
|
|
21
|
+
}
|
|
22
|
+
return join(home, '.bashrc');
|
|
23
|
+
case 'fish':
|
|
24
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'fish', 'config.fish');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** macOS needs both login and non-login Bash startup files. */
|
|
28
|
+
export function rcFiles(shell, env = process.env, platform = process.platform) {
|
|
29
|
+
const primary = rcFile(shell, env, platform);
|
|
30
|
+
return shell === 'bash' && platform === 'darwin'
|
|
31
|
+
? [primary, join(env.HOME || homedir(), '.bashrc')]
|
|
32
|
+
: [primary];
|
|
33
|
+
}
|
|
34
|
+
/** Guard the loader so uninstalling zen does not break shell startup. */
|
|
35
|
+
export function loader(shell) {
|
|
36
|
+
return shell === 'fish'
|
|
37
|
+
? 'if type -q zen; zen completion fish | source; end'
|
|
38
|
+
: `if command -v zen >/dev/null 2>&1; then eval "$(zen completion ${shell})"; fi`;
|
|
39
|
+
}
|
|
40
|
+
/** Whether `file` already loads the script; a commented-out line does not count. */
|
|
41
|
+
export function installed(shell, file) {
|
|
42
|
+
if (!existsSync(file)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const loads = new RegExp(`\\b(zen|zn|zenera)\\s+completion\\s+${shell}\\b`);
|
|
46
|
+
return readFileSync(file, 'utf8')
|
|
47
|
+
.split(/\r?\n/)
|
|
48
|
+
.some((line) => !line.trimStart().startsWith('#') && loads.test(line));
|
|
49
|
+
}
|
|
50
|
+
/** Appends the loader to `file`, unless it is already there. */
|
|
51
|
+
export function install(shell, file) {
|
|
52
|
+
if (installed(shell, file)) {
|
|
53
|
+
return 'present';
|
|
54
|
+
}
|
|
55
|
+
const have = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
const gap = have === '' || have.endsWith('\n\n') ? '' : have.endsWith('\n') ? '\n' : '\n\n';
|
|
58
|
+
appendFileSync(file, `${gap}# zen tab completion\n${loader(shell)}\n`);
|
|
59
|
+
return 'added';
|
|
60
|
+
}
|
|
61
|
+
export function autoInstall(env = process.env) {
|
|
62
|
+
// Local dependencies, npx and workspace installs must not edit shell startup.
|
|
63
|
+
if (env.npm_config_global !== 'true') {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (process.getuid?.() === 0 || env.SUDO_UID || env.SUDO_USER) {
|
|
67
|
+
console.error('zen: skipping automatic completion setup under root/sudo. Run zen completion --install as your normal user.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (process.platform === 'win32' || (env.CI && env.CI !== 'false')) {
|
|
71
|
+
console.error('zen: skipping automatic completion setup on Windows or in CI. Run zen completion --install in a supported shell.');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const shell = shellOf(env.SHELL ?? '');
|
|
75
|
+
if (!shell) {
|
|
76
|
+
console.error('zen: cannot detect a supported shell. Run zen completion <zsh|bash|fish> --install.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const file of rcFiles(shell, env)) {
|
|
80
|
+
const result = install(shell, file);
|
|
81
|
+
console.error(`zen: completion ${result === 'added' ? 'added to' : 'already set up in'} ${file}.`);
|
|
82
|
+
}
|
|
83
|
+
console.error('zen: open a new terminal to use tab completion.');
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=completion-install.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Candidate } from './candidates.ts';
|
|
2
|
+
/**
|
|
3
|
+
* `words` is everything typed after the program's name, the word being
|
|
4
|
+
* completed last — an empty string when the cursor follows a space.
|
|
5
|
+
*/
|
|
6
|
+
export declare function complete(words: readonly string[], cwd: string): Promise<Candidate[]>;
|
|
7
|
+
/** The wire format. Whitespace in a description would break a line in two. */
|
|
8
|
+
export declare function render(found: readonly Candidate[]): string;
|
|
9
|
+
//# sourceMappingURL=completion.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { matching, projectNames } from "./candidates.js";
|
|
3
|
+
import { ALIASES, COMMANDS, EXTERNAL } from "./commands/index.js";
|
|
4
|
+
import { completeMeta } from "./commands/meta.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Shell completion
|
|
7
|
+
//
|
|
8
|
+
// The shell's own glue (`zen completion zsh|bash|fish`) is deliberately dumb:
|
|
9
|
+
// it hands `zen __complete` the words typed so far, the one being completed
|
|
10
|
+
// last, and shows whatever comes back. Everything that needs to know what a
|
|
11
|
+
// project is, or which prompts it holds, is answered here, once, for all three
|
|
12
|
+
// shells.
|
|
13
|
+
//
|
|
14
|
+
// One candidate per line, `value<TAB>description`. Nothing at all means "no
|
|
15
|
+
// opinion", and the shell falls back to completing file names — which is the
|
|
16
|
+
// right answer for `-C <dir>`, for `--events <file>`, and for the files that
|
|
17
|
+
// follow a stored prompt.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Flags the frame owns, offered where a command name would be. */
|
|
20
|
+
const GLOBAL_FLAGS = [
|
|
21
|
+
{ value: '--help', description: 'Print help.' },
|
|
22
|
+
{ value: '--version', description: 'Print the version.' },
|
|
23
|
+
{ value: '--json', description: 'Machine-readable output.' },
|
|
24
|
+
{ value: '--directory', description: 'Act as if run in <dir>.' },
|
|
25
|
+
];
|
|
26
|
+
/** Commands whose first bare word is a project, not a subcommand. */
|
|
27
|
+
const TAKES_PROJECT = new Set(['run', 'open', 'check', 'export']);
|
|
28
|
+
/** `zen key <ls|add|rm>` → `ls`, `add`, `rm`; a usage line is the only list there is. */
|
|
29
|
+
function subcommands(usage) {
|
|
30
|
+
const group = /[<[]([a-z][a-z-]*(?:\|[a-z][a-z-]*)+)[>\]]/.exec(usage);
|
|
31
|
+
return group ? group[1].split('|') : [];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `words` is everything typed after the program's name, the word being
|
|
35
|
+
* completed last — an empty string when the cursor follows a space.
|
|
36
|
+
*/
|
|
37
|
+
export async function complete(words, cwd) {
|
|
38
|
+
const cur = words.at(-1) ?? '';
|
|
39
|
+
const before = [];
|
|
40
|
+
let at = cwd;
|
|
41
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
42
|
+
const w = words[i];
|
|
43
|
+
if (w === '-C' || w === '--directory') {
|
|
44
|
+
// The directory itself is being typed: that is the shell's to complete.
|
|
45
|
+
if (i + 1 === words.length - 1) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
at = resolve(cwd, words[++i]);
|
|
49
|
+
}
|
|
50
|
+
else if (w !== '--json' && w !== '--help' && w !== '-h') {
|
|
51
|
+
before.push(w);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (before.length === 0) {
|
|
55
|
+
if (cur.startsWith('-')) {
|
|
56
|
+
return matching(GLOBAL_FLAGS, cur);
|
|
57
|
+
}
|
|
58
|
+
return matching([
|
|
59
|
+
...Object.entries(COMMANDS).map(([value, c]) => ({
|
|
60
|
+
value,
|
|
61
|
+
description: c.summary,
|
|
62
|
+
})),
|
|
63
|
+
...Object.entries(EXTERNAL).map(([value, e]) => ({
|
|
64
|
+
value,
|
|
65
|
+
description: e.summary,
|
|
66
|
+
})),
|
|
67
|
+
{ value: 'help', description: 'Help for a command.' },
|
|
68
|
+
], cur);
|
|
69
|
+
}
|
|
70
|
+
const name = ALIASES[before[0]] ?? before[0];
|
|
71
|
+
const args = before.slice(1);
|
|
72
|
+
if (name === 'help') {
|
|
73
|
+
return args.length === 0 ? await complete([cur], at) : [];
|
|
74
|
+
}
|
|
75
|
+
if (name === 'meta') {
|
|
76
|
+
return await completeMeta(args, cur, at);
|
|
77
|
+
}
|
|
78
|
+
if (cur.startsWith('-') || args.some((a) => !a.startsWith('-'))) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (TAKES_PROJECT.has(name)) {
|
|
82
|
+
return matching(await projectNames(), cur);
|
|
83
|
+
}
|
|
84
|
+
const usage = COMMANDS[name]?.usage ?? EXTERNAL[name]?.usage;
|
|
85
|
+
return matching(subcommands(usage ?? '').map((value) => ({ value })), cur);
|
|
86
|
+
}
|
|
87
|
+
/** The wire format. Whitespace in a description would break a line in two. */
|
|
88
|
+
export function render(found) {
|
|
89
|
+
return found
|
|
90
|
+
.map((c) => c.description ? `${c.value}\t${c.description.replace(/\s+/g, ' ').trim()}` : c.value)
|
|
91
|
+
.join('\n');
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=completion.js.map
|
package/dist/keys.d.ts
CHANGED
|
@@ -14,9 +14,19 @@ export type Provider = (typeof PROVIDERS)[number];
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const SERVICES: readonly ["exa"];
|
|
16
16
|
export type Service = (typeof SERVICES)[number];
|
|
17
|
+
/**
|
|
18
|
+
* Somewhere every model can be reached through, with one key. Its own list for
|
|
19
|
+
* the same reason as `SERVICES`: it is neither a vendor a model comes from nor
|
|
20
|
+
* a tool, and a gateway entry only counts while the keyring is in its mode.
|
|
21
|
+
*/
|
|
22
|
+
export declare const GATEWAYS: readonly ["bifrost"];
|
|
23
|
+
export type GatewayOwner = (typeof GATEWAYS)[number];
|
|
24
|
+
/** `direct` calls each vendor with its own key; `bifrost` sends everything through the gateway. */
|
|
25
|
+
export type KeyMode = 'direct' | GatewayOwner;
|
|
26
|
+
export declare const MODES: readonly KeyMode[];
|
|
17
27
|
/** Anything the keyring can hold a credential for. */
|
|
18
|
-
export declare const OWNERS: readonly ["openai", "anthropic", "google", "vertex", "openrouter", "exa"];
|
|
19
|
-
export type KeyOwner = Provider | Service;
|
|
28
|
+
export declare const OWNERS: readonly ["openai", "anthropic", "google", "vertex", "openrouter", "exa", "bifrost"];
|
|
29
|
+
export type KeyOwner = Provider | Service | GatewayOwner;
|
|
20
30
|
/**
|
|
21
31
|
* One way a credential can arrive.
|
|
22
32
|
*
|
|
@@ -34,8 +44,8 @@ export interface CredentialForm {
|
|
|
34
44
|
where: string;
|
|
35
45
|
}
|
|
36
46
|
interface ProviderShape {
|
|
37
|
-
/** whether this is somewhere a model lives,
|
|
38
|
-
kind: 'model' | 'service';
|
|
47
|
+
/** whether this is somewhere a model lives, something a tool calls, or a way to every model */
|
|
48
|
+
kind: 'model' | 'service' | 'gateway';
|
|
39
49
|
label: string;
|
|
40
50
|
/** the ways in, first being the one this provider is usually reached by */
|
|
41
51
|
forms: [CredentialForm, ...CredentialForm[]];
|
|
@@ -64,6 +74,7 @@ export declare const FILE_ENVS: string[];
|
|
|
64
74
|
export declare function envOf(entry: Pick<KeyEntry, 'provider' | 'holds' | 'env'>): string;
|
|
65
75
|
export declare function isProvider(name: string): name is Provider;
|
|
66
76
|
export declare function isOwner(name: string): name is KeyOwner;
|
|
77
|
+
export declare function isGateway(name: string): name is GatewayOwner;
|
|
67
78
|
export declare function assertOwner(name: string): KeyOwner;
|
|
68
79
|
/**
|
|
69
80
|
* `blocked` is the credential being fine and the *account* not: an api switched
|
|
@@ -92,6 +103,8 @@ export interface KeyEntry {
|
|
|
92
103
|
/** vertex, file-shaped only: what the library would otherwise have to be told twice */
|
|
93
104
|
project?: string;
|
|
94
105
|
location?: string;
|
|
106
|
+
/** gateway only: where it listens */
|
|
107
|
+
url?: string;
|
|
95
108
|
addedAt: string;
|
|
96
109
|
check?: KeyCheck;
|
|
97
110
|
}
|
|
@@ -122,6 +135,8 @@ export interface KeyMeta {
|
|
|
122
135
|
project?: string;
|
|
123
136
|
/** GCP region, or `global` */
|
|
124
137
|
location?: string;
|
|
138
|
+
/** gateway base url */
|
|
139
|
+
url?: string;
|
|
125
140
|
}
|
|
126
141
|
export declare class KeyStore {
|
|
127
142
|
#private;
|
|
@@ -149,6 +164,9 @@ export declare class KeyStore {
|
|
|
149
164
|
remove(provider: KeyOwner, name: string): boolean;
|
|
150
165
|
use(provider: KeyOwner, name: string): KeyEntry;
|
|
151
166
|
record(entry: KeyEntry, check: KeyCheck): void;
|
|
167
|
+
get mode(): KeyMode;
|
|
168
|
+
/** Refuses a gateway mode with no gateway to send anything to. */
|
|
169
|
+
setMode(mode: KeyMode): void;
|
|
152
170
|
save(): void;
|
|
153
171
|
/**
|
|
154
172
|
* Absolute path behind a file-shaped entry. Stored entries name a file in
|
|
@@ -175,6 +193,11 @@ export declare class KeyStore {
|
|
|
175
193
|
*
|
|
176
194
|
* Services are included by default, because a tool reads its key from the
|
|
177
195
|
* environment for exactly the same reason a model adapter does.
|
|
196
|
+
*
|
|
197
|
+
* In a gateway mode the gateway's key replaces every vendor's, so those are
|
|
198
|
+
* withheld: nothing would read them, and the sandbox would be handed keys
|
|
199
|
+
* the run never needed. In `direct` mode it is the gateway that is withheld,
|
|
200
|
+
* since its url alone is what switches the library over.
|
|
178
201
|
*/
|
|
179
202
|
environment(only?: KeyOwner[]): Record<string, string>;
|
|
180
203
|
/** Applies `environment()` to this process. Returns what it set. */
|