@pugi/cli 0.1.0-beta.24 → 0.1.0-beta.25

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.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `pugi hooks` — operator surface for user-config hooks (Leak L12 MVP).
3
+ *
4
+ * Two subcommands ship in the MVP:
5
+ *
6
+ * pugi hooks list List configured hooks per event.
7
+ * pugi hooks doctor Validate the config and surface any
8
+ * parse / schema errors.
9
+ *
10
+ * Both accept `--json` for scripted callers. Argument grammar is
11
+ * intentionally narrow — no `add` / `remove` / `test` subcommands in
12
+ * the MVP. Operators hand-edit `~/.pugi/hooks-mvp.json` for now.
13
+ *
14
+ * Exit codes:
15
+ * 0 -> happy path (no hooks OR config valid).
16
+ * 1 -> config present but invalid (only `doctor` returns this).
17
+ * 2 -> unknown subcommand / argument error.
18
+ *
19
+ * Brand voice: ASCII only.
20
+ */
21
+ import { ALL_HOOK_EVENTS_V2, defaultHooksMvpPath, loadHooksConfig, } from '../../core/hooks/index.js';
22
+ function parseFlags(args) {
23
+ const rest = [];
24
+ const flags = { json: false };
25
+ for (const arg of args) {
26
+ if (arg === '--json') {
27
+ flags.json = true;
28
+ continue;
29
+ }
30
+ rest.push(arg);
31
+ }
32
+ return { rest, flags };
33
+ }
34
+ /**
35
+ * Top-level dispatcher for `pugi hooks <subcommand>`. Returns the
36
+ * intended process exit code. `cli.ts` is expected to set
37
+ * `process.exitCode = <return value>` so error states propagate to
38
+ * scripted callers without throwing.
39
+ */
40
+ export async function runHooksCommand(args, ctx) {
41
+ const { rest, flags } = parseFlags(args);
42
+ const sub = rest[0];
43
+ if (!sub || sub === 'help' || sub === '--help') {
44
+ emitUsage(ctx, flags);
45
+ return sub ? 0 : 2;
46
+ }
47
+ if (sub === 'list') {
48
+ return runList(ctx, flags);
49
+ }
50
+ if (sub === 'doctor') {
51
+ return runDoctor(ctx, flags);
52
+ }
53
+ ctx.writeOutput({ ok: false, error: `unknown subcommand: ${sub}` }, `pugi hooks: unknown subcommand '${sub}'. Try 'pugi hooks --help'.`);
54
+ return 2;
55
+ }
56
+ function emitUsage(ctx, flags) {
57
+ const text = [
58
+ 'pugi hooks — user-config lifecycle hooks (MVP).',
59
+ '',
60
+ 'Subcommands:',
61
+ ' pugi hooks list Show hooks configured per event.',
62
+ ' pugi hooks doctor Validate ~/.pugi/hooks-mvp.json.',
63
+ '',
64
+ 'Flags:',
65
+ ' --json Emit a JSON envelope instead of human text.',
66
+ '',
67
+ 'Config file:',
68
+ ' ~/.pugi/hooks-mvp.json',
69
+ '',
70
+ 'Status:',
71
+ ' MVP — 2 events out of 8. Remaining events (PostToolUse,',
72
+ " UserPromptSubmit, Stop, SubagentStop, PreCompact, Notification)",
73
+ ' deferred to fast-follow PR.',
74
+ ].join('\n');
75
+ ctx.writeOutput({
76
+ ok: true,
77
+ command: 'hooks',
78
+ usage: text,
79
+ }, text);
80
+ if (flags.json) {
81
+ // The structured payload is already emitted by writeOutput when
82
+ // --json is on; nothing extra to do.
83
+ }
84
+ }
85
+ function runList(ctx, flags) {
86
+ let config;
87
+ try {
88
+ config = loadHooksConfig(ctx.configPath);
89
+ }
90
+ catch (error) {
91
+ const msg = error.message;
92
+ ctx.writeOutput({ ok: false, error: msg }, `pugi hooks list: ${msg}\nFix the config or remove the file. Run 'pugi hooks doctor' for details.`);
93
+ return 1;
94
+ }
95
+ const perEvent = {
96
+ SessionStart: [],
97
+ PreToolUse: [],
98
+ PostToolUse: [],
99
+ UserPromptSubmit: [],
100
+ Stop: [],
101
+ SubagentStop: [],
102
+ PreCompact: [],
103
+ Notification: [],
104
+ };
105
+ for (const event of ALL_HOOK_EVENTS_V2) {
106
+ perEvent[event] = config.list(event).map((entry) => ({
107
+ matcher: entry.matcher,
108
+ command: entry.command,
109
+ timeoutMs: entry.timeoutMs,
110
+ blocking: entry.blocking,
111
+ }));
112
+ }
113
+ const total = Object.values(perEvent).reduce((acc, list) => acc + list.length, 0);
114
+ const payload = {
115
+ ok: true,
116
+ configPath: config.configPath(),
117
+ total,
118
+ perEvent,
119
+ };
120
+ if (flags.json) {
121
+ ctx.writeOutput(payload, JSON.stringify(payload, null, 2));
122
+ return 0;
123
+ }
124
+ const lines = [];
125
+ lines.push(`pugi hooks (${total} configured)`);
126
+ lines.push(` config: ${config.configPath()}`);
127
+ if (total === 0) {
128
+ lines.push(' no hooks configured — create the file above to add one.');
129
+ }
130
+ else {
131
+ for (const event of ALL_HOOK_EVENTS_V2) {
132
+ const list = perEvent[event];
133
+ if (list.length === 0)
134
+ continue;
135
+ lines.push(` ${event}:`);
136
+ for (const entry of list) {
137
+ const tags = [];
138
+ if (entry.matcher)
139
+ tags.push(`matcher=${entry.matcher}`);
140
+ if (entry.timeoutMs)
141
+ tags.push(`timeoutMs=${entry.timeoutMs}`);
142
+ if (entry.blocking)
143
+ tags.push('blocking');
144
+ const suffix = tags.length ? ` [${tags.join(', ')}]` : '';
145
+ lines.push(` - ${entry.command}${suffix}`);
146
+ }
147
+ }
148
+ }
149
+ const text = lines.join('\n');
150
+ ctx.writeOutput(payload, text);
151
+ return 0;
152
+ }
153
+ function runDoctor(ctx, flags) {
154
+ const path = ctx.configPath ?? defaultHooksMvpPath();
155
+ try {
156
+ const config = loadHooksConfig(ctx.configPath);
157
+ const total = config.flatten().length;
158
+ const payload = {
159
+ ok: true,
160
+ configPath: config.configPath(),
161
+ total,
162
+ issues: [],
163
+ };
164
+ const text = total
165
+ ? `pugi hooks doctor: ${path} OK (${total} hooks).`
166
+ : `pugi hooks doctor: ${path} not present (no hooks configured).`;
167
+ ctx.writeOutput(payload, text);
168
+ return 0;
169
+ }
170
+ catch (error) {
171
+ const msg = error.message;
172
+ const payload = {
173
+ ok: false,
174
+ configPath: path,
175
+ error: msg,
176
+ };
177
+ const text = `pugi hooks doctor: ${msg}`;
178
+ ctx.writeOutput(payload, text);
179
+ return 1;
180
+ }
181
+ // flags.json is consumed by writeOutput in the host shell.
182
+ void flags;
183
+ }
184
+ //# sourceMappingURL=hooks.js.map
@@ -24,8 +24,8 @@
24
24
  *
