@pugi/cli 0.1.0-alpha.8 → 0.1.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/repl/session.js +28 -2
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/runtime/cli.js +8 -1
- package/dist/tui/repl-render.js +26 -4
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +28 -2
- package/dist/tui/slash-palette.js +47 -10
- package/dist/tui/status-bar.js +14 -1
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +2 -2
|
@@ -96,6 +96,7 @@ export class ReplSession {
|
|
|
96
96
|
const { sessionId } = await this.options.transport.createSession({
|
|
97
97
|
apiUrl: this.options.apiUrl,
|
|
98
98
|
apiKey: this.options.apiKey,
|
|
99
|
+
workspace: this.options.workspace,
|
|
99
100
|
});
|
|
100
101
|
this.patch({ sessionId, connection: 'connecting' });
|
|
101
102
|
this.openStream();
|
|
@@ -137,7 +138,11 @@ export class ReplSession {
|
|
|
137
138
|
// UI overlays - no transport interaction.
|
|
138
139
|
return verdict;
|
|
139
140
|
case 'quit':
|
|
140
|
-
|
|
141
|
+
// UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
|
|
142
|
+
// for identity intro + landing per wave-4 prompt rule. Drop the
|
|
143
|
+
// tagline drift here; tell the operator what happened and how to
|
|
144
|
+
// resume.
|
|
145
|
+
this.appendSystemLine('On watch ended. pugi resume to come back.');
|
|
141
146
|
return verdict;
|
|
142
147
|
case 'error':
|
|
143
148
|
this.appendSystemLine(verdict.message);
|
|
@@ -403,6 +408,20 @@ export class ReplSession {
|
|
|
403
408
|
switch (event.type) {
|
|
404
409
|
case 'agent.spawned': {
|
|
405
410
|
const persona = safePersonaName(event.role);
|
|
411
|
+
// Wave 4 fix 2026-05-25: the roster collapses to one row per
|
|
412
|
+
// persona slug. The α5.7 reducer pushed a fresh row on every
|
|
413
|
+
// spawn, so after three turns the bottom panel stacked
|
|
414
|
+
// "Mira orchestrator shipped" three times. The new contract:
|
|
415
|
+
// - If a row already exists for this personaSlug, REUSE it.
|
|
416
|
+
// Replace its taskId, reset status to 'queued', clear the
|
|
417
|
+
// detail line, restart the duration clock, zero the token
|
|
418
|
+
// counters. The persona name + slug + role stay stable
|
|
419
|
+
// (they are the row identity).
|
|
420
|
+
// - If no row exists yet, push a new one.
|
|
421
|
+
// Per-task lifecycle (step/tokens/completed/blocked/failed) is
|
|
422
|
+
// keyed off `taskId` everywhere, so the reused row still folds
|
|
423
|
+
// the latest task's events correctly.
|
|
424
|
+
const existing = this.state.agents.find((a) => a.personaSlug === event.personaSlug);
|
|
406
425
|
const node = {
|
|
407
426
|
taskId: event.taskId,
|
|
408
427
|
role: event.role,
|
|
@@ -414,7 +433,14 @@ export class ReplSession {
|
|
|
414
433
|
tokensIn: 0,
|
|
415
434
|
tokensOut: 0,
|
|
416
435
|
};
|
|
417
|
-
|
|
436
|
+
if (existing) {
|
|
437
|
+
this.patch({
|
|
438
|
+
agents: this.state.agents.map((a) => a.personaSlug === event.personaSlug ? node : a),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
this.patch({ agents: [node, ...this.state.agents] });
|
|
443
|
+
}
|
|
418
444
|
// The conversation pane already prefixes persona rows with the
|
|
419
445
|
// persona name in the persona's hue colour. Skip embedding the
|
|
420
446
|
// name in the body text to avoid the `Marcus Marcus dispatched`
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace context resolver — Sprint α6.14 wave 4.
|
|
3
|
+
*
|
|
4
|
+
* Reads the operator's cwd and synthesises the workspace bundle the CLI
|
|
5
|
+
* forwards to admin-api on POST /api/pugi/sessions. Mira's prompt v1.1
|
|
6
|
+
* consumes the bundle so "what repo is this?" / "а изучи репо…" answers
|
|
7
|
+
* from the live cwd instead of bouncing back "репо не привязано" (CEO
|
|
8
|
+
* dogfood 2026-05-25).
|
|
9
|
+
*
|
|
10
|
+
* Three fields:
|
|
11
|
+
*
|
|
12
|
+
* - `workspaceCwd` — absolute path the CLI was launched from.
|
|
13
|
+
* - `workspaceSlug` — a stable short identifier (slugForCwd).
|
|
14
|
+
* - `workspaceSummary` — first ~200 chars of `.pugi/PUGI.md` if the
|
|
15
|
+
* repo has one, else the directory basename.
|
|
16
|
+
*
|
|
17
|
+
* The helper is pure-ish (reads the filesystem but does not mutate it)
|
|
18
|
+
* so the production caller in `runtime/cli.ts` can call it eagerly at
|
|
19
|
+
* REPL launch without touching the network. Tests pass an explicit cwd
|
|
20
|
+
* + `fs` stub so the resolver stays deterministic.
|
|
21
|
+
*
|
|
22
|
+
* Failure mode: any FS error (permission denied, missing PUGI.md,
|
|
23
|
+
* symlink loop) returns the basename fallback. The CLI never bubbles
|
|
24
|
+
* the error to the operator — workspace context is a best-effort hint,
|
|
25
|
+
* not a precondition for opening the session.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import { basename, resolve as resolvePath } from 'node:path';
|
|
29
|
+
import { slugForCwd } from './history.js';
|
|
30
|
+
/** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
|
|
31
|
+
const PUGI_MD_HEAD_LIMIT = 200;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a `ReplWorkspaceContext` from the operator's working directory.
|
|
34
|
+
* Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
|
|
35
|
+
* `workspaceSummary` populated. The summary is the PUGI.md head when
|
|
36
|
+
* available, else the directory basename.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveWorkspaceContext(cwd) {
|
|
39
|
+
const normalised = resolvePath(cwd);
|
|
40
|
+
const slug = slugForCwd(normalised);
|
|
41
|
+
const summary = readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace';
|
|
42
|
+
return {
|
|
43
|
+
workspaceCwd: normalised,
|
|
44
|
+
workspaceSlug: slug,
|
|
45
|
+
workspaceSummary: summary,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
|
|
50
|
+
* project's own description is the highest-signal one-line summary we
|
|
51
|
+
* can hand to Mira — `pugi init` writes it on workspace creation, and
|
|
52
|
+
* the operator may have edited it since.
|
|
53
|
+
*
|
|
54
|
+
* Returns null on any FS error so the caller falls back to the
|
|
55
|
+
* basename. We never throw — workspace context is best-effort.
|
|
56
|
+
*/
|
|
57
|
+
function readPugiSummary(cwd) {
|
|
58
|
+
const candidate = resolvePath(cwd, '.pugi', 'PUGI.md');
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(candidate))
|
|
61
|
+
return null;
|
|
62
|
+
const st = statSync(candidate);
|
|
63
|
+
if (!st.isFile())
|
|
64
|
+
return null;
|
|
65
|
+
// Cap the read at 2 KB — even a malformed PUGI.md will not be
|
|
66
|
+
// bigger than that's worth on a one-line summary, and capping
|
|
67
|
+
// bounds the slice cost on a giant file.
|
|
68
|
+
const raw = readFileSync(candidate, 'utf8').slice(0, 2048);
|
|
69
|
+
return summariseMarkdown(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a PUGI.md head to one short summary line: strip the front-
|
|
77
|
+
* matter and the leading H1 marker, take the first non-empty line,
|
|
78
|
+
* cap at PUGI_MD_HEAD_LIMIT. Whitespace collapses to single spaces so
|
|
79
|
+
* the summary survives the admin-api clamp without weird wrap.
|
|
80
|
+
*/
|
|
81
|
+
export function summariseMarkdown(raw) {
|
|
82
|
+
if (!raw || raw.trim().length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const body = stripFrontmatter(raw);
|
|
85
|
+
const lines = body.split(/\r?\n/);
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
// Strip leading `#` markers + trim whitespace. A heading like
|
|
88
|
+
// "# My Project" becomes "My Project".
|
|
89
|
+
const stripped = line.replace(/^#+\s*/, '').trim();
|
|
90
|
+
if (stripped.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
const oneLine = stripped.replace(/\s+/g, ' ');
|
|
93
|
+
return oneLine.length > PUGI_MD_HEAD_LIMIT
|
|
94
|
+
? oneLine.slice(0, PUGI_MD_HEAD_LIMIT)
|
|
95
|
+
: oneLine;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drop a YAML front-matter block (`---\n…\n---`) from the head of a
|
|
101
|
+
* Markdown file. Mira does not need to see the metadata; the prose body
|
|
102
|
+
* carries the project description.
|
|
103
|
+
*/
|
|
104
|
+
function stripFrontmatter(raw) {
|
|
105
|
+
if (!raw.startsWith('---'))
|
|
106
|
+
return raw;
|
|
107
|
+
const end = raw.indexOf('\n---', 3);
|
|
108
|
+
if (end === -1)
|
|
109
|
+
return raw;
|
|
110
|
+
const afterFrontmatter = raw.slice(end + 4);
|
|
111
|
+
return afterFrontmatter.replace(/^\r?\n/, '');
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=workspace-context.js.map
|
package/dist/runtime/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ import { runBudgetCommand } from './commands/budget.js';
|
|
|
35
35
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
36
36
|
* three are in lockstep.
|
|
37
37
|
*/
|
|
38
|
-
const PUGI_CLI_VERSION = '0.1.0-alpha.
|
|
38
|
+
const PUGI_CLI_VERSION = '0.1.0-alpha.9';
|
|
39
39
|
const handlers = {
|
|
40
40
|
accounts,
|
|
41
41
|
build: runEngineTask('build_task'),
|
|
@@ -169,6 +169,7 @@ export async function runCli(argv) {
|
|
|
169
169
|
workspaceLabel: workspaceLabel(process.cwd()),
|
|
170
170
|
cliVersion: PUGI_CLI_VERSION,
|
|
171
171
|
updateBanner,
|
|
172
|
+
skipSplash: flags.noSplash,
|
|
172
173
|
});
|
|
173
174
|
return;
|
|
174
175
|
}
|
|
@@ -204,6 +205,7 @@ function parseArgs(argv) {
|
|
|
204
205
|
noTty: false,
|
|
205
206
|
allowFetch: false,
|
|
206
207
|
noUpdateCheck: false,
|
|
208
|
+
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
207
209
|
};
|
|
208
210
|
const args = [];
|
|
209
211
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -245,6 +247,9 @@ function parseArgs(argv) {
|
|
|
245
247
|
else if (arg === '--no-update-check') {
|
|
246
248
|
flags.noUpdateCheck = true;
|
|
247
249
|
}
|
|
250
|
+
else if (arg === '--no-splash') {
|
|
251
|
+
flags.noSplash = true;
|
|
252
|
+
}
|
|
248
253
|
else if (arg.startsWith('--privacy=')) {
|
|
249
254
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
250
255
|
}
|
|
@@ -303,6 +308,8 @@ async function help(_args, flags, _session) {
|
|
|
303
308
|
' recording flows, dumb terminals).',
|
|
304
309
|
' --no-update-check Silence the REPL startup update banner. Pairs',
|
|
305
310
|
' with PUGI_SKIP_UPDATE_BANNER=1.',
|
|
311
|
+
' --no-splash Skip the REPL boot splash. Pairs with',
|
|
312
|
+
' PUGI_SKIP_SPLASH=1.',
|
|
306
313
|
'',
|
|
307
314
|
PUGI_TAGLINE,
|
|
308
315
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
import React from 'react';
|
|
20
20
|
import { render } from 'ink';
|
|
21
21
|
import { Repl } from './repl.js';
|
|
22
|
-
import { ReplSession } from '../core/repl/session.js';
|
|
22
|
+
import { ReplSession, } from '../core/repl/session.js';
|
|
23
|
+
import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
|
|
23
24
|
/**
|
|
24
25
|
* Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
|
|
25
26
|
* `/quit`. The session is closed (server-side stays alive; resume via
|
|
@@ -27,17 +28,27 @@ import { ReplSession } from '../core/repl/session.js';
|
|
|
27
28
|
*/
|
|
28
29
|
export async function renderRepl(options) {
|
|
29
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());
|
|
30
36
|
const session = new ReplSession({
|
|
31
37
|
apiUrl: options.apiUrl,
|
|
32
38
|
apiKey: options.apiKey,
|
|
33
39
|
workspaceLabel: options.workspaceLabel,
|
|
34
40
|
cliVersion: options.cliVersion,
|
|
35
41
|
transport,
|
|
42
|
+
workspace,
|
|
36
43
|
});
|
|
37
44
|
// Kick off the connect; the Repl renders the connecting state until
|
|
38
45
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
39
46
|
void session.start();
|
|
40
|
-
const instance = render(React.createElement(Repl, {
|
|
47
|
+
const instance = render(React.createElement(Repl, {
|
|
48
|
+
session,
|
|
49
|
+
updateBanner: options.updateBanner ?? null,
|
|
50
|
+
skipSplash: options.skipSplash === true,
|
|
51
|
+
}));
|
|
41
52
|
try {
|
|
42
53
|
await instance.waitUntilExit();
|
|
43
54
|
}
|
|
@@ -50,11 +61,22 @@ export async function renderRepl(options) {
|
|
|
50
61
|
/* ------------------------------------------------------------------ */
|
|
51
62
|
function createProductionTransport() {
|
|
52
63
|
return {
|
|
53
|
-
async createSession({ apiUrl, apiKey }) {
|
|
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;
|
|
54
76
|
const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
|
|
55
77
|
method: 'POST',
|
|
56
78
|
headers: jsonHeaders(apiKey),
|
|
57
|
-
body: JSON.stringify(
|
|
79
|
+
body: JSON.stringify(body),
|
|
58
80
|
});
|
|
59
81
|
const json = await readJson(response);
|
|
60
82
|
const sessionId = json.sessionId;
|
|
@@ -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
|
package/dist/tui/repl.js
CHANGED
|
@@ -23,8 +23,10 @@ import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
|
|
|
23
23
|
import { AgentTree } from './agent-tree.js';
|
|
24
24
|
import { ConversationPane } from './conversation-pane.js';
|
|
25
25
|
import { InputBox } from './input-box.js';
|
|
26
|
+
import { ReplSplash } from './repl-splash.js';
|
|
26
27
|
import { StatusBar } from './status-bar.js';
|
|
27
28
|
import { UpdateBanner } from './update-banner.js';
|
|
29
|
+
import { collectWorkspaceContext } from './workspace-context.js';
|
|
28
30
|
import { slugForCwd } from '../core/repl/history.js';
|
|
29
31
|
import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
|
|
30
32
|
const TICK_INTERVAL_MS = 200;
|
|
@@ -34,6 +36,15 @@ export function Repl(props) {
|
|
|
34
36
|
const [overlay, setOverlay] = useState('none');
|
|
35
37
|
const [pulsePhase, setPulsePhase] = useState(0);
|
|
36
38
|
const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
|
|
39
|
+
// α6.14 wave 3: boot splash visible until first input, first
|
|
40
|
+
// `agent.spawned` event, or 10s idle. The host gates the initial
|
|
41
|
+
// visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
|
|
42
|
+
const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
|
|
43
|
+
const dismissSplash = useCallback(() => setSplashVisible(false), []);
|
|
44
|
+
// α6.14 wave 3: workspace context snapshot for the status bar. We
|
|
45
|
+
// read once at mount and freeze; a brand-new PUGI.md or skill is
|
|
46
|
+
// surfaced on the next REPL boot rather than via a watcher.
|
|
47
|
+
const workspaceContext = useMemo(() => props.workspaceContext ?? collectWorkspaceContext(process.cwd()), [props.workspaceContext]);
|
|
37
48
|
// Subscribe to session state updates. The session module fires the
|
|
38
49
|
// callback synchronously inside `patch` so we mirror without a
|
|
39
50
|
// batching layer.
|
|
@@ -62,9 +73,24 @@ export function Repl(props) {
|
|
|
62
73
|
useEffect(() => {
|
|
63
74
|
props.onOverlayChange?.(overlay);
|
|
64
75
|
}, [overlay, props]);
|
|
76
|
+
// α6.14 wave 3: dismiss the boot splash once the first agent spawns
|
|
77
|
+
// (the operator has clearly engaged the system) or the transcript
|
|
78
|
+
// gains a row. Mirrors the natural attention shift Claude Code /
|
|
79
|
+
// Codex / Gemini CLI all do on their boot screens.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!splashVisible)
|
|
82
|
+
return;
|
|
83
|
+
if (state.agents.length > 0 || state.transcript.length > 0) {
|
|
84
|
+
setSplashVisible(false);
|
|
85
|
+
}
|
|
86
|
+
}, [splashVisible, state.agents.length, state.transcript.length]);
|
|
65
87
|
const personaNames = useMemo(() => buildPersonaNameMap(), []);
|
|
66
88
|
const { exit } = useApp();
|
|
67
89
|
const handleSubmit = useCallback((line) => {
|
|
90
|
+
// Dismiss the boot splash on first operator input. Idempotent —
|
|
91
|
+
// `setSplashVisible(false)` is a no-op once the state already
|
|
92
|
+
// settled to false (timer fired or `agent.spawned` arrived).
|
|
93
|
+
setSplashVisible(false);
|
|
68
94
|
// Run async without awaiting - the session module owns the
|
|
69
95
|
// network call, errors land in the transcript automatically.
|
|
70
96
|
void props.session.handleInput(line).then((verdict) => {
|
|
@@ -96,11 +122,11 @@ export function Repl(props) {
|
|
|
96
122
|
setOverlay('none');
|
|
97
123
|
}
|
|
98
124
|
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
99
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
100
126
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
101
127
|
// the same basename do not share history. state.workspaceLabel
|
|
102
128
|
// is the basename only. Codex review P2.
|
|
103
|
-
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
|
|
129
|
+
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct })] })] }));
|
|
104
130
|
}
|
|
105
131
|
function Header({ state }) {
|
|
106
132
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
|
@@ -3,16 +3,21 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
4
4
|
export const PALETTE_ROW_LIMIT = 8;
|
|
5
5
|
/**
|
|
6
|
-
* Compute the
|
|
6
|
+
* Compute the FULL filtered candidate list for a given input buffer.
|
|
7
7
|
* Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
|
|
8
8
|
* logic so the input box and the unit test agree on the shape.
|
|
9
9
|
*
|
|
10
|
+
* Wave 4 fix 2026-05-25: returns the FULL filtered set, not just the
|
|
11
|
+
* first PALETTE_ROW_LIMIT rows. The palette renderer now windows the
|
|
12
|
+
* visible slice internally based on `focusedIndex`, so the operator
|
|
13
|
+
* can scroll past row 7 via ↑/↓ on a long list (e.g. 20 commands when
|
|
14
|
+
* the buffer is `/`). `totalBeforeLimit` is preserved on the return
|
|
15
|
+
* shape for backward compatibility but always equals `rows.length`.
|
|
16
|
+
*
|
|
10
17
|
* Behaviour:
|
|
11
18
|
* - Empty / non-slash buffer → empty result; palette stays hidden.
|
|
12
19
|
* - `/` alone → all registry rows (the operator wants to browse).
|
|
13
20
|
* - `/he` → rows whose name starts with `he` (case-insensitive).
|
|
14
|
-
* - Capped at PALETTE_ROW_LIMIT; the input box renders a hint when
|
|
15
|
-
* `totalBeforeLimit > rows.length`.
|
|
16
21
|
*/
|
|
17
22
|
export function filterPalette(buffer) {
|
|
18
23
|
if (!buffer.startsWith('/')) {
|
|
@@ -31,9 +36,33 @@ export function filterPalette(buffer) {
|
|
|
31
36
|
const all = prefix.length === 0
|
|
32
37
|
? SLASH_COMMAND_HELP
|
|
33
38
|
: SLASH_COMMAND_HELP.filter((row) => row.name.toLowerCase().startsWith(prefix));
|
|
39
|
+
// Defensive copy via spread so callers cannot mutate the registry
|
|
40
|
+
// through the returned readonly array (TS-only enforcement, but
|
|
41
|
+
// future refactors might assume the contract).
|
|
42
|
+
const rows = [...all];
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
totalBeforeLimit: rows.length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function computePaletteWindow(rows, focusedIndex) {
|
|
49
|
+
const total = rows.length;
|
|
50
|
+
if (total <= PALETTE_ROW_LIMIT) {
|
|
51
|
+
return { visible: rows, startIndex: 0, total };
|
|
52
|
+
}
|
|
53
|
+
// Sliding window: anchor the start so the focused row stays inside the
|
|
54
|
+
// PALETTE_ROW_LIMIT span. Clamp at both ends so we never render fewer
|
|
55
|
+
// than PALETTE_ROW_LIMIT rows when the list is long enough to fill them.
|
|
56
|
+
let start = focusedIndex - Math.floor(PALETTE_ROW_LIMIT / 2);
|
|
57
|
+
if (start < 0)
|
|
58
|
+
start = 0;
|
|
59
|
+
const maxStart = total - PALETTE_ROW_LIMIT;
|
|
60
|
+
if (start > maxStart)
|
|
61
|
+
start = maxStart;
|
|
34
62
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
|
|
64
|
+
startIndex: start,
|
|
65
|
+
total,
|
|
37
66
|
};
|
|
38
67
|
}
|
|
39
68
|
/**
|
|
@@ -57,13 +86,21 @@ export function completePalette(buffer, rows, focusedIndex) {
|
|
|
57
86
|
export function SlashPalette(props) {
|
|
58
87
|
if (props.rows.length === 0)
|
|
59
88
|
return null;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
// Wave 4 fix 2026-05-25: compute the visible window so the operator
|
|
90
|
+
// can scroll past row 7 on long lists. Focus indexes the full rows
|
|
91
|
+
// array; the window slides to keep the focused row visible.
|
|
92
|
+
const window = computePaletteWindow(props.rows, props.focusedIndex);
|
|
93
|
+
const overflow = window.total > PALETTE_ROW_LIMIT;
|
|
94
|
+
// Indicator value: focused row is 1-based for human display
|
|
95
|
+
// ("→ 9/20" reads better than "→ 8/20" when the operator is on
|
|
96
|
+
// the ninth entry).
|
|
97
|
+
const focusedDisplayIndex = Math.min(window.total, Math.max(1, props.focusedIndex + 1));
|
|
98
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [window.visible.map((row, visibleIdx) => {
|
|
99
|
+
const absoluteIdx = window.startIndex + visibleIdx;
|
|
100
|
+
const focused = absoluteIdx === props.focusedIndex;
|
|
64
101
|
const glyph = focused ? '▸' : '·';
|
|
65
102
|
const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
|
|
66
103
|
return (_jsxs(Box, { children: [_jsx(Text, { color: focused ? 'cyan' : 'gray', children: `${glyph} ` }), _jsx(Text, { bold: focused, color: focused ? 'cyan' : undefined, dimColor: !focused, children: cmd }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name));
|
|
67
|
-
}), overflow
|
|
104
|
+
}), overflow ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` → ${focusedDisplayIndex}/${window.total}` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
|
|
68
105
|
}
|
|
69
106
|
//# sourceMappingURL=slash-palette.js.map
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -13,7 +13,20 @@ export function StatusBar(props) {
|
|
|
13
13
|
const phase = clampPhase(props.pulsePhase);
|
|
14
14
|
const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
|
|
15
15
|
const status = connectionLabel(props.connection);
|
|
16
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }));
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Render a count badge — number if defined, `—` placeholder otherwise.
|
|
20
|
+
* The placeholder mirrors the splash header convention so the operator
|
|
21
|
+
* recognises "not yet known" vs "zero" at a glance.
|
|
22
|
+
*/
|
|
23
|
+
function formatCount(value) {
|
|
24
|
+
return typeof value === 'number' ? value.toString() : '—';
|
|
25
|
+
}
|
|
26
|
+
function formatQuota(pct) {
|
|
27
|
+
if (typeof pct !== 'number' || Number.isNaN(pct))
|
|
28
|
+
return '—';
|
|
29
|
+
return `${Math.round(pct)}%`;
|
|
17
30
|
}
|
|
18
31
|
function connectionLabel(connection) {
|
|
19
32
|
switch (connection) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-context badges for the REPL bottom status bar (α6.14
|
|
3
|
+
* wave 3). Mirrors the Gemini CLI pattern where the operator sees
|
|
4
|
+
* `N GEMINI.md · N MCP · N skills · N% quota` at a glance.
|
|
5
|
+
*
|
|
6
|
+
* Pure-IO helpers: each function reads disk once, swallows every error
|
|
7
|
+
* (a missing directory is the common case for a fresh workspace), and
|
|
8
|
+
* returns a count. The REPL host calls these at mount, caches the
|
|
9
|
+
* result in component state, and passes them to `<StatusBar />`. We
|
|
10
|
+
* intentionally do NOT refresh on every keystroke — a brand-new
|
|
11
|
+
* PUGI.md does not appear mid-session often enough to warrant a
|
|
12
|
+
* watcher.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Count PUGI.md files in the workspace root. We do NOT walk
|
|
19
|
+
* subdirectories — a deep grep would burn IO on every REPL boot and
|
|
20
|
+
* the convention is one root file. Mirrors how Gemini CLI counts
|
|
21
|
+
* `GEMINI.md` at the project root only.
|
|
22
|
+
*/
|
|
23
|
+
export function countPugiMdFiles(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isFile())
|
|
29
|
+
continue;
|
|
30
|
+
// Case-insensitive so PUGI.md, Pugi.md, pugi.md all count.
|
|
31
|
+
if (entry.name.toLowerCase() === 'pugi.md')
|
|
32
|
+
count += 1;
|
|
33
|
+
}
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Count MCP servers wired into `.pugi/mcp.json` at the workspace root.
|
|
42
|
+
* Reads the file, parses JSON, returns the `servers` array length when
|
|
43
|
+
* present. Returns 0 on any failure (file missing, malformed JSON,
|
|
44
|
+
* wrong shape) — the status bar treats 0 and "error" the same.
|
|
45
|
+
*/
|
|
46
|
+
export function countMcpServers(cwd) {
|
|
47
|
+
const path = join(cwd, '.pugi', 'mcp.json');
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return 0;
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(path, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed && typeof parsed === 'object') {
|
|
54
|
+
const servers = parsed.servers;
|
|
55
|
+
if (Array.isArray(servers))
|
|
56
|
+
return servers.length;
|
|
57
|
+
// Also support the `{ "<name>": { ... } }` map shape used by
|
|
58
|
+
// the Anthropic / Claude Code mcp config convention.
|
|
59
|
+
const entries = Object.keys(parsed);
|
|
60
|
+
return entries.length;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Count installed skills across the project-local + user-global
|
|
70
|
+
* directories: `.pugi/skills/` (per-workspace) + `~/.pugi/skills/`
|
|
71
|
+
* (per-machine). Each immediate subdirectory counts as one skill;
|
|
72
|
+
* matches the `skill-creator` convention.
|
|
73
|
+
*/
|
|
74
|
+
export function countSkills(cwd, home = homedir()) {
|
|
75
|
+
const projectDir = resolve(cwd, '.pugi', 'skills');
|
|
76
|
+
const userDir = resolve(home, '.pugi', 'skills');
|
|
77
|
+
return countSubdirs(projectDir) + countSubdirs(userDir);
|
|
78
|
+
}
|
|
79
|
+
function countSubdirs(dir) {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(dir))
|
|
82
|
+
return 0;
|
|
83
|
+
const stat = statSync(dir);
|
|
84
|
+
if (!stat.isDirectory())
|
|
85
|
+
return 0;
|
|
86
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
87
|
+
let count = 0;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.isDirectory())
|
|
90
|
+
count += 1;
|
|
91
|
+
}
|
|
92
|
+
return count;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function collectWorkspaceContext(cwd, home = homedir()) {
|
|
99
|
+
return {
|
|
100
|
+
pugiMdCount: countPugiMdFiles(cwd),
|
|
101
|
+
mcpServerCount: countMcpServers(cwd),
|
|
102
|
+
skillCount: countSkills(cwd, home),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=workspace-context.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.9",
|
|
4
4
|
"description": "Pugi CLI — terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"undici": "^8.3.0",
|
|
48
48
|
"zod": "^3.23.0",
|
|
49
49
|
"@pugi/personas": "0.1.0",
|
|
50
|
-
"@pugi/sdk": "0.1.0-alpha.
|
|
50
|
+
"@pugi/sdk": "0.1.0-alpha.9"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.0.0",
|