@zenera/cli 1.1.0
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/LICENSE +21 -0
- package/README.md +239 -0
- package/dist/args.d.ts +40 -0
- package/dist/args.js +99 -0
- package/dist/audit.d.ts +53 -0
- package/dist/audit.js +144 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +103 -0
- package/dist/command.d.ts +14 -0
- package/dist/command.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +287 -0
- package/dist/commands/index.d.ts +22 -0
- package/dist/commands/index.js +56 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.js +158 -0
- package/dist/commands/key.d.ts +3 -0
- package/dist/commands/key.js +335 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/models.d.ts +9 -0
- package/dist/commands/models.js +120 -0
- package/dist/commands/open.d.ts +9 -0
- package/dist/commands/open.js +270 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +167 -0
- package/dist/commands/sandbox.d.ts +3 -0
- package/dist/commands/sandbox.js +112 -0
- package/dist/commands/version.d.ts +6 -0
- package/dist/commands/version.js +39 -0
- package/dist/engine.d.ts +49 -0
- package/dist/engine.js +208 -0
- package/dist/external.d.ts +10 -0
- package/dist/external.js +56 -0
- package/dist/home.d.ts +31 -0
- package/dist/home.js +108 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/keys.d.ts +124 -0
- package/dist/keys.js +309 -0
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +31 -0
- package/dist/liveness.d.ts +23 -0
- package/dist/liveness.js +221 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +155 -0
- package/dist/narrate.d.ts +19 -0
- package/dist/narrate.js +124 -0
- package/dist/podman.d.ts +46 -0
- package/dist/podman.js +254 -0
- package/dist/projects.d.ts +70 -0
- package/dist/projects.js +232 -0
- package/dist/resolve.d.ts +27 -0
- package/dist/resolve.js +138 -0
- package/dist/sandbox.d.ts +36 -0
- package/dist/sandbox.js +104 -0
- package/dist/scaffold.d.ts +29 -0
- package/dist/scaffold.js +220 -0
- package/dist/session.d.ts +77 -0
- package/dist/session.js +156 -0
- package/dist/term.d.ts +69 -0
- package/dist/term.js +242 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +257 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +134 -0
- package/dist/tui/wrap.d.ts +12 -0
- package/dist/tui/wrap.js +62 -0
- package/dist/validate.d.ts +145 -0
- package/dist/validate.js +959 -0
- package/package.json +76 -0
- package/templates/.github/copilot-instructions.md +1579 -0
- package/templates/.github/prompts/new-agent.prompt.md +38 -0
- package/templates/.github/prompts/new-skill.prompt.md +37 -0
- package/templates/.github/prompts/review-project.prompt.md +31 -0
- package/templates/.github/skills/zen-cli/SKILL.md +110 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { extname, normalize, resolve, sep } from 'node:path';
|
|
6
|
+
import { assertState, buildRunReport, FilePayloadStore, PayloadResolver, renderReportHtml, } from '@zenera/neo';
|
|
7
|
+
import { parse } from "../args.js";
|
|
8
|
+
import { project as resolveProject } from "../resolve.js";
|
|
9
|
+
import { display, newestRun, newestSession, requireSession, runPaths, sessionPaths, } from "../session.js";
|
|
10
|
+
import { bold, cyan, dim, invalidError, json, note, write } from "../term.js";
|
|
11
|
+
const USAGE = 'zen inspect [run] [--session <id>] [--open] [--rebuild] [--serve [port]]';
|
|
12
|
+
export const inspect = {
|
|
13
|
+
summary: "Open or rebuild a run's report.html.",
|
|
14
|
+
usage: USAGE,
|
|
15
|
+
details: [
|
|
16
|
+
'With no arguments: the newest run of the newest session.',
|
|
17
|
+
'--serve starts a local server, which the report needs for its assets.',
|
|
18
|
+
],
|
|
19
|
+
run: async (ctx) => {
|
|
20
|
+
const { values, positionals } = parse(ctx.args, {
|
|
21
|
+
project: { type: 'string' },
|
|
22
|
+
session: { type: 'string' },
|
|
23
|
+
open: { type: 'boolean' },
|
|
24
|
+
rebuild: { type: 'boolean' },
|
|
25
|
+
serve: { type: 'string' },
|
|
26
|
+
}, USAGE);
|
|
27
|
+
const found = await resolveProject({ cwd: ctx.cwd, project: values.project });
|
|
28
|
+
const dir = found.dir;
|
|
29
|
+
const session = pickSession(dir, values.session);
|
|
30
|
+
const run = pickRun(session, positionals[0]);
|
|
31
|
+
if (values.rebuild || !existsSync(run.report)) {
|
|
32
|
+
await rebuild(session, run);
|
|
33
|
+
}
|
|
34
|
+
if (ctx.json) {
|
|
35
|
+
json({ session: session.id, run: run.id, report: run.report });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (values.serve !== undefined) {
|
|
39
|
+
await serve(run, Number(values.serve) || 0, Boolean(values.open));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
write(run.report);
|
|
43
|
+
note(`${bold(run.id)} ${dim(display(run.report, ctx.cwd))}`);
|
|
44
|
+
if (values.open) {
|
|
45
|
+
reveal(`file://${run.report}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
note(dim(`open it: ${cyan('zen inspect --open')}`));
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Choosing what to show
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function pickSession(dir, asked) {
|
|
56
|
+
if (asked) {
|
|
57
|
+
return requireSession(dir, asked);
|
|
58
|
+
}
|
|
59
|
+
const newest = newestSession(dir);
|
|
60
|
+
if (!newest) {
|
|
61
|
+
throw invalidError('nothing has been run here yet', 'start one: zen run');
|
|
62
|
+
}
|
|
63
|
+
return sessionPaths(dir, newest);
|
|
64
|
+
}
|
|
65
|
+
function pickRun(session, asked) {
|
|
66
|
+
const id = asked ?? newestRun(session);
|
|
67
|
+
if (!id) {
|
|
68
|
+
throw invalidError(`session ${session.id} has no runs`);
|
|
69
|
+
}
|
|
70
|
+
const run = runPaths(session, id);
|
|
71
|
+
if (!existsSync(run.dir)) {
|
|
72
|
+
throw invalidError(`no run ${id} in session ${session.id}`);
|
|
73
|
+
}
|
|
74
|
+
return run;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A report is derived, so it can always be thrown away and remade from the run
|
|
78
|
+
* state — which is what makes `--rebuild` safe and what makes an old run
|
|
79
|
+
* readable by a newer renderer.
|
|
80
|
+
*/
|
|
81
|
+
async function rebuild(session, run) {
|
|
82
|
+
if (!existsSync(run.state)) {
|
|
83
|
+
throw invalidError(`run ${run.id} has no state to rebuild from`);
|
|
84
|
+
}
|
|
85
|
+
let state;
|
|
86
|
+
try {
|
|
87
|
+
state = assertState(JSON.parse(await readFile(run.state, 'utf8')));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw invalidError(`${run.state}: ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
}
|
|
92
|
+
const payloads = new PayloadResolver(new FilePayloadStore({ dir: session.blobs, id: 'file' }));
|
|
93
|
+
const report = await buildRunReport(state, payloads, { title: run.id });
|
|
94
|
+
await writeFile(run.report, renderReportHtml(report), 'utf8');
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Serving
|
|
98
|
+
//
|
|
99
|
+
// `file://` is enough for the report itself, but not for anything it fetches:
|
|
100
|
+
// browsers treat every local file as its own origin. A server exists only so
|
|
101
|
+
// those requests resolve, and so it binds to the loopback address — a run
|
|
102
|
+
// report is a transcript of everything the model was sent.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
const TYPES = {
|
|
105
|
+
'.html': 'text/html; charset=utf-8',
|
|
106
|
+
'.json': 'application/json; charset=utf-8',
|
|
107
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
108
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
109
|
+
};
|
|
110
|
+
async function serve(run, port, open) {
|
|
111
|
+
const root = resolve(run.dir);
|
|
112
|
+
const server = createServer((req, res) => {
|
|
113
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
114
|
+
const rel = decodeURIComponent(url.pathname);
|
|
115
|
+
const path = rel === '/' ? run.report : resolve(root, `.${normalize(rel)}`);
|
|
116
|
+
// Containment, not obscurity: anything resolving outside the run
|
|
117
|
+
// directory is refused, so a crafted path cannot walk to $HOME.
|
|
118
|
+
if (path !== root && !path.startsWith(root + sep)) {
|
|
119
|
+
res.writeHead(403).end('forbidden');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync(path)) {
|
|
123
|
+
res.writeHead(404).end('not found');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' });
|
|
127
|
+
createReadStream(path).pipe(res);
|
|
128
|
+
});
|
|
129
|
+
await new Promise((ok) => server.listen(port, '127.0.0.1', ok));
|
|
130
|
+
const address = server.address();
|
|
131
|
+
const at = typeof address === 'object' && address ? `http://127.0.0.1:${address.port}/` : '';
|
|
132
|
+
write(at);
|
|
133
|
+
note(`${bold('serving')} ${dim(display(run.dir))}`);
|
|
134
|
+
note(dim('ctrl-c to stop'));
|
|
135
|
+
if (open) {
|
|
136
|
+
reveal(at);
|
|
137
|
+
}
|
|
138
|
+
await new Promise((done) => {
|
|
139
|
+
const stop = () => {
|
|
140
|
+
server.close(() => done());
|
|
141
|
+
};
|
|
142
|
+
process.once('SIGINT', stop);
|
|
143
|
+
process.once('SIGTERM', stop);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Handing a URL to the platform opener. `spawn` without a shell, so the path
|
|
148
|
+
* is an argument rather than something a shell gets to interpret.
|
|
149
|
+
*/
|
|
150
|
+
function reveal(target) {
|
|
151
|
+
const command = process.platform === 'darwin'
|
|
152
|
+
? 'open'
|
|
153
|
+
: process.platform === 'win32'
|
|
154
|
+
? 'explorer'
|
|
155
|
+
: 'xdg-open';
|
|
156
|
+
spawn(command, [target], { stdio: 'ignore', detached: true }).unref();
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=inspect.js.map
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { parse } from "../args.js";
|
|
3
|
+
import { ensureHome } from "../home.js";
|
|
4
|
+
import { assertNotEmpty, describe, keyId, KeyStore, mask, OWNERS, parseRef, SHAPES, } from "../keys.js";
|
|
5
|
+
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]';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Display
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const MARK = {
|
|
12
|
+
live: green('live'),
|
|
13
|
+
dead: red('dead'),
|
|
14
|
+
unknown: dim('unknown'),
|
|
15
|
+
};
|
|
16
|
+
function state(entry) {
|
|
17
|
+
return entry.check ? MARK[entry.check.state] : dim('unchecked');
|
|
18
|
+
}
|
|
19
|
+
function rows(store) {
|
|
20
|
+
const out = [
|
|
21
|
+
[bold(''), bold('KEY'), bold('VALUE'), bold('STATE'), bold('CHECKED')],
|
|
22
|
+
];
|
|
23
|
+
for (const provider of OWNERS) {
|
|
24
|
+
for (const entry of store.for(provider)) {
|
|
25
|
+
const shadowed = Boolean(process.env[SHAPES[provider].env]);
|
|
26
|
+
out.push([
|
|
27
|
+
store.isActive(entry) ? green('*') : ' ',
|
|
28
|
+
keyId(entry),
|
|
29
|
+
dim(describe(store, entry)),
|
|
30
|
+
state(entry),
|
|
31
|
+
dim(entry.check ? ago(entry.check.at) : '—') +
|
|
32
|
+
(shadowed && store.isActive(entry)
|
|
33
|
+
? yellow(` shadowed by $${SHAPES[provider].env}`)
|
|
34
|
+
: ''),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return table(out);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Checking is a network round trip per key, so it says which one it is waiting
|
|
42
|
+
* on. Silence for ten seconds is indistinguishable from a hang.
|
|
43
|
+
*/
|
|
44
|
+
async function checkAll(ctx, store, targets) {
|
|
45
|
+
const bar = ctx.json ? undefined : progress();
|
|
46
|
+
try {
|
|
47
|
+
return await probeAll(store, targets, (entry, index, total) => bar?.update(dim(`checking ${keyId(entry)} … ${index + 1}/${total}`)));
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
bar?.done();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ls = async (ctx, args) => {
|
|
54
|
+
const { values } = parse(args, { check: { type: 'boolean' } }, USAGE);
|
|
55
|
+
const store = await KeyStore.open();
|
|
56
|
+
if (values.check) {
|
|
57
|
+
const checks = await checkAll(ctx, store, store.entries);
|
|
58
|
+
for (const [entry, check] of checks) {
|
|
59
|
+
store.record(entry, check);
|
|
60
|
+
}
|
|
61
|
+
store.save();
|
|
62
|
+
}
|
|
63
|
+
if (ctx.json) {
|
|
64
|
+
json(store.entries.map((e) => ({
|
|
65
|
+
...e,
|
|
66
|
+
value: e.holds === 'file' ? store.fileOf(e) : mask(e.value),
|
|
67
|
+
active: store.isActive(e),
|
|
68
|
+
env: SHAPES[e.provider].env,
|
|
69
|
+
})));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (store.entries.length === 0) {
|
|
73
|
+
note('the keyring is empty');
|
|
74
|
+
note(dim('add one: zen key add openai'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
writeAll(rows(store));
|
|
78
|
+
const missing = OWNERS.filter((p) => !store.active(p) && !process.env[SHAPES[p].env]);
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
note('');
|
|
81
|
+
note(dim(`no key for: ${missing.join(', ')}`));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The secret never comes from argv. A command line is visible in `ps`, lands in
|
|
86
|
+
* shell history and is captured by CI logs — three places a key must not be.
|
|
87
|
+
* Piped stdin or an echo-off prompt are the only two ways in.
|
|
88
|
+
*/
|
|
89
|
+
const add = async (ctx, args) => {
|
|
90
|
+
const { values, positionals } = parse(args, { name: { type: 'string' }, 'no-check': { type: 'boolean' } }, 'zen key add <provider>[/name] [--name <name>] [--no-check]');
|
|
91
|
+
const ref = positionals[0];
|
|
92
|
+
if (!ref) {
|
|
93
|
+
throw usageError('which provider?', `one of: ${OWNERS.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
const parsed = parseRef(ref);
|
|
96
|
+
const provider = parsed.provider;
|
|
97
|
+
const name = values.name ?? parsed.name ?? 'default';
|
|
98
|
+
const shape = SHAPES[provider];
|
|
99
|
+
ensureHome();
|
|
100
|
+
const store = await KeyStore.open();
|
|
101
|
+
if (store.find(provider, name) && isInteractive()) {
|
|
102
|
+
if (!(await confirm(`Replace ${provider}/${name}?`))) {
|
|
103
|
+
throw usageError('cancelled');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const raw = (await readStdin()) ?? (await promptFor(shape.holds, shape.label, shape.where));
|
|
107
|
+
if (!raw) {
|
|
108
|
+
throw usageError('no value given');
|
|
109
|
+
}
|
|
110
|
+
if (shape.holds === 'file' && !existsSync(raw)) {
|
|
111
|
+
throw usageError(`no such file: ${raw}`);
|
|
112
|
+
}
|
|
113
|
+
const entry = store.add(provider, name, raw);
|
|
114
|
+
// Verified before it is trusted, but stored either way: a key that cannot
|
|
115
|
+
// be checked right now — offline, behind a proxy — is not a key that is
|
|
116
|
+
// wrong, and refusing to save it would make `zen key add` fail on a plane.
|
|
117
|
+
if (!values['no-check']) {
|
|
118
|
+
const bar = ctx.json ? undefined : progress();
|
|
119
|
+
bar?.update(dim(`checking ${keyId(entry)} …`));
|
|
120
|
+
const check = await probe(store, entry);
|
|
121
|
+
bar?.done();
|
|
122
|
+
store.record(entry, check);
|
|
123
|
+
if (check.state === 'dead') {
|
|
124
|
+
note(`${red('rejected')} ${check.detail ?? 'the provider refused this key'}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
store.save();
|
|
128
|
+
if (ctx.json) {
|
|
129
|
+
json({
|
|
130
|
+
key: keyId(entry),
|
|
131
|
+
env: shape.env,
|
|
132
|
+
active: store.isActive(entry),
|
|
133
|
+
check: entry.check,
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
note(`${green('added')} ${bold(keyId(entry))} ${dim(describe(store, entry))} ${state(entry)}`);
|
|
138
|
+
if (process.env[shape.env]) {
|
|
139
|
+
note(yellow(`$${shape.env} is set and will win over this`));
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
async function promptFor(holds, label, where) {
|
|
143
|
+
note(dim(`${label} — ${where}`));
|
|
144
|
+
return holds === 'file'
|
|
145
|
+
? ask('Path to the credentials file:')
|
|
146
|
+
: askSecret('Paste the key (it will not be shown):');
|
|
147
|
+
}
|
|
148
|
+
const use = async (ctx, args) => {
|
|
149
|
+
const { positionals } = parse(args, {}, 'zen key use <provider>/<name>');
|
|
150
|
+
const ref = positionals[0];
|
|
151
|
+
if (!ref) {
|
|
152
|
+
throw usageError('which key?', 'see: zen key ls');
|
|
153
|
+
}
|
|
154
|
+
const { provider, name } = parseRef(ref);
|
|
155
|
+
if (!name) {
|
|
156
|
+
throw usageError(`"${ref}" names a provider, not a key`, 'use provider/name');
|
|
157
|
+
}
|
|
158
|
+
const store = await KeyStore.open();
|
|
159
|
+
const entry = store.use(provider, name);
|
|
160
|
+
store.save();
|
|
161
|
+
if (ctx.json) {
|
|
162
|
+
json({ key: keyId(entry), env: SHAPES[provider].env });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
note(`${green('using')} ${bold(keyId(entry))} for ${SHAPES[provider].label}`);
|
|
166
|
+
};
|
|
167
|
+
const check = async (ctx, args) => {
|
|
168
|
+
const { positionals } = parse(args, {}, 'zen key check [provider[/name]]');
|
|
169
|
+
const store = await KeyStore.open();
|
|
170
|
+
assertNotEmpty(store);
|
|
171
|
+
const targets = positionals[0] ? select(store, positionals[0]) : store.entries;
|
|
172
|
+
const checks = await checkAll(ctx, store, targets);
|
|
173
|
+
for (const [entry, result] of checks) {
|
|
174
|
+
store.record(entry, result);
|
|
175
|
+
}
|
|
176
|
+
store.save();
|
|
177
|
+
if (ctx.json) {
|
|
178
|
+
json(checks.map(([entry, result]) => ({ key: keyId(entry), ...result })));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
writeAll(table(checks.map(([entry, result]) => [
|
|
182
|
+
keyId(entry),
|
|
183
|
+
MARK[result.state],
|
|
184
|
+
dim(result.detail ?? ''),
|
|
185
|
+
])));
|
|
186
|
+
if (checks.some(([, r]) => r.state === 'dead')) {
|
|
187
|
+
throw credentialError('at least one key was refused');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
function select(store, ref) {
|
|
191
|
+
const { provider, name } = parseRef(ref);
|
|
192
|
+
if (!name) {
|
|
193
|
+
return store.for(provider);
|
|
194
|
+
}
|
|
195
|
+
const entry = store.find(provider, name);
|
|
196
|
+
if (!entry) {
|
|
197
|
+
throw usageError(`no key ${ref}`, 'see: zen key ls');
|
|
198
|
+
}
|
|
199
|
+
return [entry];
|
|
200
|
+
}
|
|
201
|
+
const rm = async (ctx, args) => {
|
|
202
|
+
const { values, positionals } = parse(args, { yes: { type: 'boolean' } }, 'zen key rm <provider>/<name> [--yes]');
|
|
203
|
+
const ref = positionals[0];
|
|
204
|
+
if (!ref) {
|
|
205
|
+
throw usageError('which key?', 'see: zen key ls');
|
|
206
|
+
}
|
|
207
|
+
const { provider, name } = parseRef(ref);
|
|
208
|
+
if (!name) {
|
|
209
|
+
throw usageError(`"${ref}" names a provider, not a key`, 'use provider/name');
|
|
210
|
+
}
|
|
211
|
+
const store = await KeyStore.open();
|
|
212
|
+
if (!store.find(provider, name)) {
|
|
213
|
+
throw usageError(`no key ${ref}`, 'see: zen key ls');
|
|
214
|
+
}
|
|
215
|
+
if (!values.yes && isInteractive() && !(await confirm(`Forget ${ref}?`))) {
|
|
216
|
+
throw usageError('cancelled');
|
|
217
|
+
}
|
|
218
|
+
store.remove(provider, name);
|
|
219
|
+
store.save();
|
|
220
|
+
// The stored file is left behind on purpose: it was copied from somewhere,
|
|
221
|
+
// and deleting a service-account key nobody asked us to delete is not the
|
|
222
|
+
// CLI's call to make.
|
|
223
|
+
if (ctx.json) {
|
|
224
|
+
json({ removed: ref });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
note(`${green('forgot')} ${ref}`);
|
|
228
|
+
};
|
|
229
|
+
/** The one deliberate way a secret leaves the store, and it has to be asked for. */
|
|
230
|
+
const show = async (ctx, args) => {
|
|
231
|
+
const { values, positionals } = parse(args, { reveal: { type: 'boolean' } }, 'zen key show <provider>[/name] [--reveal]');
|
|
232
|
+
const ref = positionals[0];
|
|
233
|
+
if (!ref) {
|
|
234
|
+
throw usageError('which key?', 'see: zen key ls');
|
|
235
|
+
}
|
|
236
|
+
const store = await KeyStore.open();
|
|
237
|
+
const entry = select(store, ref)[0];
|
|
238
|
+
if (!entry) {
|
|
239
|
+
throw usageError(`no key ${ref}`, 'see: zen key ls');
|
|
240
|
+
}
|
|
241
|
+
const value = values.reveal ? store.reveal(entry) : describe(store, entry);
|
|
242
|
+
if (ctx.json) {
|
|
243
|
+
json({
|
|
244
|
+
key: keyId(entry),
|
|
245
|
+
env: SHAPES[entry.provider].env,
|
|
246
|
+
value,
|
|
247
|
+
revealed: Boolean(values.reveal),
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (values.reveal) {
|
|
252
|
+
// stdout, alone, unstyled — so `zen key show openai --reveal | pbcopy`
|
|
253
|
+
// copies the key and nothing else.
|
|
254
|
+
write(value);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
writeAll(table([
|
|
258
|
+
[dim('key'), keyId(entry)],
|
|
259
|
+
[dim('env'), SHAPES[entry.provider].env],
|
|
260
|
+
[dim('value'), value],
|
|
261
|
+
[dim('state'), state(entry)],
|
|
262
|
+
[dim('added'), ago(entry.addedAt)],
|
|
263
|
+
]));
|
|
264
|
+
note(dim('--reveal prints the secret itself'));
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* `eval "$(zen key env)"` puts the active keys into a shell — for the tools that
|
|
268
|
+
* are not `zen`. Real environment variables still win inside `zen` itself, so
|
|
269
|
+
* this changes nothing about how a run resolves credentials.
|
|
270
|
+
*/
|
|
271
|
+
const env = async (ctx, args) => {
|
|
272
|
+
const { positionals } = parse(args, {}, 'zen key env [provider …]');
|
|
273
|
+
const store = await KeyStore.open();
|
|
274
|
+
const only = positionals.length
|
|
275
|
+
? positionals.map((p) => parseRef(p).provider)
|
|
276
|
+
: undefined;
|
|
277
|
+
// Ignore what is already exported: the point is to produce the exports.
|
|
278
|
+
const vars = {};
|
|
279
|
+
for (const provider of only ?? OWNERS) {
|
|
280
|
+
const entry = store.active(provider);
|
|
281
|
+
if (entry) {
|
|
282
|
+
vars[SHAPES[provider].env] = store.reveal(entry);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (ctx.json) {
|
|
286
|
+
json(vars);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
290
|
+
write(`export ${name}=${shellQuote(value)}`);
|
|
291
|
+
}
|
|
292
|
+
if (Object.keys(vars).length === 0) {
|
|
293
|
+
note(dim('nothing to export'));
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/** Single quotes, with the one escape single quotes cannot express. */
|
|
297
|
+
function shellQuote(value) {
|
|
298
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Dispatch
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
const SUBS = { ls, list: ls, add, use, check, rm, remove: rm, show, env };
|
|
304
|
+
export const key = {
|
|
305
|
+
summary: 'The credential store: add, choose, verify and export API keys.',
|
|
306
|
+
usage: USAGE,
|
|
307
|
+
details: [
|
|
308
|
+
'Keys live in ~/.zenera/neo/keys.json (0600) and are materialised into',
|
|
309
|
+
'the environment before a run. A real environment variable always wins.',
|
|
310
|
+
'',
|
|
311
|
+
'Model providers: openai, anthropic, google, vertex, openrouter.',
|
|
312
|
+
'Services the tools call: exa.',
|
|
313
|
+
'',
|
|
314
|
+
' zen key ls [--check] Everything stored, and its state.',
|
|
315
|
+
' zen key add <provider>[/name] Read a key from stdin, or ask for it.',
|
|
316
|
+
' zen key use <provider>/<name> Choose which one a run uses.',
|
|
317
|
+
' zen key check [provider[/name]] Ask the provider whether it still works.',
|
|
318
|
+
' zen key rm <provider>/<name> Forget one.',
|
|
319
|
+
' zen key show <ref> [--reveal] Masked by default.',
|
|
320
|
+
' zen key env [provider …] Shell exports, for other tools.',
|
|
321
|
+
],
|
|
322
|
+
run: async (ctx) => {
|
|
323
|
+
const [name, ...rest] = ctx.args;
|
|
324
|
+
if (!name) {
|
|
325
|
+
await ls(ctx, []);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const sub = SUBS[name];
|
|
329
|
+
if (!sub) {
|
|
330
|
+
throw usageError(`unknown: zen key ${name}`, `try: ${cyan(Object.keys(SUBS).slice(0, 7).join(', '))}`);
|
|
331
|
+
}
|
|
332
|
+
await sub(ctx, rest);
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
//# sourceMappingURL=key.js.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parse } from "../args.js";
|
|
2
|
+
// Run ids are timestamps by construction, so the newest id *is* the last-run
|
|
3
|
+
// time — no file has to be opened to learn it.
|
|
4
|
+
import { stampInstant } from "../ids.js";
|
|
5
|
+
import { openDir, Registry, summarize } from "../projects.js";
|
|
6
|
+
import { listSessions } from "../session.js";
|
|
7
|
+
import { ago, bold, count, dim, json, note, table, write, writeAll, yellow } from "../term.js";
|
|
8
|
+
const USAGE = 'zen list [--sessions] [--prune]';
|
|
9
|
+
export const list = {
|
|
10
|
+
summary: 'Every known project: sessions, last run, whether one is live.',
|
|
11
|
+
usage: USAGE,
|
|
12
|
+
details: [
|
|
13
|
+
'The registry is an index, not the truth. An entry whose directory has',
|
|
14
|
+
'gone away is shown dimmed rather than hidden; --prune forgets them.',
|
|
15
|
+
],
|
|
16
|
+
run: async (ctx) => {
|
|
17
|
+
const { values } = parse(ctx.args, { sessions: { type: 'boolean' }, prune: { type: 'boolean' } }, USAGE);
|
|
18
|
+
const registry = await Registry.open();
|
|
19
|
+
if (values.prune) {
|
|
20
|
+
const gone = registry.prune();
|
|
21
|
+
registry.save();
|
|
22
|
+
if (!ctx.json) {
|
|
23
|
+
note(gone.length ? `forgot ${count(gone.length, 'project')}` : 'nothing to prune');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const summaries = [];
|
|
27
|
+
for (const entry of registry.entries) {
|
|
28
|
+
summaries.push(await summarize(entry));
|
|
29
|
+
}
|
|
30
|
+
summaries.sort((a, b) => (b.lastRunAt ?? '').localeCompare(a.lastRunAt ?? ''));
|
|
31
|
+
if (ctx.json) {
|
|
32
|
+
json(values.sessions ? await withSessions(summaries) : summaries);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (summaries.length === 0) {
|
|
36
|
+
note('no projects yet');
|
|
37
|
+
note(dim('create one: zen init'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const rows = [
|
|
41
|
+
[bold('NAME'), bold('SESSIONS'), bold('RUNS'), bold('LAST'), bold('PATH')],
|
|
42
|
+
];
|
|
43
|
+
for (const s of summaries) {
|
|
44
|
+
const style = s.present ? (x) => x : dim;
|
|
45
|
+
rows.push([
|
|
46
|
+
style(s.name) + (s.busy ? yellow(' •') : ''),
|
|
47
|
+
style(String(s.sessions)),
|
|
48
|
+
style(String(s.runs)),
|
|
49
|
+
style(ago(s.lastRunAt ? stampInstant(s.lastRunAt) : undefined)),
|
|
50
|
+
dim(s.present ? s.path : `${s.path} (missing)`),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
writeAll(table(rows));
|
|
54
|
+
if (values.sessions) {
|
|
55
|
+
for (const s of summaries.filter((p) => p.present)) {
|
|
56
|
+
write('');
|
|
57
|
+
write(bold(s.name));
|
|
58
|
+
writeAll(await sessionRows(s));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Run ids are timestamps by construction, so the newest id *is* the last-run
|
|
65
|
+
* time — no file has to be opened to learn it.
|
|
66
|
+
*/
|
|
67
|
+
function stampToIso(id) {
|
|
68
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/.exec(id);
|
|
69
|
+
if (!m) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
73
|
+
return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)).toISOString();
|
|
74
|
+
}
|
|
75
|
+
async function sessionRows(summary) {
|
|
76
|
+
const project = await openDir(summary.path);
|
|
77
|
+
const sessions = await listSessions(project.dir);
|
|
78
|
+
if (sessions.length === 0) {
|
|
79
|
+
return [dim(' no sessions')];
|
|
80
|
+
}
|
|
81
|
+
return table(sessions.map((s) => [
|
|
82
|
+
` ${s.id}`,
|
|
83
|
+
s.title ?? dim('—'),
|
|
84
|
+
String(s.runs),
|
|
85
|
+
ago(s.lastRunAt ?? s.createdAt),
|
|
86
|
+
s.busy ? yellow('running') : '',
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
89
|
+
async function withSessions(summaries) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const s of summaries) {
|
|
92
|
+
if (!s.present) {
|
|
93
|
+
out.push({ ...s, sessionList: [] });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const project = await openDir(s.path);
|
|
97
|
+
out.push({ ...s, sessionList: await listSessions(project.dir) });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=list.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from '../command.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Everything a run would resolve, resolved — and nothing called. Loading a
|
|
4
|
+
* project constructs the model clients, so a config that names an impossible
|
|
5
|
+
* provider or an agent that hands off to nobody fails here, in a command that
|
|
6
|
+
* costs nothing, instead of three seconds into a run that costs money.
|
|
7
|
+
*/
|
|
8
|
+
export declare const models: Command;
|
|
9
|
+
//# sourceMappingURL=models.d.ts.map
|