25
25
  * Brand voice: ASCII only, no emoji, no banned words.
26
26
  */
27
- import { extname } from 'node:path';
28
27
  import { inspectLspServers, startLspClient } from '../../core/lsp/client.js';
28
+ import { languageForFile as inferLanguage } from '../../core/lsp/language-detect.js';
29
29
  import { loadSettings } from '../../core/settings.js';
30
30
  export async function runLspCommand(args, opts) {
31
31
  const [op, file, ...rest] = args;
@@ -96,6 +96,25 @@ export async function runLspCommand(args, opts) {
96
96
  };
97
97
  }
98
98
  const settings = loadSettings(opts.cwd);
99
+ // Leak L15 (2026-05-27): `pugi lsp check <file>` — manual probe of
100
+ // the post-edit diagnostics pipeline. Identical output to what the
101
+ // model sees appended to its tool envelope after a successful
102
+ // edit/write. Lets operators dry-run the auto-diagnostic surface
103
+ // without dispatching an actual edit.
104
+ if (op === 'check') {
105
+ const { runPostEditDiagnostics } = await import('../../core/lsp/post-edit-diagnostics.js');
106
+ const result = await runPostEditDiagnostics(file, {
107
+ cwd: opts.cwd,
108
+ ...(settings.lsp ? { lspSettings: settings.lsp } : {}),
109
+ });
110
+ if (opts.json) {
111
+ return { ok: true, text: JSON.stringify(result, null, 2), exitCode: 0 };
112
+ }
113
+ if (result.skip) {
114
+ return { ok: true, text: `${file}: skipped (${result.reason})`, exitCode: 0 };
115
+ }
116
+ return { ok: true, text: result.tail, exitCode: 0 };
117
+ }
99
118
  const clientResult = await startLspClient(lang, {
100
119
  cwd: opts.cwd,
101
120
  ...(settings.lsp ? { lspSettings: settings.lsp } : {}),
@@ -204,6 +223,7 @@ function usage() {
204
223
  ' pugi lsp definition <file> <line> <col>\n' +
205
224
  ' pugi lsp references <file> <line> <col>\n' +
206
225
  ' pugi lsp diagnostics <file>\n' +
226
+ ' pugi lsp check <file> (Leak L15: probe post-edit tail)\n' +
207
227
  ' pugi lsp find_definition <file> <symbol>\n' +
208
228
  ' pugi lsp servers',
209
229
  exitCode: 2,
@@ -341,26 +361,8 @@ function pullLangFlag(args) {
341
361
  function isLspLanguage(value) {
342
362
  return value === 'ts' || value === 'js' || value === 'py' || value === 'go' || value === 'rust';
343
363
  }
344
- export function inferLanguage(file) {
345
- const ext = extname(file).toLowerCase();
346
- switch (ext) {
347
- case '.ts':
348
- case '.tsx':
349
- return 'ts';
350
- case '.js':
351
- case '.jsx':
352
- case '.mjs':
353
- case '.cjs':
354
- return 'js';
355
- case '.py':
356
- case '.pyi':
357
- return 'py';
358
- case '.go':
359
- return 'go';
360
- case '.rs':
361
- return 'rust';
362
- default:
363
- return undefined;
364
- }
365
- }
364
+ // Leak L15 (2026-05-27): single source of truth for ext → language
365
+ // lives in `core/lsp/language-detect.ts`. The CLI surface re-exports
366
+ // the lookup so existing call sites keep their import path.
367
+ export { inferLanguage };
366
368
  //# sourceMappingURL=lsp.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `pugi repo-map` — Leak L28 (2026-05-27).
3
+ *
4
+ * Builds a compact AST-light summary of the workspace's source surface
5
+ * (top-level function / class / interface / type / enum declarations
6
+ * plus the leading JSDoc / markdown summary line) and renders it as
7
+ * a markdown listing. The same builder backs the engine boot-time
8
+ * system-prompt injection — running `pugi repo-map` shows the operator
9
+ * EXACTLY what the engine would inject.
10
+ *
11
+ * The command surface mirrors the L28 spec:
12
+ *
13
+ * `pugi repo-map` build + show (cache hit when fresh)
14
+ * `pugi repo-map --refresh` bust cache + rebuild from scratch
15
+ * `pugi repo-map --format=json` machine-readable envelope
16
+ *
17
+ * Exit code is always 0 — the command is informational, not a gate.
18
+ * The `--json` envelope carries an `ok: false` branch when the repo is
19
+ * too large to map (the L28 5000-file ceiling), so scripted callers
20
+ * can detect the skip without parsing prose.
21
+ *
22
+ * The handler is awaited even though the underlying pipeline is sync,
23
+ * matching the pattern of every other `run*Command` (stickers, status,
24
+ * doctor) so future async paths (remote cache pull) drop в without
25
+ * changing the call sites.
26
+ */
27
+ import { buildAndFormatRepoMap } from '../../core/repo-map/build.js';
28
+ /**
29
+ * Run the command. Always resolves к the structured envelope so the
30
+ * caller can chain into a status table. Exit code is set к 0 in every
31
+ * branch — the command is informational.
32
+ */
33
+ export async function runRepoMapCommand(ctx) {
34
+ const result = buildAndFormatRepoMap({
35
+ root: ctx.cwd,
36
+ refresh: ctx.refresh === true,
37
+ writeCache: ctx.writeCache !== false,
38
+ formatBytesCap: ctx.formatBytesCap,
39
+ omitHeader: false,
40
+ });
41
+ if (!result.build.ok) {
42
+ const payload = {
43
+ ok: false,
44
+ command: 'repo-map',
45
+ root: result.build.root,
46
+ reason: result.build.reason,
47
+ walked: result.build.walked,
48
+ };
49
+ const text = result.build.reason === 'too-large'
50
+ ? `repo-map skipped: workspace exceeds the ${result.build.walked}-file ceiling. Tighten .pugiignore and re-run.`
51
+ : `repo-map skipped: no workspace at ${result.build.root}.`;
52
+ ctx.writeOutput(payload, text);
53
+ // Informational — never a gate.
54
+ process.exitCode = 0;
55
+ return payload;
56
+ }
57
+ const build = result.build;
58
+ if (!build.ok) {
59
+ // TS narrowing — handled above.
60
+ throw new Error('unreachable');
61
+ }
62
+ const format = result.format;
63
+ const payload = {
64
+ ok: true,
65
+ command: 'repo-map',
66
+ root: build.root,
67
+ stats: {
68
+ filesScanned: build.scanStats.walked,
69
+ filesIncluded: format.filesIncluded,
70
+ filesRebuilt: build.diffStats.rebuilt,
71
+ filesReused: build.diffStats.reused,
72
+ filesDropped: build.diffStats.dropped,
73
+ skippedLarge: build.scanStats.skippedLarge,
74
+ skippedIgnored: build.scanStats.skippedIgnored,
75
+ bytes: format.bytes,
76
+ truncated: format.truncated,
77
+ },
78
+ cachePath: build.cachePath,
79
+ cacheWritten: build.cacheWritten,
80
+ text: format.text,
81
+ };
82
+ // The text surface IS the formatted markdown. We append a one-line
83
+ // stats footer so the operator sees the cache-hit ratio at a glance
84
+ // (engine context cache is opaque otherwise) — kept off the JSON
85
+ // payload so machine consumers do not have to skip a trailer.
86
+ const footer = formatFooter(payload.stats);
87
+ ctx.writeOutput(payload, `${format.text}\n${footer}`);
88
+ process.exitCode = 0;
89
+ return payload;
90
+ }
91
+ function formatFooter(stats) {
92
+ const truncatedHint = stats.truncated ? ' (truncated к budget)' : '';
93
+ return `${stats.filesIncluded} files · ${stats.filesRebuilt} rebuilt · ${stats.filesReused} cache hit · ${stats.bytes} bytes${truncatedHint}`;
94
+ }
95
+ //# sourceMappingURL=repo-map.js.map
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.24');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.25');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -89,21 +89,33 @@ export function loadPugMascotAnsi() {
89
89
  // icon, clipboard, hyperlinks, color-palette change). Drop them
90
90
  // so a corrupted asset cannot rename the operator's terminal tab
91
91
  // or smuggle a hyperlink into the splash region.
92
- // 2. Drop CSI ? <mode> [hl] for mouse-tracking and screen-buffer
93
- // switch modes (1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015,
94
- // 1049, 47, 1047, 1048). These would either start swallowing
95
- // mouse input or flip the terminal into the alternate screen.
92
+ // 2. Drop ALL CSI ? <numbers and semicolons> [lh] (DEC private-mode
93
+ // set / reset). The legitimate chafa output for a splash is
94
+ // truecolor SGR (`CSI 38;2;R;G;B m`) plus cursor-positioning
95
+ // no private-mode toggle ever appears там legitimately. A
96
+ // permissive deny-all pattern covers every disruptive private
97
+ // mode in one regex:
98
+ // - cursor visibility (25)
99
+ // - alt-screen buffer (47, 1047, 1048, 1049)
100
+ // - mouse tracking (1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015)
101
+ // - bracketed paste (2004)
102
+ // - focus reporting (1004)
103
+ // - multi-mode forms (e.g. `CSI ? 47 ; 1049 h` — legal per
104
+ // xterm ctlseqs but missed by the previous single-mode regex)
105
+ // - any future private mode a corrupt asset might emit
106
+ // Allowlisting the modes the splash needs is impossible because
107
+ // the splash needs ZERO of them — chafa renders glyph-by-glyph,
108
+ // not via private-mode toggles. A pure deny-all is strictly
109
+ // safer than enumerating known-bad modes one-by-one.
96
110
  // 3. Drop CSI 6 n (cursor-position report). Would inject a fake
97
111
  // CPR into the operator's stdin stream.
98
112
  // 4. Drop CSI [23]J / CSI [23]K (full screen / line clear). A
99
113
  // chafa render uses cursor-positioning per row, not bulk
100
114
  // erases; bulk clears would wipe whatever the operator already
101
115
  // had on screen above the splash.
102
- // The cursor-hide/show wrappers (CSI ? 25 [lh]) are handled by
103
- // the same CSI-?-mode pattern as the mouse / alt-screen modes.
104
116
  const stripped = raw
105
117
  .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
106
- .replace(/\x1b\[\?(?:25|47|1000|1001|1002|1003|1004|1005|1006|1015|1047|1048|1049)[lh]/g, '')
118
+ .replace(/\x1b\[\?[0-9;]+[lh]/g, '')
107
119
  .replace(/\x1b\[6n/g, '')
108
120
  .replace(/\x1b\[[23]?[JK]/g, '');
109
121
  if (stripped.trim().length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.24",
3
+ "version": "0.1.0-beta.25",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -53,8 +53,8 @@
53
53
  "turndown": "^7.2.4",
54
54
  "undici": "^8.3.0",
55
55
  "zod": "^3.23.0",
56
- "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.24"
56
+ "@pugi/sdk": "0.1.0-beta.25",
57
+ "@pugi/personas": "0.1.2"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",