@zenera/cli 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -377,6 +377,12 @@ clones it types `zen init repo-notes` once - a directory that is already a
377
377
  project is registered, not rebuilt, and nothing in it is touched - and the name
378
378
  is a command on their machine too.
379
379
 
380
+ When a newer `zen` is installed, `zen check --fix` brings a project's copies of
381
+ _our_ files up to date: the memory and tool house rules under `agents/`, and the
382
+ `.vscode/` and `.github/` trees. They restate how the version in hand behaves,
383
+ so a copy left behind by an upgrade is worse than none. Nothing that describes
384
+ _your_ project is touched.
385
+
380
386
  ## A project is a folder
381
387
 
382
388
  Prompts, agent wiring, skills and tool selections are all files - Markdown and
package/dist/banner.d.ts CHANGED
@@ -4,6 +4,10 @@ declare const ACCENTS: {
4
4
  readonly face: "\u001B[1;38;5;208m";
5
5
  readonly shade: "\u001B[0;38;5;130m";
6
6
  };
7
+ readonly green: {
8
+ readonly face: "\u001B[1;38;5;41m";
9
+ readonly shade: "\u001B[0;38;5;29m";
10
+ };
7
11
  readonly cyan: {
8
12
  readonly face: "\u001B[1;38;5;51m";
9
13
  readonly shade: "\u001B[0;38;5;37m";
@@ -12,6 +16,10 @@ declare const ACCENTS: {
12
16
  readonly face: "\u001B[1;38;5;121m";
13
17
  readonly shade: "\u001B[0;38;5;78m";
14
18
  };
19
+ readonly gold: {
20
+ readonly face: "\u001B[1;38;5;220m";
21
+ readonly shade: "\u001B[0;38;5;178m";
22
+ };
15
23
  };
16
24
  export type Hue = keyof typeof ACCENTS;
