@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +33 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/assets/pugi-mascot.ansi +16 -0
  4. package/dist/commands/deploy.js +439 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +1 -1
  7. package/dist/core/consensus/anvil-fanout.js +276 -0
  8. package/dist/core/consensus/diff-capture.js +382 -0
  9. package/dist/core/consensus/rubric.js +233 -0
  10. package/dist/core/context/index.js +21 -0
  11. package/dist/core/context/pugiignore.js +316 -0
  12. package/dist/core/context/repo-skeleton.js +533 -0
  13. package/dist/core/context/watcher.js +342 -0
  14. package/dist/core/context/working-set.js +165 -0
  15. package/dist/core/edits/dispatch.js +185 -0
  16. package/dist/core/edits/index.js +15 -0
  17. package/dist/core/edits/layer-a-apply.js +217 -0
  18. package/dist/core/edits/layer-b-apply.js +211 -0
  19. package/dist/core/edits/layer-c-apply.js +160 -0
  20. package/dist/core/edits/layer-d-ast.js +29 -0
  21. package/dist/core/edits/marker-parser.js +401 -0
  22. package/dist/core/edits/security-gate.js +223 -0
  23. package/dist/core/edits/worktree.js +322 -0
  24. package/dist/core/engine/native-pugi.js +6 -1
  25. package/dist/core/engine/prompts.js +8 -0
  26. package/dist/core/engine/tool-bridge.js +33 -1
  27. package/dist/core/lsp/client.js +719 -0
  28. package/dist/core/repl/ask.js +512 -0
  29. package/dist/core/repl/cancellation.js +98 -0
  30. package/dist/core/repl/dispatch-fsm.js +220 -0
  31. package/dist/core/repl/privacy-banner.js +71 -0
  32. package/dist/core/repl/session.js +1908 -13
  33. package/dist/core/repl/slash-commands.js +92 -32
  34. package/dist/core/repl/store/index.js +12 -0
  35. package/dist/core/repl/store/jsonl-log.js +321 -0
  36. package/dist/core/repl/store/lockfile.js +155 -0
  37. package/dist/core/repl/store/session-store.js +792 -0
  38. package/dist/core/repl/store/types.js +44 -0
  39. package/dist/core/repl/store/uuid-v7.js +68 -0
  40. package/dist/core/repl/workspace-context.js +72 -1
  41. package/dist/core/skills/defaults.js +457 -0
  42. package/dist/core/skills/loader.js +454 -0
  43. package/dist/core/skills/sources.js +480 -0
  44. package/dist/core/skills/trust.js +172 -0
  45. package/dist/runtime/cli.js +998 -12
  46. package/dist/runtime/commands/agents.js +385 -0
  47. package/dist/runtime/commands/config.js +338 -8
  48. package/dist/runtime/commands/delegate.js +289 -0
  49. package/dist/runtime/commands/lsp.js +206 -0
  50. package/dist/runtime/commands/patch.js +128 -0
  51. package/dist/runtime/commands/review-consensus.js +399 -0
  52. package/dist/runtime/commands/roster.js +117 -0
  53. package/dist/runtime/commands/skills.js +401 -0
  54. package/dist/runtime/commands/worktree.js +177 -0
  55. package/dist/runtime/plan-decompose.js +531 -0
  56. package/dist/tools/apply-patch.js +495 -0
  57. package/dist/tools/file-tools.js +90 -0
  58. package/dist/tools/lsp-tools.js +189 -0
  59. package/dist/tools/registry.js +26 -0
  60. package/dist/tools/web-fetch.js +1 -1
  61. package/dist/tui/agent-tree-pane.js +9 -0
  62. package/dist/tui/ask-cli.js +52 -0
  63. package/dist/tui/ask-modal.js +211 -0
  64. package/dist/tui/conversation-pane.js +48 -3
  65. package/dist/tui/input-box.js +48 -5
  66. package/dist/tui/markdown-render.js +266 -0
  67. package/dist/tui/repl-render.js +319 -3
  68. package/dist/tui/repl-splash-mascot.js +130 -0
  69. package/dist/tui/repl-splash.js +7 -1
  70. package/dist/tui/repl.js +96 -12
  71. package/dist/tui/status-bar.js +63 -3
  72. package/dist/tui/tool-stream-pane.js +91 -0
  73. package/docs/examples/codegraph.mcp.json +10 -0
  74. package/package.json +14 -6
