@zenera/cli 1.1.17 → 1.1.19

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 (56) hide show
  1. package/README.md +99 -16
  2. package/dist/banner.d.ts +19 -0
  3. package/dist/banner.js +17 -10
  4. package/dist/command.d.ts +3 -0
  5. package/dist/commands/index.js +4 -1
  6. package/dist/commands/init.js +1 -1
  7. package/dist/commands/inspect.js +77 -13
  8. package/dist/commands/key.js +49 -1
  9. package/dist/commands/memory.js +16 -3
  10. package/dist/commands/meta.d.ts +3 -0
  11. package/dist/commands/meta.js +596 -0
  12. package/dist/commands/run.js +63 -38
  13. package/dist/engine.d.ts +15 -0
  14. package/dist/engine.js +20 -1
  15. package/dist/home.d.ts +2 -0
  16. package/dist/home.js +2 -0
  17. package/dist/main.js +1 -1
  18. package/dist/meta.d.ts +221 -0
  19. package/dist/meta.js +695 -0
  20. package/dist/podman.js +64 -2
  21. package/dist/projects.d.ts +9 -1
  22. package/dist/projects.js +19 -2
  23. package/dist/resolve.js +7 -1
  24. package/dist/sandbox.d.ts +2 -0
  25. package/dist/sandbox.js +1 -1
  26. package/dist/scaffold.js +1 -1
  27. package/dist/session.d.ts +10 -0
  28. package/dist/session.js +19 -1
  29. package/dist/term.d.ts +21 -0
  30. package/dist/term.js +74 -2
  31. package/dist/tui/app.js +313 -150
  32. package/dist/tui/theme.d.ts +26 -7
  33. package/dist/tui/theme.js +62 -36
  34. package/dist/tui/wrap.d.ts +41 -16
  35. package/dist/tui/wrap.js +191 -18
  36. package/dist/validate.js +3 -16
  37. package/package.json +3 -2
  38. package/templates/editor/.github/copilot-instructions.md +61 -7
  39. package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
  40. package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
  41. package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
  42. package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
  43. package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
  44. package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
  45. package/templates/editor/.github/skills/zen-cli/references/projects.md +1 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
  47. package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
  48. package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
  49. package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
  50. package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
  51. package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
  52. package/templates/project/SPECIFICATION.md.tmpl +25 -2
  53. package/templates/project/agents/tools-instructions.md +8 -0
  54. package/templates/project/agents.yaml.tmpl +3 -3
  55. package/templates/project/env +7 -1
  56. package/templates/project/gitignore +4 -4
package/dist/podman.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { runProcess, SandboxError } from '@zenera/neo';
2
- import { readdirSync, statSync } from 'node:fs';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
@@ -111,9 +111,71 @@ function instructions() {
111
111
  case 'win32':
112
112
  return 'install it with: winget install RedHat.Podman';
113
113
  default:
114
- return 'install it with your package manager, e.g. apt install podman';
114
+ return linuxInstructions();
115
115
  }
116
116
  }
