@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/dist/args.d.ts +40 -0
  4. package/dist/args.js +99 -0
  5. package/dist/audit.d.ts +53 -0
  6. package/dist/audit.js +144 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +103 -0
  9. package/dist/command.d.ts +14 -0
  10. package/dist/command.js +12 -0
  11. package/dist/commands/check.d.ts +3 -0
  12. package/dist/commands/check.js +287 -0
  13. package/dist/commands/index.d.ts +22 -0
  14. package/dist/commands/index.js +56 -0
  15. package/dist/commands/init.d.ts +3 -0
  16. package/dist/commands/init.js +157 -0
  17. package/dist/commands/inspect.d.ts +3 -0
  18. package/dist/commands/inspect.js +158 -0
  19. package/dist/commands/key.d.ts +3 -0
  20. package/dist/commands/key.js +335 -0
  21. package/dist/commands/list.d.ts +3 -0
  22. package/dist/commands/list.js +101 -0
  23. package/dist/commands/models.d.ts +9 -0
  24. package/dist/commands/models.js +120 -0
  25. package/dist/commands/open.d.ts +9 -0
  26. package/dist/commands/open.js +270 -0
  27. package/dist/commands/run.d.ts +3 -0
  28. package/dist/commands/run.js +167 -0
  29. package/dist/commands/sandbox.d.ts +3 -0
  30. package/dist/commands/sandbox.js +112 -0
  31. package/dist/commands/version.d.ts +6 -0
  32. package/dist/commands/version.js +39 -0
  33. package/dist/engine.d.ts +49 -0
  34. package/dist/engine.js +208 -0
  35. package/dist/external.d.ts +10 -0
  36. package/dist/external.js +56 -0
  37. package/dist/home.d.ts +31 -0
  38. package/dist/home.js +108 -0
  39. package/dist/ids.d.ts +12 -0
  40. package/dist/ids.js +44 -0
  41. package/dist/keys.d.ts +124 -0
  42. package/dist/keys.js +309 -0
  43. package/dist/lib.d.ts +9 -0
  44. package/dist/lib.js +31 -0
  45. package/dist/liveness.d.ts +23 -0
  46. package/dist/liveness.js +221 -0
  47. package/dist/main.d.ts +3 -0
  48. package/dist/main.js +155 -0
  49. package/dist/narrate.d.ts +19 -0
  50. package/dist/narrate.js +124 -0
  51. package/dist/podman.d.ts +46 -0
  52. package/dist/podman.js +254 -0
  53. package/dist/projects.d.ts +70 -0
  54. package/dist/projects.js +232 -0
  55. package/dist/resolve.d.ts +27 -0
  56. package/dist/resolve.js +138 -0
  57. package/dist/sandbox.d.ts +36 -0
  58. package/dist/sandbox.js +104 -0
  59. package/dist/scaffold.d.ts +29 -0
  60. package/dist/scaffold.js +220 -0
  61. package/dist/session.d.ts +77 -0
  62. package/dist/session.js +156 -0
  63. package/dist/term.d.ts +69 -0
  64. package/dist/term.js +242 -0
  65. package/dist/tui/app.d.ts +8 -0
  66. package/dist/tui/app.js +257 -0
  67. package/dist/tui/theme.d.ts +23 -0
  68. package/dist/tui/theme.js +134 -0
  69. package/dist/tui/wrap.d.ts +12 -0
  70. package/dist/tui/wrap.js +62 -0
  71. package/dist/validate.d.ts +145 -0
  72. package/dist/validate.js +959 -0
  73. package/package.json +76 -0
  74. package/templates/.github/copilot-instructions.md +1579 -0
  75. package/templates/.github/prompts/new-agent.prompt.md +38 -0
  76. package/templates/.github/prompts/new-skill.prompt.md +37 -0
  77. package/templates/.github/prompts/review-project.prompt.md +31 -0
  78. package/templates/.github/skills/zen-cli/SKILL.md +110 -0