@@ -19,20 +19,63 @@
19
19
  import React from 'react';
20
20
  import { render } from 'ink';
21
21
  import { Repl } from './repl.js';
22
+ import { printPugMascotPreInk } from './repl-splash-mascot.js';
22
23
  import { ReplSession, } from '../core/repl/session.js';
23
24
  import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
25
+ import { SqliteSessionStore } from '../core/repl/store/index.js';
26
+ import { slugForCwd } from '../core/repl/history.js';
27
+ import { WorkingSet, buildRepoSkeleton, loadPugiIgnore, PugiWatcher, } from '../core/context/index.js';
24
28
  /**
25
29
  * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
26
30
  * `/quit`. The session is closed (server-side stays alive; resume via
27
31
  * `pugi resume <sessionId>` once that command exists).
28
32
  */
29
33
  export async function renderRepl(options) {
34
+ // beta.9 CEO dogfood 2026-05-26: claim stdin raw mode + alt-screen
35
+ // BEFORE any async bootstrap step so keystrokes typed during the
36
+ // [launch -> Ink mount] window cannot echo into the terminal in
37
+ // cooked mode. Previously openLocalStore (SQLite open) +
38
+ // bootstrapContext (chokidar start) could take hundreds of ms to
39
+ // multiple seconds on a fresh install / large repo; during that
40
+ // window stdin stayed in cooked mode and the terminal echoed
41
+ // every typed character literally onto the screen below the
42
+ // pre-printed mascot/header. The visible result was the operator's
43
+ // "ssssss" landing on the rendered status-bar bottom row (CEO
44
+ // screenshot 2026-05-26: beta.8 REPL bug 2).
45
+ //
46
+ // The claim is idempotent with Ink's own raw-mode enable: Ink
47
+ // ref-counts setRawMode calls, and Node's stdin.setRawMode is
48
+ // safe to call twice with the same value. The pre-Ink claim acts
49
+ // as a "raw-mode floor" - whatever Ink does after mount layers on
50
+ // top, and our finally{} restore drops the floor only after Ink
51
+ // has cleanly torn down (or never mounted on a bootstrap crash).
52
+ const bootstrap = claimTerminalForRepl();
30
53
  const transport = createProductionTransport();
31
54
  // Auto-bind the workspace context from process.cwd() so Mira knows
32
55
  // which repo the operator launched the CLI in. The resolver is
33
56
  // best-effort — any FS error falls back to a basename-only summary,
34
57
  // never blocks REPL launch. Wave 4 fix 2026-05-25.
35
58
  const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
59
+ // α6.4: open the local SessionStore for `/resume` persistence. The
60
+ // store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
61
+ // — we log a one-line warning to stderr and continue with the REPL
62
+ // in memory-only mode. Lock-busy errors get the friendliest message
63
+ // so an operator running two REPLs in the same project understands
64
+ // the constraint.
65
+ const projectSlug = slugForCwd(process.cwd());
66
+ const { store, openedSessionId } = await openLocalStore({
67
+ projectSlug,
68
+ workspaceRoot: process.cwd(),
69
+ resumeLocalSessionId: options.resumeLocalSessionId,
70
+ });
71
+ // α6.5 three-tier context bootstrap. The skeleton + working set
72
+ // + watcher are local-first and best-effort: every step is wrapped
73
+ // in try/catch so an unreadable workspace never blocks REPL launch.
74
+ // Opt-out via PUGI_DISABLE_CONTEXT=1 for hermetic test runs.
75
+ const { skeleton, workingSet, watcher } = await bootstrapContext({
76
+ cwd: process.cwd(),
77
+ env: process.env,
78
+ });
36
79
  const session = new ReplSession({
37
80
  apiUrl: options.apiUrl,
38
81
  apiKey: options.apiKey,
@@ -40,26 +83,257 @@ export async function renderRepl(options) {
40
83
  cliVersion: options.cliVersion,
41
84
  transport,
42
85
  workspace,
86
+ store,
87
+ localSessionId: openedSessionId,
88
+ repoSkeleton: skeleton,
89
+ workingSet,
90
+ watcher,
43
91
  });
92
+ // Restore the transcript from the JSONL log if we resumed an
93
+ // existing session. The restore is idempotent and bypasses persist
94
+ // (no double-write of replayed rows).
95
+ if (store && openedSessionId && options.resumeLocalSessionId) {
96
+ try {
97
+ const events = await store.loadEvents(openedSessionId, { limit: 500 });
98
+ session.restoreTranscript(events);
99
+ }
100
+ catch (error) {
101
+ const msg = error instanceof Error ? error.message : String(error);
102
+ process.stderr.write(`[pugi] Could not restore session ${openedSessionId.slice(0, 13)}: ${msg}\n`);
103
+ }
104
+ }
44
105
  // Kick off the connect; the Repl renders the connecting state until
45
106
  // the session pushes `connection: 'on_watch'` from the SSE onOpen.
46
107
  void session.start();
108
+ // beta.9: drain any keystrokes that landed in stdin between the
109
+ // pre-Ink raw-mode claim and now. Without this, the queued bytes
110
+ // would feed Ink's first useInput tick as a flood of "stale"
111
+ // characters once the InputBox mounts - the operator would see
112
+ // their pre-typed input materialise in the prompt as if they had
113
+ // typed it after the REPL became interactive. Idempotent: no-op
114
+ // when stdin is not a TTY or no bytes were buffered.
115
+ drainBufferedStdin(process.stdin);
116
+ // α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
117
+ // stdout BEFORE Ink mounts (but AFTER alt-screen enter). Ink's
118
+ // layout engine would mis-measure the truecolor escape sequences,
119
+ // so the pug must land verbatim. The flag is passed into <Repl />
120
+ // so the splash component knows to skip its own hand-crafted
121
+ // PUG_MASCOT column - otherwise the operator sees both the chafa
122
+ // pug AND the ASCII fallback stacked. When skipSplash is true
123
+ // (operator opted out via --no-splash), we suppress the pre-print
124
+ // too so the boot stays silent.
125
+ const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
47
126
  const instance = render(React.createElement(Repl, {
48
127
  session,
49
128
  updateBanner: options.updateBanner ?? null,
50
129
  skipSplash: options.skipSplash === true,
130
+ hideToolStream: options.hideToolStream === true,
131
+ mascotPrePrinted,
51
132
  }));
133
+ // Make sure we leave the alt screen on abrupt exits too. Without
134
+ // this the operator's shell stays "frozen" on the Pugi splash.
135
+ process.once('exit', bootstrap.restore);
136
+ process.once('SIGINT', bootstrap.restore);
137
+ process.once('SIGTERM', bootstrap.restore);
52
138
  try {
53
139
  await instance.waitUntilExit();
54
140
  }
55
141
  finally {
142
+ bootstrap.restore();
56
143
  session.close();
144
+ if (store) {
145
+ try {
146
+ await store.close();
147
+ }
148
+ catch {
149
+ /* idempotent — already closed */
150
+ }
151
+ }
152
+ if (watcher) {
153
+ try {
154
+ await watcher.close();
155
+ }
156
+ catch {
157
+ /* idempotent — chokidar may already be torn down */
158
+ }
159
+ }
160
+ }
161
+ }
162
+ export function claimTerminalForRepl(stdin = process.stdin, stdout = process.stdout) {
163
+ const isStdoutTty = stdout.isTTY === true;
164
+ const isStdinTty = stdin.isTTY === true && typeof stdin.setRawMode === 'function';
165
+ let altScreenEntered = false;
166
+ if (isStdoutTty) {
167
+ try {
168
+ stdout.write('\x1b[?1049h');
169
+ stdout.write('\x1b[H');
170
+ altScreenEntered = true;
171
+ }
172
+ catch {
173
+ /* terminal already detached */
174
+ }
175
+ }
176
+ let rawModeClaimed = false;
177
+ if (isStdinTty) {
178
+ try {
179
+ stdin.setEncoding('utf8');
180
+ stdin.setRawMode(true);
181
+ // Resume so the kernel actually delivers bytes to Node's event
182
+ // loop. Without resume, raw mode is set but data does not flow
183
+ // until something else (e.g. Ink) attaches a 'data' listener.
184
+ stdin.resume();
185
+ rawModeClaimed = true;
186
+ }
187
+ catch {
188
+ /* raw mode unsupported - the operator's shell still works */
189
+ }
190
+ }
191
+ let restored = false;
192
+ const restore = () => {
193
+ if (restored)
194
+ return;
195
+ restored = true;
196
+ if (rawModeClaimed && isStdinTty) {
197
+ try {
198
+ stdin.setRawMode(false);
199
+ }
200
+ catch {
201
+ /* terminal already detached */
202
+ }
203
+ }
204
+ if (altScreenEntered) {
205
+ try {
206
+ stdout.write('\x1b[?1049l');
207
+ }
208
+ catch {
209
+ /* shutdown race - terminal already detached */
210
+ }
211
+ }
212
+ };
213
+ return { altScreenEntered, rawModeClaimed, restore };
214
+ }
215
+ /**
216
+ * Read and discard any bytes buffered in stdin between
217
+ * `claimTerminalForRepl()` and the Ink mount. Returns the number of
218
+ * bytes drained so tests can assert the behaviour without intercepting
219
+ * the side effect.
220
+ *
221
+ * `stdin.read()` is a no-op when no data is buffered, so this is safe
222
+ * to call whether or not the operator actually typed during bootstrap.
223
+ * Wrapped in try/catch because a closed / piped stdin will throw on
224
+ * read in some Node versions.
225
+ */
226
+ export function drainBufferedStdin(stdin = process.stdin) {
227
+ if (stdin.isTTY !== true)
228
+ return 0;
229
+ try {
230
+ let bytesDrained = 0;
231
+ // Loop until read() returns null - readable streams may chunk
232
+ // buffered bytes across multiple read() calls when the operator
233
+ // typed faster than the kernel could deliver to Node's loop.
234
+ for (;;) {
235
+ const chunk = stdin.read();
236
+ if (chunk === null)
237
+ return bytesDrained;
238
+ bytesDrained += typeof chunk === 'string' ? chunk.length : chunk.byteLength;
239
+ }
240
+ }
241
+ catch {
242
+ return 0;
57
243
  }
58
244
  }
245
+ /**
246
+ * Open the local SessionStore for the REPL bootstrap. Returns
247
+ * `{ store: null, openedSessionId: undefined }` on any error so the
248
+ * caller falls through to memory-only mode rather than failing the
249
+ * launch. The one error we surface verbatim is the lock-busy case —
250
+ * that one is operator-actionable.
251
+ */
252
+ async function openLocalStore(input) {
253
+ // Honour an explicit opt-out for offline-strict environments / CI.
254
+ // PUGI_DISABLE_SESSION_STORE=1 wipes the integration to zero. Useful
255
+ // for hermetic test runs and for operators who do not want any
256
+ // persistence under $HOME.
257
+ if (process.env.PUGI_DISABLE_SESSION_STORE === '1') {
258
+ return { store: null, openedSessionId: undefined };
259
+ }
260
+ try {
261
+ const store = new SqliteSessionStore({ projectSlug: input.projectSlug });
262
+ const row = await store.open({
263
+ id: input.resumeLocalSessionId,
264
+ workspaceRoot: input.workspaceRoot,
265
+ projectSlug: input.projectSlug,
266
+ });
267
+ return { store, openedSessionId: row.id };
268
+ }
269
+ catch (error) {
270
+ const code = error?.code;
271
+ const msg = error instanceof Error ? error.message : String(error);
272
+ if (code === 'EBUSY_SESSION_LOCK') {
273
+ process.stderr.write(`[pugi] ${msg} Continuing without local session persistence.\n`);
274
+ }
275
+ else {
276
+ process.stderr.write(`[pugi] Local session store unavailable (${msg}). Continuing in memory-only mode.\n`);
277
+ }
278
+ return { store: null, openedSessionId: undefined };
279
+ }
280
+ }
281
+ /**
282
+ * Bootstrap the α6.5 three-tier context primitives:
283
+ *
284
+ * - Tier 0: `RepoSkeleton` (~5KB ASCII tree + meta) for prompt injection.
285
+ * - Tier 1: `WorkingSet` LRU bounded at 50 entries.
286
+ * - Filewatch: chokidar started against cwd, ignore-filtered.
287
+ *
288
+ * The bootstrap is fail-safe: every primitive is wrapped so the REPL
289
+ * still launches when (e.g.) chokidar refuses to start on a
290
+ * permission-blocked dir. The PUGI_DISABLE_CONTEXT=1 env var skips
291
+ * the bootstrap entirely for hermetic test runs and for operators
292
+ * who want a zero-touch REPL.
293
+ */
294
+ async function bootstrapContext(input) {
295
+ if (input.env.PUGI_DISABLE_CONTEXT === '1') {
296
+ return { skeleton: null, workingSet: null, watcher: null };
297
+ }
298
+ let ignore;
299
+ try {
300
+ ignore = loadPugiIgnore(input.cwd);
301
+ }
302
+ catch (error) {
303
+ const msg = error instanceof Error ? error.message : String(error);
304
+ process.stderr.write(`[pugi] Three-tier context bootstrap skipped (ignore matcher failed: ${msg}).\n`);
305
+ return { skeleton: null, workingSet: null, watcher: null };
306
+ }
307
+ let skeleton = null;
308
+ try {
309
+ skeleton = buildRepoSkeleton(input.cwd, { ignore });
310
+ }
311
+ catch (error) {
312
+ const msg = error instanceof Error ? error.message : String(error);
313
+ process.stderr.write(`[pugi] Repo skeleton bootstrap failed (${msg}). Continuing without Tier 0.\n`);
314
+ }
315
+ const workingSet = new WorkingSet();
316
+ let watcher = null;
317
+ // chokidar opt-out: PUGI_DISABLE_FILEWATCH=1 keeps Tier 0/1 wired
318
+ // but skips the live-update channel. Useful on CI runners and on
319
+ // network mounts where fsevents misbehaves.
320
+ if (input.env.PUGI_DISABLE_FILEWATCH !== '1') {
321
+ try {
322
+ const w = new PugiWatcher({ cwd: input.cwd, ignore });
323
+ await w.start();
324
+ watcher = w;
325
+ }
326
+ catch (error) {
327
+ const msg = error instanceof Error ? error.message : String(error);
328
+ process.stderr.write(`[pugi] Filewatch bootstrap failed (${msg}). Continuing without live updates.\n`);
329
+ }
330
+ }
331
+ return { skeleton, workingSet, watcher };
332
+ }
59
333
  /* ------------------------------------------------------------------ */
60
334
  /* Production transport */
61
335
  /* ------------------------------------------------------------------ */
62
- function createProductionTransport() {
336
+ export function createProductionTransport() {
63
337
  return {
64
338
  async createSession({ apiUrl, apiKey, workspace }) {
65
339
  // Forward the workspace bundle in the POST body so admin-api can
@@ -118,6 +392,31 @@ function createProductionTransport() {
118
392
  if (lastEventId) {
119
393
  headers['Last-Event-ID'] = lastEventId;
120
394
  }
395
+ // beta.9 CEO dogfood 2026-05-26: hard timeout on the SSE
396
+ // handshake so a CDN/proxy that buffers the response (or an
397
+ // admin-api that accepted the route but never flushed headers)
398
+ // cannot freeze the REPL in `connecting` forever. The 5s budget
399
+ // is generous - admin-api routinely responds in <500ms when
400
+ // healthy - but tight enough that an operator who launched
401
+ // `pugi` and is staring at the screen will see the status flip
402
+ // to `reconnecting` instead of an indefinite hang. The
403
+ // AbortController bound to the fetch aborts the in-flight
404
+ // request when the timer fires, which surfaces as an
405
+ // `AbortError` and routes through the existing onError handler
406
+ // (which calls scheduleReconnect via the session). The timer
407
+ // is cleared the moment onOpen fires so a slow-but-eventually-
408
+ // successful handshake still works.
409
+ const handshakeDeadlineMs = 5_000;
410
+ const handshakeTimer = setTimeout(() => {
411
+ controller.abort();
412
+ // onError is called from the catch block below (the abort
413
+ // synthesises an AbortError that consumeSseStream / fetch
414
+ // will throw). No explicit onError call here - we let the
415
+ // catch path normalise the error message so the operator
416
+ // sees the consistent "SSE handshake timed out (5s)" prose
417
+ // through the same plumbing that surfaces every other
418
+ // transport failure.
419
+ }, handshakeDeadlineMs);
121
420
  void (async () => {
122
421
  try {
123
422
  const response = await fetch(url, {
@@ -131,6 +430,9 @@ function createProductionTransport() {
131
430
  if (!response.body) {
132
431
  throw new Error('SSE response has no body');
133
432
  }
433
+ // Handshake survived; cancel the deadline so a slow
434
+ // first-event stream does not get aborted later.
435
+ clearTimeout(handshakeTimer);
134
436
  onOpen();
135
437
  await consumeSseStream(response.body, onEvent);
136
438
  // Server closed the stream cleanly. Treat as an error so
@@ -140,13 +442,27 @@ function createProductionTransport() {
140
442
  onError(new Error('SSE stream ended'));
141
443
  }
142
444
  catch (error) {
143
- if (controller.signal.aborted)
445
+ clearTimeout(handshakeTimer);
446
+ if (controller.signal.aborted) {
447
+ // Distinguish operator-driven close (session.close())
448
+ // from the handshake-deadline abort. The session sets a
449
+ // `closed` flag before calling controller.abort(); the
450
+ // handshake-deadline abort fires while the session is
451
+ // still expecting onOpen. We cannot read session state
452
+ // from here, so we surface a single error class with a
453
+ // clear message - the session-side onError handler
454
+ // already short-circuits when `closed=true`.
455
+ onError(new Error(`SSE handshake timed out after ${handshakeDeadlineMs}ms`));
144
456
  return;
457
+ }
145
458
  onError(error instanceof Error ? error : new Error(String(error)));
146
459
  }
147
460
  })();
148
461
  return {
149
- close: () => controller.abort(),
462
+ close: () => {
463
+ clearTimeout(handshakeTimer);
464
+ controller.abort();
465
+ },
150
466
  };
151
467
  },
152
468
  };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Chafa-validated brand-pug ANSI loader (α6.14.4 wave 6, mascot regen).
3
+ *
4
+ * CEO dogfood 2026-05-25 (first pass, α6.14.2 wave 5): the hand-crafted
5
+ * 9-row ASCII pug in `repl-splash-art.ts` reads as "точно не похожа" —
6
+ * too abstract to carry the brand at boot. This module loads a pre-baked
7
+ * truecolor ANSI render of the canonical hero-pug PNG (cyber-zoo pug
8
+ * face with cyan eyes + circuit + chip) so the splash matches the brand
9
+ * glyph the operator already sees on pugi.io.
10
+ *
11
+ * CEO dogfood 2026-05-25 (α6.14.4): the first chafa bake at 32x16 still
12
+ * read as "monitor on stand", not pug — too few rows to resolve the
13
+ * snout / eyes / wrinkles. The vertical resolution was the bottleneck:
14
+ * 16 char rows ≈ 16 pixel rows with the block symbol set. The fresh
15
+ * bake uses `vhalf` (vertical half blocks ▀ / ▄ with independent fg+bg
16
+ * colours per cell) which doubles the vertical resolution per character
17
+ * cell, at an 80x40 frame which is 2.5× the prior dimensions. End
18
+ * result: ~80×80 effective pixel resolution — enough to read the
19
+ * snout, eye sockets, ear lines, and the circuit board accent the
20
+ * brand glyph carries. File grew from 8.8KB to ~40KB; ship budget
21
+ * gates at 100KB so we stay well under cap.
22
+ *
23
+ * Generation (operator-side, one-shot):
24
+ * chafa --size 80x40 --symbols=vhalf --colors=full \
25
+ * apps/clawhost-web/public/brand/hero-pug.png \
26
+ * > apps/pugi-cli/assets/pugi-mascot.ansi
27
+ *
28
+ * The output is committed verbatim to the repo and shipped inside the
29
+ * `@pugi/cli` npm tarball under `assets/pugi-mascot.ansi` (the
30
+ * `package.json` `files` allowlist explicitly opts in). Runtime does
31
+ * NOT need `chafa` installed — we just read the file bytes and write
32
+ * them to stdout. If the file is missing (degraded install, tarball
33
+ * corruption, dev cwd drift), the splash falls back to the hand-crafted
34
+ * `PUG_MASCOT` art so the boot never crashes.
35
+ *
36
+ * The pre-Ink write convention mirrors the Claude Code Chrome plugin
37
+ * splash pattern: raw bytes go to `process.stdout` BEFORE the Ink
38
+ * render mount, so the terminal interprets the truecolor escapes
39
+ * directly instead of Ink trying to layout-engine over them.
40
+ */
41
+ import { existsSync, readFileSync } from 'node:fs';
42
+ import { dirname, resolve as resolvePath } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ /**
45
+ * Resolve the on-disk path to `pugi-mascot.ansi` relative to the
46
+ * compiled module. The CLI ships to `node_modules/@pugi/cli/dist/tui/`
47
+ * so the asset lives at `node_modules/@pugi/cli/assets/pugi-mascot.ansi`
48
+ * — two directory hops up from this file. In a local `pnpm dev`
49
+ * checkout the structure is the same (`src/tui/` ⇒ `../../assets/`)
50
+ * because tsx re-resolves the same relative tree.
51
+ */
52
+ export function pugMascotAssetPath() {
53
+ const here = dirname(fileURLToPath(import.meta.url));
54
+ return resolvePath(here, '..', '..', 'assets', 'pugi-mascot.ansi');
55
+ }
56
+ /**
57
+ * Read the chafa-baked ANSI render of the brand pug. Returns the raw
58
+ * bytes verbatim (UTF-8 string) — the terminal interprets the truecolor
59
+ * escapes directly. Returns null when the file is missing, unreadable,
60
+ * or trivially empty so the caller can fall back to `PUG_MASCOT`.
61
+ *
62
+ * `chafa --colors=full` wraps the render with cursor-hide (`\e[?25l`)
63
+ * on the head and cursor-show (`\e[?25h`) on the tail. We strip those
64
+ * so the splash does not accidentally hide the cursor across the rest
65
+ * of the REPL boot (Ink itself manages the cursor once it mounts).
66
+ *
67
+ * The asset is supply-chain controlled (committed in-repo, shipped in
68
+ * the npm tarball) so an arbitrary attacker cannot inject escapes
69
+ * today. The defence-in-depth strip below still drops categories of
70
+ * escapes that the splash has no legitimate need to emit — OSC window
71
+ * title sets, mouse-tracking enables, screen clears, cursor-position
72
+ * reports — so a future swap of the asset (or a corrupt tarball) cannot
73
+ * disrupt the terminal beyond the splash region. Truecolor (`CSI 38;2;
74
+ * R;G;B m`), reset (`CSI 0 m`), and explicit forms of cursor / line
75
+ * motion the render needs are left in.
76
+ */
77
+ export function loadPugMascotAnsi() {
78
+ const path = pugMascotAssetPath();
79
+ try {
80
+ if (!existsSync(path))
81
+ return null;
82
+ const raw = readFileSync(path, 'utf8');
83
+ if (!raw || raw.length === 0)
84
+ return null;
85
+ // 1. Drop OSC sequences. Two terminator forms:
86
+ // ESC ] ... BEL (0x1b 0x5d ... 0x07)
87
+ // ESC ] ... ESC \ (0x1b 0x5d ... 0x1b 0x5c, the ST form)
88
+ // A truecolor splash never needs OSC (those are for window title,
89
+ // icon, clipboard, hyperlinks, color-palette change). Drop them
90
+ // so a corrupted asset cannot rename the operator's terminal tab
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.
96
+ // 3. Drop CSI 6 n (cursor-position report). Would inject a fake
97
+ // CPR into the operator's stdin stream.
98
+ // 4. Drop CSI [23]J / CSI [23]K (full screen / line clear). A
99
+ // chafa render uses cursor-positioning per row, not bulk
100
+ // erases; bulk clears would wipe whatever the operator already
101
+ // 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
+ const stripped = raw
105
+ .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, '')
107
+ .replace(/\x1b\[6n/g, '')
108
+ .replace(/\x1b\[[23]?[JK]/g, '');
109
+ if (stripped.trim().length === 0)
110
+ return null;
111
+ return stripped;
112
+ }
113
+ catch {
114
+ // Best-effort: any FS / decode error returns null so the splash
115
+ // falls back to the hand-crafted ASCII art. Never throws.
116
+ return null;
117
+ }
118
+ }
119
+ export function printPugMascotPreInk(sink) {
120
+ const ansi = loadPugMascotAnsi();
121
+ if (ansi === null)
122
+ return false;
123
+ // Trailing newline so the Ink header lands on a fresh row rather
124
+ // than smashing into the last pug row.
125
+ sink.write(ansi);
126
+ if (!ansi.endsWith('\n'))
127
+ sink.write('\n');
128
+ return true;
129
+ }
130
+ //# sourceMappingURL=repl-splash-mascot.js.map
@@ -61,7 +61,13 @@ export function ReplSplash(props) {
61
61
  if (props.skipSplash) {
62
62
  return null;
63
63
  }
64
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(MascotColumn, {}), _jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
64
+ // α6.14.2 wave 5: when the host pre-printed the chafa-baked brand-pug
65
+ // ANSI render to stdout before Ink mounted, suppress the hand-crafted
66
+ // PUG_MASCOT column here so the operator does not see two stacked
67
+ // pugs. The header card still renders inline so wordmark + status
68
+ // rows stay attached to the splash flow.
69
+ const showHandCraftedMascot = props.mascotPrePrinted !== true;
70
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [showHandCraftedMascot ? _jsx(MascotColumn, {}) : null, _jsxs(Box, { flexDirection: "column", marginLeft: showHandCraftedMascot ? 2 : 0, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
65
71
  }
66
72
  /**
67
73
  * Renders the multi-line ASCII pug. Each row is split into colored