117
+ /**
118
+ * How podman gets installed, by distribution — podman.io/docs/installation,
119
+ * which is where any of these should be checked against.
120
+ *
121
+ * "Install it with your package manager" is advice nobody needs and everybody
122
+ * has to translate; the host already says which one it has, so the message may
123
+ * as well be the line to paste. It stops at the line: `sudo` wants a terminal
124
+ * to ask for a password on and `runProcess` gives it a pipe, so running this
125
+ * for the user would hang rather than help.
126
+ */
127
+ const LINUX_INSTALL = {
128
+ alpine: 'install it with: sudo apk add podman',
129
+ arch: 'install it with: sudo pacman -S podman',
130
+ centos: 'install it with: sudo dnf -y install podman',
131
+ debian: 'install it with: sudo apt-get -y install podman',
132
+ fedora: 'install it with: sudo dnf -y install podman',
133
+ gentoo: 'install it with: sudo emerge app-containers/podman',
134
+ // Nix installs nothing imperatively worth keeping: the podman that works
135
+ // rootless is the one the module turns on.
136
+ nixos: 'enable it with: virtualisation.podman.enable = true; then: sudo nixos-rebuild switch',
137
+ opensuse: 'install it with: sudo zypper install podman',
138
+ rhel: 'install it with: sudo dnf -y install podman',
139
+ sles: 'install it with: sudo zypper install podman',
140
+ suse: 'install it with: sudo zypper install podman',
141
+ ubuntu: 'install it with: sudo apt-get update && sudo apt-get -y install podman',
142
+ void: 'install it with: sudo xbps-install -S podman',
143
+ };
144
+ function linuxInstructions() {
145
+ for (const id of osRelease()) {
146
+ // `opensuse-leap` and `rhel-9` name the same package manager as their
147
+ // family does, so a miss on the whole id is worth one on its stem.
148
+ const how = LINUX_INSTALL[id] ?? LINUX_INSTALL[id.split(/[-.]/)[0] ?? ''];
149
+ if (how) {
150
+ return how;
151
+ }
152
+ }
153
+ return 'install it with your package manager, e.g. apt-get install podman';
154
+ }
155
+ /**
156
+ * `ID` first, then `ID_LIKE` — the distribution's own name, then the families
157
+ * it claims to behave like, which is how Rocky, Alma and Mint get answered
158
+ * without being listed.
159
+ */
160
+ function osRelease() {
161
+ let text;
162
+ try {
163
+ text = readFileSync('/etc/os-release', 'utf8');
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ const read = (key) => {
169
+ const line = new RegExp(`^${key}=(.*)$`, 'm').exec(text)?.[1] ?? '';
170
+ return line
171
+ .trim()
172
+ .replace(/^["']|["']$/g, '')
173
+ .toLowerCase()
174
+ .split(/\s+/)
175
+ .filter(Boolean);
176
+ };
177
+ return [...read('ID'), ...read('ID_LIKE')];
178
+ }
117
179
  // ---------------------------------------------------------------------------
118
180
  // The machine
119
181
  // ---------------------------------------------------------------------------
@@ -16,7 +16,7 @@ export interface Project {
16
16
  * and does not edit — and the paths are resolved, because the podman machine on
17
17
  * macOS shares the real path or nothing.
18
18
  */
19
- export declare function projectMounts(root: string, config: ProjectConfig): SandboxMount[];
19
+ export declare function projectMounts(root: string, config: ProjectConfig, memoryAt?: string): SandboxMount[];
20
20
  /** Walks up from `start` looking for a project configuration. */
21
21
  export declare function findUp(start: string): Promise<Project | undefined>;
22
22
  export declare function openDir(dir: string): Promise<Project>;
@@ -70,6 +70,14 @@ export declare const sessionsDir: (projectDir: string) => string;
70
70
  /** Session ids, oldest first. Only well-formed stamps count as sessions. */
71
71
  export declare function sessionIds(projectDir: string): string[];
72
72
  export declare function runIds(sessionDir: string): string[];
73
+ /**
74
+ * When this project was last worked in, as a stamp, or `undefined` for one
75
+ * that never has been. Session and run ids are stamps in the same format, so
76
+ * the newest of either compares directly — and reading directory NAMES is what
77
+ * keeps this cheap enough to ask of every registered project before drawing a
78
+ * chooser.
79
+ */
80
+ export declare function lastUsedAt(projectDir: string): string | undefined;
73
81
  /**
74
82
  * What a tree occupies, in allocated blocks rather than in bytes, because a
75
83
  * sparse file costs what it was given and not what it claims. Symlinks are
package/dist/projects.js CHANGED
@@ -35,7 +35,7 @@ export function isProjectDir(dir) {
35
35
  * and does not edit — and the paths are resolved, because the podman machine on
36
36
  * macOS shares the real path or nothing.
37
37
  */
38
- export function projectMounts(root, config) {
38
+ export function projectMounts(root, config, memoryAt) {
39
39
  const mounts = [];
40
40
  const assets = assetsDir(root, config);
41
41
  if (assets) {
@@ -47,7 +47,7 @@ export function projectMounts(root, config) {
47
47
  for (const dir of skillMounts(skillDirs(root, config), SKILLS_MOUNT)) {
48
48
  mounts.push({ host: realpathSync(dir.path), at: dir.at, readOnly: true });
49
49
  }
50
- const memory = memoryDir(root, config);
50
+ const memory = memoryDir(root, config, memoryAt);
51
51
  if (memory) {
52
52
  // Created here because the mount is decided before the project is
53
53
  // loaded, and podman would refuse a source that does not exist yet.
@@ -224,6 +224,23 @@ export function sessionIds(projectDir) {
224
224
  export function runIds(sessionDir) {
225
225
  return stampedChildren(join(sessionDir, 'runs'));
226
226
  }
227
+ /**
228
+ * When this project was last worked in, as a stamp, or `undefined` for one
229
+ * that never has been. Session and run ids are stamps in the same format, so
230
+ * the newest of either compares directly — and reading directory NAMES is what
231
+ * keeps this cheap enough to ask of every registered project before drawing a
232
+ * chooser.
233
+ */
234
+ export function lastUsedAt(projectDir) {
235
+ let last;
236
+ for (const id of sessionIds(projectDir)) {
237
+ const newest = runIds(join(sessionsDir(projectDir), id)).at(-1) ?? id;
238
+ if (!last || newest > last) {
239
+ last = newest;
240
+ }
241
+ }
242
+ return last;
243
+ }
227
244
  function stampedChildren(dir) {
228
245
  if (!existsSync(dir)) {
229
246
  return [];
package/dist/resolve.js CHANGED
@@ -23,7 +23,13 @@ export async function project(want) {
23
23
  if (!isInteractive()) {
24
24
  throw usageError('not inside a project', 'name one with --project');
25
25
  }
26
- const chosen = await choose('Which project?', known.map((e) => ({ label: e.name, detail: e.path, value: e })));
26
+ // Most recently worked in first: the answer to "which project" is nearly
27
+ // always the one from last time, and registration order is nothing to a
28
+ // person. A project never run sorts last, by name.
29
+ const ranked = known
30
+ .map((entry) => ({ entry, used: Projects.lastUsedAt(entry.path) ?? '' }))
31
+ .sort((a, b) => b.used.localeCompare(a.used) || a.entry.name.localeCompare(b.entry.name));
32
+ const chosen = await choose('Which project?', ranked.map(({ entry }) => ({ label: entry.name, detail: entry.path, value: entry })));
27
33
  return Projects.openDir(chosen.path);
28
34
  }
29
35
  // ---------------------------------------------------------------------------
package/dist/sandbox.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface SandboxSetup {
10
10
  build?: ResolvedBuild;
11
11
  /** host side of the container's `$HOME`, created only if it is ever needed */
12
12
  home: string;
13
+ /** every tree bound into the container besides the workspace itself */
14
+ mounts: readonly SandboxMount[];
13
15
  }
14
16
  export interface SandboxInputs {
15
17
  config: ProjectConfig;
package/dist/sandbox.js CHANGED
@@ -103,7 +103,7 @@ export function buildSandbox(opts) {
103
103
  readOnly: opts.readOnly,
104
104
  mounts,
105
105
  });
106
- return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home };
106
+ return { pool, spec, image: spec.image ?? DEFAULT_SANDBOX_IMAGE, build, home, mounts };
107
107
  }
108
108
  /**
109
109
  * An agent's overrides on the project's block. `image` and `build` answer the
package/dist/scaffold.js CHANGED
@@ -244,7 +244,7 @@ export function scaffold(opts) {
244
244
  mkdirSync(join(opts.dir, 'agents', 'skills'), { recursive: true });
245
245
  mkdirSync(join(opts.dir, 'sessions'), { recursive: true });
246
246
  mkdirSync(join(opts.dir, '.tmp'), { recursive: true });
247
- // Where `/sync-with-spec` records what it applied, so the next pass works
247
+ // Where `/spec-sync-project` records what it applied, so the next pass works
248
248
  // the difference rather than the whole specification again. Empty here, and
249
249
  // git does not carry an empty directory: what says a pass has completed is
250
250
  // `baseline/manifest.txt`, never the directory itself.
package/dist/session.d.ts CHANGED
@@ -71,6 +71,16 @@ export interface RunMeta {
71
71
  error?: string;
72
72
  }
73
73
  export declare function writeRunMeta(p: RunPaths, meta: RunMeta): void;
74
+ export interface RunSummary {
75
+ id: string;
76
+ startedAt?: string;
77
+ finishedAt?: string;
78
+ agent?: string;
79
+ stopReason?: string;
80
+ error?: string;
81
+ }
82
+ /** Newest first — the order a picker wants. */
83
+ export declare function listRuns(session: SessionPaths): Promise<RunSummary[]>;
74
84
  export declare function newestRun(session: SessionPaths): string | undefined;
75
85
  /** A path to show a human: relative when that is shorter, absolute otherwise. */
76
86
  export declare function display(path: string, from?: string): string;
package/dist/session.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
2
2
  import { hostname } from 'node:os';
3
3
  import { join, relative, resolve } from 'node:path';
4
4
  import { readJson, writeJson } from "./home.js";
5
- import { isStamp, stamp } from "./ids.js";
5
+ import { isStamp, stamp, stampInstant } from "./ids.js";
6
6
  import { alive, isBusy, runIds, sessionIds, sessionsDir } from "./projects.js";
7
7
  import { CliError, EXIT, invalidError, usageError } from "./term.js";
8
8
  export function sessionPaths(projectDir, id) {
@@ -145,6 +145,24 @@ export function createRun(session) {
145
145
  export function writeRunMeta(p, meta) {
146
146
  writeJson(p.meta, meta, 0o644);
147
147
  }
148
+ /** Newest first — the order a picker wants. */
149
+ export async function listRuns(session) {
150
+ const out = [];
151
+ for (const id of runIds(session.dir).reverse()) {
152
+ // A run killed before it recorded anything still has an id, and the id
153
+ // says when it started, so it is listable without its meta.
154
+ const meta = await readJson(runPaths(session, id).meta, {});
155
+ out.push({
156
+ id,
157
+ startedAt: meta.startedAt ?? stampInstant(id),
158
+ finishedAt: meta.finishedAt,
159
+ agent: meta.agent,
160
+ stopReason: meta.stopReason,
161
+ error: meta.error,
162
+ });
163
+ }
164
+ return out;
165
+ }
148
166
  export function newestRun(session) {
149
167
  return runIds(session.dir).at(-1);
150
168
  }
package/dist/term.d.ts CHANGED
@@ -20,12 +20,33 @@ export declare class CliError extends Error {
20
20
  export declare const usageError: (m: string, hint?: string) => CliError;
21
21
  export declare const invalidError: (m: string, hint?: string) => CliError;
22
22
  export declare const credentialError: (m: string, hint?: string) => CliError;
23
+ export type Appearance = 'dark' | 'light';
24
+ /** One grey per background. The only 256-colour indices in the CLI. */
25
+ export declare const CHROME: Record<Appearance, number>;
26
+ /** What the terminal looks like, as far as can be known without asking it. */
27
+ export declare function appearance(): Appearance;
23
28
  export declare const bold: (s: string) => string;
29
+ /**
30
+ * One step quieter, which is half of everything this tool prints: paths,
31
+ * counts, hints, the second column of every table.
32
+ *
33
+ * A stated grey rather than SGR 2. `dim` is not a colour, it is a request, and
34
+ * how far it is honoured is the terminal's own business — iTerm2 takes a step,
35
+ * Apple Terminal goes most of the way to the background and takes the quiet
36
+ * half of the output with it.
37
+ */
24
38
  export declare const dim: (s: string) => string;
25
39
  export declare const red: (s: string) => string;
26
40
  export declare const green: (s: string) => string;
27
41
  export declare const yellow: (s: string) => string;
28
42
  export declare const cyan: (s: string) => string;
43
+ /**
44
+ * Whether anything should be coloured at all — `styleText`'s own answer, asked
45
+ * by the places that write escape codes directly and so cannot inherit it.
46
+ */
47
+ export declare const styled: () => boolean;
48
+ /** The text as it reads without styling, which is what a file wants. */
49
+ export declare const plain: (s: string) => string;
29
50
  export declare function pad(s: string, to: number): string;
30
51
  /** Column-aligned rows. Trailing whitespace is trimmed so `diff` stays quiet. */
31
52
  export declare function table(rows: readonly (readonly string[])[], gap?: number): string[];
package/dist/term.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { createInterface } from 'node:readline/promises';
2
3
  import { styleText } from 'node:util';
3
4
  // ---------------------------------------------------------------------------
@@ -36,19 +37,90 @@ export class CliError extends Error {
36
37
  export const usageError = (m, hint) => new CliError(m, EXIT.usage, hint);
37
38
  export const invalidError = (m, hint) => new CliError(m, EXIT.invalid, hint);
38
39
  export const credentialError = (m, hint) => new CliError(m, EXIT.credentials, hint);
40
+ /** One grey per background. The only 256-colour indices in the CLI. */
41
+ export const CHROME = { dark: 245, light: 242 };
42
+ let sniffed;
43
+ /** What the terminal looks like, as far as can be known without asking it. */
44
+ export function appearance() {
45
+ sniffed ??= fromEnv() ?? fromColorFgBg() ?? fromAppleTerminal() ?? 'dark';
46
+ return sniffed;
47
+ }
48
+ function fromEnv() {
49
+ const v = process.env['ZENERA_THEME']?.trim().toLowerCase();
50
+ return v === 'dark' || v === 'light' ? v : undefined;
51
+ }
52
+ /**
53
+ * `COLORFGBG` is `fg;bg` or `fg;<something>;bg`; the background is the last
54
+ * field. Anything non-numeric (notably `default`) tells us nothing.
55
+ */
56
+ function fromColorFgBg() {
57
+ const parts = process.env['COLORFGBG']?.split(';');
58
+ const bg = parts?.[parts.length - 1];
59
+ if (bg === undefined || !/^\d+$/.test(bg)) {
60
+ return undefined;
61
+ }
62
+ const n = Number(bg);
63
+ return n === 7 || n >= 9 ? 'light' : 'dark';
64
+ }
65
+ /** The shipped Apple Terminal profiles that are paper rather than ink. */
66
+ const APPLE_LIGHT = new Set(['basic', 'man page', 'novel', 'silver aerogel']);
67
+ /**
68
+ * Ask `defaults` which profile Terminal.app opens with. A subprocess during
69
+ * startup is a real cost, so it is paid last, once per process, and only on
70
+ * the one terminal that leaves us nothing else to go on. A custom profile is
71
+ * not in the table and falls through to the guess.
72
+ */
73
+ function fromAppleTerminal() {
74
+ if (process.env['TERM_PROGRAM'] !== 'Apple_Terminal') {
75
+ return undefined;
76
+ }
77
+ try {
78
+ const name = execFileSync('defaults', ['read', 'com.apple.Terminal', 'Default Window Settings'], { encoding: 'utf8', timeout: 300, stdio: ['ignore', 'pipe', 'ignore'] })
79
+ .trim()
80
+ .toLowerCase();
81
+ return APPLE_LIGHT.has(name) ? 'light' : undefined;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
39
87
  // ---------------------------------------------------------------------------
40
88
  // Styling
41
89
  // ---------------------------------------------------------------------------
42
90
  export const bold = (s) => styleText('bold', s);
43
- export const dim = (s) => styleText('dim', s);
91
+ /**
92
+ * One step quieter, which is half of everything this tool prints: paths,
93
+ * counts, hints, the second column of every table.
94
+ *
95
+ * A stated grey rather than SGR 2. `dim` is not a colour, it is a request, and
96
+ * how far it is honoured is the terminal's own business — iTerm2 takes a step,
97
+ * Apple Terminal goes most of the way to the background and takes the quiet
98
+ * half of the output with it.
99
+ */
100
+ export const dim = (s) => {
101
+ if (!styled()) {
102
+ return s;
103
+ }
104
+ const open = `\u001b[38;5;${CHROME[appearance()]}m`;
105
+ // A colour nested inside closes with `39m`, which would drop the grey for
106
+ // whatever follows it.
107
+ return `${open}${s.replaceAll('\u001b[39m', `\u001b[39m${open}`)}\u001b[39m`;
108
+ };
44
109
  export const red = (s) => styleText('red', s);
45
110
  export const green = (s) => styleText('green', s);
46
111
  export const yellow = (s) => styleText('yellow', s);
47
112
  export const cyan = (s) => styleText('cyan', s);
113
+ /**
114
+ * Whether anything should be coloured at all — `styleText`'s own answer, asked
115
+ * by the places that write escape codes directly and so cannot inherit it.
116
+ */
117
+ export const styled = () => styleText('dim', '.') !== '.';
48
118
  /** Visible width — style codes must not count towards column alignment. */
49
119
  // eslint-disable-next-line no-control-regex
50
120
  const ANSI = /\u001b\[[0-9;]*m/g;
51
- const width = (s) => s.replace(ANSI, '');
121
+ /** The text as it reads without styling, which is what a file wants. */
122
+ export const plain = (s) => s.replace(ANSI, '');
123
+ const width = plain;
52
124
  export function pad(s, to) {
53
125
  return s + ' '.repeat(Math.max(0, to - width(s).length));
54
126
  }