@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,214 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * REPL root component - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
4
+ *
5
+ * Three-zone layout:
6
+ *
7
+ * header - `Pugi.io · workspace: <name> · v<X> on watch`
8
+ * main - conversation pane (top half) + agent tree (bottom half)
9
+ * footer - input box + status bar + key hints
10
+ *
11
+ * The component subscribes to a ReplSession instance for state. It does
12
+ * NOT own the SSE client or the transport - the session module does.
13
+ * This keeps the component a pure render over a typed state plus three
14
+ * callbacks (submit, exit, overlay request).
15
+ *
16
+ * Overlays (`/help`, `/agents`) are rendered as full-pane modals that
17
+ * eclipse the main area so they read top-to-bottom even on small
18
+ * terminals. Pressing any key dismisses an overlay.
19
+ */
20
+ import { useCallback, useEffect, useMemo, useState } from 'react';
21
+ import { Box, Text, useApp, useInput } from 'ink';
22
+ import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
23
+ import { AgentTree } from './agent-tree.js';
24
+ import { ConversationPane } from './conversation-pane.js';
25
+ import { InputBox } from './input-box.js';
26
+ import { ReplSplash } from './repl-splash.js';
27
+ import { StatusBar } from './status-bar.js';
28
+ import { UpdateBanner } from './update-banner.js';
29
+ import { collectWorkspaceContext } from './workspace-context.js';
30
+ import { slugForCwd } from '../core/repl/history.js';
31
+ import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
32
+ const TICK_INTERVAL_MS = 200;
33
+ const PULSE_INTERVAL_MS = 700;
34
+ export function Repl(props) {
35
+ const [state, setState] = useState(props.session.getState());
36
+ const [overlay, setOverlay] = useState('none');
37
+ const [pulsePhase, setPulsePhase] = useState(0);
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]);
48
+ // Subscribe to session state updates. The session module fires the
49
+ // callback synchronously inside `patch` so we mirror without a
50
+ // batching layer.
51
+ useEffect(() => {
52
+ const unsubscribe = props.session.subscribe(setState);
53
+ return unsubscribe;
54
+ }, [props.session]);
55
+ // 200ms tick for the status-bar wall-clock + token counter rerender.
56
+ // Cheaper than re-mounting the session subscription and lets the
57
+ // status bar lag the dispatcher events by at most one frame.
58
+ useEffect(() => {
59
+ const interval = setInterval(() => {
60
+ setTickNow((props.now ?? Date.now)());
61
+ }, TICK_INTERVAL_MS);
62
+ return () => clearInterval(interval);
63
+ }, [props.now]);
64
+ // Pulse the on-watch dot every 700ms. Three glyphs cycle, so the
65
+ // operator sees a gentle three-step animation rather than a binary
66
+ // blink. Subtle by design - brand voice forbids flashing.
67
+ useEffect(() => {
68
+ const interval = setInterval(() => {
69
+ setPulsePhase((prev) => (prev + 1) % 3);
70
+ }, PULSE_INTERVAL_MS);
71
+ return () => clearInterval(interval);
72
+ }, []);
73
+ useEffect(() => {
74
+ props.onOverlayChange?.(overlay);
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]);
87
+ const personaNames = useMemo(() => buildPersonaNameMap(), []);
88
+ const { exit } = useApp();
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);
94
+ // Run async without awaiting - the session module owns the
95
+ // network call, errors land in the transcript automatically.
96
+ void props.session.handleInput(line).then((verdict) => {
97
+ applyVerdictSideEffects(verdict, {
98
+ showHelp: () => setOverlay('help'),
99
+ showRoster: () => setOverlay('roster'),
100
+ farewell: () => {
101
+ setOverlay('farewell');
102
+ setTimeout(() => {
103
+ props.session.close();
104
+ exit();
105
+ }, 800);
106
+ },
107
+ });
108
+ });
109
+ }, [props.session, exit]);
110
+ const handleExit = useCallback(() => {
111
+ setOverlay('farewell');
112
+ setTimeout(() => {
113
+ props.session.close();
114
+ exit();
115
+ }, 400);
116
+ }, [props.session, exit]);
117
+ // Any keystroke dismisses an overlay; mounting useInput at the root
118
+ // gives us the dismiss without colliding with the input box (which
119
+ // unmounts while an overlay is active).
120
+ useInput((_input, _key) => {
121
+ if (overlay !== 'none' && overlay !== 'farewell') {
122
+ setOverlay('none');
123
+ }
124
+ }, { isActive: overlay === 'help' || overlay === 'roster' });
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,
126
+ // Slug from process.cwd() (full path) so two workspaces with
127
+ // the same basename do not share history. state.workspaceLabel
128
+ // is the basename only. Codex review P2.
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 })] })] }));
130
+ }
131
+ function Header({ state }) {
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('_', ' ') })] }));
133
+ }
134
+ function MainArea({ state, personaNames, nowEpochMs, }) {
135
+ // Show the last 12 transcript rows in the conversation slot so the
136
+ // bottom of the frame stays anchored to the input box. The agent
137
+ // tree drops below the transcript; new agents push the operator
138
+ // line up the screen, mirroring Claude Code / Codex CLI.
139
+ const conversationSlice = state.transcript.slice(-12);
140
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), _jsx(Box, { marginTop: 1, children: _jsx(AgentTree, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
141
+ }
142
+ function HelpOverlay() {
143
+ // Group commands by their `group` field so the operator scans the
144
+ // palette by intent (dispatch → session → tools → settings → meta).
145
+ // The α6.14 wave-2 expansion grew the surface from 6 to 20 commands;
146
+ // a flat list would force the operator to read 20 rows top-to-bottom
147
+ // every time. Grouping cuts perceived complexity dramatically.
148
+ const grouped = new Map();
149
+ for (const row of SLASH_COMMAND_HELP) {
150
+ const list = grouped.get(row.group);
151
+ if (list) {
152
+ grouped.set(row.group, [...list, row]);
153
+ }
154
+ else {
155
+ grouped.set(row.group, [row]);
156
+ }
157
+ }
158
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), SLASH_COMMAND_GROUPS.map((group) => {
159
+ const rows = grouped.get(group);
160
+ if (!rows || rows.length === 0)
161
+ return null;
162
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
163
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
164
+ }
165
+ function RosterOverlay() {
166
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "On-watch roster" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: THE_TEN.map((persona) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: ` ${persona.name.padEnd(10, ' ')}` }), _jsx(Text, { dimColor: true, children: `${persona.role.padEnd(20, ' ')}` }), _jsx(Text, { children: persona.oneLiner })] }, persona.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
167
+ }
168
+ function FarewellOverlay() {
169
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, children: _jsx(Text, { bold: true, color: "cyan", children: PUGI_TAGLINE }) }));
170
+ }
171
+ function applyVerdictSideEffects(verdict, handlers) {
172
+ switch (verdict.kind) {
173
+ case 'help':
174
+ handlers.showHelp();
175
+ return;
176
+ case 'roster':
177
+ handlers.showRoster();
178
+ return;
179
+ case 'quit':
180
+ handlers.farewell();
181
+ return;
182
+ case 'dispatch':
183
+ case 'stop':
184
+ case 'web':
185
+ case 'error':
186
+ case 'noop':
187
+ case 'clear':
188
+ case 'version':
189
+ case 'jobs':
190
+ case 'diff':
191
+ case 'cost':
192
+ case 'status':
193
+ case 'stub':
194
+ // All non-overlay verdicts: the session module already appended
195
+ // any operator-visible system lines. No further UI side effect.
196
+ return;
197
+ }
198
+ }
199
+ function countActive(state) {
200
+ let count = 0;
201
+ for (const agent of state.agents) {
202
+ if (agent.status === 'queued' || agent.status === 'thinking')
203
+ count += 1;
204
+ }
205
+ return count;
206
+ }
207
+ function buildPersonaNameMap() {
208
+ const map = new Map();
209
+ for (const persona of THE_TEN) {
210
+ map.set(persona.slug, persona.name);
211
+ }
212
+ return map;
213
+ }
214
+ //# sourceMappingURL=repl.js.map
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
4
+ export const PALETTE_ROW_LIMIT = 8;
5
+ /**
6
+ * Compute the FULL filtered candidate list for a given input buffer.
7
+ * Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
8
+ * logic so the input box and the unit test agree on the shape.
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
+ *
17
+ * Behaviour:
18
+ * - Empty / non-slash buffer → empty result; palette stays hidden.
19
+ * - `/` alone → all registry rows (the operator wants to browse).
20
+ * - `/he` → rows whose name starts with `he` (case-insensitive).
21
+ */
22
+ export function filterPalette(buffer) {
23
+ if (!buffer.startsWith('/')) {
24
+ return { rows: [], totalBeforeLimit: 0 };
25
+ }
26
+ // Treat anything past the first whitespace as args - palette only
27
+ // filters on the command name itself.
28
+ const headEnd = buffer.indexOf(' ');
29
+ const head = headEnd === -1 ? buffer.slice(1) : buffer.slice(1, headEnd);
30
+ const prefix = head.toLowerCase();
31
+ // Lowercase row.name too so a registry entry like `Help` still matches
32
+ // an operator typing `/he`. Today every entry is lowercase, but the
33
+ // type contract on SlashCommandHelp.name does not enforce that, and
34
+ // a future addition would silently disappear from the palette
35
+ // without this guard. Codex P2.
36
+ const all = prefix.length === 0
37
+ ? SLASH_COMMAND_HELP
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;
62
+ return {
63
+ visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
64
+ startIndex: start,
65
+ total,
66
+ };
67
+ }
68
+ /**
69
+ * Compute the auto-complete result for the Tab key. Picks the focused
70
+ * row when there is one, falls back to the longest common prefix
71
+ * otherwise (bash-like behaviour). Returns the new buffer the input
72
+ * box should adopt, or `null` when there is nothing to complete.
73
+ */
74
+ export function completePalette(buffer, rows, focusedIndex) {
75
+ if (rows.length === 0)
76
+ return null;
77
+ const target = rows[focusedIndex] ?? rows[0];
78
+ if (!target)
79
+ return null;
80
+ // Preserve any args the operator has already started typing after a
81
+ // space; only swap the head.
82
+ const headEnd = buffer.indexOf(' ');
83
+ const tail = headEnd === -1 ? '' : buffer.slice(headEnd);
84
+ return `/${target.name}${tail}`;
85
+ }
86
+ export function SlashPalette(props) {
87
+ if (props.rows.length === 0)
88
+ return null;
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;
101
+ const glyph = focused ? '▸' : '·';
102
+ const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
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));
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' }) })] }));
105
+ }
106
+ //# sourceMappingURL=slash-palette.js.map
@@ -0,0 +1,61 @@
1
+ import { homedir } from 'node:os';
2
+ import { DEFAULT_API_URL, maskApiKey, normalizeApiUrl, readCredentialsFile, resolveActiveCredential, } from '../core/credentials.js';
3
+ /**
4
+ * Decode the JWT payload for display only — no signature check. The
5
+ * splash never sends the token over the wire so a stale or malformed
6
+ * JWT is still safe to show. Mirrors `decodeJwtPrincipal` in
7
+ * runtime/cli.ts, intentionally duplicated rather than imported to
8
+ * keep the TUI layer independent of the giant CLI module.
9
+ */
10
+ function decodeJwtPayload(token) {
11
+ try {
12
+ const parts = token.split('.');
13
+ if (parts.length < 2)
14
+ return null;
15
+ const payload = parts[1];
16
+ if (!payload)
17
+ return null;
18
+ const padded = payload
19
+ .replace(/-/g, '+')
20
+ .replace(/_/g, '/')
21
+ .padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
22
+ const json = Buffer.from(padded, 'base64').toString('utf8');
23
+ const obj = JSON.parse(json);
24
+ if (!obj || typeof obj !== 'object')
25
+ return null;
26
+ return {
27
+ sub: typeof obj.sub === 'string' ? obj.sub : undefined,
28
+ email: typeof obj.email === 'string' ? obj.email : undefined,
29
+ customerId: typeof obj.customerId === 'string' ? obj.customerId : undefined,
30
+ plan: typeof obj.plan === 'string' ? obj.plan : undefined,
31
+ };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function collectSplashData(input) {
38
+ const env = input.env ?? process.env;
39
+ const home = input.home ?? homedir();
40
+ const credential = resolveActiveCredential(env, home);
41
+ if (!credential) {
42
+ const file = readCredentialsFile(home);
43
+ const apiUrl = normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
44
+ return {
45
+ cliVersion: input.cliVersion,
46
+ apiUrl,
47
+ isAuthenticated: false,
48
+ };
49
+ }
50
+ const principal = decodeJwtPayload(credential.apiKey);
51
+ return {
52
+ cliVersion: input.cliVersion,
53
+ apiUrl: credential.apiUrl,
54
+ isAuthenticated: true,
55
+ apiKeyMasked: maskApiKey(credential.apiKey),
56
+ ...(principal?.email ? { email: principal.email } : {}),
57
+ ...(principal?.customerId ? { tenant: principal.customerId } : {}),
58
+ ...(principal?.plan ? { plan: principal.plan } : {}),
59
+ };
60
+ }
61
+ //# sourceMappingURL=splash-data.js.map
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Bare `pugi` (no args) splash. Rendered only on a real TTY by the
5
+ * CLI dispatcher; non-TTY callers see the existing usage dump.
6
+ *
7
+ * Layout intentionally restrained: bold "pugi.io" wordmark, dim
8
+ * version/endpoint metadata, neutral body. No background colors, no
9
+ * emoji decoration, ASCII separators only.
10
+ */
11
+ export function Splash({ data }) {
12
+ const accountLine = data.isAuthenticated
13
+ ? `${data.email ?? data.apiKeyMasked ?? 'authenticated'}${data.tenant ? ` (tenant: ${data.tenant})` : ''}`
14
+ : 'not signed in';
15
+ const primaryHint = data.isAuthenticated
16
+ ? {
17
+ cmd: 'pugi code "fix the bug"',
18
+ gloss: 'Run a one-shot coding task',
19
+ }
20
+ : {
21
+ cmd: 'pugi login',
22
+ gloss: 'Connect this terminal to your Pugi account',
23
+ };
24
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: "cyan", children: "pugi.io" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `v${data.cliVersion} · ${data.apiUrl}` }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Account: " }), _jsx(Text, { children: accountLine })] }), data.isAuthenticated && data.plan ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Plan: " }), _jsx(Text, { children: data.plan })] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Quick start:" }), _jsx(HintRow, { command: primaryHint.cmd, gloss: primaryHint.gloss }), data.isAuthenticated ? (_jsx(HintRow, { command: 'pugi login', gloss: 'Re-authenticate or switch accounts' })) : (_jsx(HintRow, { command: 'pugi code "fix the bug"', gloss: 'Run a one-shot coding task' })), _jsx(HintRow, { command: 'pugi review --triple', gloss: 'Run the Anvil triple-review gate' }), _jsx(HintRow, { command: 'pugi help', gloss: 'Full command reference' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Docs: https://pugi.dev \u00B7 Status: https://pugi.io/status" }) })] }));
25
+ }
26
+ function HintRow({ command, gloss }) {
27
+ // Pad command names so the gloss column lines up across rows.
28
+ const padded = command.padEnd(28, ' ');
29
+ return (_jsxs(Text, { children: [_jsx(Text, { children: ` ${padded}` }), _jsx(Text, { dimColor: true, children: gloss })] }));
30
+ }
31
+ //# sourceMappingURL=splash.js.map
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Cyan dot glyphs across the pulse cycle. Three steps keep the motion
5
+ * subtle - a true gradient would force an Ink rerender on every
6
+ * 16ms frame and waste cycles on a status bar.
7
+ */
8
+ const PULSE_DOTS = ['●', '◉', '○'];
9
+ export function StatusBar(props) {
10
+ const now = props.nowEpochMs ?? Date.now();
11
+ const elapsedLabel = formatElapsed(props.briefStartedAtEpochMs, now);
12
+ const tokenLabel = formatTokens(props.tokensDownstreamTotal);
13
+ const phase = clampPhase(props.pulsePhase);
14
+ const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
15
+ const status = connectionLabel(props.connection);
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)}%`;
30
+ }
31
+ function connectionLabel(connection) {
32
+ switch (connection) {
33
+ case 'connecting':
34
+ return { label: 'connecting', color: 'cyan' };
35
+ case 'on_watch':
36
+ return { label: 'on watch', color: 'cyan' };
37
+ case 'reconnecting':
38
+ return { label: 'reconnecting', color: 'yellow' };
39
+ case 'offline':
40
+ return { label: 'offline', color: 'gray' };
41
+ }
42
+ }
43
+ function formatElapsed(startedAt, now) {
44
+ if (typeof startedAt !== 'number')
45
+ return 'idle';
46
+ const ms = Math.max(0, now - startedAt);
47
+ if (ms < 60_000) {
48
+ return `${Math.floor(ms / 1000)}s`;
49
+ }
50
+ const minutes = Math.floor(ms / 60_000);
51
+ const seconds = Math.floor((ms % 60_000) / 1000);
52
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
53
+ }
54
+ /**
55
+ * Format the downstream token counter as 1.2k / 12.4k / 1.0m. Anvil
56
+ * F1 emits totals in the tens-of-thousands range during a single
57
+ * brief, so anything more than three significant figures is noise.
58
+ */
59
+ function formatTokens(total) {
60
+ if (total < 1_000)
61
+ return total.toString();
62
+ if (total < 1_000_000)
63
+ return `${(total / 1_000).toFixed(1)}k`;
64
+ return `${(total / 1_000_000).toFixed(1)}m`;
65
+ }
66
+ function clampPhase(phase) {
67
+ if (typeof phase !== 'number' || Number.isNaN(phase))
68
+ return 0;
69
+ return Math.max(0, Math.min(PULSE_DOTS.length - 1, Math.floor(phase)));
70
+ }
71
+ //# sourceMappingURL=status-bar.js.map
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { upgradeCommand } from '../runtime/update-check.js';
4
+ export function UpdateBanner({ result }) {
5
+ const command = upgradeCommand(result.method);
6
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: '─ ' }), _jsx(Text, { bold: true, color: "cyan", children: 'Pugi ' }), _jsx(Text, { children: result.installed }), _jsx(Text, { dimColor: true, children: ' (installed) → ' }), _jsx(Text, { bold: true, children: result.latest }), _jsx(Text, { dimColor: true, children: ' (latest)' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Update: ' }), _jsx(Text, { color: "cyan", children: command })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Skip with PUGI_SKIP_UPDATE_BANNER=1' }) })] }));
7
+ }
8
+ //# sourceMappingURL=update-banner.js.map
@@ -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