@pugi/cli 0.1.0-alpha.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ const ITEMS = [
5
+ {
6
+ provider: 'device',
7
+ title: 'Browser OAuth',
8
+ hint: 'Opens app.pugi.io in your browser to approve this device',
9
+ },
10
+ {
11
+ provider: 'token',
12
+ title: 'API key',
13
+ hint: 'Paste a personal access token (pugi.io/settings/api-keys)',
14
+ },
15
+ {
16
+ provider: 'env',
17
+ title: 'Environment variable',
18
+ hint: 'Use PUGI_API_KEY from the current shell',
19
+ },
20
+ ];
21
+ /**
22
+ * Arrow-key login picker. Driven entirely by Ink's `useInput`. The
23
+ * component does NOT perform the OAuth flow itself, it only resolves
24
+ * the selected provider back to the caller (CLI dispatcher), which
25
+ * then unmounts Ink and hands control to the existing handlers in
26
+ * runtime/cli.ts.
27
+ */
28
+ export function LoginPicker(props) {
29
+ const [index, setIndex] = useState(Math.min(Math.max(props.initialIndex ?? 0, 0), ITEMS.length - 1));
30
+ useInput((input, key) => {
31
+ if (key.upArrow || input === 'k') {
32
+ setIndex((current) => (current === 0 ? ITEMS.length - 1 : current - 1));
33
+ return;
34
+ }
35
+ if (key.downArrow || input === 'j') {
36
+ setIndex((current) => (current === ITEMS.length - 1 ? 0 : current + 1));
37
+ return;
38
+ }
39
+ if (key.return) {
40
+ const selected = ITEMS[index];
41
+ if (selected)
42
+ props.onSelect(selected.provider);
43
+ return;
44
+ }
45
+ if (key.escape || input === 'q') {
46
+ props.onCancel();
47
+ return;
48
+ }
49
+ // Number shortcuts mirror the legacy text picker for muscle memory.
50
+ if (input === '1')
51
+ props.onSelect('device');
52
+ if (input === '2')
53
+ props.onSelect('token');
54
+ if (input === '3')
55
+ props.onSelect('env');
56
+ });
57
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Sign in to Pugi" }), _jsx(Text, { dimColor: true, children: ` (endpoint: ${props.apiUrl})` })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: ITEMS.map((item, itemIndex) => {
58
+ const isSelected = itemIndex === index;
59
+ return (_jsx(PickerRow, { isSelected: isSelected, title: item.title, hint: item.hint }, item.provider));
60
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '↑/↓ select Enter confirm Esc cancel' }) })] }));
61
+ }
62
+ function PickerRow({ isSelected, title, hint, }) {
63
+ // Arrow glyph + padded title so highlighted and dim rows share
64
+ // column alignment.
65
+ const indicator = isSelected ? '▸ ' : ' ';
66
+ const padded = title.padEnd(22, ' ');
67
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [indicator, padded] }), _jsx(Text, { dimColor: true, children: hint })] }));
68
+ }
69
+ //# sourceMappingURL=login-picker.js.map
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { DeviceFlow } from './device-flow.js';
4
+ import { LoginPicker } from './login-picker.js';
5
+ import { Splash } from './splash.js';
6
+ import { collectSplashData } from './splash-data.js';
7
+ /**
8
+ * Mount `<Splash />` on a TTY, await unmount, return. The CLI
9
+ * dispatcher only reaches this entry point when `isInteractive()`
10
+ * already cleared.
11
+ */
12
+ export async function renderSplash(cliVersion) {
13
+ const data = collectSplashData({ cliVersion });
14
+ const instance = render(React.createElement(Splash, { data }));
15
+ // The splash is static: nothing reads input, nothing animates. Defer
16
+ // the unmount one macrotask so Ink's log-update flush, Yoga layout
17
+ // settle, and the initial process.stdout.write all complete before
18
+ // we tear the runtime down. Without setImmediate, some terminals
19
+ // batch writes and lose the splash frame entirely on fast exit.
20
+ await new Promise((resolve) => setImmediate(resolve));
21
+ instance.unmount();
22
+ await instance.waitUntilExit();
23
+ }
24
+ /**
25
+ * Sentinel thrown when the user dismisses the login picker via Esc
26
+ * or `q`. The CLI dispatcher catches it, prints a one-line abort
27
+ * message, and exits 130 (the standard exit code for SIGINT-style
28
+ * user cancellations — matches gh CLI, codex, claude-code).
29
+ */
30
+ export class LoginCancelledError extends Error {
31
+ constructor() {
32
+ super('Login cancelled');
33
+ this.name = 'LoginCancelledError';
34
+ }
35
+ }
36
+ /**
37
+ * Mount `<LoginPicker />`, resolve to the chosen provider. Rejects
38
+ * with `LoginCancelledError` when the user cancels.
39
+ *
40
+ * After selection we call `unmount()` and resolve on the next
41
+ * macrotask. We deliberately do NOT chain `waitUntilExit()` — under
42
+ * some shimmed-stdin scenarios (CI test harnesses) Ink's
43
+ * `waitUntilExit` never settles because its raw-mode-release path
44
+ * waits on a stdin event that never fires. The next handler in the
45
+ * CLI dispatcher restores stdin state on its own (the device flow
46
+ * forks a browser, the token path attaches a fresh data listener).
47
+ */
48
+ export function renderLoginPicker(apiUrl) {
49
+ return new Promise((resolveProvider, rejectProvider) => {
50
+ let settled = false;
51
+ const finish = (cb) => {
52
+ if (settled)
53
+ return;
54
+ settled = true;
55
+ instance.unmount();
56
+ setImmediate(cb);
57
+ };
58
+ const instance = render(React.createElement(LoginPicker, {
59
+ apiUrl,
60
+ onSelect: (provider) => {
61
+ finish(() => resolveProvider(provider));
62
+ },
63
+ onCancel: () => {
64
+ finish(() => rejectProvider(new LoginCancelledError()));
65
+ },
66
+ }));
67
+ });
68
+ }
69
+ /**
70
+ * Mount `<DeviceFlow />` on a TTY and return a handle the host uses to
71
+ * drive the frame. Mirrors `renderLoginPicker`'s lifecycle: we
72
+ * deliberately do NOT call `waitUntilExit()` after unmount (some
73
+ * shimmed-stdin test harnesses never settle that promise — the next
74
+ * code path in cli.ts restores stdin state on its own).
75
+ */
76
+ export function renderDeviceFlow(options) {
77
+ let settled = false;
78
+ let resolveDone = () => undefined;
79
+ let rejectDone = () => undefined;
80
+ const donePromise = new Promise((resolveFn, rejectFn) => {
81
+ resolveDone = resolveFn;
82
+ rejectDone = rejectFn;
83
+ });
84
+ // Per the spec, the polling status drives the visible spinner; the
85
+ // host pushes status updates via `setStatus`. We use a tiny stateful
86
+ // wrapper component so React owns the re-render lifecycle (calling
87
+ // `instance.rerender` with a new element triggers a clean diff).
88
+ let currentStatus = { kind: 'polling' };
89
+ const finish = (cb) => {
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ instance.unmount();
94
+ setImmediate(cb);
95
+ };
96
+ const renderElement = () => React.createElement(DeviceFlow, {
97
+ verificationUrl: options.verificationUrl,
98
+ userCode: options.userCode,
99
+ browserOpened: options.browserOpened,
100
+ status: currentStatus,
101
+ onCopy: options.onCopy,
102
+ onCancel: () => {
103
+ finish(() => rejectDone(new LoginCancelledError()));
104
+ },
105
+ onContinue: () => {
106
+ finish(() => resolveDone());
107
+ },
108
+ });
109
+ const instance = render(renderElement());
110
+ return {
111
+ setStatus(next) {
112
+ if (settled)
113
+ return;
114
+ currentStatus = next;
115
+ instance.rerender(renderElement());
116
+ },
117
+ unmount() {
118
+ finish(() => resolveDone());
119
+ },
120
+ done() {
121
+ return donePromise;
122
+ },
123
+ };
124
+ }
125
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Production REPL mount + transport - Sprint α5.7.
3
+ *
4
+ * Owns the Ink mount lifecycle for `<Repl />` and wires the real
5
+ * fetch + SSE transport. The CLI dispatcher in `runtime/cli.ts` calls
6
+ * `renderRepl` on the bare-`pugi` path when stdin / stdout are TTYs.
7
+ *
8
+ * The transport speaks to admin-api:
9
+ * POST /api/pugi/sessions → { sessionId }
10
+ * POST /api/pugi/sessions/:id/brief → { dispatchId }
11
+ * POST /api/pugi/sessions/:id/stop → { stopped }
12
+ * GET /api/pugi/sessions/:id/stream → text/event-stream
13
+ *
14
+ * SSE is parsed client-side from a streaming fetch response - Node 22
15
+ * native fetch returns a WHATWG `ReadableStream` which we feed through
16
+ * a tiny `event:`/`data:`/`id:` parser. This keeps the dependency
17
+ * graph at zero new packages.
18
+ */
19
+ import React from 'react';
20
+ import { render } from 'ink';
21
+ import { Repl } from './repl.js';
22
+ import { ReplSession, } from '../core/repl/session.js';
23
+ import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
24
+ /**
25
+ * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
26
+ * `/quit`. The session is closed (server-side stays alive; resume via
27
+ * `pugi resume <sessionId>` once that command exists).
28
+ */
29
+ export async function renderRepl(options) {
30
+ const transport = createProductionTransport();
31
+ // Auto-bind the workspace context from process.cwd() so Mira knows
32
+ // which repo the operator launched the CLI in. The resolver is
33
+ // best-effort — any FS error falls back to a basename-only summary,
34
+ // never blocks REPL launch. Wave 4 fix 2026-05-25.
35
+ const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
36
+ const session = new ReplSession({
37
+ apiUrl: options.apiUrl,
38
+ apiKey: options.apiKey,
39
+ workspaceLabel: options.workspaceLabel,
40
+ cliVersion: options.cliVersion,
41
+ transport,
42
+ workspace,
43
+ });
44
+ // Kick off the connect; the Repl renders the connecting state until
45
+ // the session pushes `connection: 'on_watch'` from the SSE onOpen.
46
+ void session.start();
47
+ const instance = render(React.createElement(Repl, {
48
+ session,
49
+ updateBanner: options.updateBanner ?? null,
50
+ skipSplash: options.skipSplash === true,
51
+ }));
52
+ try {
53
+ await instance.waitUntilExit();
54
+ }
55
+ finally {
56
+ session.close();
57
+ }
58
+ }
59
+ /* ------------------------------------------------------------------ */
60
+ /* Production transport */
61
+ /* ------------------------------------------------------------------ */
62
+ function createProductionTransport() {
63
+ return {
64
+ async createSession({ apiUrl, apiKey, workspace }) {
65
+ // Forward the workspace bundle in the POST body so admin-api can
66
+ // surface `<workspace-context>` in Mira's prompt. Older admin-api
67
+ // builds ignore unknown fields, so this stays forward-compatible.
68
+ // Wave 4 fix 2026-05-25.
69
+ const body = {};
70
+ if (workspace?.workspaceCwd)
71
+ body.workspaceCwd = workspace.workspaceCwd;
72
+ if (workspace?.workspaceSlug)
73
+ body.workspaceSlug = workspace.workspaceSlug;
74
+ if (workspace?.workspaceSummary)
75
+ body.workspaceSummary = workspace.workspaceSummary;
76
+ const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
77
+ method: 'POST',
78
+ headers: jsonHeaders(apiKey),
79
+ body: JSON.stringify(body),
80
+ });
81
+ const json = await readJson(response);
82
+ const sessionId = json.sessionId;
83
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
84
+ throw new Error('admin-api did not return a sessionId');
85
+ }
86
+ return { sessionId };
87
+ },
88
+ async postBrief({ apiUrl, apiKey, sessionId, brief }) {
89
+ const response = await fetch(joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/brief`), {
90
+ method: 'POST',
91
+ headers: jsonHeaders(apiKey),
92
+ body: JSON.stringify({ brief }),
93
+ });
94
+ const json = await readJson(response);
95
+ const dispatchId = json.dispatchId;
96
+ if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
97
+ throw new Error('admin-api did not return a dispatchId');
98
+ }
99
+ return { dispatchId };
100
+ },
101
+ async postStop({ apiUrl, apiKey, sessionId, persona }) {
102
+ const response = await fetch(joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/stop`), {
103
+ method: 'POST',
104
+ headers: jsonHeaders(apiKey),
105
+ body: JSON.stringify({ persona }),
106
+ });
107
+ const json = await readJson(response);
108
+ const stopped = Boolean(json.stopped);
109
+ return { stopped };
110
+ },
111
+ subscribe({ apiUrl, apiKey, sessionId, lastEventId, onEvent, onError, onOpen }) {
112
+ const controller = new AbortController();
113
+ const url = joinUrl(apiUrl, `/api/pugi/sessions/${encodeURIComponent(sessionId)}/stream`);
114
+ const headers = {
115
+ Accept: 'text/event-stream',
116
+ Authorization: `Bearer ${apiKey}`,
117
+ };
118
+ if (lastEventId) {
119
+ headers['Last-Event-ID'] = lastEventId;
120
+ }
121
+ void (async () => {
122
+ try {
123
+ const response = await fetch(url, {
124
+ method: 'GET',
125
+ headers,
126
+ signal: controller.signal,
127
+ });
128
+ if (!response.ok) {
129
+ throw new Error(`HTTP ${response.status} on SSE stream`);
130
+ }
131
+ if (!response.body) {
132
+ throw new Error('SSE response has no body');
133
+ }
134
+ onOpen();
135
+ await consumeSseStream(response.body, onEvent);
136
+ // Server closed the stream cleanly. Treat as an error so
137
+ // the session reconnects (the spec says "transient
138
+ // disconnect" - a clean close from the server side is also
139
+ // transient because the operator may have toggled wifi).
140
+ onError(new Error('SSE stream ended'));
141
+ }
142
+ catch (error) {
143
+ if (controller.signal.aborted)
144
+ return;
145
+ onError(error instanceof Error ? error : new Error(String(error)));
146
+ }
147
+ })();
148
+ return {
149
+ close: () => controller.abort(),
150
+ };
151
+ },
152
+ };
153
+ }
154
+ /* ------------------------------------------------------------------ */
155
+ /* SSE parser */
156
+ /* ------------------------------------------------------------------ */
157
+ /**
158
+ * Minimal SSE parser. Reads a UTF-8 stream of `id:` / `event:` / `data:`
159
+ * lines separated by blank lines. We only need the `data` payload (a
160
+ * JSON object) and the `id` field (so we can replay on reconnect via
161
+ * Last-Event-ID).
162
+ */
163
+ async function consumeSseStream(body, onEvent) {
164
+ const reader = body.getReader();
165
+ const decoder = new TextDecoder('utf-8');
166
+ let buffer = '';
167
+ let currentId = '';
168
+ let currentData = '';
169
+ while (true) {
170
+ const { value, done } = await reader.read();
171
+ if (done)
172
+ break;
173
+ buffer += decoder.decode(value, { stream: true });
174
+ let newlineIndex;
175
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
176
+ const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, '');
177
+ buffer = buffer.slice(newlineIndex + 1);
178
+ if (rawLine.length === 0) {
179
+ // Dispatch - only if we have a data payload.
180
+ if (currentData.length > 0) {
181
+ try {
182
+ const parsed = JSON.parse(currentData);
183
+ onEvent(parsed, currentId);
184
+ }
185
+ catch {
186
+ // Drop malformed frames silently - protocol-level
187
+ // robustness is the controller's job, not the client's.
188
+ }
189
+ }
190
+ currentData = '';
191
+ currentId = '';
192
+ continue;
193
+ }
194
+ if (rawLine.startsWith(':'))
195
+ continue; // Comment / keepalive.
196
+ const colonIndex = rawLine.indexOf(':');
197
+ const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex);
198
+ const value = colonIndex === -1 ? '' : rawLine.slice(colonIndex + 1).replace(/^ /, '');
199
+ switch (field) {
200
+ case 'id':
201
+ currentId = value;
202
+ break;
203
+ case 'data':
204
+ currentData = currentData.length === 0 ? value : `${currentData}\n${value}`;
205
+ break;
206
+ case 'event':
207
+ case 'retry':
208
+ default:
209
+ // We do not surface the `event:` name to the consumer - the
210
+ // payload itself carries `type`. Future events that need
211
+ // dispatcher-side routing without parsing JSON can wire
212
+ // through this branch.
213
+ break;
214
+ }
215
+ }
216
+ }
217
+ }
218
+ /* ------------------------------------------------------------------ */
219
+ /* Small helpers */
220
+ /* ------------------------------------------------------------------ */
221
+ function jsonHeaders(apiKey) {
222
+ return {
223
+ 'Content-Type': 'application/json',
224
+ Accept: 'application/json',
225
+ Authorization: `Bearer ${apiKey}`,
226
+ };
227
+ }
228
+ async function readJson(response) {
229
+ if (!response.ok) {
230
+ const detail = await response.text().catch(() => '');
231
+ throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
232
+ }
233
+ return response.json();
234
+ }
235
+ function joinUrl(base, path) {
236
+ const trimmedBase = base.endsWith('/') ? base.slice(0, -1) : base;
237
+ const trimmedPath = path.startsWith('/') ? path : `/${path}`;
238
+ return `${trimmedBase}${trimmedPath}`;
239
+ }
240
+ //# sourceMappingURL=repl-render.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ASCII pug mascot for the REPL boot splash (α6.14 wave 3).
3
+ *
4
+ * Hand-crafted at 9 rows × 20 columns to read as a pug at a single
5
+ * glance — references the cyber-zoo hero glyph in
6
+ * `apps/clawhost-web/public/brand/hero-pug.png`: blocky pug face with
7
+ * angular ear flaps on either side of the head, forehead crease,
8
+ * angular cyan eyes (`◉`), smushed snout, undershot jaw, and a small
9
+ * cyan circuit chip (`▐■▌`) on the lower-right cheek.
10
+ *
11
+ * Separation of art + cyan mask lets the unit test assert structure
12
+ * (row count, line widths, mask shape, at-least-one cyan pixel per
13
+ * eye row) without coupling to the Ink renderer. The renderer in
14
+ * `repl-splash.tsx` splits each row into runs and colors the masked
15
+ * columns cyan (#3DA9FC, brandbook §05).
16
+ *
17
+ * Convention:
18
+ * - PUG_MASCOT[i] = one row of the silhouette
19
+ * - PUG_MASCOT_CYAN_MASK[i] = parallel boolean array, true => that
20
+ * column renders cyan instead of gray
21
+ *
22
+ * Both arrays MUST stay the same length and each mask row MUST be the
23
+ * same length as the corresponding art row. A unit test enforces this.
24
+ */
25
+ /* eslint-disable no-irregular-whitespace */
26
+ export const PUG_MASCOT = [
27
+ ' ▄▀▀▀▄▄▄▀▀▀▄ ',
28
+ ' █▄▄ ▄▄█ ',
29
+ ' █ ▀▄▄▄▄▄▀ █ ',
30
+ ' █ ◉ ◉ █ ',
31
+ ' ▀▄ ▀█▀ ▄▀ ',
32
+ ' █▀▀▀▀▀█ ',
33
+ ' █▒▒▒▒▒█ ▐■▌ ',
34
+ ' ▀▄▄▄▀ ',
35
+ ' ▀ ',
36
+ ];
37
+ /**
38
+ * Cyan accents are derived from the source characters so the art file
39
+ * stays the single source of truth. Two glyph classes get colored:
40
+ * - `◉` -> the two cyan eyes on row 3
41
+ * - `▐■▌` -> the cyan chip cluster on row 6 (right cheek)
42
+ *
43
+ * Everything else renders gray. The derivation runs at module load,
44
+ * which keeps the mask trivially auditable from the source array.
45
+ */
46
+ export const PUG_MASCOT_CYAN_MASK = PUG_MASCOT.map((row) => {
47
+ const mask = new Array(row.length).fill(false);
48
+ for (let column = 0; column < row.length; column += 1) {
49
+ const ch = row.charAt(column);
50
+ if (ch === '◉' || ch === '▐' || ch === '■' || ch === '▌') {
51
+ mask[column] = true;
52
+ }
53
+ }
54
+ return mask;
55
+ });
56
+ /**
57
+ * Pre-computed silhouette dimensions for layout math in the splash
58
+ * component. The unit test asserts these stay inside the documented
59
+ * envelope (≤22 chars wide, 9 ≤ rows ≤ 14) so a future edit can not
60
+ * silently bloat the terminal real estate.
61
+ */
62
+ export const PUG_MASCOT_MAX_WIDTH = PUG_MASCOT.reduce((max, row) => Math.max(max, row.length), 0);
63
+ export const PUG_MASCOT_HEIGHT = PUG_MASCOT.length;
64
+ //# sourceMappingURL=repl-splash-art.js.map
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * REPL boot splash (α6.14 wave 3).
4
+ *
5
+ * Rendered on REPL first paint — before the conversation pane, before
6
+ * any operator input lands. Mirrors the Claude Code / Codex / Gemini
7
+ * CLI boot-screen aesthetic while staying Pugi-brand-pure:
8
+ *
9
+ * [PUG ASCII] Pugi.io v0.1.0-alphaN
10
+ * Plan: <plan>
11
+ * Model: <model>
12
+ * Tenant: <customerId>
13
+ * Workspace: <basename>
14
+ *
15
+ * ─────────────────────────────────────
16
+ * Tips for getting started:
17
+ * 1. Type a brief, the workforce dispatches
18
+ * 2. /help for slash commands, /web <url> to pull a page
19
+ * 3. /skills install <name> for Anthropic / OpenClaw skills
20
+ *
21
+ * The splash auto-dismisses on:
22
+ * - first operator keystroke (the REPL `<Repl />` host owns this and
23
+ * calls the `onInteract` callback we expose),
24
+ * - 10s idle timeout (built-in, configurable via `skipSplash`),
25
+ * - `--no-splash` CLI flag or PUGI_SKIP_SPLASH=1 env (host gates the
26
+ * mount entirely; we still respect the `skipSplash` prop as a belt
27
+ * so a stray render in a test environment produces nothing).
28
+ *
29
+ * Brand voice gate: every visible string here is reviewed against the
30
+ * forbidden list (`journey / explore / delight / magical / friendly /
31
+ * AI-powered / pug-tastic`). Power words used: `brief / dispatch /
32
+ * ship / workforce / sentinel / skills`. No em-dashes; box-drawing
33
+ * `─` is OK (matches existing REPL header conventions).
34
+ */
35
+ import { useEffect } from 'react';
36
+ import { Box, Text } from 'ink';
37
+ import { PUG_MASCOT, PUG_MASCOT_CYAN_MASK, PUG_MASCOT_MAX_WIDTH, } from './repl-splash-art.js';
38
+ const DEFAULT_AUTO_DISMISS_MS = 10_000;
39
+ const PLACEHOLDER = '—';
40
+ export function ReplSplash(props) {
41
+ // Hooks MUST run unconditionally so the React reconciler can keep
42
+ // its hook order. We branch on `skipSplash` AFTER the effect
43
+ // declaration; the effect itself bails early when the splash is
44
+ // suppressed so no stray timer fires in the skip path.
45
+ useEffect(() => {
46
+ if (props.skipSplash)
47
+ return undefined;
48
+ const ms = props.autoDismissMs ?? DEFAULT_AUTO_DISMISS_MS;
49
+ const handle = setTimeout(() => {
50
+ props.onDismiss?.();
51
+ }, ms);
52
+ return () => clearTimeout(handle);
53
+ // Dependency on the onDismiss callback would re-arm the timer on
54
+ // every parent rerender; the host wraps it in useCallback so
55
+ // identity is stable for the splash's lifetime.
56
+ }, [props.autoDismissMs, props.onDismiss, props.skipSplash]);
57
+ // Belt for stray test renders: when the host already knows the
58
+ // operator opted out, we still want a render call to produce nothing
59
+ // visible. The host is the source of truth for mount-or-not; this is
60
+ // the no-op fallback.
61
+ if (props.skipSplash) {
62
+ return null;
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" })] })] }));
65
+ }
66
+ /**
67
+ * Renders the multi-line ASCII pug. Each row is split into colored
68
+ * runs based on `PUG_MASCOT_CYAN_MASK` so the eyes + chip come out
69
+ * cyan and the body stays gray. Pure render of the static art array;
70
+ * no IO, no state.
71
+ */
72
+ function MascotColumn() {
73
+ return (_jsx(Box, { flexDirection: "column", minWidth: PUG_MASCOT_MAX_WIDTH, children: PUG_MASCOT.map((row, rowIndex) => (_jsx(MascotRow, { row: row, mask: PUG_MASCOT_CYAN_MASK[rowIndex] ?? [] }, rowIndex))) }));
74
+ }
75
+ function MascotRow({ row, mask, }) {
76
+ // Split the row into contiguous runs of same-color cells so we emit
77
+ // one <Text> per run instead of one per character. Keeps the Ink
78
+ // render tree shallow and the snapshot diff readable.
79
+ const runs = [];
80
+ let buffer = '';
81
+ let bufferCyan = false;
82
+ for (let column = 0; column < row.length; column += 1) {
83
+ const ch = row.charAt(column);
84
+ const cyan = mask[column] === true;
85
+ if (buffer.length === 0) {
86
+ buffer = ch;
87
+ bufferCyan = cyan;
88
+ continue;
89
+ }
90
+ if (cyan === bufferCyan) {
91
+ buffer += ch;
92
+ }
93
+ else {
94
+ runs.push({ text: buffer, cyan: bufferCyan });
95
+ buffer = ch;
96
+ bufferCyan = cyan;
97
+ }
98
+ }
99
+ if (buffer.length > 0) {
100
+ runs.push({ text: buffer, cyan: bufferCyan });
101
+ }
102
+ return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: "cyan", children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
103
+ }
104
+ function HeaderRow({ label, value }) {
105
+ const padded = `${label}:`.padEnd(11, ' ');
106
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: padded }), _jsx(Text, { children: value })] }));
107
+ }
108
+ function TipRow({ index, text }) {
109
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${index}. ` }), _jsx(Text, { children: text })] }));
110
+ }
111
+ //# sourceMappingURL=repl-splash.js.map