@pugi/cli 0.1.0-beta.22 → 0.1.0-beta.23
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/dist/core/auth/env-provider.js +238 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/engine/native-pugi.js +34 -1
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/session.js +107 -0
- package/dist/core/repl/slash-commands.js +35 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/runtime/cli.js +217 -14
- package/dist/runtime/commands/doctor.js +13 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/doctor-table.js +32 -17
- package/dist/tui/repl-render.js +17 -2
- package/dist/tui/repl.js +9 -1
- package/dist/tui/style-table.js +9 -3
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/vim-input.js +267 -0
- package/package.json +2 -2
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- package/dist/core/repl/onboarding-state.js +0 -297
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep-a-Changelog parser — Leak L24 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Parses a `CHANGELOG.md` written в the Keep-a-Changelog v1.1 layout
|
|
5
|
+
* (https://keepachangelog.com) into an ordered list of version
|
|
6
|
+
* sections. The parser is intentionally minimal — it understands the
|
|
7
|
+
* `## [<version>] - <date>` header marker and the section body up к
|
|
8
|
+
* the next header, and ignores every other Markdown construct (links,
|
|
9
|
+
* footnotes, sub-sub-headers). That is enough к drive the
|
|
10
|
+
* `pugi release-notes` diff between last-seen and current.
|
|
11
|
+
*
|
|
12
|
+
* # Module contract
|
|
13
|
+
*
|
|
14
|
+
* - Pure function. Takes the raw CHANGELOG text + returns parsed
|
|
15
|
+
* sections. Zero IO; the file read happens at the call site so the
|
|
16
|
+
* spec can pin fixtures without touching disk.
|
|
17
|
+
*
|
|
18
|
+
* - Header grammar: `## [<version>] - <YYYY-MM-DD>`. The leading
|
|
19
|
+
* `## ` is required (h2). The version is everything between the
|
|
20
|
+
* square brackets — semver-shaped strings are not validated
|
|
21
|
+
* beyond non-emptiness so pre-release tags like `0.1.0-beta.21`
|
|
22
|
+
* parse correctly. The date is captured verbatim; the comparator
|
|
23
|
+
* does not parse it — version ordering is enough.
|
|
24
|
+
*
|
|
25
|
+
* - Section body: every line from the header (exclusive) up к the
|
|
26
|
+
* next `## [...] - ...` header (exclusive). Lines are joined
|
|
27
|
+
* verbatim with `\n`; leading + trailing blank lines are trimmed
|
|
28
|
+
* so the renderer can paste sections back-to-back without double
|
|
29
|
+
* blank lines piling up.
|
|
30
|
+
*
|
|
31
|
+
* - Pre-header content (the leading `# Changelog` + introduction
|
|
32
|
+
* blurb) is discarded. The parser only surfaces version sections;
|
|
33
|
+
* callers that want к render the introduction read the source
|
|
34
|
+
* file directly.
|
|
35
|
+
*
|
|
36
|
+
* - Sections appear в the order they are written in the file. The
|
|
37
|
+
* Keep-a-Changelog convention is newest-first; the parser does
|
|
38
|
+
* NOT re-sort. The slicing helpers (`sliceVersionsBetween`) trust
|
|
39
|
+
* the input order so a malformed CHANGELOG with shuffled
|
|
40
|
+
* versions produces a deterministic — if surprising — diff
|
|
41
|
+
* instead of silently swallowing entries.
|
|
42
|
+
*
|
|
43
|
+
* - Semver comparison (`compareSemver`) handles the canonical
|
|
44
|
+
* `MAJOR.MINOR.PATCH[-PRERELEASE]` shape. The pre-release tail
|
|
45
|
+
* is compared lexicographically by dot-separated identifier per
|
|
46
|
+
* semver §11. Unknown / malformed input compares lower than any
|
|
47
|
+
* valid semver so an accidental `unknown` last-seen marker
|
|
48
|
+
* never blocks the operator from seeing new notes.
|
|
49
|
+
*/
|
|
50
|
+
const SECTION_HEADER_RE = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/u;
|
|
51
|
+
/**
|
|
52
|
+
* Parse the raw `CHANGELOG.md` text into ordered version sections.
|
|
53
|
+
*
|
|
54
|
+
* Returns sections in the same order they appear in the source. The
|
|
55
|
+
* Keep-a-Changelog convention is newest-first; the parser does not
|
|
56
|
+
* re-sort.
|
|
57
|
+
*/
|
|
58
|
+
export function parseChangelog(raw) {
|
|
59
|
+
const lines = raw.split(/\r?\n/u);
|
|
60
|
+
const sections = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
const flush = () => {
|
|
63
|
+
if (!current)
|
|
64
|
+
return;
|
|
65
|
+
sections.push({
|
|
66
|
+
version: current.version,
|
|
67
|
+
date: current.date,
|
|
68
|
+
body: trimBlankEdges(current.lines).join('\n'),
|
|
69
|
+
});
|
|
70
|
+
current = null;
|
|
71
|
+
};
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const match = SECTION_HEADER_RE.exec(line);
|
|
74
|
+
if (match) {
|
|
75
|
+
flush();
|
|
76
|
+
const version = (match[1] ?? '').trim();
|
|
77
|
+
const date = (match[2] ?? '').trim();
|
|
78
|
+
if (version.length === 0) {
|
|
79
|
+
// Malformed `## []` header — skip entirely instead of
|
|
80
|
+
// emitting a zero-version row that would corrupt the diff.
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
current = { version, date, lines: [] };
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (current) {
|
|
87
|
+
current.lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
flush();
|
|
91
|
+
return sections;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Slice the section list к those strictly newer than `lastSeen` and
|
|
95
|
+
* up к (and including) `current`. Both bounds are matched on the
|
|
96
|
+
* verbatim version string — the comparator runs on every section к
|
|
97
|
+
* decide membership.
|
|
98
|
+
*
|
|
99
|
+
* Semantics:
|
|
100
|
+
*
|
|
101
|
+
* - If `lastSeen` is null OR empty OR matches no section, every
|
|
102
|
+
* section ≤ current is returned. This is the first-run path —
|
|
103
|
+
* the operator has never run the command before, so the entire
|
|
104
|
+
* bundled changelog is fair game.
|
|
105
|
+
*
|
|
106
|
+
* - If `lastSeen` equals `current`, the empty array is returned.
|
|
107
|
+
* The caller renders the "no new release notes" copy.
|
|
108
|
+
*
|
|
109
|
+
* - If `lastSeen` is newer than `current`, the empty array is
|
|
110
|
+
* returned. This is the dev-build path — operators running a
|
|
111
|
+
* local build of `0.1.0-beta.30` against a registry that
|
|
112
|
+
* publishes `0.1.0-beta.22` would otherwise see the stale
|
|
113
|
+
* bundled notes; the caller still surfaces the same no-op copy.
|
|
114
|
+
*
|
|
115
|
+
* - Otherwise: sections strictly newer than `lastSeen` and ≤
|
|
116
|
+
* `current`, in the source order (newest-first by convention).
|
|
117
|
+
*
|
|
118
|
+
* The function is pure — no IO, no clock — so the spec can pin every
|
|
119
|
+
* branch with hand-rolled fixtures.
|
|
120
|
+
*/
|
|
121
|
+
export function sliceVersionsBetween(sections, lastSeen, current) {
|
|
122
|
+
if (sections.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
// Treat а blank / sentinel last-seen as "never seen".
|
|
125
|
+
const lastSeenValue = typeof lastSeen === 'string' && lastSeen.trim().length > 0 && lastSeen !== 'none'
|
|
126
|
+
? lastSeen.trim()
|
|
127
|
+
: null;
|
|
128
|
+
// Dev-build path: operator's last-seen marker is strictly newer than
|
|
129
|
+
// the installed CLI version. This happens when running а local build
|
|
130
|
+
// older than the registry, or when the operator manually edited the
|
|
131
|
+
// marker. Either way, return the empty array — re-rendering the
|
|
132
|
+
// bundled notes would be misleading. The renderer surfaces the same
|
|
133
|
+
// "no new release notes" copy as the up-to-date branch.
|
|
134
|
+
if (lastSeenValue !== null && compareSemver(lastSeenValue, current) > 0) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
// Diff path: surface sections strictly newer than the marker and ≤
|
|
138
|
+
// current. The comparator drives every section — а marker that does
|
|
139
|
+
// not appear in the bundled changelog (operator on а stale build,
|
|
140
|
+
// hand-edited marker, version no longer published) still bisects
|
|
141
|
+
// correctly because every comparison runs against the marker value
|
|
142
|
+
// directly, not the matching-section guard.
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const section of sections) {
|
|
145
|
+
// Anything newer than current is а future entry that should not
|
|
146
|
+
// surface until the operator actually upgrades — guards against а
|
|
147
|
+
// dev build of CHANGELOG.md leaking unreleased notes к а customer
|
|
148
|
+
// install.
|
|
149
|
+
if (compareSemver(section.version, current) > 0)
|
|
150
|
+
continue;
|
|
151
|
+
if (lastSeenValue !== null && compareSemver(section.version, lastSeenValue) <= 0) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push(section);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Semver comparator. Returns a negative number when `a < b`, zero
|
|
160
|
+
* when equal, and a positive number when `a > b`. Pre-release tags
|
|
161
|
+
* compare lexicographically by dot-separated identifier per semver §11.
|
|
162
|
+
*
|
|
163
|
+
* Unknown / malformed input compares lower than any valid semver so
|
|
164
|
+
* an accidental sentinel like `unknown` or `none` never accidentally
|
|
165
|
+
* blocks the diff from surfacing newer entries.
|
|
166
|
+
*/
|
|
167
|
+
export function compareSemver(a, b) {
|
|
168
|
+
const left = parseSemver(a);
|
|
169
|
+
const right = parseSemver(b);
|
|
170
|
+
if (!left && !right)
|
|
171
|
+
return 0;
|
|
172
|
+
if (!left)
|
|
173
|
+
return -1;
|
|
174
|
+
if (!right)
|
|
175
|
+
return 1;
|
|
176
|
+
for (let i = 0; i < 3; i += 1) {
|
|
177
|
+
const cmp = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
|
178
|
+
if (cmp !== 0)
|
|
179
|
+
return cmp;
|
|
180
|
+
}
|
|
181
|
+
// A version without a pre-release tag is newer than the same core
|
|
182
|
+
// with a pre-release tag (per semver §11: 1.0.0 > 1.0.0-rc).
|
|
183
|
+
if (left.pre.length === 0 && right.pre.length === 0)
|
|
184
|
+
return 0;
|
|
185
|
+
if (left.pre.length === 0)
|
|
186
|
+
return 1;
|
|
187
|
+
if (right.pre.length === 0)
|
|
188
|
+
return -1;
|
|
189
|
+
const len = Math.max(left.pre.length, right.pre.length);
|
|
190
|
+
for (let i = 0; i < len; i += 1) {
|
|
191
|
+
const li = left.pre[i];
|
|
192
|
+
const ri = right.pre[i];
|
|
193
|
+
if (li === undefined)
|
|
194
|
+
return -1;
|
|
195
|
+
if (ri === undefined)
|
|
196
|
+
return 1;
|
|
197
|
+
const ln = Number.parseInt(li, 10);
|
|
198
|
+
const rn = Number.parseInt(ri, 10);
|
|
199
|
+
const liNumeric = Number.isFinite(ln) && String(ln) === li;
|
|
200
|
+
const riNumeric = Number.isFinite(rn) && String(rn) === ri;
|
|
201
|
+
if (liNumeric && riNumeric) {
|
|
202
|
+
if (ln !== rn)
|
|
203
|
+
return ln - rn;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (liNumeric)
|
|
207
|
+
return -1; // numeric < alphanumeric per §11
|
|
208
|
+
if (riNumeric)
|
|
209
|
+
return 1;
|
|
210
|
+
if (li < ri)
|
|
211
|
+
return -1;
|
|
212
|
+
if (li > ri)
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
function parseSemver(raw) {
|
|
218
|
+
if (typeof raw !== 'string')
|
|
219
|
+
return null;
|
|
220
|
+
const trimmed = raw.trim();
|
|
221
|
+
if (trimmed.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u.exec(trimmed);
|
|
224
|
+
if (!match)
|
|
225
|
+
return null;
|
|
226
|
+
const major = Number.parseInt(match[1] ?? '0', 10);
|
|
227
|
+
const minor = Number.parseInt(match[2] ?? '0', 10);
|
|
228
|
+
const patch = Number.parseInt(match[3] ?? '0', 10);
|
|
229
|
+
const pre = match[4] ? match[4].split('.') : [];
|
|
230
|
+
return { core: [major, minor, patch], pre };
|
|
231
|
+
}
|
|
232
|
+
function trimBlankEdges(lines) {
|
|
233
|
+
let start = 0;
|
|
234
|
+
let end = lines.length;
|
|
235
|
+
while (start < end && (lines[start] ?? '').trim().length === 0)
|
|
236
|
+
start += 1;
|
|
237
|
+
while (end > start && (lines[end - 1] ?? '').trim().length === 0)
|
|
238
|
+
end -= 1;
|
|
239
|
+
return lines.slice(start, end);
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=parser.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.pugi/.last-seen-version` state I/O — Leak L24 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* The `pugi release-notes` command renders the diff between the
|
|
5
|
+
* version the operator last saw notes for and the currently
|
|
6
|
+
* installed CLI version. This module owns the on-disk marker that
|
|
7
|
+
* tracks the last-seen value.
|
|
8
|
+
*
|
|
9
|
+
* # Module contract
|
|
10
|
+
*
|
|
11
|
+
* - File path: `<home>/.pugi/.last-seen-version`. The leading dot
|
|
12
|
+
* keeps it out of casual `ls` output; the file is plain ASCII
|
|
13
|
+
* (one line, the version string) so operators can edit it by
|
|
14
|
+
* hand when reproducing scenarios. Missing parent dir is
|
|
15
|
+
* created on write.
|
|
16
|
+
*
|
|
17
|
+
* - Reads: missing file → null. Unreadable / blank file → null.
|
|
18
|
+
* The caller treats null as "operator has never run the command"
|
|
19
|
+
* and surfaces every bundled section. Read failures NEVER throw —
|
|
20
|
+
* the command surface is informational and must keep working on
|
|
21
|
+
* a read-only mount or a misconfigured permission bit.
|
|
22
|
+
*
|
|
23
|
+
* - Writes: best-effort. EACCES / EROFS / ENOSPC log а warning к
|
|
24
|
+
* the caller-supplied logger and return the failure code so the
|
|
25
|
+
* command renderer can footer the output with "could not persist
|
|
26
|
+
* last-seen — re-run will show the same notes". The command
|
|
27
|
+
* itself stays exit 0 because the value of the render did not
|
|
28
|
+
* depend on the write succeeding.
|
|
29
|
+
*
|
|
30
|
+
* - The helpers are pure I/O wrappers — no clock, no random, no
|
|
31
|
+
* env reads. Callers pass `home` explicitly so the spec can
|
|
32
|
+
* pin a tmp dir without monkey-patching `os.homedir`.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
/** Filename inside `<home>/.pugi/` that holds the last-seen marker. */
|
|
37
|
+
export const LAST_SEEN_VERSION_FILE = '.last-seen-version';
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the absolute path of the last-seen marker file inside the
|
|
40
|
+
* passed home directory. Pure helper — no IO.
|
|
41
|
+
*/
|
|
42
|
+
export function lastSeenVersionPath(home) {
|
|
43
|
+
return resolve(home, '.pugi', LAST_SEEN_VERSION_FILE);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the last-seen marker. Returns null when the file is missing,
|
|
47
|
+
* unreadable, or empty. Never throws.
|
|
48
|
+
*/
|
|
49
|
+
export function readLastSeenVersion(home) {
|
|
50
|
+
const path = lastSeenVersionPath(home);
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path))
|
|
53
|
+
return null;
|
|
54
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
55
|
+
if (raw.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Read-only mount, permission denied, race with another process
|
|
61
|
+
// unlinking the file — every read failure degrades к null so the
|
|
62
|
+
// command treats the operator as a first-time viewer instead of
|
|
63
|
+
// dropping out of the slash dispatcher with an unhandled error.
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Persist the last-seen marker. Creates `<home>/.pugi/` if it does
|
|
69
|
+
* not exist. Returns a structured envelope describing success or the
|
|
70
|
+
* failure reason so the renderer can footer а warning when the write
|
|
71
|
+
* could not be made durable.
|
|
72
|
+
*/
|
|
73
|
+
export function writeLastSeenVersion(home, version) {
|
|
74
|
+
const path = lastSeenVersionPath(home);
|
|
75
|
+
try {
|
|
76
|
+
const dir = resolve(home, '.pugi');
|
|
77
|
+
if (!existsSync(dir)) {
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
// Trailing newline so `cat ~/.pugi/.last-seen-version` reads
|
|
81
|
+
// nicely in shells that do not auto-append one for the prompt.
|
|
82
|
+
writeFileSync(path, `${version}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
83
|
+
return { status: 'ok', path };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'failed',
|
|
88
|
+
path,
|
|
89
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Clear the last-seen marker — used by `pugi release-notes --reset`
|
|
95
|
+
* so the operator can force the full bundled changelog к re-render.
|
|
96
|
+
* Returns `absent` when the marker did not exist, `cleared` on
|
|
97
|
+
* success, `failed` on permission errors.
|
|
98
|
+
*/
|
|
99
|
+
export function clearLastSeenVersion(home) {
|
|
100
|
+
const path = lastSeenVersionPath(home);
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(path)) {
|
|
103
|
+
return { status: 'absent', path };
|
|
104
|
+
}
|
|
105
|
+
unlinkSync(path);
|
|
106
|
+
return { status: 'cleared', path };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
status: 'failed',
|
|
111
|
+
path,
|
|
112
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -788,6 +788,39 @@ export class ReplSession {
|
|
|
788
788
|
}
|
|
789
789
|
return verdict;
|
|
790
790
|
}
|
|
791
|
+
case 'theme': {
|
|
792
|
+
// Leak L30 (2026-05-27): /theme [name] [--persist|--reset|--list]
|
|
793
|
+
// forwards to the shared `runThemeCommand` runner. Same async
|
|
794
|
+
// buffer-then-flush pattern as `/style` so a future async
|
|
795
|
+
// write path inside the runner cannot drop a tail emission
|
|
796
|
+
// and so multi-line payloads (banner + preview table) land
|
|
797
|
+
// one row per visual line in the conversation pane.
|
|
798
|
+
try {
|
|
799
|
+
const { runThemeCommand } = await import('../../runtime/commands/theme.js');
|
|
800
|
+
const lines = [];
|
|
801
|
+
await runThemeCommand(verdict.args, {
|
|
802
|
+
workspaceRoot: process.cwd(),
|
|
803
|
+
writeOutput: (_payload, text) => {
|
|
804
|
+
for (const raw of text.split('\n')) {
|
|
805
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
806
|
+
lines.push(trimmed);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
if (lines.length === 0) {
|
|
811
|
+
this.appendSystemLine('/theme: no output.');
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
for (const line of lines)
|
|
815
|
+
this.appendSystemLine(line);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
820
|
+
this.appendSystemLine(`/theme failed: ${message}`);
|
|
821
|
+
}
|
|
822
|
+
return verdict;
|
|
823
|
+
}
|
|
791
824
|
case 'style': {
|
|
792
825
|
// Leak L18 (2026-05-27): /style [name] [--persist|--reset|--list]
|
|
793
826
|
// forwards to the shared `runStyleCommand` runner so the slash
|
|
@@ -871,6 +904,45 @@ export class ReplSession {
|
|
|
871
904
|
}
|
|
872
905
|
return verdict;
|
|
873
906
|
}
|
|
907
|
+
case 'vim': {
|
|
908
|
+
// Leak L26 (2026-05-27): /vim forwards to the shared
|
|
909
|
+
// `runVimCommand` runner so the slash + top-level surfaces
|
|
910
|
+
// stay single-sourced. Dynamic import mirrors /style so the
|
|
911
|
+
// dispatcher does not drag the vim module graph into every
|
|
912
|
+
// keystroke.
|
|
913
|
+
//
|
|
914
|
+
// The runner mutates `~/.pugi/config.json::vimMode`; the
|
|
915
|
+
// active REPL session does NOT live-pick-up the flip (the
|
|
916
|
+
// VimInput wrapper is mounted once at REPL boot). Operators
|
|
917
|
+
// get a hint that the next session will reflect the change.
|
|
918
|
+
// A follow-up sprint can plumb a state-store subscriber so
|
|
919
|
+
// the flip takes effect mid-session.
|
|
920
|
+
try {
|
|
921
|
+
const { runVimCommand } = await import('../../runtime/commands/vim.js');
|
|
922
|
+
const lines = [];
|
|
923
|
+
await runVimCommand(verdict.args, {
|
|
924
|
+
env: process.env,
|
|
925
|
+
writeOutput: (_payload, text) => {
|
|
926
|
+
for (const raw of text.split('\n')) {
|
|
927
|
+
const trimmed = raw.replace(/\s+$/u, '');
|
|
928
|
+
lines.push(trimmed);
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
if (lines.length === 0) {
|
|
933
|
+
this.appendSystemLine('/vim: no output.');
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
for (const line of lines)
|
|
937
|
+
this.appendSystemLine(line);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
942
|
+
this.appendSystemLine(`/vim failed: ${message}`);
|
|
943
|
+
}
|
|
944
|
+
return verdict;
|
|
945
|
+
}
|
|
874
946
|
case 'doctor': {
|
|
875
947
|
// L17 (2026-05-27): run the doctor probe sweep inline. We
|
|
876
948
|
// dynamic-import the runtime/commands/doctor module so the
|
|
@@ -1017,6 +1089,41 @@ export class ReplSession {
|
|
|
1017
1089
|
}
|
|
1018
1090
|
return verdict;
|
|
1019
1091
|
}
|
|
1092
|
+
case 'release-notes': {
|
|
1093
|
+
// Leak L24 (2026-05-27): changelog diff between the operator's
|
|
1094
|
+
// last-seen + installed CLI versions. Delegate к the shared
|
|
1095
|
+
// `runReleaseNotesCommand` runner so the slash + top-level
|
|
1096
|
+
// paths stay single-sourced. The renderer collects each line
|
|
1097
|
+
// into the system pane via `appendSystemLine` — no fresh Ink
|
|
1098
|
+
// mount, no boxed render. `--reset` is honoured via the
|
|
1099
|
+
// `verdict.reset` field parsed in slash-commands.ts.
|
|
1100
|
+
try {
|
|
1101
|
+
const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
|
|
1102
|
+
const lines = [];
|
|
1103
|
+
runReleaseNotesCommand({
|
|
1104
|
+
home: defaultReleaseNotesHome(),
|
|
1105
|
+
json: false,
|
|
1106
|
+
reset: verdict.reset,
|
|
1107
|
+
writeOutput: (_payload, text) => {
|
|
1108
|
+
for (const line of text.split('\n')) {
|
|
1109
|
+
lines.push(line.replace(/\s+$/u, ''));
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
if (lines.length === 0) {
|
|
1114
|
+
this.appendSystemLine('/release-notes: no output.');
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
for (const line of lines)
|
|
1118
|
+
this.appendSystemLine(line);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1123
|
+
this.appendSystemLine(`/release-notes failed: ${message}`);
|
|
1124
|
+
}
|
|
1125
|
+
return verdict;
|
|
1126
|
+
}
|
|
1020
1127
|
case 'stickers': {
|
|
1021
1128
|
// Leak L33 (2026-05-27): brand-personality gimmick. Delegate to
|
|
1022
1129
|
// the shared `runStickersCommand` so the slash + top-level
|
|
@@ -83,7 +83,9 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
83
83
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
84
84
|
{ name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
|
|
85
85
|
{ name: 'style', args: '[name] [--persist|--reset|--list]', gloss: 'Output-style preset (default / terse / explanatory / russian-formal / casual)', group: 'Settings' },
|
|
86
|
+
{ name: 'theme', args: '[name] [--persist|--reset|--list]', gloss: 'TUI color palette (default / dark / light / colorblind)', group: 'Settings' },
|
|
86
87
|
{ name: 'onboarding', args: '[--reset|--non-interactive]', gloss: 'First-run wizard — auth / mode / style / MCP / telemetry (leak L25)', group: 'Settings' },
|
|
88
|
+
{ name: 'vim', args: '[on|off|status]', gloss: 'Toggle vim-style modal editing in the input buffer (leak L26)', group: 'Settings' },
|
|
87
89
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
88
90
|
// Meta
|
|
89
91
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
@@ -92,6 +94,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
92
94
|
{ name: 'stickers', args: '', gloss: 'show Pugi brand stickers (gimmick)', group: 'Meta' },
|
|
93
95
|
{ name: 'feedback', args: '', gloss: 'file a bug / feature / general comment without leaving the REPL', group: 'Meta' },
|
|
94
96
|
{ name: 'share', args: '[--gist|--pugi] [--redact] [--preview]', gloss: 'Export session transcript to gist / pugi.io (leak L20)', group: 'Meta' },
|
|
97
|
+
{ name: 'release-notes', args: '[--reset]', gloss: 'Show changelog diff since last upgrade (leak L24)', group: 'Meta' },
|
|
95
98
|
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
96
99
|
]);
|
|
97
100
|
/**
|
|
@@ -388,6 +391,17 @@ export function parseSlashCommand(input) {
|
|
|
388
391
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
389
392
|
return { kind: 'style', args: tokens };
|
|
390
393
|
}
|
|
394
|
+
case 'theme':
|
|
395
|
+
case 'palette':
|
|
396
|
+
case 'colors': {
|
|
397
|
+
// Leak L30 (2026-05-27): forward the tokenized tail unchanged so
|
|
398
|
+
// the slash + top-level `pugi theme` surfaces share one parser
|
|
399
|
+
// inside `runThemeCommand`. Aliases `/palette` and `/colors`
|
|
400
|
+
// exist because the leak-landscape audit found operators reach
|
|
401
|
+
// for either word interchangeably — same surface, same handler.
|
|
402
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
403
|
+
return { kind: 'theme', args: tokens };
|
|
404
|
+
}
|
|
391
405
|
case 'onboarding':
|
|
392
406
|
case 'onboard':
|
|
393
407
|
case 'setup': {
|
|
@@ -399,6 +413,14 @@ export function parseSlashCommand(input) {
|
|
|
399
413
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
400
414
|
return { kind: 'onboarding', args: tokens };
|
|
401
415
|
}
|
|
416
|
+
case 'vim': {
|
|
417
|
+
// Leak L26 (2026-05-27): forward the tokenized tail unchanged so
|
|
418
|
+
// the slash + top-level CLI surfaces share one parser inside
|
|
419
|
+
// `runVimCommand`. Subcommands are single tokens (on / off /
|
|
420
|
+
// status); a bare `/vim` toggles.
|
|
421
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
422
|
+
return { kind: 'vim', args: tokens };
|
|
423
|
+
}
|
|
402
424
|
case 'doctor':
|
|
403
425
|
case 'health': {
|
|
404
426
|
// L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
|
|
@@ -439,6 +461,19 @@ export function parseSlashCommand(input) {
|
|
|
439
461
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
440
462
|
return { kind: 'share', args: tokens };
|
|
441
463
|
}
|
|
464
|
+
case 'release-notes':
|
|
465
|
+
case 'releasenotes':
|
|
466
|
+
case 'changelog': {
|
|
467
|
+
// Leak L24 (2026-05-27): changelog diff between last-seen +
|
|
468
|
+
// installed CLI version. Tail args are tokenized so `--reset`
|
|
469
|
+
// can flip the marker-clear bit; no other flags are honoured —
|
|
470
|
+
// the surface mirrors the CLI top-level's intentional minimalism.
|
|
471
|
+
// `changelog` alias matches operator muscle memory from npm /
|
|
472
|
+
// cargo / brew, all of which ship `changelog` subcommands.
|
|
473
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
474
|
+
const reset = tokens.includes('--reset') || tokens.includes('-r');
|
|
475
|
+
return { kind: 'release-notes', reset };
|
|
476
|
+
}
|
|
442
477
|
case 'memory':
|
|
443
478
|
case 'config':
|
|
444
479
|
case 'budget':
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Leak L30 (2026-05-27) — Theme React context + `useTheme` hook.
|
|
4
|
+
*
|
|
5
|
+
* Threads the active theme's color tokens through the Ink component
|
|
6
|
+
* tree so individual components do not need to call `resolveTheme()`
|
|
7
|
+
* on every render. The provider is mounted once at the top of the
|
|
8
|
+
* REPL Ink tree (in `repl-render.tsx`); standalone CLI commands
|
|
9
|
+
* (`pugi doctor`, `pugi theme`) can mount the provider themselves
|
|
10
|
+
* when they print colored output.
|
|
11
|
+
*
|
|
12
|
+
* Design contract:
|
|
13
|
+
*
|
|
14
|
+
* - The hook returns the resolved `ThemeColors` token set, NOT the
|
|
15
|
+
* full `ResolvedTheme`. Component code only needs the color
|
|
16
|
+
* values; the slug + source label live on the parent (the
|
|
17
|
+
* `/theme` table renders them, individual components do not).
|
|
18
|
+
*
|
|
19
|
+
* - When no provider is mounted, `useTheme()` returns the
|
|
20
|
+
* `default` preset's colors. This is intentional — pure render
|
|
21
|
+
* components that get imported into a test without a wrapper
|
|
22
|
+
* should not crash. The behaviour matches `useContext` semantics
|
|
23
|
+
* of every other Pugi context (`SessionContext`, `WorkspaceContext`).
|
|
24
|
+
*
|
|
25
|
+
* - The provider takes the *resolved slug* (not the file path).
|
|
26
|
+
* The caller is responsible for calling `resolveTheme()` once at
|
|
27
|
+
* mount time and re-mounting on slug change. We deliberately do
|
|
28
|
+
* NOT poll the config file from inside the provider — Ink
|
|
29
|
+
* re-renders on every prop change would otherwise risk
|
|
30
|
+
* reentrancy with the input box's raw-mode handler.
|
|
31
|
+
*
|
|
32
|
+
* - The provider value is memoised against the slug so child
|
|
33
|
+
* components see referentially-equal colors across re-renders
|
|
34
|
+
* when the slug has not changed. This matters for `useMemo` /
|
|
35
|
+
* `useEffect` dependency lists in downstream consumers.
|
|
36
|
+
*
|
|
37
|
+
* Test surface: `test/commands/theme-context.spec.tsx` mounts the
|
|
38
|
+
* provider with each preset slug, asserts the hook returns the
|
|
39
|
+
* matching color tokens, and asserts the default-when-no-provider
|
|
40
|
+
* fallback path.
|
|
41
|
+
*/
|
|
42
|
+
import { createContext, useContext, useMemo, } from 'react';
|
|
43
|
+
import { DEFAULT_THEME, getThemeColors, } from './presets.js';
|
|
44
|
+
/**
|
|
45
|
+
* The context default is the `default` preset's colors. Components
|
|
46
|
+
* imported into a test or non-REPL render that lack a provider
|
|
47
|
+
* therefore behave as if the operator never overrode the theme.
|
|
48
|
+
*/
|
|
49
|
+
const ThemeContext = createContext({
|
|
50
|
+
slug: DEFAULT_THEME,
|
|
51
|
+
colors: getThemeColors(DEFAULT_THEME),
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Mount the theme provider with a resolved slug. The provider
|
|
55
|
+
* memoises the color lookup against `slug` so child components see
|
|
56
|
+
* referentially-stable colors across re-renders.
|
|
57
|
+
*
|
|
58
|
+
* Production wiring (`tui/repl-render.tsx`):
|
|
59
|
+
*
|
|
60
|
+
* const resolved = resolveTheme({ workspaceRoot, env: process.env });
|
|
61
|
+
* render(<ThemeProvider slug={resolved.slug}><Repl … /></ThemeProvider>);
|
|
62
|
+
*
|
|
63
|
+
* The wrapper is intentionally a thin pass-through (no side effects,
|
|
64
|
+
* no `useEffect`) so it can be mounted from any Ink renderer
|
|
65
|
+
* including the one-shot CLI surfaces in `runtime/cli.ts`.
|
|
66
|
+
*/
|
|
67
|
+
export function ThemeProvider({ slug, children }) {
|
|
68
|
+
const value = useMemo(() => ({ slug, colors: getThemeColors(slug) }), [slug]);
|
|
69
|
+
return _jsx(ThemeContext.Provider, { value: value, children: children });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Hook that returns the active theme's color tokens.
|
|
73
|
+
*
|
|
74
|
+
* Components reference tokens by semantic name (`accent`, `success`,
|
|
75
|
+
* `error`) instead of literal hex codes so a theme flip is a
|
|
76
|
+
* single-write operation. Tests can mount any preset without
|
|
77
|
+
* touching the disk; the production REPL resolves once at mount and
|
|
78
|
+
* re-mounts on `/theme <name>`.
|
|
79
|
+
*/
|
|
80
|
+
export function useTheme() {
|
|
81
|
+
return useContext(ThemeContext).colors;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Debug helper — returns the currently-active slug + colors. Used by
|
|
85
|
+
* the `/theme` slash command's preview path; production components
|
|
86
|
+
* should call `useTheme()` so the boundary stays narrow.
|
|
87
|
+
*/
|
|
88
|
+
export function useThemeDebug() {
|
|
89
|
+
return useContext(ThemeContext);
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=context.js.map
|