@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -0,0 +1,152 @@
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 { StatusBar } from './status-bar.js';
27
+ import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
28
+ const TICK_INTERVAL_MS = 200;
29
+ const PULSE_INTERVAL_MS = 700;
30
+ export function Repl(props) {
31
+ const [state, setState] = useState(props.session.getState());
32
+ const [overlay, setOverlay] = useState('none');
33
+ const [pulsePhase, setPulsePhase] = useState(0);
34
+ const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
35
+ // Subscribe to session state updates. The session module fires the
36
+ // callback synchronously inside `patch` so we mirror without a
37
+ // batching layer.
38
+ useEffect(() => {
39
+ const unsubscribe = props.session.subscribe(setState);
40
+ return unsubscribe;
41
+ }, [props.session]);
42
+ // 200ms tick for the status-bar wall-clock + token counter rerender.
43
+ // Cheaper than re-mounting the session subscription and lets the
44
+ // status bar lag the dispatcher events by at most one frame.
45
+ useEffect(() => {
46
+ const interval = setInterval(() => {
47
+ setTickNow((props.now ?? Date.now)());
48
+ }, TICK_INTERVAL_MS);
49
+ return () => clearInterval(interval);
50
+ }, [props.now]);
51
+ // Pulse the on-watch dot every 700ms. Three glyphs cycle, so the
52
+ // operator sees a gentle three-step animation rather than a binary
53
+ // blink. Subtle by design - brand voice forbids flashing.
54
+ useEffect(() => {
55
+ const interval = setInterval(() => {
56
+ setPulsePhase((prev) => (prev + 1) % 3);
57
+ }, PULSE_INTERVAL_MS);
58
+ return () => clearInterval(interval);
59
+ }, []);
60
+ useEffect(() => {
61
+ props.onOverlayChange?.(overlay);
62
+ }, [overlay, props]);
63
+ const personaNames = useMemo(() => buildPersonaNameMap(), []);
64
+ const { exit } = useApp();
65
+ const handleSubmit = useCallback((line) => {
66
+ // Run async without awaiting - the session module owns the
67
+ // network call, errors land in the transcript automatically.
68
+ void props.session.handleInput(line).then((verdict) => {
69
+ applyVerdictSideEffects(verdict, {
70
+ showHelp: () => setOverlay('help'),
71
+ showRoster: () => setOverlay('roster'),
72
+ farewell: () => {
73
+ setOverlay('farewell');
74
+ setTimeout(() => {
75
+ props.session.close();
76
+ exit();
77
+ }, 800);
78
+ },
79
+ });
80
+ });
81
+ }, [props.session, exit]);
82
+ const handleExit = useCallback(() => {
83
+ setOverlay('farewell');
84
+ setTimeout(() => {
85
+ props.session.close();
86
+ exit();
87
+ }, 400);
88
+ }, [props.session, exit]);
89
+ // Any keystroke dismisses an overlay; mounting useInput at the root
90
+ // gives us the dismiss without colliding with the input box (which
91
+ // unmounts while an overlay is active).
92
+ useInput((_input, _key) => {
93
+ if (overlay !== 'none' && overlay !== 'farewell') {
94
+ setOverlay('none');
95
+ }
96
+ }, { isActive: overlay === 'help' || overlay === 'roster' });
97
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_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 })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
98
+ }
99
+ function Header({ state }) {
100
+ 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('_', ' ') })] }));
101
+ }
102
+ function MainArea({ state, personaNames, nowEpochMs, }) {
103
+ // Show the last 12 transcript rows in the conversation slot so the
104
+ // bottom of the frame stays anchored to the input box. The agent
105
+ // tree drops below the transcript; new agents push the operator
106
+ // line up the screen, mirroring Claude Code / Codex CLI.
107
+ const conversationSlice = state.transcript.slice(-12);
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), _jsx(Box, { marginTop: 1, children: _jsx(AgentTree, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
109
+ }
110
+ function HelpOverlay() {
111
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: SLASH_COMMAND_HELP.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))) }), _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.' }) })] }));
112
+ }
113
+ function RosterOverlay() {
114
+ 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.' }) })] }));
115
+ }
116
+ function FarewellOverlay() {
117
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, children: _jsx(Text, { bold: true, color: "cyan", children: PUGI_TAGLINE }) }));
118
+ }
119
+ function applyVerdictSideEffects(verdict, handlers) {
120
+ switch (verdict.kind) {
121
+ case 'help':
122
+ handlers.showHelp();
123
+ return;
124
+ case 'roster':
125
+ handlers.showRoster();
126
+ return;
127
+ case 'quit':
128
+ handlers.farewell();
129
+ return;
130
+ case 'dispatch':
131
+ case 'stop':
132
+ case 'error':
133
+ case 'noop':
134
+ return;
135
+ }
136
+ }
137
+ function countActive(state) {
138
+ let count = 0;
139
+ for (const agent of state.agents) {
140
+ if (agent.status === 'queued' || agent.status === 'thinking')
141
+ count += 1;
142
+ }
143
+ return count;
144
+ }
145
+ function buildPersonaNameMap() {
146
+ const map = new Map();
147
+ for (const persona of THE_TEN) {
148
+ map.set(persona.slug, persona.name);
149
+ }
150
+ return map;
151
+ }
152
+ //# sourceMappingURL=repl.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,58 @@
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, { 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}` })] }));
17
+ }
18
+ function connectionLabel(connection) {
19
+ switch (connection) {
20
+ case 'connecting':
21
+ return { label: 'connecting', color: 'cyan' };
22
+ case 'on_watch':
23
+ return { label: 'on watch', color: 'cyan' };
24
+ case 'reconnecting':
25
+ return { label: 'reconnecting', color: 'yellow' };
26
+ case 'offline':
27
+ return { label: 'offline', color: 'gray' };
28
+ }
29
+ }
30
+ function formatElapsed(startedAt, now) {
31
+ if (typeof startedAt !== 'number')
32
+ return 'idle';
33
+ const ms = Math.max(0, now - startedAt);
34
+ if (ms < 60_000) {
35
+ return `${Math.floor(ms / 1000)}s`;
36
+ }
37
+ const minutes = Math.floor(ms / 60_000);
38
+ const seconds = Math.floor((ms % 60_000) / 1000);
39
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
40
+ }
41
+ /**
42
+ * Format the downstream token counter as 1.2k / 12.4k / 1.0m. Anvil
43
+ * F1 emits totals in the tens-of-thousands range during a single
44
+ * brief, so anything more than three significant figures is noise.
45
+ */
46
+ function formatTokens(total) {
47
+ if (total < 1_000)
48
+ return total.toString();
49
+ if (total < 1_000_000)
50
+ return `${(total / 1_000).toFixed(1)}k`;
51
+ return `${(total / 1_000_000).toFixed(1)}m`;
52
+ }
53
+ function clampPhase(phase) {
54
+ if (typeof phase !== 'number' || Number.isNaN(phase))
55
+ return 0;
56
+ return Math.max(0, Math.min(PULSE_DOTS.length - 1, Math.floor(phase)));
57
+ }
58
+ //# sourceMappingURL=status-bar.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Pugi CLI — terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -38,19 +38,25 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
+ "ink": "^5.0.1",
42
+ "react": "^18.3.1",
43
+ "tinyglobby": "^0.2.16",
41
44
  "zod": "^3.23.0",
42
- "@pugi/sdk": "0.1.0-alpha.3"
45
+ "@pugi/personas": "0.1.0",
46
+ "@pugi/sdk": "0.1.0-alpha.6"
43
47
  },
44
48
  "devDependencies": {
45
49
  "@types/node": "^22.0.0",
50
+ "@types/react": "^18.3.3",
51
+ "ink-testing-library": "^4.0.0",
46
52
  "tsx": "^4.19.0",
47
53
  "typescript": "~5.6.0"
48
54
  },
49
55
  "scripts": {
50
- "build": "pnpm --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
56
+ "build": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json && node scripts/make-bin-executable.mjs",
51
57
  "dev": "tsx src/index.ts",
52
- "typecheck": "pnpm --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
53
- "test": "pnpm run build && node --test --import tsx test/*.spec.ts",
58
+ "typecheck": "pnpm --filter @pugi/personas --filter @pugi/sdk build && tsc -p tsconfig.json --noEmit",
59
+ "test": "pnpm run build && node --test --import tsx 'test/**/*.spec.ts' 'test/**/*.spec.tsx'",
54
60
  "version:cli": "tsx src/index.ts version",
55
61
  "doctor": "tsx src/index.ts doctor --json",
56
62
  "pack:smoke": "node scripts/pack-smoke.mjs"