@zenera/cli 1.1.27 → 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 +127 -75
- 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 +235 -62
- package/dist/commands/models.d.ts +12 -0
- package/dist/commands/models.js +79 -112
- 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/meta/model.d.ts +1 -1
- package/dist/meta/model.js +1 -1
- 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 +8 -1
- package/dist/tui/models.js +25 -18
- 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-analyze-run/SKILL.md +5 -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/meta/finetune/guard.d.ts +0 -29
- package/dist/meta/finetune/guard.js +0 -104
- package/dist/meta.d.ts +0 -291
- package/dist/meta.js +0 -981
package/dist/catalog.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// `source` for exactly that reason: a curated guess must never be mistaken for
|
|
17
17
|
// the vendor's own word.
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
import { ModelRegistry, vertexEndpoint } from '@zenera/neo';
|
|
19
|
+
import { GATEWAY_KEY_HEADER, GATEWAY_NAME, GATEWAY_UPSTREAMS, gatewayOf, ModelRegistry, vertexEndpoint, } from '@zenera/neo';
|
|
20
20
|
import { Cache, cacheKey } from "./cache.js";
|
|
21
21
|
import { PROVIDERS } from "./keys.js";
|
|
22
22
|
import { classify } from "./liveness.js";
|
|
@@ -72,11 +72,8 @@ export const CURATED = {
|
|
|
72
72
|
],
|
|
73
73
|
};
|
|
74
74
|
/**
|
|
75
|
-
* The
|
|
76
|
-
*
|
|
77
|
-
* Cheap and fast first. `pick` exists to answer "give me something that works"
|
|
78
|
-
* in one round trip where it can, and the small models answer soonest and cost
|
|
79
|
-
* least when the answer is thrown away — which it always is.
|
|
75
|
+
* The cheap, fast models per provider and role, best first. `zen init` takes
|
|
76
|
+
* its default embedder from here.
|
|
80
77
|
*/
|
|
81
78
|
export const PREFERRED = {
|
|
82
79
|
openai: {
|
|
@@ -369,6 +366,65 @@ export async function fetchCatalog(provider, client = new ModelRegistry().client
|
|
|
369
366
|
return enrich(provider, await fromRouter(client, provider));
|
|
370
367
|
}
|
|
371
368
|
}
|
|
369
|
+
const UPSTREAM_OF = new Map(PROVIDERS.map((p) => [GATEWAY_UPSTREAMS[p], p]));
|
|
370
|
+
/** One request per gateway per process, however many providers ask. */
|
|
371
|
+
const listings = new Map();
|
|
372
|
+
function gatewayListing(at) {
|
|
373
|
+
const key = `${at.baseURL}\0${at.apiKey ?? ''}`;
|
|
374
|
+
let listing = listings.get(key);
|
|
375
|
+
if (!listing) {
|
|
376
|
+
listing = fetchGatewayListing(at);
|
|
377
|
+
listings.set(key, listing);
|
|
378
|
+
listing.catch(() => listings.delete(key));
|
|
379
|
+
}
|
|
380
|
+
return listing;
|
|
381
|
+
}
|
|
382
|
+
async function fetchGatewayListing(at) {
|
|
383
|
+
const res = await fetch(`${at.baseURL}/v1/models`, {
|
|
384
|
+
headers: at.apiKey
|
|
385
|
+
? { authorization: `Bearer ${at.apiKey}`, [GATEWAY_KEY_HEADER]: at.apiKey }
|
|
386
|
+
: {},
|
|
387
|
+
signal: AbortSignal.timeout(30_000),
|
|
388
|
+
});
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
const body = await res.text().catch(() => '');
|
|
391
|
+
throw Object.assign(new Error(`${GATEWAY_NAME} answered ${res.status}: ${body}`), {
|
|
392
|
+
status: res.status,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const { data } = (await res.json());
|
|
396
|
+
const by = new Map();
|
|
397
|
+
for (const model of data ?? []) {
|
|
398
|
+
if (typeof model.id !== 'string') {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const slash = model.id.indexOf('/');
|
|
402
|
+
const provider = slash > 0 ? UPSTREAM_OF.get(model.id.slice(0, slash)) : undefined;
|
|
403
|
+
const id = model.id.slice(slash + 1);
|
|
404
|
+
const role = openaiRole(id);
|
|
405
|
+
if (!provider || !id || !role) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const rows = by.get(provider) ?? [];
|
|
409
|
+
rows.push(entry(provider, {
|
|
410
|
+
id,
|
|
411
|
+
roles: [role],
|
|
412
|
+
name: typeof model.name === 'string' ? model.name : undefined,
|
|
413
|
+
contextLength: typeof model.context_length === 'number' ? model.context_length : undefined,
|
|
414
|
+
created: iso(typeof model.created === 'number' ? model.created : undefined),
|
|
415
|
+
publisher: provider === 'vertex' ? publisherOf(id) : undefined,
|
|
416
|
+
}));
|
|
417
|
+
by.set(provider, rows);
|
|
418
|
+
}
|
|
419
|
+
return by;
|
|
420
|
+
}
|
|
421
|
+
function publisherOf(id) {
|
|
422
|
+
return /^claude-/.test(id) ? 'anthropic' : id.includes('/') ? id.split('/')[0] : 'google';
|
|
423
|
+
}
|
|
424
|
+
/** What the gateway serves under one provider's name, enriched like a vendor listing. */
|
|
425
|
+
export async function fetchGatewayCatalog(provider, at) {
|
|
426
|
+
return enrich(provider, (await gatewayListing(at)).get(provider) ?? []);
|
|
427
|
+
}
|
|
372
428
|
// ---------------------------------------------------------------------------
|
|
373
429
|
// Enrichment and cache
|
|
374
430
|
// ---------------------------------------------------------------------------
|
|
@@ -407,6 +463,10 @@ const store = () => new Cache(CATALOG_KIND, { mode: 0o644 });
|
|
|
407
463
|
* to both. A project left to the service-account file is not seen here.
|
|
408
464
|
*/
|
|
409
465
|
function scope(provider) {
|
|
466
|
+
const at = gatewayOf();
|
|
467
|
+
if (at) {
|
|
468
|
+
return cacheKey(GATEWAY_NAME, at.baseURL);
|
|
469
|
+
}
|
|
410
470
|
if (provider !== 'vertex') {
|
|
411
471
|
return undefined;
|
|
412
472
|
}
|
|
@@ -433,19 +493,31 @@ function readCache(provider) {
|
|
|
433
493
|
* model list to four rows.
|
|
434
494
|
*/
|
|
435
495
|
export async function loadCatalog(provider, opts = {}) {
|
|
496
|
+
const at = gatewayOf();
|
|
497
|
+
const via = at ? { via: { name: GATEWAY_NAME, url: at.baseURL } } : {};
|
|
498
|
+
// The built-in list names vendor models the gateway may not serve at all.
|
|
499
|
+
const fallback = () => (at ? [] : curated(provider));
|
|
436
500
|
const cached = readCache(provider);
|
|
437
501
|
const fresh = cached && Date.now() - new Date(cached.fetchedAt).getTime() < CATALOG_TTL_MS
|
|
438
502
|
? cached
|
|
439
503
|
: undefined;
|
|
440
504
|
if (fresh && !opts.refresh) {
|
|
441
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
provider,
|
|
507
|
+
entries: fresh.entries,
|
|
508
|
+
origin: 'cache',
|
|
509
|
+
fetchedAt: fresh.fetchedAt,
|
|
510
|
+
...via,
|
|
511
|
+
};
|
|
442
512
|
}
|
|
443
513
|
if (!opts.offline) {
|
|
444
514
|
try {
|
|
445
|
-
const entries =
|
|
515
|
+
const entries = at
|
|
516
|
+
? await fetchGatewayCatalog(provider, at)
|
|
517
|
+
: await fetchCatalog(provider);
|
|
446
518
|
const fetchedAt = new Date().toISOString();
|
|
447
519
|
store().put(catalogKey(provider), { provider, fetchedAt, entries });
|
|
448
|
-
return { provider, entries, origin: 'live', fetchedAt };
|
|
520
|
+
return { provider, entries, origin: 'live', fetchedAt, ...via };
|
|
449
521
|
}
|
|
450
522
|
catch (err) {
|
|
451
523
|
const problem = classify(err);
|
|
@@ -456,25 +528,34 @@ export async function loadCatalog(provider, opts = {}) {
|
|
|
456
528
|
origin: 'stale',
|
|
457
529
|
fetchedAt: cached.fetchedAt,
|
|
458
530
|
problem,
|
|
531
|
+
...via,
|
|
459
532
|
};
|
|
460
533
|
}
|
|
461
534
|
return {
|
|
462
535
|
provider,
|
|
463
|
-
entries:
|
|
536
|
+
entries: fallback(),
|
|
464
537
|
origin: 'curated',
|
|
465
538
|
fetchedAt: new Date(0).toISOString(),
|
|
466
539
|
problem,
|
|
540
|
+
...via,
|
|
467
541
|
};
|
|
468
542
|
}
|
|
469
543
|
}
|
|
470
544
|
if (cached) {
|
|
471
|
-
return {
|
|
545
|
+
return {
|
|
546
|
+
provider,
|
|
547
|
+
entries: cached.entries,
|
|
548
|
+
origin: 'stale',
|
|
549
|
+
fetchedAt: cached.fetchedAt,
|
|
550
|
+
...via,
|
|
551
|
+
};
|
|
472
552
|
}
|
|
473
553
|
return {
|
|
474
554
|
provider,
|
|
475
|
-
entries:
|
|
555
|
+
entries: fallback(),
|
|
476
556
|
origin: 'curated',
|
|
477
557
|
fetchedAt: new Date(0).toISOString(),
|
|
558
|
+
...via,
|
|
478
559
|
};
|
|
479
560
|
}
|
|
480
561
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from '../command.ts';
|
|
2
|
+
export { install, installed, loader, rcFile } from '../completion-install.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Prints the shell's side of tab completion. The script is a few lines that
|
|
5
|
+
* call back into `zen __complete`, so the project names and stored prompts it
|
|
6
|
+
* offers are always read from the machine it runs on, never baked in.
|
|
7
|
+
*/
|
|
8
|
+
export declare const completion: Command;
|
|
9
|
+
//# sourceMappingURL=completion.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { one, parse } from "../args.js";
|
|
4
|
+
import { install, installed, loader, rcFiles, shellOf } from "../completion-install.js";
|
|
5
|
+
import { TEMPLATES } from "../scaffold.js";
|
|
6
|
+
import { bold, confirm, usageError, write } from "../term.js";
|
|
7
|
+
export { install, installed, loader, rcFile } from "../completion-install.js";
|
|
8
|
+
const USAGE = 'zen completion [<zsh|bash|fish>] [--install [--yes]]';
|
|
9
|
+
/**
|
|
10
|
+
* Prints the shell's side of tab completion. The script is a few lines that
|
|
11
|
+
* call back into `zen __complete`, so the project names and stored prompts it
|
|
12
|
+
* offers are always read from the machine it runs on, never baked in.
|
|
13
|
+
*/
|
|
14
|
+
export const completion = {
|
|
15
|
+
summary: 'Print the tab-completion script for your shell.',
|
|
16
|
+
usage: USAGE,
|
|
17
|
+
banner: { head: 'Zenera', accent: 'Completion', subtitle: 'Shell Integration', hue: 'magenta' },
|
|
18
|
+
quiet: () => true,
|
|
19
|
+
details: [
|
|
20
|
+
'Global npm installs set this up automatically. Open a new shell afterward.',
|
|
21
|
+
'',
|
|
22
|
+
'Add one line to your shell’s startup file:',
|
|
23
|
+
' zsh eval "$(zen completion zsh)" in ~/.zshrc',
|
|
24
|
+
' bash eval "$(zen completion bash)" in ~/.bashrc',
|
|
25
|
+
' fish zen completion fish | source in ~/.config/fish/config.fish',
|
|
26
|
+
'',
|
|
27
|
+
'Or let zen do it — it appends that line to the file, once, after asking:',
|
|
28
|
+
' zen completion --install for the shell you are in',
|
|
29
|
+
' zen completion zsh --install --yes without asking',
|
|
30
|
+
'',
|
|
31
|
+
'Then, in a new shell:',
|
|
32
|
+
' zen meta run my-project /<Tab> the project’s stored prompts',
|
|
33
|
+
' zen meta <Tab> run, prompts, resume, model …',
|
|
34
|
+
' zen run <Tab> your projects',
|
|
35
|
+
'',
|
|
36
|
+
'The prompts are the files in .github/prompts/ — the same ones an editor',
|
|
37
|
+
'offers as slash commands. Where there is nothing to offer, file names are.',
|
|
38
|
+
],
|
|
39
|
+
run: async (ctx) => {
|
|
40
|
+
const { values, positionals } = parse(ctx.args, { install: { type: 'boolean' }, yes: { type: 'boolean' } }, USAGE);
|
|
41
|
+
const name = one(positionals, 'shell', USAGE) ?? basename(process.env.SHELL ?? '');
|
|
42
|
+
const shell = shellOf(name);
|
|
43
|
+
if (!shell) {
|
|
44
|
+
throw usageError(name ? `no completion for "${name}"` : 'which shell?', 'try: zen completion zsh|bash|fish');
|
|
45
|
+
}
|
|
46
|
+
if (values.yes && !values.install) {
|
|
47
|
+
throw usageError('--yes only goes with --install', USAGE);
|
|
48
|
+
}
|
|
49
|
+
if (values.install) {
|
|
50
|
+
const files = rcFiles(shell).filter((file) => !installed(shell, file));
|
|
51
|
+
if (files.length === 0) {
|
|
52
|
+
write(`Already set up in ${rcFiles(shell).join(', ')}.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const line = bold(loader(shell));
|
|
56
|
+
if (!values.yes && !(await confirm(`Add ${line} to ${files.join(', ')}?`, true))) {
|
|
57
|
+
write('Nothing changed.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
install(shell, file);
|
|
62
|
+
write(`Added to ${file}.`);
|
|
63
|
+
}
|
|
64
|
+
write('Open a new shell to use tab completion.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write(readFileSync(`${TEMPLATES}/completions/zen.${shell}`, 'utf8'));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=completion.js.map
|
package/dist/commands/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cache } from "./cache.js";
|
|
2
2
|
import { check } from "./check.js";
|
|
3
|
+
import { completion } from "./completion.js";
|
|
3
4
|
import { pack } from "./export.js";
|
|
4
5
|
import { unpack } from "./import.js";
|
|
5
6
|
import { init } from "./init.js";
|
|
@@ -39,6 +40,7 @@ export const COMMANDS = {
|
|
|
39
40
|
memory,
|
|
40
41
|
sandbox,
|
|
41
42
|
cache,
|
|
43
|
+
completion,
|
|
42
44
|
version,
|
|
43
45
|
};
|
|
44
46
|
/** Names that are not listed in help but still work. */
|
package/dist/commands/init.js
CHANGED
|
@@ -47,7 +47,7 @@ const DEFAULT_MODEL = {
|
|
|
47
47
|
/**
|
|
48
48
|
* What vectorises the project's memory, given the provider it ends up on.
|
|
49
49
|
*
|
|
50
|
-
* Taken from the
|
|
50
|
+
* Taken from the catalogue's `PREFERRED` table rather than written out
|
|
51
51
|
* again here, so the scaffold cannot drift from what the catalogue calls
|
|
52
52
|
* current. Anthropic publishes no embeddings API, and a `--model` naming a
|
|
53
53
|
* provider we do not ship gives nothing to guess from; both scaffold without
|
package/dist/commands/key.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { GATEWAY_URL_ENV } from '@zenera/neo';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { parse } from "../args.js";
|
|
3
4
|
import { ensureHome } from "../home.js";
|
|
4
|
-
import { ambient, ambientId, assertNotEmpty, checkRegion, describe, envNames, envOf, formOf, keyId, KeyStore, mask, OWNERS, parseRef, SHAPES, } from "../keys.js";
|
|
5
|
+
import { ambient, ambientId, assertNotEmpty, checkRegion, describe, envNames, envOf, formOf, GATEWAYS, isGateway, keyId, KeyStore, mask, MODES, OWNERS, parseRef, SHAPES, } from "../keys.js";
|
|
5
6
|
import { probe, probeAll } from "../liveness.js";
|
|
6
|
-
import { ago, ask, askSecret, bold, confirm, credentialError, cyan, dim, green, isInteractive, json, note, progress, readStdin, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
7
|
-
const USAGE = 'zen key <ls|add|use|check|rm|show|env> [ref] [options]';
|
|
7
|
+
import { ago, ask, askSecret, badge, bold, confirm, credentialError, cyan, dim, green, isInteractive, json, note, progress, readStdin, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
|
+
const USAGE = 'zen key <ls|add|use|mode|check|rm|show|env> [ref] [options]';
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Display
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
@@ -32,6 +33,9 @@ const ORIGIN = {
|
|
|
32
33
|
* the environment supplied either, because then the stored one is not in play.
|
|
33
34
|
*/
|
|
34
35
|
function placed(store, entry) {
|
|
36
|
+
if (entry.url) {
|
|
37
|
+
return dim(entry.url);
|
|
38
|
+
}
|
|
35
39
|
if (entry.provider !== 'vertex' || entry.holds !== 'file') {
|
|
36
40
|
return '';
|
|
37
41
|
}
|
|
@@ -61,14 +65,28 @@ function asEntry(cred) {
|
|
|
61
65
|
}
|
|
62
66
|
function rows(store, borrowed) {
|
|
63
67
|
const out = [
|
|
64
|
-
[bold(''), bold('KEY'), bold('VALUE'), bold('
|
|
68
|
+
[bold(''), bold('KEY'), bold('VALUE'), bold('WHERE'), bold('STATE'), bold('CHECKED')],
|
|
65
69
|
];
|
|
66
70
|
for (const provider of OWNERS) {
|
|
71
|
+
// Held but not in play under the current mode: drawn grey, so the live set stands out.
|
|
72
|
+
const idle = unused(store, provider);
|
|
67
73
|
for (const entry of store.for(provider)) {
|
|
68
74
|
const shadow = envNames(provider).find((name) => process.env[name]);
|
|
75
|
+
if (idle) {
|
|
76
|
+
out.push([
|
|
77
|
+
dim('-'),
|
|
78
|
+
dim(keyId(entry)),
|
|
79
|
+
dim(describe(store, entry)),
|
|
80
|
+
dim(entry.url ?? ''),
|
|
81
|
+
dim(entry.check?.state ?? 'unchecked'),
|
|
82
|
+
dim(`not used in ${store.mode} mode`),
|
|
83
|
+
]);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const gateway = isGateway(provider);
|
|
69
87
|
out.push([
|
|
70
88
|
store.isActive(entry) ? green('*') : ' ',
|
|
71
|
-
keyId(entry),
|
|
89
|
+
gateway && store.isActive(entry) ? bold(cyan(keyId(entry))) : keyId(entry),
|
|
72
90
|
dim(describe(store, entry)),
|
|
73
91
|
placed(store, entry),
|
|
74
92
|
state(entry),
|
|
@@ -78,17 +96,47 @@ function rows(store, borrowed) {
|
|
|
78
96
|
}
|
|
79
97
|
for (const [cred, entry] of borrowed.filter(([c]) => c.provider === provider)) {
|
|
80
98
|
out.push([
|
|
81
|
-
dim('~'),
|
|
99
|
+
dim(idle ? '-' : '~'),
|
|
82
100
|
dim(ambientId(cred)),
|
|
83
101
|
dim(describe(store, entry)),
|
|
84
|
-
placed(store, entry),
|
|
85
|
-
state(entry),
|
|
86
|
-
dim(
|
|
102
|
+
idle ? '' : placed(store, entry),
|
|
103
|
+
idle ? dim(entry.check?.state ?? 'unchecked') : state(entry),
|
|
104
|
+
dim(idle
|
|
105
|
+
? `not used in ${store.mode} mode`
|
|
106
|
+
: cred.env
|
|
107
|
+
? 'from the environment'
|
|
108
|
+
: 'from gcloud'),
|
|
87
109
|
]);
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
return table(out);
|
|
91
113
|
}
|
|
114
|
+
/** A vendor key under a gateway mode, or a gateway key under any other. */
|
|
115
|
+
function unused(store, provider) {
|
|
116
|
+
if (isGateway(provider)) {
|
|
117
|
+
return store.mode !== provider;
|
|
118
|
+
}
|
|
119
|
+
return SHAPES[provider].kind === 'model' && store.mode !== 'direct';
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Above the table, because it decides which half of the table matters. Said
|
|
123
|
+
* only once a gateway key exists: before that there is no choice to report.
|
|
124
|
+
*/
|
|
125
|
+
function modeLine(store) {
|
|
126
|
+
const mode = store.mode;
|
|
127
|
+
if (mode === 'direct') {
|
|
128
|
+
if (GATEWAYS.some((g) => store.active(g))) {
|
|
129
|
+
note(`${bold('mode')} ${cyan('direct')} ${dim('— each provider with its own key; zen key mode bifrost to send every model through the gateway')}`);
|
|
130
|
+
note('');
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const gateway = store.active(mode);
|
|
135
|
+
note(`${cyan(badge(`⇄ ${mode.toUpperCase()} MODE`))} ${bold(gateway?.url ?? 'no url stored')}`);
|
|
136
|
+
note(dim(`every model goes through the gateway with ${gateway ? keyId(gateway) : 'no key'}; ` +
|
|
137
|
+
'vendor keys are not used — zen key mode direct to stop'));
|
|
138
|
+
note('');
|
|
139
|
+
}
|
|
92
140
|
/**
|
|
93
141
|
* Checking is a network round trip per key, so it says which ones have come
|
|
94
142
|
* back. Silence for ten seconds is indistinguishable from a hang.
|
|
@@ -123,6 +171,7 @@ const ls = async (ctx, args) => {
|
|
|
123
171
|
}
|
|
124
172
|
if (ctx.json) {
|
|
125
173
|
json({
|
|
174
|
+
mode: store.mode,
|
|
126
175
|
keys: store.entries.map((e) => ({
|
|
127
176
|
...e,
|
|
128
177
|
value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
|
|
@@ -145,8 +194,12 @@ const ls = async (ctx, args) => {
|
|
|
145
194
|
note(dim('add one: zen key add openai'));
|
|
146
195
|
return;
|
|
147
196
|
}
|
|
197
|
+
modeLine(store);
|
|
148
198
|
writeAll(rows(store, borrowed));
|
|
149
|
-
const missing = OWNERS.filter((p) => !store.active(p) &&
|
|
199
|
+
const missing = OWNERS.filter((p) => !store.active(p) &&
|
|
200
|
+
!borrowed.some(([c]) => c.provider === p) &&
|
|
201
|
+
(!isGateway(p) || store.mode === p) &&
|
|
202
|
+
(store.mode === 'direct' || SHAPES[p].kind !== 'model'));
|
|
150
203
|
if (missing.length) {
|
|
151
204
|
note('');
|
|
152
205
|
note(dim(`no key for: ${missing.join(', ')}`));
|
|
@@ -164,8 +217,9 @@ const add = async (ctx, args) => {
|
|
|
164
217
|
// Prefixed because every other command's --project is a Zenera one.
|
|
165
218
|
'gcp-project': { type: 'string' },
|
|
166
219
|
'gcp-location': { type: 'string' },
|
|
220
|
+
url: { type: 'string' },
|
|
167
221
|
}, 'zen key add <provider>[/name] [--name <name>] [--gcp-project <id>] ' +
|
|
168
|
-
'[--gcp-location <region>] [--no-check]');
|
|
222
|
+
'[--gcp-location <region>] [--url <gateway url>] [--no-check]');
|
|
169
223
|
const ref = positionals[0];
|
|
170
224
|
if (!ref) {
|
|
171
225
|
throw usageError('which provider?', `one of: ${OWNERS.join(', ')}`);
|
|
@@ -177,6 +231,7 @@ const add = async (ctx, args) => {
|
|
|
177
231
|
if ((values['gcp-project'] || values['gcp-location']) && provider !== 'vertex') {
|
|
178
232
|
throw usageError(`--gcp-project and --gcp-location mean nothing to ${shape.label}`, 'they configure a Vertex service account');
|
|
179
233
|
}
|
|
234
|
+
const url = gatewayUrl(provider, values.url);
|
|
180
235
|
// Checked before the prompt, so a typo does not cost you pasting the key.
|
|
181
236
|
const region = values['gcp-location'];
|
|
182
237
|
if (region !== undefined && !checkRegion(region)) {
|
|
@@ -208,6 +263,7 @@ const add = async (ctx, args) => {
|
|
|
208
263
|
const entry = store.add(provider, name, raw, {
|
|
209
264
|
project: values['gcp-project'],
|
|
210
265
|
location: values['gcp-location'],
|
|
266
|
+
url,
|
|
211
267
|
});
|
|
212
268
|
// Verified before it is trusted, but stored either way: a key that cannot
|
|
213
269
|
// be checked right now — offline, behind a proxy — is not a key that is
|
|
@@ -249,6 +305,9 @@ const add = async (ctx, args) => {
|
|
|
249
305
|
if (process.env[envOf(entry)]) {
|
|
250
306
|
note(yellow(`$${envOf(entry)} is set and will win over this`));
|
|
251
307
|
}
|
|
308
|
+
if (isGateway(provider) && store.mode !== provider) {
|
|
309
|
+
note(dim(`not in use yet — send every model through it with: zen key mode ${provider}`));
|
|
310
|
+
}
|
|
252
311
|
if (provider === 'vertex' && entry.holds === 'file' && !entry.project) {
|
|
253
312
|
const found = store.projectOf(entry);
|
|
254
313
|
note(dim(found
|
|
@@ -256,6 +315,36 @@ const add = async (ctx, args) => {
|
|
|
256
315
|
: 'no --gcp-project given, and the key file names no project_id'));
|
|
257
316
|
}
|
|
258
317
|
};
|
|
318
|
+
/**
|
|
319
|
+
* A gateway is nothing without its address, and nothing else has one. Only
|
|
320
|
+
* http(s) is taken: the url ends up as an SDK base url, where anything else
|
|
321
|
+
* fails three frames down.
|
|
322
|
+
*/
|
|
323
|
+
function gatewayUrl(provider, raw) {
|
|
324
|
+
if (!isGateway(provider)) {
|
|
325
|
+
if (raw !== undefined) {
|
|
326
|
+
throw usageError(`--url means nothing to ${SHAPES[provider].label}`, 'it locates a gateway');
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
if (!raw) {
|
|
331
|
+
throw usageError(`${SHAPES[provider].label} needs --url`, `e.g. zen key add ${provider} --url https://bifrost.example.com`);
|
|
332
|
+
}
|
|
333
|
+
let url;
|
|
334
|
+
try {
|
|
335
|
+
url = new URL(raw);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
throw usageError(`"${raw}" is not a url`);
|
|
339
|
+
}
|
|
340
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
341
|
+
throw usageError(`"${raw}" is not an http(s) url`);
|
|
342
|
+
}
|
|
343
|
+
if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
|
344
|
+
note(yellow('plain http: the virtual key will cross the network unencrypted'));
|
|
345
|
+
}
|
|
346
|
+
return url.href.replace(/\/+$/, '');
|
|
347
|
+
}
|
|
259
348
|
/**
|
|
260
349
|
* A provider with two forms cannot ask for one of them. It asks with the echo
|
|
261
350
|
* off, because one of the two answers is a secret and there is no way to know
|
|
@@ -294,6 +383,31 @@ const use = async (ctx, args) => {
|
|
|
294
383
|
}
|
|
295
384
|
note(`${green('using')} ${bold(keyId(entry))} for ${SHAPES[provider].label}`);
|
|
296
385
|
};
|
|
386
|
+
/** Bare, it says which mode a run would use; with a word, it switches. */
|
|
387
|
+
const mode = async (ctx, args) => {
|
|
388
|
+
const { positionals } = parse(args, {}, `zen key mode [${MODES.join('|')}]`);
|
|
389
|
+
const wanted = positionals[0];
|
|
390
|
+
const store = await KeyStore.open();
|
|
391
|
+
if (wanted !== undefined) {
|
|
392
|
+
if (!MODES.includes(wanted)) {
|
|
393
|
+
throw usageError(`unknown mode "${wanted}"`, `one of: ${MODES.join(', ')}`);
|
|
394
|
+
}
|
|
395
|
+
store.setMode(wanted);
|
|
396
|
+
store.save();
|
|
397
|
+
}
|
|
398
|
+
const now = store.mode;
|
|
399
|
+
const gateway = now === 'direct' ? undefined : store.active(now);
|
|
400
|
+
if (ctx.json) {
|
|
401
|
+
json({ mode: now, ...(gateway ? { key: keyId(gateway), url: gateway.url } : {}) });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!gateway) {
|
|
405
|
+
note(`${cyan('direct')} ${dim('— each provider is called with its own key')}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
note(`${cyan(now)} ${dim('— every model goes through')} ${gateway.url} ` +
|
|
409
|
+
dim(`with ${keyId(gateway)}`));
|
|
410
|
+
};
|
|
297
411
|
const check = async (ctx, args) => {
|
|
298
412
|
const { positionals } = parse(args, {}, 'zen key check [provider[/name]]');
|
|
299
413
|
const store = await KeyStore.open();
|
|
@@ -404,6 +518,7 @@ const show = async (ctx, args) => {
|
|
|
404
518
|
? [[dim('gcp project'), `${project.id} ${dim(ORIGIN[project.from])}`]]
|
|
405
519
|
: []),
|
|
406
520
|
...(entry.location ? [[dim('gcp location'), entry.location]] : []),
|
|
521
|
+
...(entry.url ? [[dim('url'), entry.url]] : []),
|
|
407
522
|
[dim('state'), state(entry)],
|
|
408
523
|
...(entry.check?.fix ? [[dim('fix'), entry.check.fix]] : []),
|
|
409
524
|
[dim('added'), ago(entry.addedAt)],
|
|
@@ -424,11 +539,18 @@ const env = async (ctx, args) => {
|
|
|
424
539
|
// Ignore what is already exported: the point is to produce the exports.
|
|
425
540
|
const vars = {};
|
|
426
541
|
for (const provider of only ?? OWNERS) {
|
|
542
|
+
// Unasked-for, a gateway is exported only in its own mode, where it is the switch.
|
|
543
|
+
if (!only && isGateway(provider) && store.mode !== provider) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
427
546
|
const entry = store.active(provider);
|
|
428
547
|
if (!entry) {
|
|
429
548
|
continue;
|
|
430
549
|
}
|
|
431
550
|
vars[envOf(entry)] = store.reveal(entry);
|
|
551
|
+
if (entry.url) {
|
|
552
|
+
vars[GATEWAY_URL_ENV] = entry.url;
|
|
553
|
+
}
|
|
432
554
|
// A service account says which project it belongs to but not which
|
|
433
555
|
// region to call, and a shell that has the file and not the region is
|
|
434
556
|
// still a shell that cannot reach a model.
|
|
@@ -498,6 +620,7 @@ const SUBS = {
|
|
|
498
620
|
list: ls,
|
|
499
621
|
add,
|
|
500
622
|
use,
|
|
623
|
+
mode,
|
|
501
624
|
check,
|
|
502
625
|
rm,
|
|
503
626
|
remove: rm,
|
|
@@ -516,14 +639,22 @@ export const key = {
|
|
|
516
639
|
'',
|
|
517
640
|
'Model providers: openai, anthropic, google, vertex, openrouter.',
|
|
518
641
|
'Services the tools call: exa.',
|
|
642
|
+
`Gateways every model can go through: ${GATEWAYS.join(', ')}.`,
|
|
519
643
|
'',
|
|
520
644
|
'Vertex takes either shape: a service-account JSON file, which wants',
|
|
521
645
|
'--gcp-project and --gcp-location too, or an express-mode API key, which',
|
|
522
646
|
'wants neither. Which one you gave is read off the value.',
|
|
523
647
|
'',
|
|
648
|
+
'A gateway holds one virtual key and its --url. In its mode every model',
|
|
649
|
+
'goes through it, each in its own vendor protocol, and agents.yaml does',
|
|
650
|
+
'not change: vertex:gemini-3.5-flash reaches <url>/genai as',
|
|
651
|
+
'vertex/gemini-3.5-flash. Vendor keys are withheld from runs meanwhile.',
|
|
652
|
+
'',
|
|
524
653
|
' zen key ls [--check] Everything stored, and its state.',
|
|
525
654
|
' zen key add <provider>[/name] Read a key from stdin, or ask for it.',
|
|
655
|
+
' zen key add bifrost --url <url> A gateway key, and where the gateway is.',
|
|
526
656
|
' zen key use <provider>/<name> Choose which one a run uses.',
|
|
657
|
+
' zen key mode [direct|bifrost] Call vendors directly, or via the gateway.',
|
|
527
658
|
' zen key check [provider[/name]] Ask the provider whether it still works.',
|
|
528
659
|
' zen key rm <provider>/<name> Forget one.',
|
|
529
660
|
' zen key show <ref> [--reveal] Masked by default.',
|
package/dist/commands/meta.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type Candidate } from '../candidates.ts';
|
|
1
2
|
import type { Command } from '../command.ts';
|
|
2
3
|
export declare const meta: Command;
|
|
4
|
+
/** `args` is what came after `meta`, without the word being completed. */
|
|
5
|
+
export declare function completeMeta(args: readonly string[], cur: string, cwd: string): Promise<Candidate[]>;
|
|
3
6
|
//# sourceMappingURL=meta.d.ts.map
|