17
25
  export interface BannerText {
package/dist/banner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { dim, note, styled } from "./term.js";
1
+ import { appearance, dim, note, styled } from "./term.js";
2
2
  // ---------------------------------------------------------------------------
3
3
  // The banner
4
4
  //
@@ -65,12 +65,17 @@ function big(word) {
65
65
  /** A wordmark rather than a sentence, so the letters are set apart. */
66
66
  const spaced = (s) => [...s.toUpperCase()].join(' ');
67
67
  const RESET = '\u001b[0m';
68
- const HEAD = { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' };
68
+ const HEADS = {
69
+ dark: { face: '\u001b[1;38;5;231m', shade: '\u001b[0;38;5;244m' },
70
+ light: { face: '\u001b[1;38;5;235m', shade: '\u001b[0;38;5;245m' },
71
+ };
69
72
  /** One per sub-brand, so two of them are told apart before the word is read. */
70
73
  const ACCENTS = {
71
74
  orange: { face: '\u001b[1;38;5;208m', shade: '\u001b[0;38;5;130m' },
75
+ green: { face: '\u001b[1;38;5;41m', shade: '\u001b[0;38;5;29m' },
72
76
  cyan: { face: '\u001b[1;38;5;51m', shade: '\u001b[0;38;5;37m' },
73
77
  mint: { face: '\u001b[1;38;5;121m', shade: '\u001b[0;38;5;78m' },
78
+ gold: { face: '\u001b[1;38;5;220m', shade: '\u001b[0;38;5;178m' },
74
79
  };
75
80
  /** Runs of stroke and runs of bevel, alternating; spaces stay unstyled. */
76
81
  const RUNS = /[█▀▄]+|[^█▀▄ ]+/g;
@@ -95,17 +100,18 @@ export function bannerLines(text, columns = process.stderr.columns || 80) {
95
100
  // inside a styled string cannot be trimmed away later.
96
101
  const accent = big(text.accent).map((row) => row.trimEnd());
97
102
  const tone = ACCENTS[text.hue ?? 'orange'];
103
+ const ink = HEADS[appearance()];
98
104
  const margin = ' '.repeat(INDENT);
99
105
  const width = INDENT + head[0].length + GAP + Math.max(...accent.map((row) => row.length));
100
106
  if (width > columns) {
101
107
  return [
102
- `${margin}${flat(text.head.toUpperCase(), HEAD)} ${flat(text.accent.toUpperCase(), tone)}`,
108
+ `${margin}${flat(text.head.toUpperCase(), ink)} ${flat(text.accent.toUpperCase(), tone)}`,
103
109
  `${margin}${dim(spaced(text.subtitle))}`,
104
110
  ];
105
111
  }
106
112
  const lines = [];
107
113
  for (let r = 0; r < HEIGHT; r++) {
108
- lines.push(`${margin}${paint(head[r], HEAD)}${' '.repeat(GAP)}${paint(accent[r], tone)}`);
114
+ lines.push(`${margin}${paint(head[r], ink)}${' '.repeat(GAP)}${paint(accent[r], tone)}`);
109
115
  }
110
116
  // Rules on both sides, centred under the art: the subtitle is a caption,
111
117
  // and without them a short line of spaced capitals floats.
@@ -20,6 +20,7 @@ const LISTED = 20;
20
20
  export const cache = {
21
21
  summary: 'What work has been kept, and getting rid of it.',
22
22
  usage: USAGE,
23
+ banner: { head: 'Zenera', accent: 'Cache', subtitle: 'Stored Work', hue: 'gold' },
23
24
  details: [
24
25
  ' ls What is stored, by kind. Changes nothing.',
25
26
  ' prune Remove what the filters name.',
@@ -6,9 +6,10 @@ import { KeyStore } from "../keys.js";
6
6
  import { duration } from "../narrate.js";
7
7
  import { Registry } from "../projects.js";
8
8
  import { project as resolveProject } from "../resolve.js";
9
+ import { refreshShared } from "../scaffold.js";
9
10
  import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
10
11
  import { validateProject, } from "../validate.js";
11
- const USAGE = 'zen check [name|dir] [--project <name|dir>] [--no-sandbox] [--no-models] [--strict] [--quiet]';
12
+ const USAGE = 'zen check [name|dir] [--project <name|dir>] [--fix] [--no-sandbox] [--no-models] [--strict] [--quiet]';
12
13
  // ---------------------------------------------------------------------------
13
14
  // zen check
14
15
  //
@@ -52,6 +53,14 @@ export const check = {
52
53
  'Unlike a run, it does not stop at the first problem — the report lists',
53
54
  'everything it found, each with a code and the fix for it.',
54
55
  '',
56
+ '--fix rewrites the files that are ours rather than the project\u2019s: the',
57
+ 'memory and tool house rules under agents/, and the editor tree',
58
+ '(.vscode/ and .github/). They restate how this version of zen behaves,',
59
+ 'so a copy left behind by an upgrade is worse than none — they are',
60
+ 'replaced whether or not they were edited, and the report that follows is',
61
+ 'of the project as repaired. Nothing else is touched: agents.yaml, the',
62
+ 'prompts, the specification and agents/instructions.md are yours.',
63
+ '',
55
64
  'The argument is a directory if one is there and a registered project',
56
65
  'name otherwise; with neither, the project you are standing in.',
57
66
  '',
@@ -61,6 +70,7 @@ export const check = {
61
70
  run: async (ctx) => {
62
71
  const { values, positionals } = parse(ctx.args, {
63
72
  project: { type: 'string' },
73
+ fix: { type: 'boolean' },
64
74
  'no-sandbox': { type: 'boolean' },
65
75
  'no-models': { type: 'boolean' },
66
76
  strict: { type: 'boolean' },
@@ -74,6 +84,11 @@ export const check = {
74
84
  // read here and handed to the check rather than looked up inside it.
75
85
  const entry = (await Registry.open()).findPath(dir);
76
86
  const name = entry?.name ?? basename(dir);
87
+ // Before the report rather than after it, so what it says is true of
88
+ // the files that are there now and the exit code answers the repaired
89
+ // project. A fix that left an error behind in the output would be read
90
+ // as a fix that failed.
91
+ const fixed = values.fix ? refreshShared(dir) : [];
77
92
  // Materialised first, so the credential verdicts are the ones a run
78
93
  // would reach: a key in the environment and a key in the keyring are
79
94
  // the same key by the time the library asks. The project's own `.env`
@@ -99,13 +114,15 @@ export const check = {
99
114
  });
100
115
  bar.done();
101
116
  if (ctx.json) {
102
- json(report);
117
+ json({ ...report, fixed });
103
118
  }
104
119
  else if (values.quiet) {
120
+ writeAll(fixedLines(fixed));
105
121
  writeAll(findingLines(report.findings));
106
122
  write(verdict(report, Boolean(values.strict)));
107
123
  }
108
124
  else {
125
+ writeAll(fixedLines(fixed));
109
126
  writeAll(render(report));
110
127
  }
111
128
  const failed = report.counts.errors > 0 || (Boolean(values.strict) && report.counts.warnings > 0);
@@ -303,6 +320,21 @@ function agentBlock(agent) {
303
320
  }
304
321
  return [head, ...table(rows)];
305
322
  }
323
+ /**
324
+ * What `--fix` replaced, said before the report so the two read in the order
325
+ * they happened. Silent when nothing was asked for, so the only difference
326
+ * between `zen check` and `zen check --fix` on a healthy project is these lines.
327
+ */
328
+ function fixedLines(fixed) {
329
+ if (fixed.length === 0) {
330
+ return [];
331
+ }
332
+ return [
333
+ `${bold('Replaced')} ${dim(`${count(fixed.length, 'file')} \u2014 ours, not the project's`)}`,
334
+ ...fixed.map((f) => ` ${green('wrote')} ${f}`),
335
+ '',
336
+ ];
337
+ }
306
338
  /**
307
339
  * One finding, three lines at most: what and where, then why, then the fix.
308
340
  * The code is printed because it is the part a script — or a model asked to
@@ -59,7 +59,7 @@ export const EXTERNAL = {
59
59
  summary: 'Retrieval over a corpus: index it, then ask it something.',
60
60
  usage: 'zen rag <subject> <command> [args...]',
61
61
  install: 'npm i -g @zenera/rag',
62
- banner: { head: 'Zenera', accent: 'Rag', subtitle: 'Corpus Retrieval' },
62
+ banner: { head: 'Zenera', accent: 'Rag', subtitle: 'Corpus Retrieval', hue: 'green' },
63
63
  },
64
64
  };
65
65
  //# sourceMappingURL=index.js.map
@@ -130,7 +130,7 @@ export const init = {
130
130
  'Writes SPECIFICATION.md, agents.yaml, agents/ (house rules, the memory',
131
131
  'rules and one role prompt) and scripts/, then records the directory so',
132
132
  '`zen list` and `zen open` can find it by name. Editor files',
133
- '(.vscode/settings.json and the .github/ tree) are written alongside,',
133
+ '(.vscode/ and the .github/ tree) are written alongside,',
134
134
  'replacing any already there.',
135
135
  '',
136
136
  'A directory that is already a project is adopted rather than rebuilt:',
@@ -56,7 +56,7 @@ export const meta = {
56
56
  'Nothing it needs is put on a command line: every credential reaches it',
57
57
  'through the environment, where other processes cannot read it.',
58
58
  '',
59
- 'The editor files `zen init` writes (.vscode/settings.json and the .github/',
59
+ 'The editor files `zen init` writes (.vscode/ and the .github/',
60
60
  'tree) are refreshed in the project first, the way `zen open` refreshes',
61
61
  'them: the agent is about to read that brief, and it describes this version',
62
62
  'of zen. Edits to them do not survive.',
@@ -20,7 +20,7 @@ export const open = {
20
20
  'Editor: --editor, then $ZENERA_EDITOR, then the editor this terminal',
21
21
  'belongs to, then $VISUAL or $EDITOR, then a known editor on PATH or',
22
22
  'installed, then the platform opener.',
23
- 'The editor files `zen init` writes (.vscode/settings.json and the',
23
+ 'The editor files `zen init` writes (.vscode/ and the',
24
24
  '.github/ tree) are refreshed in the directory being opened; edits to',
25
25
  'them do not survive.',
26
26
  'VS Code and its forks are launched with --disable-workspace-trust so',
package/dist/main.js CHANGED
@@ -6,7 +6,7 @@ import { NEO_BANNER, printBanner } from "./banner.js";
6
6
  import { ALIASES, COMMANDS, EXTERNAL } from "./commands/index.js";
7
7
  import { cliManifest, versionOf } from "./commands/version.js";
8
8
  import { hasExternal, loadExternal } from "./external.js";
9
- import { CliError, EXIT, bold, cyan, dim, fail, note, pad, write } from "./term.js";
9
+ import { CliError, EXIT, bold, cyan, dim, fail, note, pad, title, write } from "./term.js";
10
10
  /** What the user typed: `zen`, `zn` or `zenera` all arrive here. */
11
11
  const NAME = invokedAs('zen');
12
12
  /** Usage lines are written against `zen`. Say them back in the reader's word. */
@@ -22,6 +22,8 @@ const spell = (usage) => (NAME === 'zen' ? usage : usage.replace(/^zen\b/, NAME)
22
22
  // inside `run`. Everything on this path is Node's own, which is why `zen --help`
23
23
  // starts instantly and why the CLI adds no weight to the library.
24
24
  // ---------------------------------------------------------------------------
25
+ /** Commands whose name is not simply its first letter capitalised. */
26
+ const TITLES = { rag: 'RAG' };
25
27
  const GLOBAL = {
26
28
  help: { type: 'boolean', short: 'h' },
27
29
  version: { type: 'boolean', short: 'v' },
@@ -54,6 +56,11 @@ async function main(argv) {
54
56
  const name = parts.name ? (ALIASES[parts.name] ?? parts.name) : undefined;
55
57
  const command = name ? COMMANDS[name] : undefined;
56
58
  const external = name && !command ? EXTERNAL[name] : undefined;
59
+ // Named before anything is printed, so a run that takes an hour is
60
+ // identifiable in `ps` and on its own tab for the whole of it.
61
+ if (name && (command || external)) {
62
+ title(`Zen ${TITLES[name] ?? name.replace(/^./, (c) => c.toUpperCase())}`);
63
+ }
57
64
  // Narration, so `--json` and every pipe are untouched by it. A command
58
65
  // living in another package brings its own brand, but only once it is
59
66
  // actually there — a banner over "not installed" is a claim about nothing.
package/dist/meta.js CHANGED
@@ -6,7 +6,7 @@ import { invokedAs } from "./args.js";
6
6
  import { paths, readJson, writeJson } from "./home.js";
7
7
  import { envOf } from "./keys.js";
8
8
  import { credentialError, cut, cyan, dim, note, pad, plain, red, styled, usageError, write, yellow, } from "./term.js";
9
- import { answerWidth, segmentsOf, wrap } from "./tui/wrap.js";
9
+ import { blocksOf, boxWidth, textOf, wrap } from "./tui/wrap.js";
10
10
  export const PROMPT_DIR = '.github/prompts';
11
11
  const PROMPT_SUFFIX = '.prompt.md';
12
12
  // ---------------------------------------------------------------------------
@@ -492,19 +492,31 @@ export function answerBox(text, columns = process.stdout.columns ?? 80) {
492
492
  if (!process.stdout.isTTY) {
493
493
  return text.split('\n');
494
494
  }
495
- const outer = answerWidth(columns);
495
+ const outer = boxWidth(text, columns);
496
496
  const inner = outer - 4;
497
497
  const body = [];
498
- for (const segment of segmentsOf(text)) {
499
- if (segment.code) {
498
+ let previous;
499
+ for (const block of blocksOf(text)) {
500
+ if (previous && !(block.kind === 'item' && previous.kind === 'item')) {
501
+ body.push('');
502
+ }
503
+ previous = block;
504
+ if (block.kind === 'code') {
500
505
  // Indentation is the meaning of a fenced block, so it is cut rather
501
506
  // than reflowed.
502
- body.push(dim(`\u250c\u2500${segment.title ? ` ${segment.title}` : ''}`));
503
- body.push(...segment.lines.map((l) => `${dim('\u2502')} ${cut(l, inner - 2)}`));
507
+ body.push(dim(`\u250c\u2500${block.title ? ` ${block.title}` : ''}`));
508
+ body.push(...(block.lines ?? []).map((l) => `${dim('\u2502')} ${cut(l, inner - 2)}`));
504
509
  body.push(dim('\u2514\u2500'));
505
510
  }
511
+ else if (block.lines || block.kind === 'table') {
512
+ body.push(...textOf(block)
513
+ .split('\n')
514
+ .map((l) => cut(l, inner)));
515
+ }
506
516
  else {
507
- body.push(...wrap(segment.lines.join('\n'), inner));
517
+ // Plain, not styled: `wrap` counts characters, and a bold run
518
+ // measured with its escape codes in it folds the line early.
519
+ body.push(...wrap(textOf(block, inner), inner));
508
520
  }
509
521
  }
510
522
  const rule = BOX.h.repeat(outer - 2);
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The project files that are ours rather than the project's.
3
+ *
4
+ * Everything else under `templates/project/` describes *this project* and is
5
+ * written once. These describe *this version of `zen`* — how the memory graph
6
+ * works, how its tools are called, what survives a join — and land under
7
+ * `agents/` only because that is where house rules have to be to reach a
8
+ * prompt. A copy that has fallen behind the runtime is worse than none, so
9
+ * `zen check --fix` replaces them, and nothing in them is worth editing in
10
+ * place: project policy on any of those subjects goes in a topic file of its
11
+ * own beside them.
12
+ */
13
+ export declare const SHARED_RULES: readonly string[];
1
14
  /**
2
15
  * Writes the editor's files under `dir`, replacing what is there. They are
3
16
  * ours: they say how the editor is to treat a directory the agents write into,
@@ -6,6 +19,30 @@
6
19
  * none. Returns the relative paths written.
7
20
  */
8
21
  export declare function editorFiles(dir: string): string[];
22
+ /**
23
+ * Replaces every file in a project that nobody is meant to be maintaining:
24
+ * `SHARED_RULES` in the project tree, and the editor tree whole. Returns the
25
+ * relative paths written, project files first.
26
+ *
27
+ * This is the other half of `keep: true`. A scaffold never overwrites, which is
28
+ * right for the files a project goes on to make its own and wrong for the ones
29
+ * that only ever restate how this version of `zen` behaves — those a project
30
+ * upgrades into, and before this there was no way to get them except by hand.
31
+ */
32
+ export declare function refreshShared(dir: string): string[];
33
+ /**
34
+ * Which of a project's `SHARED_RULES` are not the bytes this `zen` ships.
35
+ *
36
+ * The comparison is the whole file rather than anything read out of it,
37
+ * because there is no reading that would have caught the copies that went
38
+ * stale in practice: prose describing a tool that was renamed, a section about
39
+ * behaviour that changed, frontmatter added after the copy was written. All of
40
+ * it parses, all of it loads, and all of it is wrong. A file that is not there
41
+ * is not stale — what must exist is checked where it is required, not here.
42
+ *
43
+ * Paths come back with forward slashes, the way a report names a file.
44
+ */
45
+ export declare function staleShared(dir: string): string[];
9
46
  export interface ScaffoldOptions {
10
47
  /** the project directory */
11
48
  dir: string;
package/dist/scaffold.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, sep } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // ---------------------------------------------------------------------------
5
5
  // Scaffolding
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
11
11
  //
12
12
  // templates/project/ the project's own files. Written once and edited from
13
13
  // then on, so anything already there is left alone.
14
- // templates/editor/ ours: `.vscode/settings.json` and the `.github/` tree,
14
+ // templates/editor/ ours: the `.vscode/` and `.github/` trees,
15
15
  // which describe this version of `zen` to the editor and
16
16
  // are replaced every time.
17
17
  // templates/parts/ fragments spliced into a template above.
@@ -35,6 +35,23 @@ const TEMPLATES = fileURLToPath(new URL('../templates', import.meta.url));
35
35
  const TEMPLATE = '.tmpl';
36
36
  /** How to use the memory graph: a house rules file, landing under `agents/`. */
37
37
  const MEMORY_RULES = join('agents', 'memory-instructions.md');
38
+ /** How tools are called: the same, and equally not the project's to maintain. */
39
+ const TOOL_RULES = join('agents', 'tools-instructions.md');
40
+ /** How forking behaves: the same again, reaching only agents that declare `fork:`. */
41
+ const FORK_RULES = join('agents', 'fork-instructions.md');
42
+ /**
43
+ * The project files that are ours rather than the project's.
44
+ *
45
+ * Everything else under `templates/project/` describes *this project* and is
46
+ * written once. These describe *this version of `zen`* — how the memory graph
47
+ * works, how its tools are called, what survives a join — and land under
48
+ * `agents/` only because that is where house rules have to be to reach a
49
+ * prompt. A copy that has fallen behind the runtime is worse than none, so
50
+ * `zen check --fix` replaces them, and nothing in them is worth editing in
51
+ * place: project policy on any of those subjects goes in a topic file of its
52
+ * own beside them.
53
+ */
54
+ export const SHARED_RULES = [FORK_RULES, MEMORY_RULES, TOOL_RULES];
38
55
  /** The same bytes, kept in the `zen-memory` skill to restore or diff against. */
39
56
  const MEMORY_REFERENCE = join('.github', 'skills', 'zen-memory', 'references', 'memory-instructions.md');
40
57
  /**
@@ -194,7 +211,7 @@ function copyTree(from, dir, rel, opts) {
194
211
  export function editorFiles(dir) {
195
212
  const written = copyTree(join(TEMPLATES, 'editor'), dir, '', {});
196
213
  // The one file that belongs to both trees. It is a project file first —
197
- // house rules prepended to every agent that can see the graph — and it is
214
+ // house rules reaching every agent that can see the graph — and it is
198
215
  // written once and edited from then on, like every other project file. But
199
216
  // a project whose copy drifted, or that deleted it and turned memory back
200
217
  // on later, needs somewhere to get the current text from, and the editor
@@ -208,6 +225,53 @@ export function editorFiles(dir) {
208
225
  written.push(MEMORY_REFERENCE);
209
226
  return written;
210
227
  }
228
+ /**
229
+ * Replaces every file in a project that nobody is meant to be maintaining:
230
+ * `SHARED_RULES` in the project tree, and the editor tree whole. Returns the
231
+ * relative paths written, project files first.
232
+ *
233
+ * This is the other half of `keep: true`. A scaffold never overwrites, which is
234
+ * right for the files a project goes on to make its own and wrong for the ones
235
+ * that only ever restate how this version of `zen` behaves — those a project
236
+ * upgrades into, and before this there was no way to get them except by hand.
237
+ */
238
+ export function refreshShared(dir) {
239
+ const written = [];
240
+ for (const rel of SHARED_RULES) {
241
+ const path = join(dir, rel);
242
+ mkdirSync(dirname(path), { recursive: true });
243
+ copyFileSync(join(TEMPLATES, 'project', rel), path);
244
+ written.push(rel);
245
+ }
246
+ written.push(...editorFiles(dir));
247
+ return written;
248
+ }
249
+ /**
250
+ * Which of a project's `SHARED_RULES` are not the bytes this `zen` ships.
251
+ *
252
+ * The comparison is the whole file rather than anything read out of it,
253
+ * because there is no reading that would have caught the copies that went
254
+ * stale in practice: prose describing a tool that was renamed, a section about
255
+ * behaviour that changed, frontmatter added after the copy was written. All of
256
+ * it parses, all of it loads, and all of it is wrong. A file that is not there
257
+ * is not stale — what must exist is checked where it is required, not here.
258
+ *
259
+ * Paths come back with forward slashes, the way a report names a file.
260
+ */
261
+ export function staleShared(dir) {
262
+ const stale = [];
263
+ for (const rel of SHARED_RULES) {
264
+ const path = join(dir, rel);
265
+ if (!existsSync(path)) {
266
+ continue;
267
+ }
268
+ const ours = readFileSync(join(TEMPLATES, 'project', rel), 'utf8');
269
+ if (readFileSync(path, 'utf8') !== ours) {
270
+ stale.push(rel.split(sep).join('/'));
271
+ }
272
+ }
273
+ return stale;
274
+ }
211
275
  /**
212
276
  * Writes a project. Never overwrites the project's own files — a second `init`
213
277
  * over a directory fills in what is missing and leaves the rest alone — but the
package/dist/term.d.ts CHANGED
@@ -20,7 +20,21 @@ 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;
@@ -52,6 +66,8 @@ export declare function progress(): {
52
66
  done: () => void;
53
67
  };
54
68
  export declare function warn(message: string): void;
69
+ /** What this run calls itself, in the process table and on the terminal's tab. */
70
+ export declare function title(text: string): void;
55
71
  export declare function fail(message: string, hint?: string): void;
56
72
  export declare function isInteractive(): boolean;
57
73
  export declare function ask(question: string, fallback?: string): Promise<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,11 +37,75 @@ 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);
@@ -121,6 +186,31 @@ export function progress() {
121
186
  export function warn(message) {
122
187
  note(`${yellow('warning')} ${message}`);
123
188
  }
189
+ // ---------------------------------------------------------------------------
190
+ // Naming the run
191
+ //
192
+ // Two different things answer to the word "title", and a long run wants both.
193
+ // `process.title` is what the process table says, which is how `ps` and
194
+ // Activity Monitor tell one `zen` from another. The tab is the terminal's own,
195
+ // and nothing a process does to itself reaches it — macOS reads the tab name
196
+ // from the executable — so it takes an OSC sequence, written to stderr with the
197
+ // rest of the narration and only when there is a terminal to read it.
198
+ // ---------------------------------------------------------------------------
199
+ let restores = false;
200
+ /** What this run calls itself, in the process table and on the terminal's tab. */
201
+ export function title(text) {
202
+ process.title = text;
203
+ if (!process.stderr.isTTY) {
204
+ return;
205
+ }
206
+ // Push what the shell had there first, so leaving puts it back.
207
+ if (!restores) {
208
+ restores = true;
209
+ process.stderr.write('\u001b[22;0t');
210
+ process.on('exit', () => process.stderr.write('\u001b[23;0t'));
211
+ }
212
+ process.stderr.write(`\u001b]0;${text}\u0007`);
213
+ }
124
214
  export function fail(message, hint) {
125
215
  note(`${red('error')} ${message}`);
126
216
  if (hint) {