@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1896 -13
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const HEADING_COLORS = ['cyan', 'magenta', 'yellow'];
|
|
4
|
+
/**
|
|
5
|
+
* Very small keyword table for cosmetic code accent. The fence label is
|
|
6
|
+
* matched case-insensitively against a handful of languages we expect
|
|
7
|
+
* the personas to emit (ts/tsx/js/jsx/py/sh). Unknown languages render
|
|
8
|
+
* the body in white - no syntax error, no warning. Tests assert that
|
|
9
|
+
* an empty fence label still produces a bordered Box (no crash).
|
|
10
|
+
*/
|
|
11
|
+
const KEYWORDS_BY_LANG = {
|
|
12
|
+
ts: ['const', 'let', 'var', 'function', 'class', 'interface', 'type', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this', 'static', 'public', 'private', 'protected', 'readonly', 'extends', 'implements'],
|
|
13
|
+
tsx: ['const', 'let', 'var', 'function', 'class', 'interface', 'type', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this', 'static', 'public', 'private', 'protected', 'readonly', 'extends', 'implements'],
|
|
14
|
+
js: ['const', 'let', 'var', 'function', 'class', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this'],
|
|
15
|
+
jsx: ['const', 'let', 'var', 'function', 'class', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this'],
|
|
16
|
+
py: ['def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif', 'for', 'while', 'with', 'as', 'try', 'except', 'finally', 'raise', 'lambda', 'pass', 'yield', 'None', 'True', 'False'],
|
|
17
|
+
sh: ['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'return', 'export'],
|
|
18
|
+
bash: ['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'return', 'export'],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Top-level entry point. Splits the source into lines, runs the
|
|
22
|
+
* block-level state machine, and emits an Ink tree. The component is
|
|
23
|
+
* memo-friendly (pure function of `source`) but we don't wrap in
|
|
24
|
+
* React.memo here - the conversation pane already memoises row keys.
|
|
25
|
+
*/
|
|
26
|
+
export function MarkdownRender(props) {
|
|
27
|
+
const blocks = parseBlocks(props.source);
|
|
28
|
+
const children = blocks.map((block, index) => renderBlock(block, index));
|
|
29
|
+
if (props.inline) {
|
|
30
|
+
return _jsx(_Fragment, { children: children });
|
|
31
|
+
}
|
|
32
|
+
return _jsx(Box, { flexDirection: "column", children: children });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Walk the source line by line and emit one Block per visual unit. The
|
|
36
|
+
* state machine has two modes: default (heading / list / paragraph) and
|
|
37
|
+
* in-code (every line is appended verbatim to the current code block).
|
|
38
|
+
*
|
|
39
|
+
* Inside a code fence we DO NOT process inline syntax - the body is
|
|
40
|
+
* preserved literally so the operator sees code that actually runs.
|
|
41
|
+
*/
|
|
42
|
+
function parseBlocks(source) {
|
|
43
|
+
const lines = source.split(/\r?\n/);
|
|
44
|
+
const blocks = [];
|
|
45
|
+
let inCode = false;
|
|
46
|
+
let codeLang = '';
|
|
47
|
+
let codeBody = [];
|
|
48
|
+
for (const raw of lines) {
|
|
49
|
+
const line = raw;
|
|
50
|
+
if (inCode) {
|
|
51
|
+
if (/^```/.test(line)) {
|
|
52
|
+
blocks.push({ kind: 'code', lang: codeLang, body: codeBody.join('\n') });
|
|
53
|
+
inCode = false;
|
|
54
|
+
codeBody = [];
|
|
55
|
+
codeLang = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
codeBody.push(line);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const fenceMatch = /^```([A-Za-z0-9_+-]*)\s*$/.exec(line);
|
|
62
|
+
if (fenceMatch) {
|
|
63
|
+
inCode = true;
|
|
64
|
+
codeLang = (fenceMatch[1] ?? '').toLowerCase();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (line.trim().length === 0) {
|
|
68
|
+
blocks.push({ kind: 'blank' });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const headingMatch = /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
72
|
+
if (headingMatch) {
|
|
73
|
+
const level = headingMatch[1].length;
|
|
74
|
+
const text = headingMatch[2];
|
|
75
|
+
blocks.push({ kind: 'heading', level, text });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const bulletMatch = /^\s*[-*]\s+(.+)$/.exec(line);
|
|
79
|
+
if (bulletMatch) {
|
|
80
|
+
blocks.push({ kind: 'bullet', text: bulletMatch[1] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const orderedMatch = /^\s*(\d+)\.\s+(.+)$/.exec(line);
|
|
84
|
+
if (orderedMatch) {
|
|
85
|
+
blocks.push({ kind: 'ordered', index: orderedMatch[1], text: orderedMatch[2] });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
blocks.push({ kind: 'paragraph', text: line });
|
|
89
|
+
}
|
|
90
|
+
// Unterminated fence: surface what we have as code so the operator
|
|
91
|
+
// still sees the body. Mirrors GitHub's render behavior.
|
|
92
|
+
if (inCode) {
|
|
93
|
+
blocks.push({ kind: 'code', lang: codeLang, body: codeBody.join('\n') });
|
|
94
|
+
}
|
|
95
|
+
return blocks;
|
|
96
|
+
}
|
|
97
|
+
/* ------------------------------------------------------------------ */
|
|
98
|
+
/* Block renderers */
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
function renderBlock(block, key) {
|
|
101
|
+
switch (block.kind) {
|
|
102
|
+
case 'heading': {
|
|
103
|
+
const color = HEADING_COLORS[block.level - 1] ?? 'cyan';
|
|
104
|
+
const prefix = '#'.repeat(block.level);
|
|
105
|
+
return (_jsx(Text, { bold: true, color: color, children: `${prefix} ${block.text}` }, key));
|
|
106
|
+
}
|
|
107
|
+
case 'paragraph':
|
|
108
|
+
return (_jsx(Text, { children: renderInline(block.text) }, key));
|
|
109
|
+
case 'bullet':
|
|
110
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '• ' }), _jsx(Text, { children: renderInline(block.text) })] }, key));
|
|
111
|
+
case 'ordered':
|
|
112
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `${block.index}. ` }), _jsx(Text, { children: renderInline(block.text) })] }, key));
|
|
113
|
+
case 'code':
|
|
114
|
+
return renderCodeBlock(block.lang, block.body, key);
|
|
115
|
+
case 'blank':
|
|
116
|
+
// One-line spacer between blocks. We render an empty Text so the
|
|
117
|
+
// height accounting in Ink matches what the operator sees.
|
|
118
|
+
return _jsx(Text, { children: " " }, key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function renderCodeBlock(lang, body, key) {
|
|
122
|
+
const lines = body.split('\n');
|
|
123
|
+
const keywords = KEYWORDS_BY_LANG[lang] ?? [];
|
|
124
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: lines.map((line, index) => (_jsx(Text, { children: renderCodeLine(line, keywords) }, index))) }, key));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Per-line code accent. We do NOT build a real lexer - we split on
|
|
128
|
+
* whitespace + punctuation boundaries and color matched keywords cyan,
|
|
129
|
+
* single/double quoted strings green, and `//` / `#` comment tails gray.
|
|
130
|
+
* The output is a list of Ink Text spans. Anything unmatched stays
|
|
131
|
+
* plain white.
|
|
132
|
+
*/
|
|
133
|
+
function renderCodeLine(line, keywords) {
|
|
134
|
+
// Comment tail wins first - we strip it and color the rest.
|
|
135
|
+
const commentIndex = findCommentStart(line);
|
|
136
|
+
const code = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
|
|
137
|
+
const comment = commentIndex >= 0 ? line.slice(commentIndex) : '';
|
|
138
|
+
const spans = [];
|
|
139
|
+
// Tokenise the code portion: alternate runs of word chars vs the rest.
|
|
140
|
+
// Strings are matched as a whole quoted span. The regex is anchored
|
|
141
|
+
// sticky to avoid catastrophic backtracking on long lines.
|
|
142
|
+
const tokenRe = /("[^"]*"|'[^']*'|`[^`]*`|\w+|\s+|[^\s\w]+)/g;
|
|
143
|
+
let match;
|
|
144
|
+
let key = 0;
|
|
145
|
+
while ((match = tokenRe.exec(code)) !== null) {
|
|
146
|
+
const tok = match[0];
|
|
147
|
+
if (/^["'`].*["'`]$/.test(tok)) {
|
|
148
|
+
spans.push(_jsx(Text, { color: "green", children: tok }, key));
|
|
149
|
+
}
|
|
150
|
+
else if (keywords.includes(tok)) {
|
|
151
|
+
spans.push(_jsx(Text, { color: "cyan", bold: true, children: tok }, key));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
spans.push(_jsx(Text, { children: tok }, key));
|
|
155
|
+
}
|
|
156
|
+
key += 1;
|
|
157
|
+
}
|
|
158
|
+
if (comment.length > 0) {
|
|
159
|
+
spans.push(_jsx(Text, { color: "gray", children: comment }, "comment"));
|
|
160
|
+
}
|
|
161
|
+
return _jsx(_Fragment, { children: spans });
|
|
162
|
+
}
|
|
163
|
+
function findCommentStart(line) {
|
|
164
|
+
// Single-line // or # outside of string literals. The probe is
|
|
165
|
+
// adequate for the persona-emitted snippets - a string literal that
|
|
166
|
+
// contains `//` would mis-color, but the output stays readable.
|
|
167
|
+
let inString = null;
|
|
168
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
169
|
+
const ch = line[i];
|
|
170
|
+
if (inString) {
|
|
171
|
+
if (ch === inString && line[i - 1] !== '\\')
|
|
172
|
+
inString = null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
176
|
+
inString = ch;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '/' && line[i + 1] === '/')
|
|
180
|
+
return i;
|
|
181
|
+
if (ch === '#')
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
|
186
|
+
/* ------------------------------------------------------------------ */
|
|
187
|
+
/* Inline tokeniser */
|
|
188
|
+
/* ------------------------------------------------------------------ */
|
|
189
|
+
/**
|
|
190
|
+
* Parse one paragraph line into a list of inline spans, then render
|
|
191
|
+
* them as Ink Text nodes. We walk the source left-to-right and greedily
|
|
192
|
+
* consume the longest delimiter we find. Unmatched delimiters fall
|
|
193
|
+
* through as literal text - personas type `**` mid-sentence sometimes.
|
|
194
|
+
*/
|
|
195
|
+
function renderInline(source) {
|
|
196
|
+
const spans = tokeniseInline(source);
|
|
197
|
+
return (_jsx(_Fragment, { children: spans.map((span, index) => renderSpan(span, index)) }));
|
|
198
|
+
}
|
|
199
|
+
function tokeniseInline(source) {
|
|
200
|
+
const spans = [];
|
|
201
|
+
let buffer = '';
|
|
202
|
+
let i = 0;
|
|
203
|
+
while (i < source.length) {
|
|
204
|
+
const rest = source.slice(i);
|
|
205
|
+
// Inline code wins over bold/italic so that `**` inside backticks
|
|
206
|
+
// renders literally.
|
|
207
|
+
const codeMatch = /^`([^`]+)`/.exec(rest);
|
|
208
|
+
if (codeMatch) {
|
|
209
|
+
flush(buffer, spans);
|
|
210
|
+
buffer = '';
|
|
211
|
+
spans.push({ kind: 'code', text: codeMatch[1] });
|
|
212
|
+
i += codeMatch[0].length;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Link before bold/italic so `[text **bold**](url)` renders linkish.
|
|
216
|
+
const linkMatch = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest);
|
|
217
|
+
if (linkMatch) {
|
|
218
|
+
flush(buffer, spans);
|
|
219
|
+
buffer = '';
|
|
220
|
+
spans.push({ kind: 'link', text: linkMatch[1], url: linkMatch[2] });
|
|
221
|
+
i += linkMatch[0].length;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Bold (**) before italic (*) so the greedy match wins.
|
|
225
|
+
const boldMatch = /^\*\*([^*]+)\*\*/.exec(rest);
|
|
226
|
+
if (boldMatch) {
|
|
227
|
+
flush(buffer, spans);
|
|
228
|
+
buffer = '';
|
|
229
|
+
spans.push({ kind: 'bold', text: boldMatch[1] });
|
|
230
|
+
i += boldMatch[0].length;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const italicMatch = /^\*([^*]+)\*/.exec(rest);
|
|
234
|
+
if (italicMatch) {
|
|
235
|
+
flush(buffer, spans);
|
|
236
|
+
buffer = '';
|
|
237
|
+
spans.push({ kind: 'italic', text: italicMatch[1] });
|
|
238
|
+
i += italicMatch[0].length;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
buffer += source[i];
|
|
242
|
+
i += 1;
|
|
243
|
+
}
|
|
244
|
+
flush(buffer, spans);
|
|
245
|
+
return spans;
|
|
246
|
+
}
|
|
247
|
+
function flush(buffer, spans) {
|
|
248
|
+
if (buffer.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
spans.push({ kind: 'text', text: buffer });
|
|
251
|
+
}
|
|
252
|
+
function renderSpan(span, key) {
|
|
253
|
+
switch (span.kind) {
|
|
254
|
+
case 'text':
|
|
255
|
+
return _jsx(Text, { children: span.text }, key);
|
|
256
|
+
case 'bold':
|
|
257
|
+
return _jsx(Text, { bold: true, children: span.text }, key);
|
|
258
|
+
case 'italic':
|
|
259
|
+
return _jsx(Text, { italic: true, children: span.text }, key);
|
|
260
|
+
case 'code':
|
|
261
|
+
return _jsx(Text, { color: "green", children: span.text }, key);
|
|
262
|
+
case 'link':
|
|
263
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", underline: true, children: span.text }), _jsx(Text, { dimColor: true, children: ` (${span.url})` })] }, key));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=markdown-render.js.map
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -19,8 +19,12 @@
|
|
|
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
|
|
@@ -33,6 +37,26 @@ export async function renderRepl(options) {
|
|
|
33
37
|
// best-effort — any FS error falls back to a basename-only summary,
|
|
34
38
|
// never blocks REPL launch. Wave 4 fix 2026-05-25.
|
|
35
39
|
const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
|
|
40
|
+
// α6.4: open the local SessionStore for `/resume` persistence. The
|
|
41
|
+
// store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
|
|
42
|
+
// — we log a one-line warning to stderr and continue with the REPL
|
|
43
|
+
// in memory-only mode. Lock-busy errors get the friendliest message
|
|
44
|
+
// so an operator running two REPLs in the same project understands
|
|
45
|
+
// the constraint.
|
|
46
|
+
const projectSlug = slugForCwd(process.cwd());
|
|
47
|
+
const { store, openedSessionId } = await openLocalStore({
|
|
48
|
+
projectSlug,
|
|
49
|
+
workspaceRoot: process.cwd(),
|
|
50
|
+
resumeLocalSessionId: options.resumeLocalSessionId,
|
|
51
|
+
});
|
|
52
|
+
// α6.5 three-tier context bootstrap. The skeleton + working set
|
|
53
|
+
// + watcher are local-first and best-effort: every step is wrapped
|
|
54
|
+
// in try/catch so an unreadable workspace never blocks REPL launch.
|
|
55
|
+
// Opt-out via PUGI_DISABLE_CONTEXT=1 for hermetic test runs.
|
|
56
|
+
const { skeleton, workingSet, watcher } = await bootstrapContext({
|
|
57
|
+
cwd: process.cwd(),
|
|
58
|
+
env: process.env,
|
|
59
|
+
});
|
|
36
60
|
const session = new ReplSession({
|
|
37
61
|
apiUrl: options.apiUrl,
|
|
38
62
|
apiKey: options.apiKey,
|
|
@@ -40,21 +64,182 @@ export async function renderRepl(options) {
|
|
|
40
64
|
cliVersion: options.cliVersion,
|
|
41
65
|
transport,
|
|
42
66
|
workspace,
|
|
67
|
+
store,
|
|
68
|
+
localSessionId: openedSessionId,
|
|
69
|
+
repoSkeleton: skeleton,
|
|
70
|
+
workingSet,
|
|
71
|
+
watcher,
|
|
43
72
|
});
|
|
73
|
+
// Restore the transcript from the JSONL log if we resumed an
|
|
74
|
+
// existing session. The restore is idempotent and bypasses persist
|
|
75
|
+
// (no double-write of replayed rows).
|
|
76
|
+
if (store && openedSessionId && options.resumeLocalSessionId) {
|
|
77
|
+
try {
|
|
78
|
+
const events = await store.loadEvents(openedSessionId, { limit: 500 });
|
|
79
|
+
session.restoreTranscript(events);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
83
|
+
process.stderr.write(`[pugi] Could not restore session ${openedSessionId.slice(0, 13)}: ${msg}\n`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
44
86
|
// Kick off the connect; the Repl renders the connecting state until
|
|
45
87
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
46
88
|
void session.start();
|
|
89
|
+
// α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
|
|
90
|
+
// stdout BEFORE Ink mounts. Ink's layout engine would mis-measure
|
|
91
|
+
// the truecolor escape sequences, so the pug must land verbatim.
|
|
92
|
+
// The flag is passed into <Repl /> so the splash component knows to
|
|
93
|
+
// skip its own hand-crafted PUG_MASCOT column — otherwise the
|
|
94
|
+
// operator sees both the chafa pug AND the ASCII fallback stacked.
|
|
95
|
+
// When skipSplash is true (operator opted out via --no-splash), we
|
|
96
|
+
// suppress the pre-print too so the boot stays silent.
|
|
97
|
+
const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
|
|
98
|
+
// α6.14.4 CEO dogfood 2026-05-25 (parity with Claude Code): enter
|
|
99
|
+
// the terminal's alternate screen buffer so the REPL renders on a
|
|
100
|
+
// fresh "screen" the operator cannot scroll above. On exit, leave
|
|
101
|
+
// restores the previous terminal contents — the conversation does
|
|
102
|
+
// not pollute the operator's shell history. Skipped under --no-tty
|
|
103
|
+
// and when stdout is not a TTY (pipe/CI), where the escapes would
|
|
104
|
+
// appear as literal characters.
|
|
105
|
+
const supportsAltScreen = process.stdout.isTTY === true;
|
|
106
|
+
if (supportsAltScreen) {
|
|
107
|
+
process.stdout.write('\x1b[?1049h');
|
|
108
|
+
process.stdout.write('\x1b[H');
|
|
109
|
+
}
|
|
47
110
|
const instance = render(React.createElement(Repl, {
|
|
48
111
|
session,
|
|
49
112
|
updateBanner: options.updateBanner ?? null,
|
|
50
113
|
skipSplash: options.skipSplash === true,
|
|
114
|
+
hideToolStream: options.hideToolStream === true,
|
|
115
|
+
mascotPrePrinted,
|
|
51
116
|
}));
|
|
117
|
+
const restoreAltScreen = () => {
|
|
118
|
+
if (supportsAltScreen) {
|
|
119
|
+
try {
|
|
120
|
+
process.stdout.write('\x1b[?1049l');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* shutdown race — terminal already detached */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
// Make sure we leave the alt screen on abrupt exits too. Without
|
|
128
|
+
// this the operator's shell stays "frozen" on the Pugi splash.
|
|
129
|
+
process.once('exit', restoreAltScreen);
|
|
130
|
+
process.once('SIGINT', restoreAltScreen);
|
|
131
|
+
process.once('SIGTERM', restoreAltScreen);
|
|
52
132
|
try {
|
|
53
133
|
await instance.waitUntilExit();
|
|
54
134
|
}
|
|
55
135
|
finally {
|
|
136
|
+
restoreAltScreen();
|
|
56
137
|
session.close();
|
|
138
|
+
if (store) {
|
|
139
|
+
try {
|
|
140
|
+
await store.close();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* idempotent — already closed */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (watcher) {
|
|
147
|
+
try {
|
|
148
|
+
await watcher.close();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* idempotent — chokidar may already be torn down */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Open the local SessionStore for the REPL bootstrap. Returns
|
|
158
|
+
* `{ store: null, openedSessionId: undefined }` on any error so the
|
|
159
|
+
* caller falls through to memory-only mode rather than failing the
|
|
160
|
+
* launch. The one error we surface verbatim is the lock-busy case —
|
|
161
|
+
* that one is operator-actionable.
|
|
162
|
+
*/
|
|
163
|
+
async function openLocalStore(input) {
|
|
164
|
+
// Honour an explicit opt-out for offline-strict environments / CI.
|
|
165
|
+
// PUGI_DISABLE_SESSION_STORE=1 wipes the integration to zero. Useful
|
|
166
|
+
// for hermetic test runs and for operators who do not want any
|
|
167
|
+
// persistence under $HOME.
|
|
168
|
+
if (process.env.PUGI_DISABLE_SESSION_STORE === '1') {
|
|
169
|
+
return { store: null, openedSessionId: undefined };
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const store = new SqliteSessionStore({ projectSlug: input.projectSlug });
|
|
173
|
+
const row = await store.open({
|
|
174
|
+
id: input.resumeLocalSessionId,
|
|
175
|
+
workspaceRoot: input.workspaceRoot,
|
|
176
|
+
projectSlug: input.projectSlug,
|
|
177
|
+
});
|
|
178
|
+
return { store, openedSessionId: row.id };
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const code = error?.code;
|
|
182
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
183
|
+
if (code === 'EBUSY_SESSION_LOCK') {
|
|
184
|
+
process.stderr.write(`[pugi] ${msg} Continuing without local session persistence.\n`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
process.stderr.write(`[pugi] Local session store unavailable (${msg}). Continuing in memory-only mode.\n`);
|
|
188
|
+
}
|
|
189
|
+
return { store: null, openedSessionId: undefined };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Bootstrap the α6.5 three-tier context primitives:
|
|
194
|
+
*
|
|
195
|
+
* - Tier 0: `RepoSkeleton` (~5KB ASCII tree + meta) for prompt injection.
|
|
196
|
+
* - Tier 1: `WorkingSet` LRU bounded at 50 entries.
|
|
197
|
+
* - Filewatch: chokidar started against cwd, ignore-filtered.
|
|
198
|
+
*
|
|
199
|
+
* The bootstrap is fail-safe: every primitive is wrapped so the REPL
|
|
200
|
+
* still launches when (e.g.) chokidar refuses to start on a
|
|
201
|
+
* permission-blocked dir. The PUGI_DISABLE_CONTEXT=1 env var skips
|
|
202
|
+
* the bootstrap entirely for hermetic test runs and for operators
|
|
203
|
+
* who want a zero-touch REPL.
|
|
204
|
+
*/
|
|
205
|
+
async function bootstrapContext(input) {
|
|
206
|
+
if (input.env.PUGI_DISABLE_CONTEXT === '1') {
|
|
207
|
+
return { skeleton: null, workingSet: null, watcher: null };
|
|
208
|
+
}
|
|
209
|
+
let ignore;
|
|
210
|
+
try {
|
|
211
|
+
ignore = loadPugiIgnore(input.cwd);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
215
|
+
process.stderr.write(`[pugi] Three-tier context bootstrap skipped (ignore matcher failed: ${msg}).\n`);
|
|
216
|
+
return { skeleton: null, workingSet: null, watcher: null };
|
|
217
|
+
}
|
|
218
|
+
let skeleton = null;
|
|
219
|
+
try {
|
|
220
|
+
skeleton = buildRepoSkeleton(input.cwd, { ignore });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
224
|
+
process.stderr.write(`[pugi] Repo skeleton bootstrap failed (${msg}). Continuing without Tier 0.\n`);
|
|
225
|
+
}
|
|
226
|
+
const workingSet = new WorkingSet();
|
|
227
|
+
let watcher = null;
|
|
228
|
+
// chokidar opt-out: PUGI_DISABLE_FILEWATCH=1 keeps Tier 0/1 wired
|
|
229
|
+
// but skips the live-update channel. Useful on CI runners and on
|
|
230
|
+
// network mounts where fsevents misbehaves.
|
|
231
|
+
if (input.env.PUGI_DISABLE_FILEWATCH !== '1') {
|
|
232
|
+
try {
|
|
233
|
+
const w = new PugiWatcher({ cwd: input.cwd, ignore });
|
|
234
|
+
await w.start();
|
|
235
|
+
watcher = w;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
239
|
+
process.stderr.write(`[pugi] Filewatch bootstrap failed (${msg}). Continuing without live updates.\n`);
|
|
240
|
+
}
|
|
57
241
|
}
|
|
242
|
+
return { skeleton, workingSet, watcher };
|
|
58
243
|
}
|
|
59
244
|
/* ------------------------------------------------------------------ */
|
|
60
245
|
/* Production transport */
|
|
@@ -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
|
package/dist/tui/repl-splash.js
CHANGED
|
@@ -61,7 +61,13 @@ export function ReplSplash(props) {
|
|
|
61
61
|
if (props.skipSplash) {
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
-
|
|
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
|