@@ -0,0 +1,112 @@
1
+ import { readProjectConfig } from '@zenera/neo';
2
+ import { parse } from "../args.js";
3
+ import { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers } from "../podman.js";
4
+ import { project as findProject } from "../resolve.js";
5
+ import { bold, dim, green, json, note, red, usageError, write, yellow } from "../term.js";
6
+ const USAGE = 'zen sandbox [status|up|pull|clean] [options]';
7
+ // ---------------------------------------------------------------------------
8
+ // The container engine, on its own
9
+ //
10
+ // Everything here also happens inside `zen run`, and that is the point of
11
+ // having it: the slow, one-time, machine-wide half of a run is the half most
12
+ // likely to fail, and debugging it should not cost a model call. `up` is what
13
+ // you run on a new laptop; `status` is what you read when a run says the engine
14
+ // did not answer.
15
+ // ---------------------------------------------------------------------------
16
+ export const sandbox = {
17
+ summary: 'Check and prepare the container command-line tools run in.',
18
+ usage: USAGE,
19
+ details: [
20
+ ' status What is installed, running and pulled. Changes nothing.',
21
+ ' up Install if asked, start the machine, pull the image.',
22
+ ' pull Just the image.',
23
+ ' clean Remove every container this CLI created.',
24
+ '',
25
+ ' --project <name|dir> Which project the image comes from.',
26
+ ' --image <ref> Use this image instead of the project\u2019s.',
27
+ '',
28
+ 'None of this is required. A run does all of it on its own, the first',
29
+ 'time an agent that can reach a shell is about to start one.',
30
+ ],
31
+ run: async (ctx) => {
32
+ const { values, positionals } = parse(ctx.args, {
33
+ project: { type: 'string' },
34
+ image: { type: 'string' },
35
+ }, USAGE);
36
+ const what = positionals[0] ?? 'status';
37
+ if (!['status', 'up', 'pull', 'clean'].includes(what)) {
38
+ throw usageError(`unknown subcommand: ${what}`, USAGE);
39
+ }
40
+ if (positionals.length > 1) {
41
+ throw usageError('one subcommand at a time', USAGE);
42
+ }
43
+ const image = values.image ?? (await projectImage(ctx.cwd, values));
44
+ switch (what) {
45
+ case 'status':
46
+ return status(image, ctx.json);
47
+ case 'up':
48
+ return up(image, ctx.json, ctx.json);
49
+ case 'pull':
50
+ return up(image, true, ctx.json);
51
+ case 'clean':
52
+ return clean(ctx.json);
53
+ }
54
+ },
55
+ };
56
+ /**
57
+ * The project's image, when there is a project to ask. `zen sandbox status`
58
+ * run from anywhere at all is still a useful thing, so failing to find one is
59
+ * not a failure — it just means there is no image to report on. Notably this
60
+ * does *not* go through `target`: reading a setting must not create a session.
61
+ */
62
+ async function projectImage(cwd, values) {
63
+ try {
64
+ const found = await findProject({ cwd, project: values.project, yes: true });
65
+ return readProjectConfig(found.dir).config.sandbox?.image;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ async function status(image, asJson) {
72
+ const found = await podmanStatus({ image });
73
+ const containers = found.ready ? await ownedContainers(found.engine) : [];
74
+ if (asJson) {
75
+ json({ ...found, containers });
76
+ return;
77
+ }
78
+ const mark = (ok) => (ok ? green('ok') : red('no'));
79
+ write(`${bold('engine')} ${found.engine} ${dim(found.version ?? '')} ${mark(found.installed)}`);
80
+ if (found.machine) {
81
+ const state = found.machine.starting ? yellow('starting') : mark(found.machine.running);
82
+ write(`${bold('machine')} ${found.machine.name} ${state}`);
83
+ }
84
+ write(`${bold('responds')} ${mark(found.ready)}`);
85
+ if (found.image) {
86
+ write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
87
+ }
88
+ const listed = containers.map((c) => c.state === 'running' ? `${c.name} ${green('running')}` : `${c.name} ${dim(c.state)}`);
89
+ write(`${bold('containers')} ${listed.length ? listed.join(', ') : dim('none')}`);
90
+ if (!found.installed || !found.ready) {
91
+ note('');
92
+ note(dim('run `zen sandbox up` to fix what can be fixed.'));
93
+ }
94
+ }
95
+ async function up(image, yes, asJson) {
96
+ await ensurePodmanReady({ image, yes });
97
+ if (asJson) {
98
+ json({ ready: true, image });
99
+ return;
100
+ }
101
+ write(`${green('ready')}${image ? ` ${dim(image)}` : ''}`);
102
+ }
103
+ async function clean(asJson) {
104
+ const names = (await ownedContainers()).map((c) => c.name);
105
+ await removeContainers(names);
106
+ if (asJson) {
107
+ json({ removed: names });
108
+ return;
109
+ }
110
+ write(names.length ? `removed ${names.length}: ${names.join(', ')}` : dim('nothing to remove'));
111
+ }
112
+ //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1,6 @@
1
+ import type { Command } from '../command.ts';
2
+ /** A package's version, read from its manifest rather than inlined at build time. */
3
+ export declare function versionOf(manifest: URL): Promise<string>;
4
+ export declare const cliManifest: import("url").URL;
5
+ export declare const version: Command;
6
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { dim, json, pad, write } from "../term.js";
4
+ /** A package's version, read from its manifest rather than inlined at build time. */
5
+ export async function versionOf(manifest) {
6
+ const { version } = JSON.parse(await readFile(manifest, 'utf8'));
7
+ return version;
8
+ }
9
+ export const cliManifest = new URL('../../package.json', import.meta.url);
10
+ /**
11
+ * Where the library actually resolved from — the workspace symlink in
12
+ * development, `node_modules` once installed. Going through its `exports` map
13
+ * rather than a guessed path means this also fails loudly if that map is ever
14
+ * broken, which is the one packaging mistake nothing else catches.
15
+ */
16
+ function libraryManifest() {
17
+ const resolve = createRequire(import.meta.url).resolve;
18
+ return new URL(`file://${resolve('@zenera/neo/package.json')}`);
19
+ }
20
+ export const version = {
21
+ summary: 'Print the CLI, library and Node versions.',
22
+ usage: 'zen version',
23
+ run: async (ctx) => {
24
+ const versions = {
25
+ cli: await versionOf(cliManifest),
26
+ '@zenera/neo': await versionOf(libraryManifest()),
27
+ node: process.versions.node,
28
+ };
29
+ if (ctx.json) {
30
+ json(versions);
31
+ return;
32
+ }
33
+ const width = Math.max(...Object.keys(versions).map((k) => k.length));
34
+ for (const [name, value] of Object.entries(versions)) {
35
+ write(`${dim(pad(name, width))} ${value}`);
36
+ }
37
+ },
38
+ };
39
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1,49 @@
1
+ import { AgentRunner, type AgentEvent, type AgentProject, type AgentState, type Input, type RunResult } from '@zenera/neo';
2
+ import type { Project } from './projects.ts';
3
+ import { type SandboxSetup } from './sandbox.ts';
4
+ import { type Held, type RunPaths, type SessionPaths } from './session.ts';
5
+ export interface EngineOptions {
6
+ project: Project;
7
+ session: SessionPaths;
8
+ readOnly?: boolean;
9
+ /** default model override — `--model` */
10
+ model?: string;
11
+ /** sandbox image override — `--image` */
12
+ image?: string;
13
+ /** answer the sandbox's install question without asking — `--yes` */
14
+ yes?: boolean;
15
+ }
16
+ export interface Engine {
17
+ project: AgentProject;
18
+ runner: AgentRunner;
19
+ name: string;
20
+ workspace: string;
21
+ session: SessionPaths;
22
+ /** the session's accumulated state, when it has one */
23
+ state?: AgentState;
24
+ /** the containers this session may start; present whether or not it does */
25
+ sandbox: SandboxSetup;
26
+ lock: Held;
27
+ close(): Promise<void>;
28
+ }
29
+ /**
30
+ * Wires the library to a session directory. Every store is file-backed and
31
+ * rooted inside `.data/`, so a session is self-contained: copy the directory
32
+ * and the whole conversation, its memory and its blobs travel with it.
33
+ */
34
+ export declare function open(opts: EngineOptions): Promise<Engine>;
35
+ export interface RunOutcome {
36
+ run: RunPaths;
37
+ result: RunResult<string>;
38
+ text: string;
39
+ durationMs: number;
40
+ /** where the report landed, when one could be rendered */
41
+ report?: string;
42
+ }
43
+ /**
44
+ * One turn. The session's state decides whether this starts a run or continues
45
+ * one — which is the whole of what `resume` used to be, and why it is not a
46
+ * command.
47
+ */
48
+ export declare function run(engine: Engine, input: Input, onEvent?: (event: AgentEvent) => void, signal?: AbortSignal): Promise<RunOutcome>;
49
+ //# sourceMappingURL=engine.d.ts.map
package/dist/engine.js ADDED
@@ -0,0 +1,208 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
5
+ import { auditModels, describeIssue } from "./audit.js";
6
+ import { readJson, writeJson } from "./home.js";
7
+ import { KeyStore, assertUsable } from "./keys.js";
8
+ import { buildSandbox, preflight, teardown, usesSandbox } from "./sandbox.js";
9
+ import { acquire, createRun, readSessionMeta, writeSessionMeta, } from "./session.js";
10
+ import { CliError, EXIT, invalidError, warn } from "./term.js";
11
+ /**
12
+ * Wires the library to a session directory. Every store is file-backed and
13
+ * rooted inside `.data/`, so a session is self-contained: copy the directory
14
+ * and the whole conversation, its memory and its blobs travel with it.
15
+ */
16
+ export async function open(opts) {
17
+ const keys = await KeyStore.open();
18
+ keys.materialize();
19
+ assertUsable(keys);
20
+ // Said before the load, because the load stops at the first model it cannot
21
+ // build: one SDK's words about one model, when the useful answer is which
22
+ // of the project's models are reachable and which are not.
23
+ for (const issue of auditModels(opts.project.dir, keys)) {
24
+ warn(describeIssue(issue));
25
+ }
26
+ const meta = await readSessionMeta(opts.session);
27
+ const workspace = resolve(meta.workspace);
28
+ const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
29
+ const memory = [new FileMemoryStore({ dir: opts.session.memory, id: 'file' })];
30
+ // The config is read before the project is loaded because the sandbox is
31
+ // configured by it and the tools have to exist before selectors can be
32
+ // resolved against them. They are always registered, whether or not anyone
33
+ // selects them: a project that names `sandbox:*` against an empty tool list
34
+ // fails to load with "unknown tool", which would be a lie.
35
+ let sandbox;
36
+ let project;
37
+ try {
38
+ const { config } = readProjectConfig(opts.project.dir);
39
+ sandbox = buildSandbox({
40
+ config,
41
+ session: opts.session,
42
+ workspace,
43
+ readOnly: opts.readOnly,
44
+ image: opts.image,
45
+ });
46
+ project = await loadProject(opts.project.dir, {
47
+ tools: [
48
+ // Both groups are pointed at one directory, so both are told the
49
+ // one name for it: whatever `run_command` prints a path as,
50
+ // `read_file` accepts.
51
+ ...workspaceTools({
52
+ root: workspace,
53
+ readOnly: opts.readOnly,
54
+ mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
55
+ }),
56
+ ...sandboxTools(sandbox.pool),
57
+ // Registered whether or not a key exists: the credential is
58
+ // read when a tool is called, so a project that names `exa:*`
59
+ // loads on a machine that cannot yet search, and says so on the
60
+ // turn that tried.
61
+ ...exaTools(),
62
+ ],
63
+ payloads,
64
+ memory,
65
+ });
66
+ }
67
+ catch (err) {
68
+ throw invalidError(err instanceof Error ? err.message : String(err), `while loading ${opts.project.dir}`);
69
+ }
70
+ // Asked here rather than at the first `run_command`, so a host with no
71
+ // container engine costs an error instead of a round trip. Nothing is
72
+ // started: the container itself waits until something actually runs.
73
+ if (usesSandbox(project)) {
74
+ await preflight(sandbox, opts.yes);
75
+ }
76
+ // `recordRequests` is what makes the report show the exact bytes sent
77
+ // rather than a reconstruction. It costs state size, and a CLI run is
78
+ // written to disk once and inspected by a human — exactly the case the
79
+ // library leaves it off for by default.
80
+ //
81
+ // `--model` becomes the runner's fallback rather than an alias, so it
82
+ // overrides the config's `model:` without touching agents that pinned one.
83
+ const runner = project.runner({
84
+ recordRequests: true,
85
+ model: opts.model ? project.models.model(opts.model) : undefined,
86
+ });
87
+ const state = await loadState(opts.session);
88
+ const lock = acquire(opts.session);
89
+ return {
90
+ project,
91
+ runner,
92
+ name: opts.project.name,
93
+ workspace,
94
+ session: opts.session,
95
+ state,
96
+ sandbox,
97
+ lock,
98
+ close: async () => {
99
+ await teardown(sandbox.pool);
100
+ lock.release();
101
+ },
102
+ };
103
+ }
104
+ async function loadState(session) {
105
+ if (!existsSync(session.state)) {
106
+ return undefined;
107
+ }
108
+ const json = await readJson(session.state, undefined);
109
+ try {
110
+ return assertState(json);
111
+ }
112
+ catch {
113
+ throw invalidError(`${session.state} is not a usable run state`, 'start a fresh session with --new');
114
+ }
115
+ }
116
+ /**
117
+ * One turn. The session's state decides whether this starts a run or continues
118
+ * one — which is the whole of what `resume` used to be, and why it is not a
119
+ * command.
120
+ */
121
+ export async function run(engine, input, onEvent, signal) {
122
+ const startedAt = new Date();
123
+ const stream = engine.state
124
+ ? engine.runner.send(engine.state, input, { signal })
125
+ : engine.runner.run(engine.project.entry, input, { signal });
126
+ if (onEvent) {
127
+ for await (const event of stream) {
128
+ onEvent(event);
129
+ }
130
+ }
131
+ const result = await stream.final();
132
+ const durationMs = Date.now() - startedAt.getTime();
133
+ engine.state = result.state;
134
+ const outcome = await record(engine, input, result, startedAt, durationMs);
135
+ if (result.stopReason === 'failed') {
136
+ throw new CliError(result.state.error ?? 'the run failed', EXIT.failed, `report: ${outcome.run.report}`);
137
+ }
138
+ return outcome;
139
+ }
140
+ /**
141
+ * Everything a finished turn leaves behind. The session state is written first:
142
+ * if the report fails to render, the conversation is still resumable, which is
143
+ * the ordering that loses least.
144
+ */
145
+ async function record(engine, input, result, startedAt, durationMs) {
146
+ const { session } = engine;
147
+ writeJson(session.state, result.state, 0o644);
148
+ const meta = await readSessionMeta(session);
149
+ meta.lastRunAt = new Date().toISOString();
150
+ meta.title ??= title(input);
151
+ writeSessionMeta(session, meta);
152
+ const run = createRun(session);
153
+ const text = await lastText(result.state, engine.runner.services.payloads);
154
+ await writeFile(run.input, `${asText(input)}\n`, 'utf8');
155
+ await writeFile(run.output, `${text}\n`, 'utf8');
156
+ writeJson(run.state, result.state, 0o644);
157
+ let report;
158
+ try {
159
+ const built = await buildRunReport(result.state, engine.runner.services.payloads, {
160
+ title: `${engine.name} · ${run.id}`,
161
+ architecture: await engine.runner.describe(),
162
+ });
163
+ await writeFile(run.report, renderReportHtml(built), 'utf8');
164
+ report = run.report;
165
+ }
166
+ catch {
167
+ // A report is a convenience. Losing it must not lose the run.
168
+ }
169
+ writeJson(run.meta, {
170
+ version: 1,
171
+ id: run.id,
172
+ session: session.id,
173
+ startedAt: startedAt.toISOString(),
174
+ finishedAt: new Date().toISOString(),
175
+ durationMs,
176
+ agent: result.agent,
177
+ stopReason: result.stopReason,
178
+ turns: turns(result.state),
179
+ usage: result.usage,
180
+ workspace: engine.workspace,
181
+ error: result.state.error,
182
+ }, 0o644);
183
+ return { run, result, text, durationMs, report };
184
+ }
185
+ function asText(input) {
186
+ if (typeof input === 'string') {
187
+ return input;
188
+ }
189
+ return input
190
+ .map((part) => {
191
+ if (typeof part === 'string') {
192
+ return part;
193
+ }
194
+ if ('text' in part && typeof part.text === 'string') {
195
+ return part.text;
196
+ }
197
+ // A media shorthand is a single key; naming it is more useful in a
198
+ // transcript than printing the url or the base64 behind it.
199
+ return `[${Object.keys(part)[0] ?? 'part'}]`;
200
+ })
201
+ .join('\n');
202
+ }
203
+ /** A session's label in a picker: the first thing that was ever asked of it. */
204
+ function title(input) {
205
+ const text = asText(input).replace(/\s+/g, ' ').trim();
206
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text;
207
+ }
208
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1,10 @@
1
+ import type { Command } from './command.ts';
2
+ import type { External } from './commands/index.ts';
3
+ /**
4
+ * Whether the package is installed. Resolution only — nothing is loaded, and
5
+ * `import.meta.resolve` is the same resolver the import below will use, so it
6
+ * cannot say yes to something that then fails to load.
7
+ */
8
+ export declare function hasExternal(ext: External): boolean;
9
+ export declare function loadExternal(name: string, ext: External): Promise<Command>;
10
+ //# sourceMappingURL=external.d.ts.map
@@ -0,0 +1,56 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { CliError, EXIT, bold } from "./term.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Loading a command out of a sibling package
12
+ //
13
+ // The specifier is built rather than written, and that is the whole mechanism:
14
+ // a literal `import('@zenera/faker/command')` would make the sibling a
15
+ // compile-time dependency of `zen` — a project reference, a package.json entry,
16
+ // and a cycle, since the sibling already depends on `zen`. Built, it is
17
+ // resolved by Node at the moment the user asks for it and by nobody before.
18
+ //
19
+ // So `zen --help` costs nothing whether or not the package is there, and a
20
+ // machine without it gets the same answer the SDK loader gives: the line to run.
21
+ // ---------------------------------------------------------------------------
22
+ const entry = (ext) => `${ext.package}/command`;
23
+ /**
24
+ * Whether the package is installed. Resolution only — nothing is loaded, and
25
+ * `import.meta.resolve` is the same resolver the import below will use, so it
26
+ * cannot say yes to something that then fails to load.
27
+ */
28
+ export function hasExternal(ext) {
29
+ try {
30
+ import.meta.resolve(entry(ext));
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ export async function loadExternal(name, ext) {
38
+ let module;
39
+ try {
40
+ module = await import(__rewriteRelativeImportExtension(entry(ext)));
41
+ }
42
+ catch (err) {
43
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
44
+ throw new CliError(`zen ${name} needs ${ext.package}`, EXIT.usage, `run ${bold(ext.install)}`);
45
+ }
46
+ throw err;
47
+ }
48
+ // A sibling built against another version of this package is a version
49
+ // problem, and saying so beats `undefined is not a function`.
50
+ const command = module.command;
51
+ if (typeof command?.run !== 'function') {
52
+ throw new CliError(`${ext.package} does not export a command`, EXIT.failed, `it may be older than this ${bold('zen')} — try ${bold(ext.install)}`);
53
+ }
54
+ return command;
55
+ }
56
+ //# sourceMappingURL=external.js.map
package/dist/home.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ export declare function home(): string;
2
+ export declare const paths: {
3
+ home: typeof home;
4
+ projects: () => string;
5
+ keys: () => string;
6
+ keyDir: () => string;
7
+ faker: () => string;
8
+ };
9
+ /** Creates a directory owner-only, and leaves an existing one's mode alone. */
10
+ export declare function ensureDir(dir: string, mode?: number): string;
11
+ export declare function ensureHome(): string;
12
+ /**
13
+ * Refuses to read a credential file that anyone else can read, the way `ssh`
14
+ * does. A world-readable key is not a warning-level event: warnings are
15
+ * ignored, and the whole point of the store is that the secret is not lying
16
+ * around in the open.
17
+ *
18
+ * Windows reports a mode that means nothing here, so the check is POSIX-only.
19
+ */
20
+ export declare function assertPrivate(path: string): void;
21
+ export declare function readJson<T>(path: string, fallback: T): Promise<T>;
22
+ /**
23
+ * Write to a sibling and rename. A crash then leaves either the old file or the
24
+ * new one, never a half-written registry — which matters more here than it
25
+ * looks, because the alternative is a corrupt `keys.json` locking someone out
26
+ * of every provider at once.
27
+ *
28
+ * Synchronous on purpose: this is also called from exit paths.
29
+ */
30
+ export declare function writeJson(path: string, value: unknown, mode?: number): void;
31
+ //# sourceMappingURL=home.d.ts.map
package/dist/home.js ADDED
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { CliError, EXIT } from "./term.js";
7
+ // ---------------------------------------------------------------------------
8
+ // The home directory
9
+ //
10
+ // ~/.zenera/neo holds exactly two kinds of thing: credentials, which belong to
11
+ // the machine and never to a project, and an index of where projects are, which
12
+ // is a convenience and is allowed to be wrong.
13
+ //
14
+ // `ZENERA_HOME` moves the whole tree. That is what makes any of this testable
15
+ // and what lets CI start from an empty one.
16
+ // ---------------------------------------------------------------------------
17
+ const DIR_MODE = 0o700;
18
+ const FILE_MODE = 0o600;
19
+ export function home() {
20
+ const override = process.env.ZENERA_HOME?.trim();
21
+ return override ? override : join(homedir(), '.zenera', 'neo');
22
+ }
23
+ export const paths = {
24
+ home,
25
+ projects: () => join(home(), 'projects.json'),
26
+ keys: () => join(home(), 'keys.json'),
27
+ keyDir: () => join(home(), 'keys'),
28
+ faker: () => join(home(), 'faker'),
29
+ };
30
+ /** Creates a directory owner-only, and leaves an existing one's mode alone. */
31
+ export function ensureDir(dir, mode = DIR_MODE) {
32
+ mkdirSync(dir, { recursive: true, mode });
33
+ return dir;
34
+ }
35
+ export function ensureHome() {
36
+ return ensureDir(home());
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Permissions
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Refuses to read a credential file that anyone else can read, the way `ssh`
43
+ * does. A world-readable key is not a warning-level event: warnings are
44
+ * ignored, and the whole point of the store is that the secret is not lying
45
+ * around in the open.
46
+ *
47
+ * Windows reports a mode that means nothing here, so the check is POSIX-only.
48
+ */
49
+ export function assertPrivate(path) {
50
+ if (process.platform === 'win32') {
51
+ return;
52
+ }
53
+ let mode;
54
+ try {
55
+ mode = statSync(path).mode;
56
+ }
57
+ catch {
58
+ return; // absent is fine; it will be created with the right mode
59
+ }
60
+ const open = mode & 0o077;
61
+ if (open !== 0) {
62
+ const octal = (mode & 0o777).toString(8).padStart(3, '0');
63
+ throw new CliError(`permissions ${octal} on ${path} are too open`, EXIT.credentials, `run: chmod 600 ${path}`);
64
+ }
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // JSON files
68
+ // ---------------------------------------------------------------------------
69
+ export async function readJson(path, fallback) {
70
+ let text;
71
+ try {
72
+ text = await readFile(path, 'utf8');
73
+ }
74
+ catch (err) {
75
+ if (err.code === 'ENOENT') {
76
+ return fallback;
77
+ }
78
+ throw err;
79
+ }
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ throw new CliError(`${path} is not valid JSON`, EXIT.invalid, 'fix or delete the file');
85
+ }
86
+ }
87
+ /**
88
+ * Write to a sibling and rename. A crash then leaves either the old file or the
89
+ * new one, never a half-written registry — which matters more here than it
90
+ * looks, because the alternative is a corrupt `keys.json` locking someone out
91
+ * of every provider at once.
92
+ *
93
+ * Synchronous on purpose: this is also called from exit paths.
94
+ */
95
+ export function writeJson(path, value, mode = FILE_MODE) {
96
+ ensureDir(dirname(path));
97
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
98
+ try {
99
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
100
+ chmodSync(tmp, mode);
101
+ renameSync(tmp, path);
102
+ }
103
+ catch (err) {
104
+ rmSync(tmp, { force: true });
105
+ throw err;
106
+ }
107
+ }
108
+ //# sourceMappingURL=home.js.map
package/dist/ids.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export declare function stamp(now?: Date): string;
2
+ /**
3
+ * Ids arrive from `--session` and `--run` and become path segments, so they are
4
+ * checked rather than trusted. The shape has no `.` and no separator, which
5
+ * makes traversal unrepresentable rather than merely rejected.
6
+ */
7
+ export declare function isStamp(value: string): boolean;
8
+ /** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
9
+ export declare function stampDate(id: string): string;
10
+ /** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
11
+ export declare function stampInstant(id: string): string | undefined;
12
+ //# sourceMappingURL=ids.d.ts.map