@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.
- package/README.md +99 -16
- package/dist/banner.d.ts +19 -0
- package/dist/banner.js +17 -10
- package/dist/command.d.ts +3 -0
- package/dist/commands/index.js +4 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/inspect.js +77 -13
- package/dist/commands/key.js +49 -1
- package/dist/commands/memory.js +16 -3
- package/dist/commands/meta.d.ts +3 -0
- package/dist/commands/meta.js +596 -0
- package/dist/commands/run.js +63 -38
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +20 -1
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/main.js +1 -1
- package/dist/meta.d.ts +221 -0
- package/dist/meta.js +695 -0
- package/dist/podman.js +64 -2
- package/dist/projects.d.ts +9 -1
- package/dist/projects.js +19 -2
- package/dist/resolve.js +7 -1
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +1 -1
- package/dist/scaffold.js +1 -1
- package/dist/session.d.ts +10 -0
- package/dist/session.js +19 -1
- package/dist/term.d.ts +21 -0
- package/dist/term.js +74 -2
- package/dist/tui/app.js +313 -150
- package/dist/tui/theme.d.ts +26 -7
- package/dist/tui/theme.js +62 -36
- package/dist/tui/wrap.d.ts +41 -16
- package/dist/tui/wrap.js +191 -18
- package/dist/validate.js +3 -16
- package/package.json +3 -2
- package/templates/editor/.github/copilot-instructions.md +61 -7
- package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
- package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
- package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
- package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
- package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
- package/templates/editor/.github/skills/zen-cli/references/projects.md +1 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
- package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
- package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
- package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
- package/templates/project/SPECIFICATION.md.tmpl +25 -2
- package/templates/project/agents/tools-instructions.md +8 -0
- package/templates/project/agents.yaml.tmpl +3 -3
- package/templates/project/env +7 -1
- 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
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/projects.d.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|