@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
package/dist/liveness.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { EXA_BASE_URL, ModelRegistry } from '@zenera/neo';
|
|
2
|
+
import { SHAPES, } from "./keys.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Liveness
|
|
5
|
+
//
|
|
6
|
+
// The distinction that matters in the output is *dead* — the provider looked at
|
|
7
|
+
// the credential and said no — versus *unknown* — we could not ask. Collapsing
|
|
8
|
+
// them into one red mark is the classic way to send someone hunting for the
|
|
9
|
+
// wrong bug: rotating a perfectly good key because the office wifi was down.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** Words a provider uses when the credential itself is the problem. */
|
|
12
|
+
const REJECTED = [
|
|
13
|
+
'invalid_api_key',
|
|
14
|
+
'invalid api key',
|
|
15
|
+
'incorrect api key',
|
|
16
|
+
'authentication',
|
|
17
|
+
'unauthenticated',
|
|
18
|
+
'unauthorized',
|
|
19
|
+
'permission_denied',
|
|
20
|
+
'permission denied',
|
|
21
|
+
'api key not valid',
|
|
22
|
+
'could not load the default credentials',
|
|
23
|
+
];
|
|
24
|
+
/** Words that mean the question never arrived. */
|
|
25
|
+
const UNREACHED = [
|
|
26
|
+
'enotfound',
|
|
27
|
+
'econnrefused',
|
|
28
|
+
'econnreset',
|
|
29
|
+
'etimedout',
|
|
30
|
+
'eai_again',
|
|
31
|
+
'fetch failed',
|
|
32
|
+
'network',
|
|
33
|
+
'timeout',
|
|
34
|
+
'socket hang up',
|
|
35
|
+
];
|
|
36
|
+
function classify(err) {
|
|
37
|
+
const at = new Date().toISOString();
|
|
38
|
+
// OpenAI, Anthropic and the GenAI SDK all say `status`; OpenRouter's says
|
|
39
|
+
// `statusCode`. Reading only the first would classify a revoked key as
|
|
40
|
+
// *unknown*, which is the one confusion this module exists to prevent.
|
|
41
|
+
const e = err;
|
|
42
|
+
const status = e?.status ?? e?.statusCode;
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
const haystack = `${status ?? ''} ${message}`.toLowerCase();
|
|
45
|
+
if (status === 401 || status === 403) {
|
|
46
|
+
return { state: 'dead', at, detail: `${status} ${firstLine(message)}` };
|
|
47
|
+
}
|
|
48
|
+
if (REJECTED.some((needle) => haystack.includes(needle))) {
|
|
49
|
+
return { state: 'dead', at, detail: firstLine(message) };
|
|
50
|
+
}
|
|
51
|
+
if (UNREACHED.some((needle) => haystack.includes(needle))) {
|
|
52
|
+
return { state: 'unknown', at, detail: 'could not reach the provider' };
|
|
53
|
+
}
|
|
54
|
+
// 429 means the credential authenticated and then got rate limited, which
|
|
55
|
+
// is a live key having a bad day.
|
|
56
|
+
if (status === 429) {
|
|
57
|
+
return { state: 'live', at, detail: 'rate limited, but authenticated' };
|
|
58
|
+
}
|
|
59
|
+
return { state: 'unknown', at, detail: firstLine(message) };
|
|
60
|
+
}
|
|
61
|
+
const firstLine = (s) => s.split('\n')[0].slice(0, 160);
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Deadline
|
|
64
|
+
//
|
|
65
|
+
// Every SDK here retries, and some of them retry a connection that will never
|
|
66
|
+
// be answered — a proxy that swallows packets, a VPN half up. Left alone that
|
|
67
|
+
// is a `zen key check` which never returns, and a command that hangs teaches
|
|
68
|
+
// nobody anything. The credential question is one round trip; if it has not
|
|
69
|
+
// been answered by now, the honest answer is *unknown*.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
const DEADLINE_MS = 15_000;
|
|
72
|
+
class Deadline extends Error {
|
|
73
|
+
}
|
|
74
|
+
async function within(work) {
|
|
75
|
+
let timer;
|
|
76
|
+
try {
|
|
77
|
+
return await Promise.race([
|
|
78
|
+
work,
|
|
79
|
+
new Promise((_, reject) => {
|
|
80
|
+
timer = setTimeout(() => reject(new Deadline()), DEADLINE_MS);
|
|
81
|
+
// The abandoned request must not keep the process alive.
|
|
82
|
+
timer.unref?.();
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The cheapest authenticated call each SDK has. Nothing here reads a model or
|
|
92
|
+
* spends a token: the question is only whether the credential is accepted.
|
|
93
|
+
*
|
|
94
|
+
* The client is built through the library's own registry rather than by
|
|
95
|
+
* requiring the SDKs directly, so a missing optional dependency produces the
|
|
96
|
+
* library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
|
|
97
|
+
*/
|
|
98
|
+
export async function probe(store, entry) {
|
|
99
|
+
const shape = SHAPES[entry.provider];
|
|
100
|
+
if (shape.kind === 'service') {
|
|
101
|
+
return probeService(entry.provider, store.reveal(entry));
|
|
102
|
+
}
|
|
103
|
+
const previous = process.env[shape.env];
|
|
104
|
+
process.env[shape.env] = store.reveal(entry);
|
|
105
|
+
try {
|
|
106
|
+
const registry = new ModelRegistry();
|
|
107
|
+
registry.provider('probe', { kind: entry.provider });
|
|
108
|
+
await within(authenticate(entry.provider, registry.client('probe')));
|
|
109
|
+
return { state: 'live', at: new Date().toISOString() };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof Deadline) {
|
|
113
|
+
return {
|
|
114
|
+
state: 'unknown',
|
|
115
|
+
at: new Date().toISOString(),
|
|
116
|
+
detail: `no answer in ${DEADLINE_MS / 1000}s`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return classify(err);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (previous === undefined) {
|
|
123
|
+
delete process.env[shape.env];
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.env[shape.env] = previous;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A service has no model catalog to list, and its cheapest endpoint is one that
|
|
132
|
+
* bills. So the question is asked with a request that cannot succeed: an empty
|
|
133
|
+
* body. Authentication is checked before the body is, which makes the two
|
|
134
|
+
* refusals say different things — a rejected key never gets far enough to be
|
|
135
|
+
* told its body is wrong, and a good key is told nothing else.
|
|
136
|
+
*
|
|
137
|
+
* 401 INVALID_API_KEY → dead
|
|
138
|
+
* 400 INVALID_REQUEST_BODY → live, and free
|
|
139
|
+
*
|
|
140
|
+
* Anything else is left to `classify`, which already knows how to tell a
|
|
141
|
+
* refusal from an unreachable host.
|
|
142
|
+
*/
|
|
143
|
+
async function probeService(service, key) {
|
|
144
|
+
const at = new Date().toISOString();
|
|
145
|
+
// One service so far, and a switch rather than an `if`, so the next one is
|
|
146
|
+
// added where it belongs instead of alongside.
|
|
147
|
+
const url = { exa: `${EXA_BASE_URL}/contents` }[service];
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json', 'x-api-key': key },
|
|
152
|
+
body: '{}',
|
|
153
|
+
signal: AbortSignal.timeout(DEADLINE_MS),
|
|
154
|
+
});
|
|
155
|
+
if (res.status === 401 || res.status === 403) {
|
|
156
|
+
return { state: 'dead', at, detail: `${res.status} ${await said(res)}` };
|
|
157
|
+
}
|
|
158
|
+
// 402 is a key the vendor recognised and then declined to serve. It
|
|
159
|
+
// authenticated; the account behind it is out of money, which is a
|
|
160
|
+
// different problem and one no amount of rotating the key will fix.
|
|
161
|
+
if (res.status === 402) {
|
|
162
|
+
return { state: 'live', at, detail: await said(res) };
|
|
163
|
+
}
|
|
164
|
+
if (res.status === 400 || res.ok) {
|
|
165
|
+
return { state: 'live', at };
|
|
166
|
+
}
|
|
167
|
+
return { state: 'unknown', at, detail: `${res.status} ${await said(res)}` };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return classify(err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** The vendor's own sentence about a refusal, when the body carries one. */
|
|
174
|
+
async function said(res) {
|
|
175
|
+
try {
|
|
176
|
+
const body = (await res.json());
|
|
177
|
+
return firstLine(body.error ?? res.statusText);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return res.statusText;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function authenticate(provider, client) {
|
|
184
|
+
// OpenRouter's model catalog is *public*: it answers 200 to a request
|
|
185
|
+
// carrying no key at all, so listing it would report every credential live,
|
|
186
|
+
// including a revoked one. `/key` describes the key that asked and is the
|
|
187
|
+
// only cheap call that actually looks at it.
|
|
188
|
+
if (provider === 'openrouter') {
|
|
189
|
+
await client.apiKeys.getCurrentKeyMetadata();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const args = provider === 'anthropic'
|
|
193
|
+
? { limit: 1 }
|
|
194
|
+
: provider === 'google' || provider === 'vertex'
|
|
195
|
+
? { config: { pageSize: 1 } }
|
|
196
|
+
: undefined;
|
|
197
|
+
const result = await client.models.list(args);
|
|
198
|
+
// OpenAI and Anthropic resolve to a page; the GenAI SDK resolves to a lazy
|
|
199
|
+
// pager whose first fetch has already happened by the time we get here.
|
|
200
|
+
void result;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Probes many entries, one at a time. In flight together would be quicker, but
|
|
204
|
+
* `probe` reaches the SDKs the only way they can be reached — through
|
|
205
|
+
* `process.env` — and two probes sharing that variable would each read the
|
|
206
|
+
* other's key. One at a time is also what makes progress reportable: there is
|
|
207
|
+
* exactly one answer being waited on, and `onProbe` can name it.
|
|
208
|
+
*
|
|
209
|
+
* Pairs rather than a map, because the caller needs the entry itself to record
|
|
210
|
+
* the result against, and a map keyed by a string would only have to be
|
|
211
|
+
* un-joined again.
|
|
212
|
+
*/
|
|
213
|
+
export async function probeAll(store, entries, onProbe) {
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const [index, entry] of entries.entries()) {
|
|
216
|
+
onProbe?.(entry, index, entries.length);
|
|
217
|
+
out.push([entry, await probe(store, entry)]);
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=liveness.js.map
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { extract, invokedAs, split } from "./args.js";
|
|
5
|
+
import { NEO_BANNER, printBanner } from "./banner.js";
|
|
6
|
+
import { ALIASES, COMMANDS, EXTERNAL } from "./commands/index.js";
|
|
7
|
+
import { cliManifest, versionOf } from "./commands/version.js";
|
|
8
|
+
import { hasExternal, loadExternal } from "./external.js";
|
|
9
|
+
import { CliError, EXIT, bold, cyan, dim, fail, note, pad, write } from "./term.js";
|
|
10
|
+
/** What the user typed: `zen`, `zn` or `zenera` all arrive here. */
|
|
11
|
+
const NAME = invokedAs('zen');
|
|
12
|
+
/** Usage lines are written against `zen`. Say them back in the reader's word. */
|
|
13
|
+
const spell = (usage) => (NAME === 'zen' ? usage : usage.replace(/^zen\b/, NAME));
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// zen — the command line over `@zenera/neo`
|
|
16
|
+
//
|
|
17
|
+
// A shell, deliberately. Argument parsing, help, version and exit codes are
|
|
18
|
+
// settled here so that adding a command is a matter of writing one function and
|
|
19
|
+
// naming it in `COMMANDS` — nothing about the frame has to be revisited.
|
|
20
|
+
//
|
|
21
|
+
// Only the drawing surface has a dependency, and it is behind a dynamic import
|
|
22
|
+
// inside `run`. Everything on this path is Node's own, which is why `zen --help`
|
|
23
|
+
// starts instantly and why the CLI adds no weight to the library.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const GLOBAL = {
|
|
26
|
+
help: { type: 'boolean', short: 'h' },
|
|
27
|
+
version: { type: 'boolean', short: 'v' },
|
|
28
|
+
json: { type: 'boolean' },
|
|
29
|
+
// Uppercase, like make, tar and git: it changes where the command applies,
|
|
30
|
+
// not what it does.
|
|
31
|
+
directory: { type: 'string', short: 'C' },
|
|
32
|
+
};
|
|
33
|
+
async function main(argv) {
|
|
34
|
+
const parts = split(argv);
|
|
35
|
+
const { rest, global } = extract(parts.after);
|
|
36
|
+
let values;
|
|
37
|
+
try {
|
|
38
|
+
values = parseArgs({
|
|
39
|
+
args: [...parts.before, ...global],
|
|
40
|
+
options: GLOBAL,
|
|
41
|
+
strict: true,
|
|
42
|
+
allowPositionals: false,
|
|
43
|
+
}).values;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
fail(e.message, `run ${bold(`${NAME} --help`)} for the options`);
|
|
47
|
+
return EXIT.usage;
|
|
48
|
+
}
|
|
49
|
+
const json = Boolean(values.json);
|
|
50
|
+
if (values.version && !parts.name) {
|
|
51
|
+
write(await versionOf(cliManifest));
|
|
52
|
+
return EXIT.ok;
|
|
53
|
+
}
|
|
54
|
+
const name = parts.name ? (ALIASES[parts.name] ?? parts.name) : undefined;
|
|
55
|
+
const command = name ? COMMANDS[name] : undefined;
|
|
56
|
+
const external = name && !command ? EXTERNAL[name] : undefined;
|
|
57
|
+
// Narration, so `--json` and every pipe are untouched by it. A command
|
|
58
|
+
// living in another package brings its own brand, but only once it is
|
|
59
|
+
// actually there — a banner over "not installed" is a claim about nothing.
|
|
60
|
+
if (!json) {
|
|
61
|
+
const brand = external && hasExternal(external) ? external.banner : undefined;
|
|
62
|
+
printBanner(brand ?? NEO_BANNER);
|
|
63
|
+
}
|
|
64
|
+
if (parts.name === 'help') {
|
|
65
|
+
await usage(rest[0]);
|
|
66
|
+
return EXIT.ok;
|
|
67
|
+
}
|
|
68
|
+
if (!name) {
|
|
69
|
+
await usage();
|
|
70
|
+
return values.help ? EXIT.ok : EXIT.usage;
|
|
71
|
+
}
|
|
72
|
+
if (!command && !external) {
|
|
73
|
+
fail(`unknown command "${parts.name}"`, `run ${bold(`${NAME} --help`)} for the list`);
|
|
74
|
+
return EXIT.usage;
|
|
75
|
+
}
|
|
76
|
+
if (values.help) {
|
|
77
|
+
await usage(name);
|
|
78
|
+
return EXIT.ok;
|
|
79
|
+
}
|
|
80
|
+
// `-C` is consumed here so no command ever reads `process.cwd()` itself,
|
|
81
|
+
// and every relative path in every command means the same thing.
|
|
82
|
+
const cwd = resolve(values.directory ?? process.cwd());
|
|
83
|
+
try {
|
|
84
|
+
const one = command ?? (await loadExternal(name, external));
|
|
85
|
+
await one.run({ args: rest, json, cwd });
|
|
86
|
+
return EXIT.ok;
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e instanceof CliError) {
|
|
90
|
+
fail(e.message, e.hint ? spell(e.hint) : undefined);
|
|
91
|
+
return e.code;
|
|
92
|
+
}
|
|
93
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
94
|
+
if (process.env.ZENERA_DEBUG && e instanceof Error && e.stack) {
|
|
95
|
+
note(dim(e.stack));
|
|
96
|
+
}
|
|
97
|
+
return EXIT.failed;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Help
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
async function usage(name) {
|
|
104
|
+
const resolved = name ? (ALIASES[name] ?? name) : undefined;
|
|
105
|
+
const ext = resolved ? EXTERNAL[resolved] : undefined;
|
|
106
|
+
// Asking for one command's help is already asking for that package, so
|
|
107
|
+
// loading it here costs nothing the reader did not request.
|
|
108
|
+
const one = resolved
|
|
109
|
+
? (COMMANDS[resolved] ??
|
|
110
|
+
(ext && hasExternal(ext)
|
|
111
|
+
? await loadExternal(resolved, ext).catch(() => undefined)
|
|
112
|
+
: undefined))
|
|
113
|
+
: undefined;
|
|
114
|
+
if (one) {
|
|
115
|
+
write(bold(spell(one.usage)));
|
|
116
|
+
write(`\n ${one.summary}`);
|
|
117
|
+
if (ext) {
|
|
118
|
+
write(`\n ${dim(`Provided by ${cyan(ext.package)}.`)}`);
|
|
119
|
+
}
|
|
120
|
+
if (one.details?.length) {
|
|
121
|
+
write('');
|
|
122
|
+
for (const line of one.details) {
|
|
123
|
+
write(line ? ` ${line}` : '');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Not installed: the table is all there is to say, and it is enough.
|
|
129
|
+
if (ext) {
|
|
130
|
+
write(bold(spell(ext.usage)));
|
|
131
|
+
write(`\n ${ext.summary}`);
|
|
132
|
+
write(`\n ${dim(`Provided by ${cyan(ext.package)} — run ${cyan(ext.install)}.`)}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
write(`${bold(NAME)} ${dim('— run agent projects from the command line')}`);
|
|
136
|
+
write(`\n${bold('Usage')}\n ${NAME} <command> [options]`);
|
|
137
|
+
write(`\n${bold('Commands')}`);
|
|
138
|
+
const names = [...Object.keys(COMMANDS), ...Object.keys(EXTERNAL)];
|
|
139
|
+
const width = Math.max(...names.map((k) => k.length));
|
|
140
|
+
for (const [key, cmd] of Object.entries(COMMANDS)) {
|
|
141
|
+
write(` ${pad(key, width)} ${dim(cmd.summary)}`);
|
|
142
|
+
}
|
|
143
|
+
for (const [key, ext] of Object.entries(EXTERNAL)) {
|
|
144
|
+
const tail = hasExternal(ext) ? '' : dim(` (${ext.install})`);
|
|
145
|
+
write(` ${pad(key, width)} ${dim(ext.summary)}${tail}`);
|
|
146
|
+
}
|
|
147
|
+
write(`\n${bold('Options')}`);
|
|
148
|
+
write(` -h, --help ${dim('This, or a command’s own.')}`);
|
|
149
|
+
write(` -v, --version ${dim('Print the version.')}`);
|
|
150
|
+
write(` --json ${dim('Machine-readable output.')}`);
|
|
151
|
+
write(` -C, --directory <d> ${dim('Act as if run in <d>.')}`);
|
|
152
|
+
write(`\n${dim(`Start with ${cyan(`${NAME} init`)}, then ${cyan(`${NAME} run`)}.`)}`);
|
|
153
|
+
}
|
|
154
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
155
|
+
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AgentEvent, type TokenUsage } from '@zenera/neo';
|
|
2
|
+
export interface NarratorOptions {
|
|
3
|
+
/** print nothing at all */
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
/** print text deltas as they arrive, for a terminal that is watching */
|
|
6
|
+
live?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare class Narrator {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(opts?: NarratorOptions);
|
|
11
|
+
handle: (event: AgentEvent) => void;
|
|
12
|
+
done(): void;
|
|
13
|
+
get agent(): string | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare function summary(usage: TokenUsage): string;
|
|
16
|
+
export declare function format(n: number): string;
|
|
17
|
+
export declare function duration(ms: number): string;
|
|
18
|
+
export declare const stopMark: (reason: string) => string;
|
|
19
|
+
//# sourceMappingURL=narrate.d.ts.map
|
package/dist/narrate.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isCheckpoint } from '@zenera/neo';
|
|
2
|
+
import { cyan, dim, green, note, red, yellow } from "./term.js";
|
|
3
|
+
export class Narrator {
|
|
4
|
+
#quiet;
|
|
5
|
+
#live;
|
|
6
|
+
#streaming;
|
|
7
|
+
#agent;
|
|
8
|
+
constructor(opts = {}) {
|
|
9
|
+
this.#quiet = Boolean(opts.quiet);
|
|
10
|
+
this.#live = Boolean(opts.live);
|
|
11
|
+
}
|
|
12
|
+
handle = (event) => {
|
|
13
|
+
if (this.#quiet) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!isCheckpoint(event)) {
|
|
17
|
+
this.#delta(event);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
this.#break();
|
|
21
|
+
const where = event.branch ? dim(`[${event.branch.name}] `) : '';
|
|
22
|
+
switch (event.type) {
|
|
23
|
+
case 'run_created':
|
|
24
|
+
this.#agent = event.agent;
|
|
25
|
+
note(`${dim('·')} ${cyan(event.agent)}`);
|
|
26
|
+
break;
|
|
27
|
+
case 'before_tool_call':
|
|
28
|
+
note(`${where}${dim('→')} ${event.call.name}`);
|
|
29
|
+
break;
|
|
30
|
+
case 'after_tool_call':
|
|
31
|
+
if (event.node.isError) {
|
|
32
|
+
note(`${where}${red(' failed')} ${dim(event.node.name)}`);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
case 'handoff':
|
|
36
|
+
this.#agent = event.to;
|
|
37
|
+
note(`${where}${dim('⇢')} ${cyan(event.to)} ${dim(`from ${event.from}`)}`);
|
|
38
|
+
break;
|
|
39
|
+
case 'before_fork':
|
|
40
|
+
note(`${where}${dim('⑂')} ${event.node.branches.map((b) => b.name).join(', ')}`);
|
|
41
|
+
break;
|
|
42
|
+
case 'branch_finished':
|
|
43
|
+
note(`${dim(' ⑂')} ${event.child.name} ` +
|
|
44
|
+
(event.status === 'ok' ? green('ok') : red(event.status)));
|
|
45
|
+
break;
|
|
46
|
+
case 'run_finished':
|
|
47
|
+
if (event.branch) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
note(`${dim('·')} ${summary(event.result.usage)}`);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
#delta(event) {
|
|
57
|
+
if (!this.#live) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const kind = event.type === 'text_delta'
|
|
61
|
+
? 'text'
|
|
62
|
+
: event.type === 'thinking_delta'
|
|
63
|
+
? 'thinking'
|
|
64
|
+
: undefined;
|
|
65
|
+
if (!kind) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const { delta } = event;
|
|
69
|
+
if (!delta) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Reasoning and the answer are two different streams; run together they
|
|
73
|
+
// read as one confused paragraph, so a switch between them breaks the
|
|
74
|
+
// line first.
|
|
75
|
+
if (this.#streaming && this.#streaming !== kind) {
|
|
76
|
+
process.stderr.write('\n');
|
|
77
|
+
}
|
|
78
|
+
this.#streaming = kind;
|
|
79
|
+
process.stderr.write(dim(delta));
|
|
80
|
+
}
|
|
81
|
+
/** Closes an open delta line before a checkpoint line lands on top of it. */
|
|
82
|
+
#break() {
|
|
83
|
+
if (this.#streaming) {
|
|
84
|
+
process.stderr.write('\n');
|
|
85
|
+
this.#streaming = undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
done() {
|
|
89
|
+
this.#break();
|
|
90
|
+
}
|
|
91
|
+
get agent() {
|
|
92
|
+
return this.#agent;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function summary(usage) {
|
|
96
|
+
const cached = usage.cachedInputTokens
|
|
97
|
+
? dim(` (${format(usage.cachedInputTokens)} cached)`)
|
|
98
|
+
: '';
|
|
99
|
+
const thinking = usage.reasoningTokens
|
|
100
|
+
? dim(` (${format(usage.reasoningTokens)} thinking)`)
|
|
101
|
+
: '';
|
|
102
|
+
return `${format(usage.inputTokens)} in${cached} ${format(usage.outputTokens)} out${thinking}`;
|
|
103
|
+
}
|
|
104
|
+
export function format(n) {
|
|
105
|
+
if (n < 1000) {
|
|
106
|
+
return String(n);
|
|
107
|
+
}
|
|
108
|
+
if (n < 1_000_000) {
|
|
109
|
+
return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
110
|
+
}
|
|
111
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
112
|
+
}
|
|
113
|
+
export function duration(ms) {
|
|
114
|
+
if (ms < 1000) {
|
|
115
|
+
return `${ms}ms`;
|
|
116
|
+
}
|
|
117
|
+
if (ms < 60_000) {
|
|
118
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
119
|
+
}
|
|
120
|
+
const minutes = Math.floor(ms / 60_000);
|
|
121
|
+
return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`;
|
|
122
|
+
}
|
|
123
|
+
export const stopMark = (reason) => reason === 'final' ? green('done') : reason === 'aborted' ? yellow('aborted') : red('failed');
|
|
124
|
+
//# sourceMappingURL=narrate.js.map
|
package/dist/podman.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { runProcess } from '@zenera/neo';
|
|
2
|
+
export interface PodmanOptions {
|
|
3
|
+
/** the image the project needs on disk before the first run */
|
|
4
|
+
image?: string;
|
|
5
|
+
/** machine size, when one has to be created */
|
|
6
|
+
cpus?: number;
|
|
7
|
+
/** MiB */
|
|
8
|
+
memory?: number;
|
|
9
|
+
engine?: string;
|
|
10
|
+
/** never prompt; `--yes`, `--json`, or no terminal */
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
/** so the tests can watch the sequence without a container engine */
|
|
13
|
+
exec?: typeof runProcess;
|
|
14
|
+
}
|
|
15
|
+
export interface PodmanStatus {
|
|
16
|
+
engine: string;
|
|
17
|
+
installed: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
/** absent on Linux, where there is no machine to have */
|
|
20
|
+
machine?: {
|
|
21
|
+
name: string;
|
|
22
|
+
running: boolean;
|
|
23
|
+
starting: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** whether `podman info` answered */
|
|
26
|
+
ready: boolean;
|
|
27
|
+
image?: string;
|
|
28
|
+
imagePresent?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function ensurePodmanReady(opts?: PodmanOptions): Promise<void>;
|
|
31
|
+
/** What `zn sandbox status` prints. Changes nothing, and never throws. */
|
|
32
|
+
export declare function podmanStatus(opts?: PodmanOptions): Promise<PodmanStatus>;
|
|
33
|
+
export interface OwnedContainer {
|
|
34
|
+
name: string;
|
|
35
|
+
/** podman's own word: `running`, `exited`, `created`, `paused` */
|
|
36
|
+
state: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Containers this CLI created, whatever session they belong to, and whether
|
|
40
|
+
* each is up. `--all` is the point: with `persist: true` a session leaves a
|
|
41
|
+
* *stopped* container behind, and a listing that only showed running ones
|
|
42
|
+
* would say nothing is there while the disk says otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner): Promise<OwnedContainer[]>;
|
|
45
|
+
export declare function removeContainers(names: readonly string[], engine?: string, exec?: import("@zenera/neo").Runner): Promise<void>;
|
|
46
|
+
//# sourceMappingURL=podman.d.ts.map
|