@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,3405 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { statSync } from 'node:fs';
5
+ import { dirname, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
8
+ import { NoopEngineAdapter } from '../core/engine/noop.js';
9
+ import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
+ import { decidePermission } from '../core/permission.js';
11
+ import { openSession, recordCommandCompleted, recordCommandStarted, recordToolCall, recordToolResult, } from '../core/session.js';
12
+ import { loadSettings } from '../core/settings.js';
13
+ import { FileReadCache } from '../core/file-cache.js';
14
+ import { resolveWorkspacePath } from '../core/path-security.js';
15
+ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
16
+ import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
+ import { webFetchTool } from '../tools/web-fetch.js';
18
+ import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
19
+ import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
20
+ import { PUGI_TAGLINE } from '@pugi/personas';
21
+ import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
22
+ import { runJobsCommand } from '../commands/jobs.js';
23
+ import { runConfigCommand } from './commands/config.js';
24
+ import { runPrivacyCommand } from './commands/privacy.js';
25
+ import { runUndoCommand } from './commands/undo.js';
26
+ import { runBudgetCommand } from './commands/budget.js';
27
+ import { runSkillsCommand } from './commands/skills.js';
28
+ import { runAgentsCommand } from './commands/agents.js';
29
+ /**
30
+ * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
31
+ *
32
+ * Kept as a single hard-coded string (rather than reading package.json at
33
+ * runtime) because the CLI ships compiled JS via npm — the `.tgz` does not
34
+ * include package.json in a location relative to the compiled bundle, and
35
+ * the build does not run `--resolveJsonModule`. Bumping CLI version is a
36
+ * three-line ritual (this constant + apps/pugi-cli/package.json +
37
+ * packages/pugi-sdk/package.json); the publish workflow validates the
38
+ * three are in lockstep.
39
+ */
40
+ const PUGI_CLI_VERSION = '0.1.0-alpha.10';
41
+ const handlers = {
42
+ accounts,
43
+ agents: dispatchAgents,
44
+ build: runEngineTask('build_task'),
45
+ budget: dispatchBudget,
46
+ code: runEngineTask('code'),
47
+ config: dispatchConfig,
48
+ doctor,
49
+ explain: runEngineTask('explain'),
50
+ fix: runEngineTask('fix'),
51
+ handoff,
52
+ help,
53
+ idea,
54
+ init,
55
+ jobs,
56
+ login,
57
+ logout,
58
+ plan: runEngineTask('plan'),
59
+ privacy: dispatchPrivacy,
60
+ review,
61
+ resume,
62
+ sessions,
63
+ skills: dispatchSkills,
64
+ sync,
65
+ undo: dispatchUndo,
66
+ version,
67
+ web: dispatchWeb,
68
+ whoami,
69
+ };
70
+ async function dispatchConfig(args, flags, _session) {
71
+ await runConfigCommand(args, {
72
+ workspaceRoot: process.cwd(),
73
+ json: flags.json,
74
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
75
+ });
76
+ }
77
+ async function dispatchPrivacy(args, flags, _session) {
78
+ await runPrivacyCommand(args, {
79
+ json: flags.json,
80
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
81
+ });
82
+ }
83
+ async function dispatchUndo(args, flags, session) {
84
+ await runUndoCommand(args, {
85
+ workspaceRoot: process.cwd(),
86
+ session,
87
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
88
+ });
89
+ }
90
+ async function dispatchBudget(args, flags, _session) {
91
+ await runBudgetCommand(args, {
92
+ workspaceRoot: process.cwd(),
93
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
94
+ });
95
+ }
96
+ async function dispatchSkills(args, flags, _session) {
97
+ await runSkillsCommand(args, {
98
+ workspaceRoot: process.cwd(),
99
+ json: flags.json,
100
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
101
+ });
102
+ }
103
+ async function dispatchAgents(args, flags, _session) {
104
+ await runAgentsCommand(args, {
105
+ workspaceRoot: process.cwd(),
106
+ json: flags.json,
107
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
108
+ });
109
+ }
110
+ /**
111
+ * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
112
+ *
113
+ * One-shot fetch + Markdown convert + print to stdout. The REPL slash
114
+ * `/web <url>` goes through ReplSession; this surface is for non-REPL
115
+ * pipelines (CI, scripts, `pugi web ... | pbcopy`). Gated by either
116
+ * `--allow-fetch` on this invocation or `web.fetch.enabled` in
117
+ * `.pugi/settings.json`.
118
+ */
119
+ async function dispatchWeb(args, flags, _session) {
120
+ const url = args[0];
121
+ if (!url) {
122
+ writeOutput(flags, { ok: false, error: 'Usage: pugi web <url> [--allow-fetch]' }, 'Usage: pugi web <url> [--allow-fetch]');
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+ // Malformed `.pugi/settings.json` must not crash the dispatch — the
127
+ // fetch is gated default-off so we fail safe: refuse with a clear
128
+ // error and let the operator repair the file.
129
+ let settings;
130
+ try {
131
+ settings = loadSettings(process.cwd());
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ const result = {
136
+ ok: false,
137
+ error: `Failed to load .pugi/settings.json (${message}). web_fetch refused.`,
138
+ };
139
+ writeOutput(flags, result, `web_fetch refused: ${result.error}`);
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const result = await webFetchTool({ url }, { settings, allowFetch: flags.allowFetch });
144
+ if (!result.ok) {
145
+ writeOutput(flags, result, `web_fetch refused: ${result.error}`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
150
+ }
151
+ export async function runCli(argv) {
152
+ const { command, args, flags, isBareInvocation } = parseArgs(argv);
153
+ // Bare `pugi` on a TTY enters the REPL-by-default agentic session
154
+ // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
155
+ // that brings Pugi to parity with Claude Code / Codex CLI. When the
156
+ // operator has no credentials yet, we fall back to the α5.0 splash
157
+ // so the install-time `pugi` surface still shows the wordmark +
158
+ // quick-start hints. Non-TTY (CI, pipes, `--no-tty`) also falls
159
+ // through to the splash because the REPL needs raw input and SSE.
160
+ if (isBareInvocation && isInteractive(flags)) {
161
+ const runtimeConfig = resolveRuntimeConfig();
162
+ if (runtimeConfig) {
163
+ // The REPL session reads PUGI_ALLOW_FETCH from env to decide
164
+ // whether to honor `/web <url>` without the settings flag.
165
+ // Propagating via env keeps the session module transport-free.
166
+ if (flags.allowFetch)
167
+ process.env.PUGI_ALLOW_FETCH = '1';
168
+ // α6.2: peek the npm registry for a newer @pugi/cli before
169
+ // mounting Ink. Wrapped in a try/catch belt-and-braces even
170
+ // though `checkForUpdate` already swallows every failure mode —
171
+ // a thrown bug here must never block REPL startup.
172
+ const { checkForUpdate } = await import('./update-check.js');
173
+ let updateBanner = null;
174
+ try {
175
+ updateBanner = await checkForUpdate({
176
+ installed: PUGI_CLI_VERSION,
177
+ cliSkip: flags.noUpdateCheck,
178
+ });
179
+ }
180
+ catch {
181
+ updateBanner = null;
182
+ }
183
+ const { renderRepl } = await import('../tui/repl-render.js');
184
+ await renderRepl({
185
+ apiUrl: runtimeConfig.apiUrl,
186
+ apiKey: runtimeConfig.apiKey,
187
+ workspaceLabel: workspaceLabel(process.cwd()),
188
+ cliVersion: PUGI_CLI_VERSION,
189
+ updateBanner,
190
+ skipSplash: flags.noSplash,
191
+ });
192
+ return;
193
+ }
194
+ const { renderSplash } = await import('../tui/render.js');
195
+ await renderSplash(PUGI_CLI_VERSION);
196
+ return;
197
+ }
198
+ const handler = handlers[command] ?? help;
199
+ const session = openSession(process.cwd());
200
+ recordCommandStarted(session, command);
201
+ try {
202
+ await handler(args, flags, session);
203
+ // Handlers can signal a failed gate by setting `process.exitCode`
204
+ // (e.g. BLOCK verdict, auth_missing, rate_limited) instead of
205
+ // throwing. Treat any non-zero exit as 'error' so the session log
206
+ // and `.pugi/index.json` agree with the actual process outcome.
207
+ const status = process.exitCode && process.exitCode !== 0 ? 'error' : 'success';
208
+ recordCommandCompleted(session, command, status);
209
+ }
210
+ catch (error) {
211
+ recordCommandCompleted(session, command, 'error');
212
+ throw error;
213
+ }
214
+ }
215
+ function parseArgs(argv) {
216
+ const flags = {
217
+ json: false,
218
+ remote: false,
219
+ web: false,
220
+ dryRun: false,
221
+ triple: false,
222
+ offline: false,
223
+ noTty: false,
224
+ allowFetch: false,
225
+ noUpdateCheck: false,
226
+ noSplash: process.env.PUGI_SKIP_SPLASH === '1',
227
+ };
228
+ const args = [];
229
+ // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
230
+ // (npm uses --version on every published bin, Homebrew formula uses it in
231
+ // the test block). Normalize them to the `version` command so users can
232
+ // discover the CLI works without knowing our subcommand grammar.
233
+ if (argv[0] === '--version' || argv[0] === '-v') {
234
+ return { command: 'version', args: [], flags, isBareInvocation: false };
235
+ }
236
+ if (argv[0] === '--help' || argv[0] === '-h') {
237
+ return { command: 'help', args: [], flags, isBareInvocation: false };
238
+ }
239
+ for (let index = 0; index < argv.length; index += 1) {
240
+ const arg = argv[index] ?? '';
241
+ if (arg === '--json') {
242
+ flags.json = true;
243
+ }
244
+ else if (arg === '--remote') {
245
+ flags.remote = true;
246
+ }
247
+ else if (arg === '--web') {
248
+ flags.web = true;
249
+ }
250
+ else if (arg === '--dry-run') {
251
+ flags.dryRun = true;
252
+ }
253
+ else if (arg === '--triple' || arg === '--consensus') {
254
+ flags.triple = true;
255
+ }
256
+ else if (arg === '--offline') {
257
+ flags.offline = true;
258
+ }
259
+ else if (arg === '--no-tty') {
260
+ flags.noTty = true;
261
+ }
262
+ else if (arg === '--allow-fetch') {
263
+ flags.allowFetch = true;
264
+ }
265
+ else if (arg === '--no-update-check') {
266
+ flags.noUpdateCheck = true;
267
+ }
268
+ else if (arg === '--no-splash') {
269
+ flags.noSplash = true;
270
+ }
271
+ else if (arg.startsWith('--privacy=')) {
272
+ flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
273
+ }
274
+ else if (arg === '--privacy') {
275
+ const next = argv[index + 1];
276
+ if (!next)
277
+ throw new Error('--privacy requires a mode');
278
+ flags.privacy = parsePrivacyMode(next);
279
+ index += 1;
280
+ }
281
+ else {
282
+ args.push(arg);
283
+ }
284
+ }
285
+ const isBareInvocation = args.length === 0;
286
+ return {
287
+ command: args.shift() ?? 'help',
288
+ args,
289
+ flags,
290
+ isBareInvocation,
291
+ };
292
+ }
293
+ async function version(_args, flags, _session) {
294
+ const payload = {
295
+ name: 'pugi',
296
+ version: PUGI_CLI_VERSION,
297
+ };
298
+ writeOutput(flags, payload, `pugi ${payload.version}`);
299
+ }
300
+ async function help(_args, flags, _session) {
301
+ const commands = Object.keys(handlers).sort();
302
+ writeOutput(flags, { commands }, [
303
+ 'Pugi CLI',
304
+ '',
305
+ 'Usage: pugi <command> [--json] [--web] [--remote] [--no-tty]',
306
+ '',
307
+ 'Commands:',
308
+ ...commands.map((command) => ` ${command}`),
309
+ '',
310
+ 'Authentication:',
311
+ ' pugi login Interactive picker (browser OAuth / PAT / env).',
312
+ ' pugi login --provider device|token|env Non-interactive variant.',
313
+ ' pugi whoami Show active credential, JWT account, plan tier.',
314
+ ' pugi accounts list All stored accounts across multiple endpoints.',
315
+ ' pugi accounts switch <label> Re-point the active account.',
316
+ ' pugi accounts remove <label> Delete a stored credential.',
317
+ '',
318
+ 'Review gate:',
319
+ ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
320
+ '',
321
+ 'Skills + agents marketplace:',
322
+ ' pugi skills list All installed skills.',
323
+ ' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
324
+ ' pugi skills info <name> Metadata + body preview.',
325
+ ' pugi agents list All installed sub-agents.',
326
+ ' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
327
+ '',
328
+ 'Sync safety:',
329
+ ' pugi sync --dry-run --privacy metadata',
330
+ '',
331
+ 'Interactivity:',
332
+ ' --no-tty Force the line-buffered output path (CI, pipes,',
333
+ ' recording flows, dumb terminals).',
334
+ ' --no-update-check Silence the REPL startup update banner. Pairs',
335
+ ' with PUGI_SKIP_UPDATE_BANNER=1.',
336
+ ' --no-splash Skip the REPL boot splash. Pairs with',
337
+ ' PUGI_SKIP_SPLASH=1.',
338
+ '',
339
+ PUGI_TAGLINE,
340
+ 'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
341
+ ].join('\n'));
342
+ }
343
+ async function doctor(_args, flags, _session) {
344
+ const cwd = process.cwd();
345
+ const settings = loadSettings(cwd);
346
+ // `doctor` reports adapter capabilities only; we pass a no-op client
347
+ // so we do not require an Anvil endpoint to run `pugi doctor`. The
348
+ // adapter never invokes `client.send()` from inside `capabilities()`.
349
+ const inertClient = {
350
+ async send() {
351
+ return {
352
+ stop: 'error',
353
+ code: 'failed',
354
+ message: 'doctor: inert client',
355
+ };
356
+ },
357
+ };
358
+ const adapters = [
359
+ new NoopEngineAdapter(),
360
+ new NativePugiEngineAdapter({ client: inertClient }),
361
+ ];
362
+ const capabilities = await Promise.all(adapters.map(async (adapter) => ({
363
+ name: adapter.name,
364
+ capabilities: await adapter.capabilities(),
365
+ })));
366
+ const payload = {
367
+ cliVersion: PUGI_CLI_VERSION,
368
+ nodeVersion: process.version,
369
+ workspaceRoot: cwd,
370
+ pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
371
+ pugiDir: existsSync(resolve(cwd, '.pugi')),
372
+ eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
373
+ permissionMode: settings.permissions.mode,
374
+ approvals: settings.workflow.approvals,
375
+ notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
376
+ protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
377
+ protectedFileSafety: 'configured-in-m1',
378
+ mcpTrust: 'not-configured',
379
+ releaseGuard: 'scaffolded',
380
+ tools: toolRegistry,
381
+ engineAdapters: capabilities,
382
+ schemaBundleHash: createHash('sha256')
383
+ .update(toolSchemaBundleHashInput())
384
+ .digest('hex'),
385
+ };
386
+ writeOutput(flags, payload, [
387
+ 'Pugi doctor',
388
+ `CLI: ${payload.cliVersion}`,
389
+ `Node: ${payload.nodeVersion}`,
390
+ `Workspace: ${payload.workspaceRoot}`,
391
+ `Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
392
+ `Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
393
+ `Event log: ${payload.eventLog ? 'present' : 'missing'}`,
394
+ `Permission mode: ${payload.permissionMode}`,
395
+ `Approvals: ${payload.approvals}`,
396
+ `Release guard: ${payload.releaseGuard}`,
397
+ ].join('\n'));
398
+ }
399
+ async function init(_args, flags, _session) {
400
+ const cwd = process.cwd();
401
+ const pugiDir = resolve(cwd, '.pugi');
402
+ const created = [];
403
+ const skipped = [];
404
+ ensureDir(pugiDir, created, skipped);
405
+ ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
406
+ ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
407
+ writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
408
+ schema: 1,
409
+ workflow: {
410
+ brand: 'pugi',
411
+ legacyName: 'codeforge',
412
+ approvals: 'auto',
413
+ notAutomatic: [],
414
+ defaultBaseBranch: 'dev',
415
+ branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
416
+ aiCoAuthorTrailers: false,
417
+ },
418
+ permissions: {
419
+ mode: 'auto',
420
+ allow: [],
421
+ deny: [],
422
+ notAutomatic: [],
423
+ },
424
+ privacy: {
425
+ mode: 'balanced',
426
+ telemetry: 'off',
427
+ },
428
+ artifacts: {
429
+ defaultPath: '.pugi/artifacts',
430
+ promoteExplicitly: true,
431
+ },
432
+ }, created, skipped);
433
+ writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
434
+ schema: 1,
435
+ servers: [],
436
+ }, created, skipped);
437
+ writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
438
+ writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
439
+ '# Pugi Project Context',
440
+ '',
441
+ '## Product Workflow',
442
+ '',
443
+ '- Public product name: Pugi',
444
+ '- Default flow: idea -> build -> review',
445
+ '- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
446
+ '- Do not add AI Co-Authored-By trailers.',
447
+ '- Generated code, comments, commits, PR text, and technical docs default to English.',
448
+ '',
449
+ '## Project Notes',
450
+ '',
451
+ '- Add repo-specific architecture, commands, and business rules here.',
452
+ '- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
453
+ '',
454
+ ].join('\n'), created, skipped);
455
+ writeTextIfMissing(resolve(cwd, '.pugiignore'), [
456
+ '# Pugi ignore rules',
457
+ '.env',
458
+ '.env.*',
459
+ '!.env.example',
460
+ 'node_modules/',
461
+ 'dist/',
462
+ '.next/',
463
+ 'coverage/',
464
+ '*.log',
465
+ '*.pem',
466
+ '*.key',
467
+ '*.crt',
468
+ '*.p12',
469
+ '*.sql',
470
+ '*.dump',
471
+ '',
472
+ ].join('\n'), created, skipped);
473
+ // Ensure `.pugi/` is git-ignored so users do not accidentally commit
474
+ // local audit logs, artifacts, or triple-review request payloads.
475
+ ensurePugiGitIgnore(cwd, created, skipped);
476
+ const payload = {
477
+ status: 'initialized',
478
+ root: cwd,
479
+ created,
480
+ skipped,
481
+ };
482
+ writeOutput(flags, payload, [
483
+ 'Pugi initialized',
484
+ `Root: ${cwd}`,
485
+ created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
486
+ skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
487
+ ].join('\n'));
488
+ }
489
+ async function idea(args, flags, session) {
490
+ const prompt = args.join(' ').trim();
491
+ if (!prompt) {
492
+ throw new Error('pugi idea requires a prompt');
493
+ }
494
+ const root = process.cwd();
495
+ const pugiDir = resolve(root, '.pugi');
496
+ if (!existsSync(pugiDir)) {
497
+ throw new Error('Run pugi init before pugi idea');
498
+ }
499
+ const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(prompt)}`;
500
+ const artifactDir = resolve(pugiDir, 'artifacts', id);
501
+ mkdirSync(artifactDir, { recursive: true });
502
+ const toolCallId = recordToolCall(session, 'artifact:idea', prompt);
503
+ const briefPath = resolve(artifactDir, 'brief.md');
504
+ const graphPath = resolve(artifactDir, 'execution-graph.json');
505
+ const criteriaPath = resolve(artifactDir, 'acceptance-criteria.md');
506
+ const brief = [
507
+ '# Pugi Idea Brief',
508
+ '',
509
+ `Prompt: ${prompt}`,
510
+ '',
511
+ '## Forcing Questions',
512
+ '',
513
+ '- Who exactly has this pain?',
514
+ '- What do they do now?',
515
+ '- What breaks if this stays unsolved?',
516
+ '- What is the narrowest useful wedge?',
517
+ '- What is the current status quo cost in time or money?',
518
+ '- Who is the first design partner?',
519
+ '',
520
+ '## Initial Positioning',
521
+ '',
522
+ 'Pugi should turn this idea into a reviewed execution graph before code is written.',
523
+ '',
524
+ ].join('\n');
525
+ const executionGraph = {
526
+ id,
527
+ title: prompt,
528
+ nodes: [
529
+ {
530
+ id: 'brief',
531
+ title: 'Clarify founder intent',
532
+ kind: 'plan',
533
+ dependsOn: [],
534
+ status: 'pending',
535
+ acceptanceCriteria: ['Forcing questions are answered or explicitly deferred.'],
536
+ },
537
+ {
538
+ id: 'scope',
539
+ title: 'Define narrowest wedge',
540
+ kind: 'plan',
541
+ dependsOn: ['brief'],
542
+ status: 'pending',
543
+ acceptanceCriteria: ['One bounded MVP path is selected.'],
544
+ },
545
+ {
546
+ id: 'build',
547
+ title: 'Implement approved wedge',
548
+ kind: 'code',
549
+ dependsOn: ['scope'],
550
+ status: 'pending',
551
+ acceptanceCriteria: ['Working diff is produced with tests or explicit test gap.'],
552
+ },
553
+ {
554
+ id: 'review',
555
+ title: 'Run Pugi review gate',
556
+ kind: 'review',
557
+ dependsOn: ['build'],
558
+ status: 'pending',
559
+ acceptanceCriteria: ['Review returns PASS or BLOCK with findings.'],
560
+ },
561
+ ],
562
+ artifacts: [
563
+ { id: 'brief', kind: 'brief', path: relative(root, briefPath), title: 'Idea brief', createdAt: new Date().toISOString() },
564
+ {
565
+ id: 'execution_graph',
566
+ kind: 'execution_graph',
567
+ path: relative(root, graphPath),
568
+ title: 'Execution graph',
569
+ createdAt: new Date().toISOString(),
570
+ },
571
+ ],
572
+ };
573
+ const criteria = [
574
+ '# Acceptance Criteria',
575
+ '',
576
+ '- The target user and pain are explicit.',
577
+ '- The first wedge is narrow enough for a supervised build.',
578
+ '- The execution graph can be reviewed before implementation.',
579
+ '- The review gate can block incomplete or unsafe output.',
580
+ '',
581
+ ].join('\n');
582
+ writeFileSync(briefPath, brief, { encoding: 'utf8', mode: 0o600 });
583
+ writeFileSync(graphPath, `${JSON.stringify(executionGraph, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
584
+ writeFileSync(criteriaPath, criteria, { encoding: 'utf8', mode: 0o600 });
585
+ registerArtifact(root, {
586
+ id,
587
+ kind: 'idea',
588
+ path: relative(root, artifactDir),
589
+ sessionId: session.id,
590
+ createdAt: new Date().toISOString(),
591
+ files: ['brief.md', 'execution-graph.json', 'acceptance-criteria.md'],
592
+ });
593
+ recordToolResult(session, toolCallId, 'success', `Created idea artifacts ${id}`);
594
+ const payload = {
595
+ status: 'created',
596
+ id,
597
+ artifacts: {
598
+ brief: relative(root, briefPath),
599
+ executionGraph: relative(root, graphPath),
600
+ acceptanceCriteria: relative(root, criteriaPath),
601
+ },
602
+ };
603
+ writeOutput(flags, payload, [
604
+ 'Pugi idea artifacts created',
605
+ `ID: ${id}`,
606
+ `Brief: ${payload.artifacts.brief}`,
607
+ `Execution graph: ${payload.artifacts.executionGraph}`,
608
+ `Acceptance criteria: ${payload.artifacts.acceptanceCriteria}`,
609
+ ].join('\n'));
610
+ }
611
+ /**
612
+ * Sprint 2 Track A offline fallbacks.
613
+ *
614
+ * `pugi plan` / `pugi build` / `pugi explain` are now engine-driven by
615
+ * default (`runEngineTask` above). When the operator has no credential
616
+ * configured (`pugi login` never run, no PUGI_API_KEY env) OR the
617
+ * `--offline` flag is set, the engine path is skipped and these
618
+ * helpers produce the same local-first artifacts the pre-Sprint-2 CLI
619
+ * shipped. This preserves the local-first invariant proven by
620
+ * `test/local-first-invariants.spec.ts` — a brand-new repo with no
621
+ * credential can still run `init → idea → plan → build → review`.
622
+ *
623
+ * The offline implementations are intentionally identical to the
624
+ * pre-Sprint-2 handlers (modulo the rename); behaviour drift between
625
+ * the two paths is a P1 — keep them aligned when extending either.
626
+ */
627
+ async function offlinePlan(args, flags, session) {
628
+ const root = process.cwd();
629
+ ensureInitialized(root);
630
+ const prompt = args.join(' ').trim();
631
+ const latestIdea = latestArtifactDir(root);
632
+ // Code Reviewer P2 retro 2026-05-23: both engine and offline `plan`
633
+ // accept an empty prompt ONLY when a previous artifact set exists
634
+ // to plan against. Without one the operator has handed us nothing
635
+ // to plan, so we fail loudly instead of producing an empty
636
+ // skeleton-of-a-plan artifact.
637
+ if (!prompt && !latestIdea) {
638
+ throw new Error('pugi plan requires a prompt or a previous artifact in .pugi/artifacts/');
639
+ }
640
+ const artifactDir = createArtifactDir(root, prompt || 'plan');
641
+ const planPath = resolve(artifactDir, 'plan.md');
642
+ const graphPath = resolve(artifactDir, 'execution-graph.json');
643
+ const toolCallId = recordToolCall(session, 'artifact:plan', prompt || 'latest local context');
644
+ const planText = [
645
+ '# Pugi Execution Plan (offline)',
646
+ '',
647
+ prompt ? `Prompt: ${prompt}` : 'Prompt: continue from local artifacts/context',
648
+ latestIdea ? `Previous artifact set: ${relative(root, latestIdea)}` : 'Previous artifact set: none',
649
+ '',
650
+ '## Mode',
651
+ '',
652
+ '- Offline plan: no Pugi credential configured or --offline flag set.',
653
+ '- To run the engine-driven plan, set PUGI_API_KEY or run `pugi login`.',
654
+ '',
655
+ '## Local-First Contract',
656
+ '',
657
+ '- CLI works with the repository locally by default.',
658
+ '- Remote/web execution is opt-in and represented by a handoff bundle.',
659
+ '- Generated artifacts are reviewable before code execution.',
660
+ '',
661
+ '## Steps',
662
+ '',
663
+ '1. Confirm scope and acceptance criteria.',
664
+ '2. Inspect repository context.',
665
+ '3. Produce or update the implementation diff locally.',
666
+ '4. Run available checks.',
667
+ '5. Run review gate before PR/deploy.',
668
+ '',
669
+ '## Open Questions',
670
+ '',
671
+ '- Which files/modules are in scope?',
672
+ '- Which command proves the change works?',
673
+ '- Should this continue locally, in web, or on a remote runner?',
674
+ '',
675
+ ].join('\n');
676
+ const graph = {
677
+ id: artifactIdFromDir(artifactDir),
678
+ title: prompt || 'Local execution plan',
679
+ mode: flags.remote ? 'remote' : flags.web ? 'web' : 'local',
680
+ nodes: [
681
+ { id: 'scope', title: 'Confirm scope', kind: 'plan', dependsOn: [], status: 'pending' },
682
+ { id: 'inspect', title: 'Inspect repository', kind: 'research', dependsOn: ['scope'], status: 'pending' },
683
+ { id: 'implement', title: 'Implement locally or hand off', kind: 'code', dependsOn: ['inspect'], status: 'pending' },
684
+ { id: 'verify', title: 'Run checks', kind: 'test', dependsOn: ['implement'], status: 'pending' },
685
+ { id: 'review', title: 'Review result', kind: 'review', dependsOn: ['verify'], status: 'pending' },
686
+ ],
687
+ };
688
+ writeFileSync(planPath, planText, { encoding: 'utf8', mode: 0o600 });
689
+ writeFileSync(graphPath, `${JSON.stringify(graph, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
690
+ registerArtifact(root, {
691
+ id: artifactIdFromDir(artifactDir),
692
+ kind: 'plan',
693
+ path: relative(root, artifactDir),
694
+ sessionId: session.id,
695
+ createdAt: new Date().toISOString(),
696
+ files: ['plan.md', 'execution-graph.json'],
697
+ });
698
+ recordToolResult(session, toolCallId, 'success', `Created offline plan ${relative(root, planPath)}`);
699
+ writeOutput(flags, { status: 'planned', mode: 'offline', plan: relative(root, planPath), executionGraph: relative(root, graphPath) }, [`Pugi plan created (offline)`, `Plan: ${relative(root, planPath)}`, `Execution graph: ${relative(root, graphPath)}`].join('\n'));
700
+ }
701
+ async function offlineBuild(args, flags, session) {
702
+ const root = process.cwd();
703
+ ensureInitialized(root);
704
+ const prompt = args.join(' ').trim();
705
+ if (!prompt) {
706
+ throw new Error('pugi build requires a prompt');
707
+ }
708
+ if (flags.remote || flags.web) {
709
+ const bundle = createHandoffBundle(root, session, flags.remote ? 'remote_build' : 'web_continue', prompt);
710
+ writeOutput(flags, bundle, [
711
+ 'Pugi build handoff created',
712
+ `Bundle: ${bundle.path}`,
713
+ flags.remote ? 'Next: remote runner can claim this job.' : 'Next: web can import this bundle and continue.',
714
+ ].join('\n'));
715
+ return;
716
+ }
717
+ const artifactDir = createArtifactDir(root, prompt);
718
+ const buildPath = resolve(artifactDir, 'build.md');
719
+ const toolCallId = recordToolCall(session, 'artifact:build', prompt);
720
+ const text = [
721
+ '# Pugi Local Build Request (offline)',
722
+ '',
723
+ `Prompt: ${prompt}`,
724
+ '',
725
+ 'Status: pending local implementation',
726
+ '',
727
+ '## Mode',
728
+ '',
729
+ '- Offline build: no Pugi credential configured or --offline flag set.',
730
+ '- To run the engine-driven build, set PUGI_API_KEY or run `pugi login`.',
731
+ '',
732
+ '## Contract',
733
+ '',
734
+ '- This command is local-first.',
735
+ '- Code execution should happen in this repository unless --remote or --web is used.',
736
+ '- Before mutating files, the engine must inspect relevant files and respect permissions.',
737
+ '',
738
+ '## Suggested Next Checks',
739
+ '',
740
+ '- `git status --short`',
741
+ '- project-specific test/lint/build command',
742
+ '- `pugi review`',
743
+ '',
744
+ ].join('\n');
745
+ writeFileSync(buildPath, text, { encoding: 'utf8', mode: 0o600 });
746
+ registerArtifact(root, {
747
+ id: artifactIdFromDir(artifactDir),
748
+ kind: 'build',
749
+ path: relative(root, artifactDir),
750
+ sessionId: session.id,
751
+ createdAt: new Date().toISOString(),
752
+ files: ['build.md'],
753
+ });
754
+ recordToolResult(session, toolCallId, 'success', `Created offline build request ${relative(root, buildPath)}`);
755
+ writeOutput(flags, { status: 'created', mode: 'offline', build: relative(root, buildPath) }, [
756
+ 'Pugi local build request created (offline)',
757
+ `Build artifact: ${relative(root, buildPath)}`,
758
+ 'Configure PUGI_API_KEY or run `pugi login` to use the engine-driven build loop.',
759
+ ].join('\n'));
760
+ }
761
+ async function offlineExplain(args, flags, session) {
762
+ const target = args[0];
763
+ if (!target) {
764
+ throw new Error('pugi explain requires a file or directory path');
765
+ }
766
+ const root = process.cwd();
767
+ const settings = loadSettings(root);
768
+ const ctx = {
769
+ root,
770
+ settings,
771
+ session,
772
+ readCache: new FileReadCache(),
773
+ };
774
+ // Validate the target stays inside the workspace BEFORE statting it.
775
+ // `resolveWorkspacePath` follows symlinks at both the parent and the
776
+ // target itself, so `pugi explain etc-link` (a symlink to /etc) and
777
+ // `pugi explain /etc` are both refused here before any directory walk
778
+ // or file read fans out.
779
+ const resolvedTarget = resolveWorkspacePath(root, target);
780
+ const stat = statSync(resolvedTarget);
781
+ if (stat.isDirectory()) {
782
+ const paths = globTool(ctx, `${target.replace(/\/$/, '')}/**/*`).slice(0, 80);
783
+ const matches = grepTool(ctx, 'TODO').slice(0, 20);
784
+ writeOutput(flags, { target, kind: 'directory', mode: 'offline', paths, todoMatches: matches }, [`Directory: ${target}`, `Files: ${paths.length}`, ...paths.map((path) => ` ${path}`)].join('\n'));
785
+ return;
786
+ }
787
+ const content = readTool(ctx, target);
788
+ const lines = content.split('\n');
789
+ const excerpt = lines.slice(0, 120).join('\n');
790
+ writeOutput(flags, {
791
+ target,
792
+ kind: 'file',
793
+ mode: 'offline',
794
+ lineCount: lines.length,
795
+ excerpt,
796
+ }, [`File: ${target}`, `Lines: ${lines.length}`, '', excerpt].join('\n'));
797
+ }
798
+ async function review(args, flags, session) {
799
+ const root = process.cwd();
800
+ ensureInitialized(root);
801
+ const prompt = args.join(' ').trim();
802
+ if (flags.triple && flags.remote) {
803
+ await performRemoteTripleReview(root, session, flags, prompt);
804
+ return;
805
+ }
806
+ if (flags.remote || flags.web) {
807
+ const bundle = createHandoffBundle(root, session, flags.remote ? 'remote_review' : 'web_review', prompt || 'review local diff');
808
+ writeOutput(flags, bundle, ['Pugi review handoff created', `Bundle: ${bundle.path}`].join('\n'));
809
+ return;
810
+ }
811
+ const artifactDir = createArtifactDir(root, prompt || 'review');
812
+ const reviewPath = resolve(artifactDir, flags.triple ? 'triple-review.md' : 'review.md');
813
+ const toolCallId = recordToolCall(session, 'artifact:review', prompt || 'local diff');
814
+ const status = safeGit(root, ['status', '--short']);
815
+ const diffStat = safeGit(root, ['diff', '--stat']);
816
+ const currentBranch = safeGit(root, ['branch', '--show-current']).trim() || 'unknown';
817
+ const mergeBase = safeGit(root, ['merge-base', 'HEAD', 'origin/main']).trim();
818
+ const branchDiffStat = mergeBase ? safeGit(root, ['diff', '--stat', `${mergeBase}..HEAD`]) : '';
819
+ const branchNameStatus = mergeBase ? safeGit(root, ['diff', '--name-status', `${mergeBase}..HEAD`]) : '';
820
+ const text = [
821
+ flags.triple ? '# Pugi Anvil Triple Review Request' : '# Pugi Local Review',
822
+ '',
823
+ prompt ? `Prompt: ${prompt}` : 'Prompt: review current local state',
824
+ flags.triple ? 'Gate: Anvil-backed triple-review for main merge readiness' : 'Gate: local review',
825
+ `Branch: ${currentBranch}`,
826
+ '',
827
+ '## Git Status',
828
+ '',
829
+ '```text',
830
+ status || 'clean',
831
+ '```',
832
+ '',
833
+ '## Diff Stat',
834
+ '',
835
+ '```text',
836
+ diffStat || 'no unstaged diff',
837
+ '```',
838
+ '',
839
+ ...(flags.triple
840
+ ? [
841
+ '## Branch Diff Against origin/main',
842
+ '',
843
+ '```text',
844
+ branchDiffStat || 'origin/main merge-base unavailable or no branch diff',
845
+ '```',
846
+ '',
847
+ '## Files Changed',
848
+ '',
849
+ '```text',
850
+ branchNameStatus || 'origin/main merge-base unavailable or no branch diff',
851
+ '```',
852
+ '',
853
+ '## Anvil Contract',
854
+ '',
855
+ '- Pugi CLI prepares evidence locally; Anvil runs the reviewer fan-out.',
856
+ '- Do not invoke Claude dev-only `/triple-review` from product runtime.',
857
+ '- Runtime review uses the same deterministic rubric as the Claude skill and OES MCP triple_review tool.',
858
+ '- Any P0 = BLOCK.',
859
+ '- P1 from two or more reviewers = BLOCK.',
860
+ '- P1 from one reviewer = WARN.',
861
+ '- No P0/P1 = PASS.',
862
+ '',
863
+ '## Reviewer Shape',
864
+ '',
865
+ '- Architecture reviewer: scope, module boundaries, migrations, contracts, blast radius.',
866
+ '- Security/reliability reviewer: auth, secrets, permissions, destructive ops, data loss, deploy risk.',
867
+ '- QA/regression reviewer: commands run, smoke path, acceptance criteria, rollback path.',
868
+ '',
869
+ '## Future Transport',
870
+ '',
871
+ '- Send this evidence bundle to Anvil review endpoint once CLI auth/transport is wired.',
872
+ '- Persist result in local `.pugi/artifacts` and server ledger.',
873
+ '- Feed merge readiness into the existing agent merge gate instead of trusting a client-side PASS claim.',
874
+ '',
875
+ ]
876
+ : []),
877
+ '## Review Checklist',
878
+ '',
879
+ '- Are acceptance criteria explicit?',
880
+ '- Are generated artifacts present?',
881
+ '- Were tests/lint/build run or explicitly deferred?',
882
+ '- Are secrets, env files, and destructive ops untouched?',
883
+ '- Is remote/web handoff needed for longer execution?',
884
+ '',
885
+ flags.triple
886
+ ? 'Status: block until Anvil triple-review returns PASS or a WARN is explicitly accepted.'
887
+ : 'Status: block until a model-backed or human review fills findings.',
888
+ '',
889
+ ].join('\n');
890
+ writeFileSync(reviewPath, text, { encoding: 'utf8', mode: 0o600 });
891
+ registerArtifact(root, {
892
+ id: artifactIdFromDir(artifactDir),
893
+ kind: flags.triple ? 'triple-review' : 'review',
894
+ path: relative(root, artifactDir),
895
+ sessionId: session.id,
896
+ createdAt: new Date().toISOString(),
897
+ files: [flags.triple ? 'triple-review.md' : 'review.md'],
898
+ });
899
+ recordToolResult(session, toolCallId, 'success', `Created review ${relative(root, reviewPath)}`);
900
+ writeOutput(flags, { status: 'created', review: relative(root, reviewPath) }, [
901
+ flags.triple ? 'Pugi Anvil triple-review request created' : 'Pugi local review created',
902
+ `Review: ${relative(root, reviewPath)}`,
903
+ ].join('\n'));
904
+ }
905
+ async function sync(_args, flags, session) {
906
+ const root = process.cwd();
907
+ ensureInitialized(root);
908
+ const settings = loadSettings(root);
909
+ const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
910
+ const createdAt = new Date().toISOString();
911
+ const dryRunPlan = pugiSyncDryRunPlanSchema.parse({
912
+ schema: 1,
913
+ createdAt,
914
+ mode,
915
+ uploadEnabled: false,
916
+ workspace: workspaceSnapshot(root),
917
+ items: buildSyncDryRunItems(root, mode),
918
+ exclusions: [
919
+ '.env',
920
+ '.env.*',
921
+ '*.pem',
922
+ '*.key',
923
+ '*.p12',
924
+ '*.sql',
925
+ '*.dump',
926
+ 'node_modules/',
927
+ 'dist/',
928
+ '.next/',
929
+ 'coverage/',
930
+ ],
931
+ notes: [
932
+ 'Local repository and .pugi/ remain the source of truth (ADR-0037).',
933
+ 'Server sync is explicit continuation, not default storage.',
934
+ mode === 'full-sync'
935
+ ? 'full-sync is a future explicit opt-in and is not upload-enabled in this scaffold.'
936
+ : 'Raw file contents are excluded by default.',
937
+ ],
938
+ });
939
+ if (flags.dryRun) {
940
+ writeOutput(flags, { status: 'dry_run', plan: dryRunPlan }, [
941
+ 'Pugi sync dry-run',
942
+ `Mode: ${dryRunPlan.mode}`,
943
+ 'Upload: disabled (dry-run)',
944
+ '',
945
+ ...dryRunPlan.items.map((item) => ` ${item.action.toUpperCase()} ${item.kind} ${item.path} (${item.bytes} bytes) - ${item.reason}`),
946
+ '',
947
+ 'No upload performed.',
948
+ ].join('\n'));
949
+ return;
950
+ }
951
+ await performRemoteSync(root, session, flags, dryRunPlan, createdAt, mode);
952
+ }
953
+ async function performRemoteSync(root, session, flags, dryRunPlan, createdAt, mode) {
954
+ const toolCallId = recordToolCall(session, 'sync:upload', `mode=${mode}`);
955
+ const config = resolveRuntimeConfig();
956
+ if (!config) {
957
+ recordToolResult(session, toolCallId, 'error', 'pugi sync requires login (PUGI_API_KEY or `pugi login`)');
958
+ writeOutput(flags, { status: 'unauthenticated', reason: 'no_credentials', plan: dryRunPlan }, [
959
+ 'Pugi sync requires credentials.',
960
+ 'Run `pugi login --token=<api-key>` or set PUGI_API_URL + PUGI_API_KEY in your environment.',
961
+ 'Run `pugi sync --dry-run` to inspect the plan offline.',
962
+ ].join('\n'));
963
+ process.exitCode = 5;
964
+ return;
965
+ }
966
+ // Build the handoff bundle in-memory (no `.pugi/handoffs/*.json`
967
+ // side-effect — the server record IS the durable handoff). The
968
+ // operator can call `pugi handoff` separately for a local-only
969
+ // snapshot.
970
+ //
971
+ // Mix a randomUUID() suffix into the id so two `pugi sync`
972
+ // invocations in the same millisecond (CI parallelism, scripted
973
+ // retries) produce distinct `bundle.id` values — the
974
+ // server-side `clientId` column relies on them being unique
975
+ // for the "which server records correspond to my local
976
+ // handoff #foo" affordance.
977
+ const id = `${createdAt.replace(/[:.]/g, '-')}-sync-${randomUUID().slice(0, 8)}`;
978
+ const latest = latestArtifactDir(root);
979
+ const bundle = pugiHandoffBundleSchema.parse({
980
+ schema: 1,
981
+ id,
982
+ reason: `pugi sync (mode=${mode})`,
983
+ prompt: `Continue from the most recent Pugi session on ${workspaceSnapshot(root).rootName}.`,
984
+ createdAt,
985
+ workspace: workspaceSnapshot(root),
986
+ session: {
987
+ id: session.id,
988
+ eventsPath: relative(root, session.eventsPath),
989
+ },
990
+ artifacts: {
991
+ latest: latest ? relative(root, latest) : null,
992
+ },
993
+ privacy: {
994
+ includesFileContents: false,
995
+ includesSecrets: false,
996
+ },
997
+ });
998
+ const uploadPlan = pugiSyncUploadPlanSchema.parse({
999
+ ...dryRunPlan,
1000
+ uploadEnabled: true,
1001
+ });
1002
+ const request = pugiSyncRequestSchema.parse({
1003
+ schema: 1,
1004
+ bundle,
1005
+ plan: uploadPlan,
1006
+ });
1007
+ const result = await submitSync(config, request);
1008
+ if (result.status === 'ok') {
1009
+ recordToolResult(session, toolCallId, 'success', `sync ${result.response.syncId} status=${result.response.status}`);
1010
+ writeOutput(flags, {
1011
+ status: 'completed',
1012
+ syncId: result.response.syncId,
1013
+ syncStatus: result.response.status,
1014
+ receivedAt: result.response.receivedAt,
1015
+ }, [
1016
+ `Pugi sync uploaded — server id ${result.response.syncId}`,
1017
+ `Status: ${result.response.status} at ${result.response.receivedAt}`,
1018
+ 'Local repository and .pugi/ remain the source of truth (ADR-0037).',
1019
+ ].join('\n'));
1020
+ return;
1021
+ }
1022
+ const outcome = describeSyncFailure(result);
1023
+ // SECURITY: do NOT persist the raw upstream body into the local
1024
+ // session events log. If the runtime ever echoes the submitted
1025
+ // prompt / reason / bundle fragment back in its 4xx/5xx body
1026
+ // (NestJS BadRequestException echoes failing field values by
1027
+ // default), this would leak the customer's prompt text to disk
1028
+ // under `.pugi/sessions/.../events.jsonl`. The class-only
1029
+ // `recordedMessage` carries the status class + HTTP code so the
1030
+ // session log still proves the failure happened without exposing
1031
+ // the upstream body. The verbose `message` goes to stdout/JSON
1032
+ // output only, where it is operator-visible and ephemeral.
1033
+ recordToolResult(session, toolCallId, 'error', outcome.recordedMessage);
1034
+ writeOutput(flags, {
1035
+ status: result.status,
1036
+ code: 'code' in result ? result.code : undefined,
1037
+ message: outcome.message,
1038
+ plan: dryRunPlan,
1039
+ }, [outcome.headline, outcome.next ? `Next: ${outcome.next}` : '']
1040
+ .filter(Boolean)
1041
+ .join('\n'));
1042
+ process.exitCode = outcome.exitCode;
1043
+ }
1044
+ function describeSyncFailure(result) {
1045
+ switch (result.status) {
1046
+ case 'endpoint_missing':
1047
+ return {
1048
+ headline: 'Pugi runtime sync endpoint not deployed on this Anvil instance.',
1049
+ message: result.message,
1050
+ recordedMessage: 'sync: endpoint_missing (HTTP 404)',
1051
+ next: 'Operator must deploy POST /api/pugi/sync on api.pugi.io (Phase 1 of explicit continuation).',
1052
+ exitCode: 6,
1053
+ };
1054
+ case 'unauthenticated':
1055
+ return {
1056
+ headline: `Pugi runtime rejected credentials (HTTP ${result.code}).`,
1057
+ message: result.message,
1058
+ recordedMessage: `sync: unauthenticated (HTTP ${result.code})`,
1059
+ next: 'Rotate PUGI_API_KEY or check tenant entitlement.',
1060
+ exitCode: 5,
1061
+ };
1062
+ case 'rate_limited':
1063
+ return {
1064
+ headline: `Pugi runtime rate-limited the sync request (HTTP ${result.code}).`,
1065
+ message: result.message,
1066
+ recordedMessage: `sync: rate_limited (HTTP ${result.code})`,
1067
+ next: `Retry after ${Math.round(result.retryAfterMs / 1000)}s.`,
1068
+ exitCode: 7,
1069
+ };
1070
+ case 'failed':
1071
+ default:
1072
+ return {
1073
+ headline: 'Pugi runtime sync failed.',
1074
+ message: result.message,
1075
+ // `code === 0` denotes a transport-layer error (network /
1076
+ // AbortError); keep it out of the recorded line so the
1077
+ // session log normalises to "sync: failed".
1078
+ recordedMessage: `sync: failed${'code' in result && result.code > 0 ? ` (HTTP ${result.code})` : ''}`,
1079
+ next: 'Run `pugi sync --dry-run` to inspect the plan and retry.',
1080
+ exitCode: 8,
1081
+ };
1082
+ }
1083
+ }
1084
+ function resolveRuntimeConfig() {
1085
+ // Prefer env-only path first so CI use cases keep their fast path.
1086
+ const envConfig = loadRuntimeConfig();
1087
+ if (envConfig)
1088
+ return envConfig;
1089
+ // Fall back to the local credentials store written by `pugi login`.
1090
+ const credential = resolveActiveCredential();
1091
+ if (!credential)
1092
+ return null;
1093
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
1094
+ }
1095
+ async function performRemoteTripleReview(root, session, flags, prompt) {
1096
+ const config = resolveRuntimeConfig();
1097
+ const artifactDir = createArtifactDir(root, prompt || 'triple-review');
1098
+ const requestPath = resolve(artifactDir, 'triple-review-request.json');
1099
+ const resultPath = resolve(artifactDir, 'triple-review-result.json');
1100
+ const summaryPath = resolve(artifactDir, 'triple-review.md');
1101
+ const toolCallId = recordToolCall(session, 'review:triple-remote', prompt || 'review branch diff');
1102
+ const settings = loadSettings(root);
1103
+ const baseRef = resolveBaseRef(root, settings);
1104
+ // Compute the diff against the merge-base SHA so the review covers BOTH
1105
+ // committed-since-base AND uncommitted (staged + working tree) changes.
1106
+ // The previous shape `base...HEAD` used triple-dot range but only sent
1107
+ // committed-since-base, hiding pre-commit edits in the most common
1108
+ // review case ("review what I'm about to commit").
1109
+ const mergeBaseSha = baseRef ? safeGit(root, ['merge-base', baseRef, 'HEAD']).trim() : '';
1110
+ const diffRange = mergeBaseSha || 'HEAD';
1111
+ // Exclude protected paths from the patch at the git layer so we cannot
1112
+ // accidentally POST a tracked `.env` / `*.key` / `*.sql` to the runtime.
1113
+ const diffArgs = ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
1114
+ const diffStatArgs = ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
1115
+ const diffPatch = safeGit(root, diffArgs);
1116
+ const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
1117
+ // Untracked files are not in `git diff`. Include their paths (NOT contents)
1118
+ // so reviewers know the diff is incomplete. Protected basenames/suffixes
1119
+ // are stripped from the list before egress.
1120
+ const untracked = collectUntrackedSummary(root);
1121
+ // Append an untracked summary so reviewers see the path of any new file
1122
+ // that is dirty-but-not-yet-staged. Contents are never included; only
1123
+ // path counts and a capped list.
1124
+ const augmentedDiff = untracked.paths.length === 0 && untracked.excludedProtected === 0
1125
+ ? diffPatch
1126
+ : [
1127
+ diffPatch,
1128
+ '',
1129
+ '## Untracked files (paths only, contents withheld)',
1130
+ `Total visible: ${untracked.paths.length}`,
1131
+ `Protected paths excluded: ${untracked.excludedProtected}`,
1132
+ ...untracked.paths.map((p) => `- ${p}`),
1133
+ '',
1134
+ ].join('\n');
1135
+ const requestBody = pugiTripleReviewRequestSchema.parse({
1136
+ schema: 1,
1137
+ workspace: {
1138
+ rootName: root.split('/').at(-1) ?? 'workspace',
1139
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
1140
+ gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
1141
+ baseRef,
1142
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
1143
+ },
1144
+ diffPatch: augmentedDiff,
1145
+ diffStats,
1146
+ prompt: prompt || undefined,
1147
+ locale: 'en-US',
1148
+ reviewerPersona: 'oes-dev',
1149
+ });
1150
+ writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
1151
+ registerArtifact(root, {
1152
+ id: artifactIdFromDir(artifactDir),
1153
+ kind: 'triple-review',
1154
+ path: relative(root, artifactDir),
1155
+ sessionId: session.id,
1156
+ createdAt: new Date().toISOString(),
1157
+ files: ['triple-review-request.json'],
1158
+ });
1159
+ if (!config) {
1160
+ const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
1161
+ recordToolResult(session, toolCallId, 'error', reason);
1162
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
1163
+ prompt,
1164
+ requestPath: relative(root, requestPath),
1165
+ verdict: null,
1166
+ reason,
1167
+ response: null,
1168
+ }), { encoding: 'utf8', mode: 0o600 });
1169
+ writeOutput(flags, {
1170
+ status: 'auth_missing',
1171
+ request: relative(root, requestPath),
1172
+ summary: relative(root, summaryPath),
1173
+ }, [
1174
+ 'Pugi triple-review request prepared but not sent — no active credentials.',
1175
+ `Request: ${relative(root, requestPath)}`,
1176
+ `Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --remote\`.`,
1177
+ ].join('\n'));
1178
+ process.exitCode = 5;
1179
+ return;
1180
+ }
1181
+ const submitResult = await submitTripleReview(config, requestBody);
1182
+ if (submitResult.status === 'ok') {
1183
+ persistTripleReviewResult(resultPath, submitResult.response);
1184
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
1185
+ prompt,
1186
+ requestPath: relative(root, requestPath),
1187
+ verdict: submitResult.response.verdict,
1188
+ reason: submitResult.response.reason,
1189
+ response: submitResult.response,
1190
+ }), { encoding: 'utf8', mode: 0o600 });
1191
+ recordToolResult(session, toolCallId, submitResult.response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${submitResult.response.verdict} (${submitResult.response.reason})`);
1192
+ writeOutput(flags, {
1193
+ status: 'completed',
1194
+ verdict: submitResult.response.verdict,
1195
+ reason: submitResult.response.reason,
1196
+ counts: submitResult.response.counts,
1197
+ reviewerCount: submitResult.response.reviewerCount,
1198
+ effectiveTier: submitResult.response.effectiveTier,
1199
+ result: relative(root, resultPath),
1200
+ summary: relative(root, summaryPath),
1201
+ }, [
1202
+ `Pugi triple-review ${submitResult.response.verdict}: ${submitResult.response.reason}`,
1203
+ `Reviewers: ${submitResult.response.reviewerCount} (tier ${submitResult.response.effectiveTier})`,
1204
+ `Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
1205
+ `Result: ${relative(root, resultPath)}`,
1206
+ `Summary: ${relative(root, summaryPath)}`,
1207
+ ].join('\n'));
1208
+ if (submitResult.response.verdict === 'BLOCK') {
1209
+ process.exitCode = 9;
1210
+ }
1211
+ return;
1212
+ }
1213
+ // Non-OK paths: persist local artifact noting outcome, surface actionable error.
1214
+ const outcome = describeSubmitFailure(submitResult);
1215
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
1216
+ prompt,
1217
+ requestPath: relative(root, requestPath),
1218
+ verdict: null,
1219
+ reason: outcome.message,
1220
+ response: null,
1221
+ }), { encoding: 'utf8', mode: 0o600 });
1222
+ recordToolResult(session, toolCallId, 'error', outcome.message);
1223
+ writeOutput(flags, {
1224
+ status: submitResult.status,
1225
+ code: submitResult.code,
1226
+ message: outcome.message,
1227
+ request: relative(root, requestPath),
1228
+ summary: relative(root, summaryPath),
1229
+ }, [
1230
+ outcome.headline,
1231
+ `Request: ${relative(root, requestPath)}`,
1232
+ `Summary: ${relative(root, summaryPath)}`,
1233
+ outcome.next ? `Next: ${outcome.next}` : '',
1234
+ ]
1235
+ .filter(Boolean)
1236
+ .join('\n'));
1237
+ process.exitCode = outcome.exitCode;
1238
+ }
1239
+ function describeSubmitFailure(result) {
1240
+ switch (result.status) {
1241
+ case 'endpoint_missing':
1242
+ return {
1243
+ headline: 'Pugi runtime triple-review endpoint not deployed on this Anvil instance.',
1244
+ message: result.message,
1245
+ next: 'Operator must deploy POST /api/pugi/triple-review on api.pugi.io (proxies to AnvilBridgeService.askPersona with the configured reviewer persona).',
1246
+ exitCode: 6,
1247
+ };
1248
+ case 'unauthenticated':
1249
+ return {
1250
+ headline: `Pugi runtime rejected credentials (HTTP ${result.code}).`,
1251
+ message: result.message,
1252
+ next: 'Rotate PUGI_API_KEY or check tenant entitlement.',
1253
+ exitCode: 5,
1254
+ };
1255
+ case 'rate_limited':
1256
+ return {
1257
+ headline: `Pugi runtime rate-limited the triple-review request (HTTP ${result.code}).`,
1258
+ message: result.message,
1259
+ next: `Retry after ${Math.round(result.retryAfterMs / 1000)}s or upgrade the tenant tier.`,
1260
+ exitCode: 7,
1261
+ };
1262
+ case 'failed':
1263
+ return {
1264
+ headline: `Pugi runtime triple-review failed (HTTP ${result.code}).`,
1265
+ message: result.message,
1266
+ next: 'Inspect server logs and retry once the runtime stabilises.',
1267
+ exitCode: 6,
1268
+ };
1269
+ case 'ok':
1270
+ throw new Error('describeSubmitFailure called with ok status');
1271
+ }
1272
+ }
1273
+ function persistTripleReviewResult(path, response) {
1274
+ writeFileSync(path, `${JSON.stringify(response, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
1275
+ }
1276
+ function buildTripleReviewMarkdown(input) {
1277
+ const { prompt, requestPath, verdict, reason, response } = input;
1278
+ const header = verdict
1279
+ ? `# Pugi Triple Review — ${verdict}`
1280
+ : '# Pugi Triple Review — request prepared';
1281
+ const lines = [
1282
+ header,
1283
+ '',
1284
+ prompt ? `Prompt: ${prompt}` : 'Prompt: review branch diff vs origin/main',
1285
+ `Reason: ${reason}`,
1286
+ `Request: ${requestPath}`,
1287
+ '',
1288
+ ];
1289
+ if (response) {
1290
+ lines.push(`Reviewers: ${response.reviewerCount} (effective tier ${response.effectiveTier}${response.draft ? ', draft' : ''})`, `Findings: P0=${response.counts.P0} P1=${response.counts.P1} P2=${response.counts.P2} P3=${response.counts.P3}`, '', '## Findings', '');
1291
+ if (response.findings.length === 0) {
1292
+ lines.push('- No findings reported.');
1293
+ }
1294
+ else {
1295
+ for (const finding of response.findings) {
1296
+ const location = [
1297
+ finding.path ? finding.path : null,
1298
+ finding.line ? `line ${finding.line}` : null,
1299
+ ]
1300
+ .filter(Boolean)
1301
+ .join(' ');
1302
+ const fix = finding.fix ? ` Fix: ${finding.fix}` : '';
1303
+ lines.push(`- [${finding.severity}] ${finding.reviewer}${location ? ` — ${location}` : ''} — ${finding.issue}${fix}`);
1304
+ }
1305
+ }
1306
+ lines.push('', '## Reviewer Verdicts', '');
1307
+ for (const reviewer of response.reviewers) {
1308
+ const declared = reviewer.declaredVerdict ?? 'unknown';
1309
+ lines.push(`- ${reviewer.model}: ${declared}${reviewer.error ? ` (error: ${reviewer.error})` : ''}`);
1310
+ }
1311
+ }
1312
+ else {
1313
+ lines.push('## Outcome', '', '- Local request artifact preserved.', '- Runtime call did not return a verdict.');
1314
+ }
1315
+ lines.push('');
1316
+ return lines.join('\n');
1317
+ }
1318
+ function resolveBaseRef(root, settings) {
1319
+ // Honor `workflow.defaultBaseBranch` from `.pugi/settings.json` (defaults
1320
+ // to `dev` per `pugi init`). Without this the resolver only checks
1321
+ // `origin/main`/`master` and silently submits an empty patch for any
1322
+ // repo on a non-main integration branch.
1323
+ const configured = settings?.workflow.defaultBaseBranch;
1324
+ const candidates = [
1325
+ configured ? `origin/${configured}` : null,
1326
+ configured ?? null,
1327
+ 'origin/main',
1328
+ 'origin/master',
1329
+ 'main',
1330
+ 'master',
1331
+ ].filter((value) => Boolean(value));
1332
+ const seen = new Set();
1333
+ for (const candidate of candidates) {
1334
+ if (seen.has(candidate))
1335
+ continue;
1336
+ seen.add(candidate);
1337
+ const mergeBase = safeGit(root, ['merge-base', 'HEAD', candidate]).trim();
1338
+ if (mergeBase)
1339
+ return candidate;
1340
+ }
1341
+ return null;
1342
+ }
1343
+ function parseDiffStats(raw) {
1344
+ const stats = { filesChanged: 0, insertions: 0, deletions: 0 };
1345
+ const filesMatch = raw.match(/(\d+)\s+files?\s+changed/);
1346
+ if (filesMatch?.[1])
1347
+ stats.filesChanged = Number.parseInt(filesMatch[1], 10);
1348
+ const insMatch = raw.match(/(\d+)\s+insertions?/);
1349
+ if (insMatch?.[1])
1350
+ stats.insertions = Number.parseInt(insMatch[1], 10);
1351
+ const delMatch = raw.match(/(\d+)\s+deletions?/);
1352
+ if (delMatch?.[1])
1353
+ stats.deletions = Number.parseInt(delMatch[1], 10);
1354
+ return stats;
1355
+ }
1356
+ async function handoff(args, flags, session) {
1357
+ const root = process.cwd();
1358
+ ensureInitialized(root);
1359
+ const reason = args[0] || 'web_continue';
1360
+ const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
1361
+ const bundle = createHandoffBundle(root, session, reason, prompt);
1362
+ writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1363
+ }
1364
+ async function sessions(args, flags, _session) {
1365
+ const root = process.cwd();
1366
+ ensureInitialized(root);
1367
+ const rebuild = args.includes('--rebuild');
1368
+ let index = rebuild ? null : readIndex(root);
1369
+ if (!index) {
1370
+ index = rebuildIndex(root);
1371
+ writeIndex(root, index);
1372
+ }
1373
+ else if (hasStubSession(index)) {
1374
+ // `registerArtifact` writes minimal session placeholders to keep the
1375
+ // hot path O(1). If any are still stubs (no commands recorded yet),
1376
+ // opportunistically materialize them from `events.jsonl` so the
1377
+ // default `pugi sessions` output isn't misleadingly empty. Users no
1378
+ // longer need to know to pass `--rebuild`.
1379
+ index = rebuildIndex(root);
1380
+ writeIndex(root, index);
1381
+ }
1382
+ const sessionsView = index.sessions.slice(0, 10).map((session) => {
1383
+ const artifactsForSession = index.artifacts.filter((artifact) => artifact.sessionId === session.id);
1384
+ const lastCommand = session.commands.at(-1) ?? null;
1385
+ return {
1386
+ id: session.id,
1387
+ startedAt: session.startedAt,
1388
+ endedAt: session.endedAt,
1389
+ commandCount: session.commandCount,
1390
+ lastCommand: lastCommand
1391
+ ? { command: lastCommand.command, status: lastCommand.status, timestamp: lastCommand.timestamp }
1392
+ : null,
1393
+ artifactCount: artifactsForSession.length,
1394
+ latestArtifact: artifactsForSession[0]?.path ?? null,
1395
+ };
1396
+ });
1397
+ const orphans = index.artifacts.filter((artifact) => !artifact.sessionId);
1398
+ const handoffs = index.artifacts.filter((artifact) => artifact.kind === 'handoff');
1399
+ const payload = {
1400
+ root,
1401
+ indexPath: '.pugi/index.json',
1402
+ updatedAt: index.updatedAt,
1403
+ sessions: sessionsView,
1404
+ artifacts: index.artifacts.slice(0, 20),
1405
+ orphanArtifacts: orphans.slice(0, 10),
1406
+ handoffs: handoffs.slice(0, 10),
1407
+ latestSession: sessionsView[0] ?? null,
1408
+ latestArtifact: index.artifacts[0] ?? null,
1409
+ latestHandoff: handoffs[0] ?? null,
1410
+ };
1411
+ writeOutput(flags, payload, [
1412
+ 'Pugi local sessions',
1413
+ `Index: ${payload.indexPath} (updated ${payload.updatedAt})`,
1414
+ '',
1415
+ 'Sessions:',
1416
+ ...(sessionsView.length
1417
+ ? sessionsView.map((session) => {
1418
+ const lastCmd = session.lastCommand
1419
+ ? `${session.lastCommand.command} (${session.lastCommand.status})`
1420
+ : 'none';
1421
+ return ` ${session.id} cmds=${session.commandCount} last=${lastCmd} artifacts=${session.artifactCount}`;
1422
+ })
1423
+ : [' none']),
1424
+ '',
1425
+ 'Artifacts (latest 10):',
1426
+ ...(index.artifacts.length
1427
+ ? index.artifacts.slice(0, 10).map((artifact) => ` ${artifact.id} kind=${artifact.kind} session=${artifact.sessionId ?? 'orphan'} files=${artifact.files.length}`)
1428
+ : [' none']),
1429
+ '',
1430
+ 'Handoffs:',
1431
+ ...(handoffs.length
1432
+ ? handoffs.slice(0, 10).map((handoff) => ` ${handoff.id} path=${handoff.path}`)
1433
+ : [' none']),
1434
+ ].join('\n'));
1435
+ }
1436
+ function hasStubSession(index) {
1437
+ return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
1438
+ }
1439
+ function registerArtifact(root, artifact) {
1440
+ // Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
1441
+ // that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
1442
+ // which is O(N) per command. Instead we read the existing index and inject
1443
+ // a minimal session placeholder if needed; the next `pugi sessions` call
1444
+ // (or `pugi sessions --rebuild`) materialises the full session record from
1445
+ // `events.jsonl`.
1446
+ const current = readIndex(root) ?? emptyIndex();
1447
+ const needsSessionStub = Boolean(artifact.sessionId) &&
1448
+ !current.sessions.some((session) => session.id === artifact.sessionId);
1449
+ const withSession = needsSessionStub
1450
+ ? {
1451
+ ...current,
1452
+ sessions: [
1453
+ ...current.sessions,
1454
+ {
1455
+ id: artifact.sessionId,
1456
+ startedAt: new Date().toISOString(),
1457
+ endedAt: null,
1458
+ commandCount: 0,
1459
+ commands: [],
1460
+ artifactIds: [],
1461
+ },
1462
+ ],
1463
+ }
1464
+ : current;
1465
+ const updated = upsertArtifact(withSession, artifact);
1466
+ writeIndex(root, updated);
1467
+ return updated;
1468
+ }
1469
+ async function resume(args, flags, session) {
1470
+ const root = process.cwd();
1471
+ ensureInitialized(root);
1472
+ const target = args[0];
1473
+ const artifacts = listArtifactSets(root);
1474
+ const selected = target
1475
+ ? artifacts.find((artifact) => artifact.id === target || artifact.path.endsWith(target))
1476
+ : artifacts[0];
1477
+ if (!selected) {
1478
+ throw new Error('No local Pugi artifact session found');
1479
+ }
1480
+ const resumeDir = createArtifactDir(root, `resume-${selected.id}`);
1481
+ const resumePath = resolve(resumeDir, 'resume.md');
1482
+ const toolCallId = recordToolCall(session, 'artifact:resume', selected.id);
1483
+ const handoffHint = listHandoffBundles(root)[0] ?? null;
1484
+ const text = [
1485
+ '# Pugi Resume',
1486
+ '',
1487
+ `Resuming: ${selected.path}`,
1488
+ handoffHint ? `Latest handoff: ${handoffHint.path}` : 'Latest handoff: none',
1489
+ '',
1490
+ '## Available Files',
1491
+ '',
1492
+ ...selected.files.map((file) => `- ${file}`),
1493
+ '',
1494
+ '## Next Local Actions',
1495
+ '',
1496
+ '- Inspect the selected artifact set.',
1497
+ '- Continue with `pugi build`, `pugi review`, or `pugi handoff --web`.',
1498
+ '- Use `pugi build --remote` only when local execution is insufficient.',
1499
+ '',
1500
+ ].join('\n');
1501
+ writeFileSync(resumePath, text, { encoding: 'utf8', mode: 0o600 });
1502
+ registerArtifact(root, {
1503
+ id: artifactIdFromDir(resumeDir),
1504
+ kind: 'resume',
1505
+ path: relative(root, resumeDir),
1506
+ sessionId: session.id,
1507
+ createdAt: new Date().toISOString(),
1508
+ files: ['resume.md'],
1509
+ });
1510
+ recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
1511
+ writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
1512
+ }
1513
+ /**
1514
+ * Per-command exit code map. Surfaced to the operator so shell scripts
1515
+ * can branch on the engine outcome:
1516
+ * - 0 = done (model returned a final answer)
1517
+ * - 8 = failed (transport / runtime / unhandled adapter error)
1518
+ * - 9 = blocked (budget exhausted, plan-mode refusal, abort)
1519
+ *
1520
+ * 1 is reserved for the credential gate (engine_unavailable) so
1521
+ * existing shell wrappers that branch on "any non-zero" still work.
1522
+ */
1523
+ const ENGINE_EXIT_CODES = {
1524
+ done: 0,
1525
+ failed: 8,
1526
+ blocked: 9,
1527
+ engine_unavailable: 1,
1528
+ };
1529
+ function commandLabel(kind) {
1530
+ return kind === 'build_task' ? 'build' : kind;
1531
+ }
1532
+ /**
1533
+ * Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
1534
+ * `NativePugiEngineAdapter`. Each command:
1535
+ *
1536
+ * 1. Resolves the active credential (falls back to PUGI_API_KEY env).
1537
+ * 2. Builds an `AnvilEngineLoopClient` against the resolved API URL.
1538
+ * 3. Runs the adapter to completion; collects status + result events.
1539
+ * 4. For `plan`: writes a `.pugi/artifacts/<slug>/plan.md` artifact
1540
+ * (and registers it in the local index) so the operator gets a
1541
+ * reviewable file, not just stdout.
1542
+ * 5. Prints a summary (files modified, tool calls, token usage) and
1543
+ * sets `process.exitCode` per the table above.
1544
+ *
1545
+ * `--json` flag swaps the text summary for a JSON envelope that wraps
1546
+ * every event + the headline metrics, so downstream tooling (cabinet UI,
1547
+ * cron pipelines) can consume the run directly.
1548
+ *
1549
+ * Per memory `feedback_decide_defaults_proactively` the explicit `code`/
1550
+ * `fix`/`build` commands require a prompt; `explain` accepts either a
1551
+ * path or a prompt and `plan` accepts an empty prompt only when there is
1552
+ * a previous artifact in `.pugi/artifacts/` to plan against.
1553
+ */
1554
+ /**
1555
+ * Test seam: when this module-scoped factory is set, every engine task
1556
+ * invocation uses it to build the client instead of constructing an
1557
+ * `AnvilEngineLoopClient` directly. The CLI exports `setEngineClientFactory`
1558
+ * so unit tests can inject a `FixtureClient`; production code never
1559
+ * sets it.
1560
+ */
1561
+ let engineClientFactory = null;
1562
+ export function setEngineClientFactory(factory) {
1563
+ engineClientFactory = factory;
1564
+ }
1565
+ function runEngineTask(kind) {
1566
+ return async (args, flags, session) => {
1567
+ const label = commandLabel(kind);
1568
+ const root = process.cwd();
1569
+ // `.pugi/` is created by `pugi init`. The engine writes the per-
1570
+ // session events mirror under it, so we fail fast here instead of
1571
+ // silently no-op'ing the mirror inside the adapter.
1572
+ ensureInitialized(root);
1573
+ const credential = resolveActiveCredential();
1574
+ const envConfig = loadRuntimeConfig();
1575
+ const config = credential
1576
+ ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
1577
+ : envConfig;
1578
+ // Offline fallback: preserves the local-first invariant. `plan` /
1579
+ // `build` / `explain` drop back to their pre-Sprint-2 stub
1580
+ // behaviour so an operator without an API key (or with --offline)
1581
+ // still gets reviewable artifacts. `code` and `fix` reject the
1582
+ // offline path because the value proposition IS the engine —
1583
+ // attempting them offline would produce nothing useful.
1584
+ const isExplicitlyOffline = flags.offline;
1585
+ const isImplicitlyOffline = !config && !flags.offline;
1586
+ if (isExplicitlyOffline || isImplicitlyOffline) {
1587
+ if (kind === 'code' || kind === 'fix') {
1588
+ const reason = isExplicitlyOffline
1589
+ ? `pugi ${label} requires the engine — drop --offline or run \`pugi ${label}\` with credentials configured`
1590
+ : 'no Pugi credential configured — run `pugi login` or set PUGI_API_KEY before invoking the engine';
1591
+ writeOutput(flags, { command: label, status: 'engine_unavailable', reason }, [`Pugi engine unavailable`, `Reason: ${reason}`].join('\n'));
1592
+ process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
1593
+ return;
1594
+ }
1595
+ if (kind === 'plan')
1596
+ return offlinePlan(args, flags, session);
1597
+ if (kind === 'build_task')
1598
+ return offlineBuild(args, flags, session);
1599
+ if (kind === 'explain')
1600
+ return offlineExplain(args, flags, session);
1601
+ }
1602
+ // Engine path prompt gate. (Offline `explain` accepts a path as
1603
+ // its first positional arg — that branch returned above before
1604
+ // we reach this gate.)
1605
+ //
1606
+ // Code Reviewer P2 retro 2026-05-23: previously `pugi plan` with
1607
+ // an empty prompt threw here (engine path) but the offline path
1608
+ // accepted the empty prompt and synthesised a plan from the
1609
+ // latest `.pugi/artifacts/` entry. The handler docstring
1610
+ // documents the offline behaviour as canonical — engine should
1611
+ // mirror it. For `code/fix/build/explain` an empty prompt is
1612
+ // still an error (nothing to act on), but for `plan` we resynth
1613
+ // the prompt from the most recent artifact if one exists,
1614
+ // keeping the two paths aligned.
1615
+ let prompt = args.join(' ').trim();
1616
+ if (!prompt) {
1617
+ if (kind === 'plan') {
1618
+ const latest = latestArtifactDir(root);
1619
+ if (latest) {
1620
+ prompt = `Continue planning from the previous artifact set at ${relative(root, latest)}`;
1621
+ }
1622
+ else {
1623
+ throw new Error('pugi plan requires a prompt or a previous artifact in .pugi/artifacts/');
1624
+ }
1625
+ }
1626
+ else {
1627
+ throw new Error(`pugi ${label} requires a prompt`);
1628
+ }
1629
+ }
1630
+ // Narrow `config` for the type checker — the offline branches above
1631
+ // return whenever `config` is null, so by this point it must be set.
1632
+ if (!config) {
1633
+ throw new Error('internal: engine config missing after offline gate');
1634
+ }
1635
+ const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
1636
+ const adapter = new NativePugiEngineAdapter({ client, session });
1637
+ const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
1638
+ const taskId = `${kind}-${Date.now()}`;
1639
+ const events = adapter.run({
1640
+ id: taskId,
1641
+ kind,
1642
+ prompt,
1643
+ workspaceRoot: root,
1644
+ allowedPaths: [root],
1645
+ deniedPaths: [],
1646
+ artifacts: [],
1647
+ // plan mode is enforced inside the tool-bridge (read-only schema +
1648
+ // executor refusal sentinel). The permission mode here is the
1649
+ // workspace-level toggle and is unchanged from interactive default.
1650
+ permissionMode: 'auto',
1651
+ }, { sessionId: session.id });
1652
+ const statusEvents = [];
1653
+ let result = null;
1654
+ for await (const event of events) {
1655
+ if (event.type === 'status') {
1656
+ statusEvents.push(event.message);
1657
+ // For `explain` the spec wants status events on stderr so the
1658
+ // final summary on stdout is grep-able. Other commands keep the
1659
+ // events on stdout-via-final-text so the operator sees the
1660
+ // chronological trace.
1661
+ if (kind === 'explain' && !flags.json) {
1662
+ process.stderr.write(`${event.message}\n`);
1663
+ }
1664
+ }
1665
+ else {
1666
+ result = {
1667
+ status: event.result.status,
1668
+ summary: event.result.summary,
1669
+ filesChanged: event.result.filesChanged,
1670
+ eventRefs: event.result.eventRefs,
1671
+ risks: event.result.risks,
1672
+ };
1673
+ }
1674
+ }
1675
+ if (!result) {
1676
+ // Adapter MUST emit a terminal result event. Treat the empty
1677
+ // outcome as a failure so the CLI surfaces a clear error rather
1678
+ // than exiting 0 with no output.
1679
+ result = {
1680
+ status: 'failed',
1681
+ summary: 'engine adapter returned no result',
1682
+ filesChanged: [],
1683
+ eventRefs: [],
1684
+ risks: ['adapter terminated without emitting a result event'],
1685
+ };
1686
+ }
1687
+ // For `plan` we always write a plan.md artifact, regardless of
1688
+ // outcome. A blocked plan (budget exhausted, tool refusal) still
1689
+ // produces a reviewable artifact — the reason is recorded inline.
1690
+ let planArtifact = null;
1691
+ if (kind === 'plan') {
1692
+ planArtifact = writePlanArtifact({
1693
+ root,
1694
+ session,
1695
+ prompt,
1696
+ result,
1697
+ statusEvents,
1698
+ });
1699
+ }
1700
+ // Pull the headline metrics out of `eventRefs` so the summary and
1701
+ // JSON envelope match without re-parsing strings in two places.
1702
+ const metrics = parseEventRefs(result.eventRefs);
1703
+ const finalStatus = result.status === 'failed' ? 'error' : 'success';
1704
+ recordToolResult(session, toolCallId, finalStatus, result.summary);
1705
+ // Exit code policy (spec §1-§5):
1706
+ // code/fix/build → 0 done, 8 failed, 9 blocked
1707
+ // explain → same triple; read-only blocked = budget exhaustion
1708
+ // plan → 0 on done OR plan-mode refusal (refusal is a
1709
+ // SUCCESS for plan: the gate worked); 8 on failed
1710
+ // transport; 9 on budget exhaustion.
1711
+ //
1712
+ // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
1713
+ // `budget_exhausted` as exit 0, so a CI loop with a token budget
1714
+ // hit looked identical to a successful plan. We now distinguish
1715
+ // via the adapter's `outcome=<status>` echo on `eventRefs` so
1716
+ // shell wrappers can branch on the real cause.
1717
+ if (kind === 'plan') {
1718
+ if (result.status === 'failed') {
1719
+ process.exitCode = ENGINE_EXIT_CODES.failed;
1720
+ }
1721
+ else if (result.status === 'blocked' &&
1722
+ metrics.outcome === 'budget_exhausted') {
1723
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
1724
+ }
1725
+ else {
1726
+ // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
1727
+ // gate fired, which is the contract working as designed), or
1728
+ // `blocked` with no outcome echo (legacy adapter — preserve the
1729
+ // pre-retro 0 behaviour to avoid breaking external scripts).
1730
+ process.exitCode = 0;
1731
+ }
1732
+ }
1733
+ else {
1734
+ process.exitCode = ENGINE_EXIT_CODES[result.status];
1735
+ }
1736
+ const payload = {
1737
+ command: label,
1738
+ taskId,
1739
+ status: result.status,
1740
+ summary: result.summary,
1741
+ filesChanged: result.filesChanged,
1742
+ toolCalls: metrics.toolCalls,
1743
+ turns: metrics.turns,
1744
+ tokens: metrics.tokens,
1745
+ sessionId: session.id,
1746
+ sessionEventsMirror: metrics.mirror,
1747
+ risks: result.risks,
1748
+ plan: planArtifact ? { path: planArtifact.relPath } : undefined,
1749
+ // The full event stream is useful for cabinet UI replay. We surface
1750
+ // it in JSON mode only — text mode operators want the summary, not
1751
+ // 30 turn-level lines.
1752
+ events: flags.json ? statusEvents : undefined,
1753
+ };
1754
+ const textLines = [];
1755
+ if (kind === 'plan' && planArtifact) {
1756
+ textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
1757
+ }
1758
+ textLines.push(`Pugi ${label}: ${result.status}`);
1759
+ textLines.push(`Summary: ${result.summary}`);
1760
+ if (result.filesChanged.length > 0) {
1761
+ textLines.push(`Files modified (${result.filesChanged.length}):`);
1762
+ for (const file of result.filesChanged)
1763
+ textLines.push(` - ${file}`);
1764
+ }
1765
+ else if (kind !== 'explain' && kind !== 'plan') {
1766
+ textLines.push('Files modified: none');
1767
+ }
1768
+ textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
1769
+ if (result.risks.length > 0) {
1770
+ textLines.push(`Risks: ${result.risks.join('; ')}`);
1771
+ }
1772
+ textLines.push(`Session: ${session.id}`);
1773
+ if (metrics.mirror)
1774
+ textLines.push(`Events mirror: ${metrics.mirror}`);
1775
+ writeOutput(flags, payload, textLines.join('\n'));
1776
+ };
1777
+ }
1778
+ /**
1779
+ * Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
1780
+ * already emits the canonical strings (`tool_calls=N`, `turns=N`,
1781
+ * `tokens=N`, `mirror=<path>`); parsing back to a typed object keeps the
1782
+ * CLI from sprinkling regex-on-eventRefs across the handler.
1783
+ */
1784
+ function parseEventRefs(refs) {
1785
+ const out = {
1786
+ toolCalls: 0,
1787
+ turns: 0,
1788
+ tokens: 0,
1789
+ mirror: null,
1790
+ outcome: null,
1791
+ };
1792
+ for (const ref of refs) {
1793
+ const idx = ref.indexOf('=');
1794
+ if (idx <= 0)
1795
+ continue;
1796
+ const key = ref.slice(0, idx);
1797
+ const value = ref.slice(idx + 1);
1798
+ if (key === 'tool_calls')
1799
+ out.toolCalls = Number(value) || 0;
1800
+ else if (key === 'turns')
1801
+ out.turns = Number(value) || 0;
1802
+ else if (key === 'tokens')
1803
+ out.tokens = Number(value) || 0;
1804
+ else if (key === 'mirror')
1805
+ out.mirror = value || null;
1806
+ else if (key === 'outcome')
1807
+ out.outcome = value || null;
1808
+ }
1809
+ return out;
1810
+ }
1811
+ /**
1812
+ * Write the `plan.md` artifact for `pugi plan`. The plan body wraps the
1813
+ * model's final answer (or the refusal reason when the model attempted
1814
+ * to write/edit/bash from plan mode) so the operator gets a single file
1815
+ * to review, share, or attach to a handoff bundle.
1816
+ */
1817
+ function writePlanArtifact(input) {
1818
+ const { root, session, prompt, result, statusEvents } = input;
1819
+ const artifactDir = createArtifactDir(root, prompt);
1820
+ const planPath = resolve(artifactDir, 'plan.md');
1821
+ const metrics = parseEventRefs(result.eventRefs);
1822
+ const lines = [
1823
+ '# Pugi Plan',
1824
+ '',
1825
+ `Prompt: ${prompt}`,
1826
+ `Status: ${result.status}`,
1827
+ `Generated: ${new Date().toISOString()}`,
1828
+ '',
1829
+ '## Plan',
1830
+ '',
1831
+ result.summary || '_(empty)_',
1832
+ '',
1833
+ '## Metrics',
1834
+ '',
1835
+ `- Tool calls: ${metrics.toolCalls}`,
1836
+ `- Turns: ${metrics.turns}`,
1837
+ `- Tokens: ${metrics.tokens}`,
1838
+ `- Session: ${session.id}`,
1839
+ '',
1840
+ ];
1841
+ if (result.risks.length > 0) {
1842
+ lines.push('## Risks', '');
1843
+ for (const risk of result.risks)
1844
+ lines.push(`- ${risk}`);
1845
+ lines.push('');
1846
+ }
1847
+ if (statusEvents.length > 0) {
1848
+ lines.push('## Engine trace', '', '```text');
1849
+ for (const event of statusEvents)
1850
+ lines.push(event);
1851
+ lines.push('```', '');
1852
+ }
1853
+ writeFileSync(planPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
1854
+ registerArtifact(root, {
1855
+ id: artifactIdFromDir(artifactDir),
1856
+ kind: 'plan',
1857
+ path: relative(root, artifactDir),
1858
+ sessionId: session.id,
1859
+ createdAt: new Date().toISOString(),
1860
+ files: ['plan.md'],
1861
+ });
1862
+ return { path: planPath, relPath: relative(root, planPath) };
1863
+ }
1864
+ // `explain` previously walked the workspace locally with read/grep/glob
1865
+ // only. Sprint 2 Track A retired it; the engine adapter now drives the
1866
+ // read-only loop (`runEngineTask('explain')`) with the same tool subset.
1867
+ /**
1868
+ * The three login providers `pugi login` knows how to dispatch to.
1869
+ *
1870
+ * - `device` — RFC 8628 device authorization grant against the cabinet.
1871
+ * - `token` — paste a PAT (or pipe it via `--token-stdin`).
1872
+ * - `env` — promote `PUGI_API_KEY` from the current environment into
1873
+ * the persistent store so subsequent commands work without
1874
+ * re-exporting.
1875
+ */
1876
+ const PUGI_LOGIN_PROVIDERS = ['device', 'token', 'env'];
1877
+ function parseProviderFlag(args) {
1878
+ const raw = extractNamedFlagValue(args, 'provider');
1879
+ if (raw === undefined)
1880
+ return undefined;
1881
+ const lower = raw.toLowerCase();
1882
+ if (!PUGI_LOGIN_PROVIDERS.includes(lower)) {
1883
+ throw new Error(`pugi login --provider must be one of ${PUGI_LOGIN_PROVIDERS.join('|')}; got "${raw}".`);
1884
+ }
1885
+ return lower;
1886
+ }
1887
+ /**
1888
+ * Returns true when BOTH stdin and stdout are attached to a TTY AND
1889
+ * `--json` / `--no-tty` / CI markers were not supplied. We only
1890
+ * prompt or render Ink surfaces when a human is plausibly watching
1891
+ * the screen. The multi-condition gate matches Claude Code, gh CLI,
1892
+ * Codex CLI, and the npm CLI conventions.
1893
+ */
1894
+ function isInteractive(flags) {
1895
+ if (flags.json)
1896
+ return false;
1897
+ if (flags.noTty)
1898
+ return false;
1899
+ // Common CI / scripted-context markers. CI is set by every major
1900
+ // provider (GitHub Actions, GitLab, CircleCI, Travis, Buildkite).
1901
+ if (process.env.CI)
1902
+ return false;
1903
+ if (process.env.PUGI_NO_TTY)
1904
+ return false;
1905
+ // `process.stdin.isTTY` / `process.stdout.isTTY` are `undefined`
1906
+ // when the stream is a pipe and `true` when attached to a real
1907
+ // terminal. Require both so a `pugi | tee` invocation falls back
1908
+ // to the line-buffered output path.
1909
+ const stdinTty = Boolean(process.stdin.isTTY);
1910
+ const stdoutTty = Boolean(process.stdout.isTTY);
1911
+ return stdinTty && stdoutTty;
1912
+ }
1913
+ async function login(args, flags, _session) {
1914
+ if (args.includes('--help') || args.includes('-h')) {
1915
+ writeOutput(flags, {
1916
+ command: 'login',
1917
+ usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
1918
+ }, [
1919
+ 'Usage: pugi login [options]',
1920
+ '',
1921
+ 'Authenticate Pugi CLI against an Anvil instance.',
1922
+ '',
1923
+ 'When run on a TTY with no arguments, Pugi shows an interactive picker',
1924
+ 'with three variants (browser OAuth, paste a key, use PUGI_API_KEY).',
1925
+ '',
1926
+ 'Non-interactive options:',
1927
+ ' --provider device Run the device-flow login (recommended).',
1928
+ ' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
1929
+ ' --provider env Promote PUGI_API_KEY from the environment into the store.',
1930
+ ' --token <PAT> Inline API key (visible in `ps`).',
1931
+ ' --token-stdin Read API key from stdin (gh-CLI style).',
1932
+ ' --label <name> Short label surfaced in `pugi accounts list`.',
1933
+ ' --api-url <url> Override the Anvil endpoint (self-hosted).',
1934
+ ' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
1935
+ '',
1936
+ 'Examples:',
1937
+ ' pugi login # interactive picker on a TTY',
1938
+ ' pugi login --provider device # explicit browser OAuth',
1939
+ ' pugi login --provider token --token sk-xx # paste in a key',
1940
+ ' echo $TOKEN | pugi login --provider token --token-stdin',
1941
+ ' PUGI_API_KEY=sk-xx pugi login --provider env',
1942
+ ].join('\n'));
1943
+ return;
1944
+ }
1945
+ // Login dispatch (highest precedence first):
1946
+ // 1. `--provider device|token|env` — explicit non-interactive choice
1947
+ // for scripts. Bypasses both the menu and the env-only shortcut.
1948
+ // 2. `--token <PAT>` / `--token-stdin` / PUGI_LOGIN_TOKEN — paste-token
1949
+ // fast path, identical to gh CLI's `gh auth login --with-token`.
1950
+ // 3. Auto-mode: PUGI_LOGIN_TOKEN or PUGI_API_KEY is set AND stdin is
1951
+ // not a TTY (CI). Promote the env credential into the store
1952
+ // without prompting.
1953
+ // 4. Interactive: stdin is a TTY → render a 3-item menu and let the
1954
+ // user pick. Default selection is browser-based device flow.
1955
+ // 5. Non-interactive without a token (rare; e.g. nohup, no env) →
1956
+ // fall back to device flow unless `--no-device-flow` is set, in
1957
+ // which case raise a deterministic error.
1958
+ const tokenFromArgs = extractTokenFlag(args);
1959
+ const tokenStdinFlag = args.includes('--token-stdin');
1960
+ const noDeviceFlow = args.includes('--no-device-flow');
1961
+ const apiUrlOverride = extractApiUrlFlag(args);
1962
+ const labelFlag = extractLabelFlag(args);
1963
+ const provider = parseProviderFlag(args);
1964
+ const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
1965
+ // Path 1: explicit --provider trumps everything else.
1966
+ if (provider) {
1967
+ await dispatchLoginProvider(provider, {
1968
+ apiUrl,
1969
+ flags,
1970
+ label: labelFlag,
1971
+ explicitToken: tokenFromArgs,
1972
+ tokenStdinFlag,
1973
+ noDeviceFlow,
1974
+ });
1975
+ return;
1976
+ }
1977
+ // Path 2: token paste-in (CLI flag / stdin / env).
1978
+ let explicitToken = tokenFromArgs ?? process.env.PUGI_LOGIN_TOKEN;
1979
+ if (!explicitToken && tokenStdinFlag) {
1980
+ explicitToken = readFileSync(0, 'utf8').trim();
1981
+ }
1982
+ if (explicitToken) {
1983
+ storeAndAnnounceToken({
1984
+ apiUrl,
1985
+ apiKey: explicitToken,
1986
+ label: labelFlag,
1987
+ source: 'token',
1988
+ flags,
1989
+ });
1990
+ return;
1991
+ }
1992
+ // Path 3: auto-mode (env primed + no TTY) — promote the env key into
1993
+ // the store. This keeps CI deterministic and avoids stalling on the
1994
+ // menu when there is no human at the terminal.
1995
+ if (!isInteractive(flags) && process.env.PUGI_API_KEY) {
1996
+ storeAndAnnounceToken({
1997
+ apiUrl,
1998
+ apiKey: process.env.PUGI_API_KEY,
1999
+ label: labelFlag,
2000
+ source: 'env',
2001
+ flags,
2002
+ });
2003
+ return;
2004
+ }
2005
+ // Path 4: interactive menu (TTY, not --json, no token args).
2006
+ if (isInteractive(flags)) {
2007
+ const choice = await promptLoginVariant(apiUrl);
2008
+ if (choice === null) {
2009
+ // User dismissed the picker via Esc / q. Use exit 130, the
2010
+ // standard "terminated by user signal" exit code (gh CLI,
2011
+ // codex, ssh, vim all use this).
2012
+ writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
2013
+ process.exitCode = 130;
2014
+ return;
2015
+ }
2016
+ await dispatchLoginProvider(choice, {
2017
+ apiUrl,
2018
+ flags,
2019
+ label: labelFlag,
2020
+ noDeviceFlow,
2021
+ });
2022
+ return;
2023
+ }
2024
+ // Path 5: no token, no TTY → previously fell through to a silent
2025
+ // device flow that nobody could answer. The Ink-TUI work refuses
2026
+ // that branch and raises a deterministic error so CI surfaces a
2027
+ // failed login immediately. The message lists every escape hatch.
2028
+ throw new Error('pugi login requires a token in non-interactive mode. Pass `--provider device|token|env`, `--token <PAT>`, pipe via `--token-stdin`, set PUGI_LOGIN_TOKEN, or use `--provider env` with PUGI_API_KEY exported.');
2029
+ }
2030
+ /**
2031
+ * Render the interactive Ink picker shown when `pugi login` runs on
2032
+ * a TTY with no token args. Returns the chosen provider, or `null`
2033
+ * when the user dismisses the picker via Esc / q. Mirrors the
2034
+ * Claude Code / Codex CLI auth picker UX.
2035
+ *
2036
+ * The Ink import is dynamic so a non-interactive `pugi <anything>`
2037
+ * never pays the React+Ink module-load cost. ESM dynamic-import is
2038
+ * cached after first call (same as require).
2039
+ */
2040
+ async function promptLoginVariant(apiUrl) {
2041
+ const { renderLoginPicker, LoginCancelledError } = await import('../tui/render.js');
2042
+ try {
2043
+ return await renderLoginPicker(apiUrl);
2044
+ }
2045
+ catch (error) {
2046
+ if (error instanceof LoginCancelledError)
2047
+ return null;
2048
+ throw error;
2049
+ }
2050
+ }
2051
+ /**
2052
+ * Carry-over buffer between successive `readSingleChoice` calls. When
2053
+ * the user pastes two prompt answers into stdin at once
2054
+ * ("2\nsk-token-xyz\n") the first invocation consumes only up to the
2055
+ * first newline; the rest survives here so the next call returns
2056
+ * "sk-token-xyz" instead of stalling on a closed pipe.
2057
+ */
2058
+ let stdinReadBuffer = '';
2059
+ /**
2060
+ * Single-line prompt → trimmed answer. Used for menu picks where we
2061
+ * just need a digit (or empty for the default). Reads directly from
2062
+ * stdin so the surrounding `--json` mode (which would short-circuit
2063
+ * `isInteractive`) cannot accidentally consume the prompt.
2064
+ *
2065
+ * Keeps the implementation dependency-free by reading bytes from fd 0
2066
+ * until the first newline. Avoids pulling in `readline` to keep the
2067
+ * CLI startup cheap.
2068
+ *
2069
+ * If the carry-over buffer (`stdinReadBuffer`) already contains a
2070
+ * newline we resolve synchronously — that handles piped multi-answer
2071
+ * input correctly (`printf '2\\nsk-xyz\\n' | pugi login`).
2072
+ */
2073
+ async function readSingleChoice(prompt) {
2074
+ process.stderr.write(prompt);
2075
+ // Drain any leftover bytes from a previous prompt before re-attaching
2076
+ // a listener. This keeps the CLI usable from `here-doc` style scripts.
2077
+ const existingNewline = stdinReadBuffer.indexOf('\n');
2078
+ if (existingNewline >= 0) {
2079
+ const answer = stdinReadBuffer.slice(0, existingNewline).trim();
2080
+ stdinReadBuffer = stdinReadBuffer.slice(existingNewline + 1);
2081
+ return answer;
2082
+ }
2083
+ return new Promise((resolveLine, rejectLine) => {
2084
+ const onData = (chunk) => {
2085
+ const text = chunk.toString('utf8');
2086
+ stdinReadBuffer += text;
2087
+ const newlineIndex = stdinReadBuffer.indexOf('\n');
2088
+ if (newlineIndex >= 0) {
2089
+ const answer = stdinReadBuffer.slice(0, newlineIndex).trim();
2090
+ stdinReadBuffer = stdinReadBuffer.slice(newlineIndex + 1);
2091
+ cleanup();
2092
+ resolveLine(answer);
2093
+ }
2094
+ };
2095
+ const onError = (err) => {
2096
+ cleanup();
2097
+ rejectLine(err);
2098
+ };
2099
+ const onEnd = () => {
2100
+ cleanup();
2101
+ // EOF reached mid-prompt — treat the carry-over as the final
2102
+ // answer so a script that omits the trailing newline still works.
2103
+ const tail = stdinReadBuffer;
2104
+ stdinReadBuffer = '';
2105
+ resolveLine(tail.trim());
2106
+ };
2107
+ const cleanup = () => {
2108
+ process.stdin.removeListener('data', onData);
2109
+ process.stdin.removeListener('error', onError);
2110
+ process.stdin.removeListener('end', onEnd);
2111
+ // Stop reading so the parent process does not hold stdin open.
2112
+ if (typeof process.stdin.pause === 'function') {
2113
+ process.stdin.pause();
2114
+ }
2115
+ };
2116
+ process.stdin.on('data', onData);
2117
+ process.stdin.on('error', onError);
2118
+ process.stdin.on('end', onEnd);
2119
+ if (typeof process.stdin.resume === 'function') {
2120
+ process.stdin.resume();
2121
+ }
2122
+ });
2123
+ }
2124
+ /**
2125
+ * No-echo TTY read for a single secret line. Used by `pugi login` to
2126
+ * prompt for an API key without leaking it into shell scrollback /
2127
+ * terminal recordings. Falls back to `readSingleChoice` (echoing) when
2128
+ * stdin is not a TTY — pipe sources have no echo to suppress.
2129
+ *
2130
+ * Implementation pinned to Node's `tty.ReadStream.setRawMode` which is
2131
+ * stable across Node 20+. We toggle raw mode + isRaw, read bytes
2132
+ * char-by-char until \n, and emit `*` per byte for visual progress.
2133
+ * Pasted multi-char chunks are masked uniformly.
2134
+ */
2135
+ async function readSecretLine(prompt) {
2136
+ const stdin = process.stdin;
2137
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
2138
+ // Non-TTY (test harness, piped input). Fall back to the
2139
+ // line-buffered reader; the absence of a real terminal means
2140
+ // there is no echo to suppress in the first place.
2141
+ return readSingleChoice(prompt);
2142
+ }
2143
+ process.stderr.write(prompt);
2144
+ return new Promise((resolveLine, rejectLine) => {
2145
+ let collected = '';
2146
+ const onData = (chunk) => {
2147
+ const text = chunk.toString('utf8');
2148
+ for (const ch of text) {
2149
+ const code = ch.charCodeAt(0);
2150
+ if (ch === '\n' || ch === '\r') {
2151
+ process.stderr.write('\n');
2152
+ cleanup();
2153
+ resolveLine(collected.trim());
2154
+ return;
2155
+ }
2156
+ if (code === 0x03) {
2157
+ // Ctrl-C — abort.
2158
+ cleanup();
2159
+ process.stderr.write('\n');
2160
+ rejectLine(new Error('pugi login: aborted by user'));
2161
+ return;
2162
+ }
2163
+ if (code === 0x7f || code === 0x08) {
2164
+ // Backspace / DEL — drop last char.
2165
+ if (collected.length > 0) {
2166
+ collected = collected.slice(0, -1);
2167
+ process.stderr.write('\b \b');
2168
+ }
2169
+ continue;
2170
+ }
2171
+ if (code < 0x20) {
2172
+ // Other control codes — ignore.
2173
+ continue;
2174
+ }
2175
+ collected += ch;
2176
+ process.stderr.write('*');
2177
+ }
2178
+ };
2179
+ const onError = (err) => {
2180
+ cleanup();
2181
+ rejectLine(err);
2182
+ };
2183
+ const cleanup = () => {
2184
+ stdin.removeListener('data', onData);
2185
+ stdin.removeListener('error', onError);
2186
+ stdin.setRawMode?.(false);
2187
+ stdin.pause?.();
2188
+ };
2189
+ stdin.setRawMode?.(true);
2190
+ stdin.resume?.();
2191
+ stdin.on('data', onData);
2192
+ stdin.on('error', onError);
2193
+ });
2194
+ }
2195
+ async function dispatchLoginProvider(provider, ctx) {
2196
+ switch (provider) {
2197
+ case 'device': {
2198
+ if (ctx.noDeviceFlow) {
2199
+ throw new Error('pugi login --provider device conflicts with --no-device-flow. Drop one of the two.');
2200
+ }
2201
+ await performDeviceFlowLogin(ctx.apiUrl, ctx.flags, ctx.label);
2202
+ return;
2203
+ }
2204
+ case 'token': {
2205
+ // P2 fix Code Reviewer 2026-05-23: an explicit interactive
2206
+ // `--provider token` should NOT silently inherit
2207
+ // `PUGI_LOGIN_TOKEN` from env — the operator who typed the
2208
+ // flag wants the prompt. Only honour the env var on the
2209
+ // non-interactive path (CI shells) where there is no prompt
2210
+ // surface to override.
2211
+ const isInteractiveToken = isInteractive(ctx.flags);
2212
+ let token = ctx.explicitToken ?? (isInteractiveToken ? undefined : process.env.PUGI_LOGIN_TOKEN);
2213
+ if (!token && ctx.tokenStdinFlag) {
2214
+ token = readFileSync(0, 'utf8').trim();
2215
+ }
2216
+ if (!token) {
2217
+ // No-echo TTY read for the PAT prompt. Pasted secret never
2218
+ // lands in shell scrollback / terminal recordings. Code
2219
+ // Reviewer P1 2026-05-23: previous echoing prompt was a
2220
+ // secret-handling regression vs gh CLI / npm login / aws
2221
+ // configure.
2222
+ if (isInteractiveToken) {
2223
+ token = await readSecretLine('Paste your Pugi API key (PAT): ');
2224
+ }
2225
+ }
2226
+ if (!token) {
2227
+ throw new Error('pugi login --provider token requires a key. Pass `--token <PAT>`, pipe via `--token-stdin`, or set PUGI_LOGIN_TOKEN.');
2228
+ }
2229
+ storeAndAnnounceToken({
2230
+ apiUrl: ctx.apiUrl,
2231
+ apiKey: token,
2232
+ label: ctx.label,
2233
+ source: 'token',
2234
+ flags: ctx.flags,
2235
+ });
2236
+ return;
2237
+ }
2238
+ case 'env': {
2239
+ const envKey = process.env.PUGI_API_KEY;
2240
+ if (!envKey) {
2241
+ throw new Error('pugi login --provider env requires PUGI_API_KEY to be exported in the current shell.');
2242
+ }
2243
+ storeAndAnnounceToken({
2244
+ apiUrl: ctx.apiUrl,
2245
+ apiKey: envKey,
2246
+ label: ctx.label,
2247
+ source: 'env',
2248
+ flags: ctx.flags,
2249
+ });
2250
+ return;
2251
+ }
2252
+ default: {
2253
+ // Type-level exhaustiveness check — adding a new provider without
2254
+ // updating this switch becomes a compile error.
2255
+ const exhaustive = provider;
2256
+ throw new Error(`Unhandled login provider: ${String(exhaustive)}`);
2257
+ }
2258
+ }
2259
+ }
2260
+ function storeAndAnnounceToken(input) {
2261
+ const record = storeApiKey({
2262
+ apiUrl: input.apiUrl,
2263
+ apiKey: input.apiKey,
2264
+ label: input.label,
2265
+ source: input.source,
2266
+ });
2267
+ writeOutput(input.flags, {
2268
+ status: 'logged_in',
2269
+ apiUrl: record.apiUrl,
2270
+ apiKeyMasked: maskApiKey(record.apiKey),
2271
+ label: record.label ?? null,
2272
+ createdAt: record.createdAt,
2273
+ source: input.source,
2274
+ }, [
2275
+ `Pugi logged in for ${record.apiUrl}`,
2276
+ `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
2277
+ `Token: ${maskApiKey(record.apiKey)}`,
2278
+ 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2279
+ ].join('\n'));
2280
+ }
2281
+ /**
2282
+ * OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
2283
+ * progress to stderr so the user sees the verification URL and code
2284
+ * even when `--json` redirects stdout to a file. Polls every
2285
+ * `interval` seconds until the runtime returns authorized / denied /
2286
+ * expired. Stores the issued JWT into the credentials store on
2287
+ * success.
2288
+ *
2289
+ * Local-first contract: device flow only runs when the user invokes
2290
+ * `pugi login` without a token AND without `--no-device-flow`. The
2291
+ * CLI itself never reads files during this flow (the JWT and the
2292
+ * verification URL are the only state). Polling is wall-clock,
2293
+ * not retries-on-error; transient HTTP failures abort with a
2294
+ * clear error.
2295
+ */
2296
+ async function performDeviceFlowLogin(apiUrl, flags, label) {
2297
+ process.stderr.write(`Pugi device-flow login at ${apiUrl}\n`);
2298
+ const startResult = await startDeviceFlow(apiUrl);
2299
+ if (startResult.status !== 'ok') {
2300
+ const failure = describeDeviceFlowFailure(startResult, 'start');
2301
+ writeOutput(flags, {
2302
+ status: failure.status,
2303
+ code: 'code' in startResult ? startResult.code : undefined,
2304
+ message: failure.message,
2305
+ }, [failure.headline, failure.next ? `Next: ${failure.next}` : '']
2306
+ .filter(Boolean)
2307
+ .join('\n'));
2308
+ process.exitCode = failure.exitCode;
2309
+ return;
2310
+ }
2311
+ const start = startResult.response;
2312
+ // Two render strategies share the same poll loop:
2313
+ // - TTY (Ink): auto-opens the browser, renders the Claude Code
2314
+ // parity device-flow screen, drives the spinner, transitions to
2315
+ // a clean success/failure frame, returns on Enter / Esc.
2316
+ // - non-TTY (stdout): the legacy line-buffered output kept
2317
+ // verbatim for `--json`, `--no-tty`, CI and piped contexts so
2318
+ // scripts that parse stdout are not broken.
2319
+ if (isInteractive(flags) && !flags.json) {
2320
+ await runDeviceFlowLoginInk(apiUrl, flags, label, start);
2321
+ return;
2322
+ }
2323
+ await runDeviceFlowLoginStdout(apiUrl, flags, label, start);
2324
+ }
2325
+ /**
2326
+ * Legacy stdout polling path. Kept verbatim from the pre-parity
2327
+ * implementation so `--json`, `--no-tty`, CI scripts, and piped
2328
+ * invocations see exactly the same output they did before. Anything
2329
+ * a downstream tool parsed (the `Polling every Ns` line, the
2330
+ * `Pugi logged in for ...` block, the JSON shape) is unchanged.
2331
+ */
2332
+ async function runDeviceFlowLoginStdout(apiUrl, flags, label, start) {
2333
+ // Surface the user-visible parts to stderr so JSON consumers on
2334
+ // stdout are unaffected. The deviceCode itself is NEVER printed —
2335
+ // it is the secret poll handle.
2336
+ process.stderr.write([
2337
+ '',
2338
+ `1. Open this URL in your browser: ${start.verification_uri}`,
2339
+ `2. Enter this code: ${start.user_code}`,
2340
+ ` (or use the complete link: ${start.verification_uri_complete})`,
2341
+ `3. Approve the request when prompted.`,
2342
+ '',
2343
+ `Polling every ${start.interval}s, expires in ${start.expires_in}s...`,
2344
+ '',
2345
+ ].join('\n'));
2346
+ const outcome = await pollDeviceFlowUntilTerminal(apiUrl, start);
2347
+ emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
2348
+ }
2349
+ /**
2350
+ * TTY device-flow path — Claude Code parity. Auto-opens the browser,
2351
+ * mounts the Ink device-flow component, drives status transitions as
2352
+ * the poll loop advances, and resolves on Enter (success) or Esc
2353
+ * (cancel). On non-TTY this entry point is never called.
2354
+ */
2355
+ async function runDeviceFlowLoginInk(apiUrl, flags, label, start) {
2356
+ const [{ autoOpenBrowser }, { writeClipboard }, { renderDeviceFlow, LoginCancelledError }] = await Promise.all([
2357
+ import('../core/auto-open-browser.js'),
2358
+ import('../core/clipboard.js'),
2359
+ import('../tui/render.js'),
2360
+ ]);
2361
+ // Best-effort: spawn the user's default browser. We do not block on
2362
+ // the browser process — auto-open resolves as soon as spawn returns
2363
+ // (the child is detached and ref-released, so its long-lived browser
2364
+ // handle does not keep this CLI alive). The device-flow loop is the
2365
+ // source of truth for success; if auto-open fails, the Ink screen
2366
+ // renders the copy-the-URL fallback and we keep polling.
2367
+ const open = await autoOpenBrowser(start.verification_uri_complete ?? start.verification_uri);
2368
+ const handle = renderDeviceFlow({
2369
+ verificationUrl: start.verification_uri_complete ?? start.verification_uri,
2370
+ userCode: start.user_code,
2371
+ browserOpened: open.opened,
2372
+ onCopy: () => {
2373
+ // Fire-and-forget; the visual flash is owned by the component.
2374
+ // Clipboard write failures are silent — the user already sees
2375
+ // the URL on screen.
2376
+ void writeClipboard(start.verification_uri_complete ?? start.verification_uri);
2377
+ },
2378
+ });
2379
+ // Race the poll loop against the user's Esc keystroke. We DO NOT
2380
+ // await `handle.done()` in parallel here: the loop pushes status
2381
+ // updates via handle.setStatus, and on terminal status we await
2382
+ // handle.done() (which resolves on Enter / rejects on Esc). On Esc
2383
+ // BEFORE a terminal status, handle.done() rejects and we surface a
2384
+ // cancel exit code without writing any credential.
2385
+ // P3 polish (triple-review 2026-05-24): the old code mirrored the
2386
+ // cancel state into a `cancelled` flag and then checked
2387
+ // `winner.kind === 'cancel' || cancelled` — the second clause was
2388
+ // already implied by the first, since the Promise.race winner is
2389
+ // the only way the cancel branch is taken. Drop the flag.
2390
+ const cancelWatch = handle.done().catch((error) => {
2391
+ if (error instanceof LoginCancelledError)
2392
+ return;
2393
+ throw error;
2394
+ });
2395
+ // P1-1 (triple-review 2026-05-24): the poll loop must be abortable
2396
+ // so an Esc cancel does not leave a background poll running until
2397
+ // the device-flow deadline (up to 1 hour). The controller is wired
2398
+ // into the loop's abortable `sleep()` so the loop returns within
2399
+ // one event-loop tick of `abort()`.
2400
+ const pollAbort = new AbortController();
2401
+ const pollPromise = pollDeviceFlowUntilTerminal(apiUrl, start, pollAbort.signal);
2402
+ // Whichever settles first wins. If the user pressed Esc before the
2403
+ // server returned a terminal status, the cancel branch aborts the
2404
+ // poll and short-circuits. Otherwise the poll outcome drives the
2405
+ // final render frame.
2406
+ const winner = await Promise.race([
2407
+ pollPromise.then((outcome) => ({ kind: 'poll', outcome })),
2408
+ cancelWatch.then(() => ({ kind: 'cancel' })),
2409
+ ]);
2410
+ if (winner.kind === 'cancel') {
2411
+ // Tell the poll loop to stop. The signal short-circuits the inter-
2412
+ // poll sleep and the in-flight HTTP call settles on its own; we
2413
+ // swallow the resulting rejection so an aborted poll does not
2414
+ // surface as an unhandled promise rejection.
2415
+ pollAbort.abort();
2416
+ await pollPromise.catch(() => undefined);
2417
+ // The handle is already unmounted by the Esc path inside
2418
+ // renderDeviceFlow's finish() helper. Surface the cancel state to
2419
+ // the surrounding dispatcher exactly as the legacy stdout path
2420
+ // would have done on a forced Ctrl+C.
2421
+ writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
2422
+ process.exitCode = 130;
2423
+ return;
2424
+ }
2425
+ const outcome = winner.outcome;
2426
+ // Translate the terminal outcome into both a final Ink frame AND
2427
+ // the same writeOutput payload the stdout path would have emitted —
2428
+ // tests that pin JSON shape stay green regardless of TTY.
2429
+ applyDeviceFlowOutcomeToInk(outcome, handle);
2430
+ emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
2431
+ // On success, the host now waits for the user's Enter keystroke
2432
+ // before returning control to the caller (REPL / shell). Failure
2433
+ // frames also wait — Esc dismisses them. Either way we await done()
2434
+ // so the Ink screen stays on the user's terminal until they react.
2435
+ await handle.done().catch((error) => {
2436
+ if (error instanceof LoginCancelledError)
2437
+ return;
2438
+ throw error;
2439
+ });
2440
+ }
2441
+ /**
2442
+ * Sentinel thrown (P1-1, triple-review 2026-05-24) when the caller
2443
+ * aborts the poll loop via the optional `AbortSignal`. The Ink host
2444
+ * treats this as a silent cancel — the surrounding
2445
+ * `runDeviceFlowLoginInk` already owns the cancel exit code via the
2446
+ * keystroke race.
2447
+ */
2448
+ class DeviceFlowPollAbortedError extends Error {
2449
+ constructor() {
2450
+ super('Device-flow poll aborted by caller');
2451
+ this.name = 'DeviceFlowPollAbortedError';
2452
+ }
2453
+ }
2454
+ async function pollDeviceFlowUntilTerminal(apiUrl, start, signal, deps = {}) {
2455
+ const poll = deps.poll ?? pollDeviceFlow;
2456
+ const sleepImpl = deps.sleepFn ?? sleep;
2457
+ const now = deps.now ?? Date.now;
2458
+ // Hard local cap on the polling deadline so a hostile or buggy
2459
+ // runtime returning an absurdly large `expires_in` cannot trap
2460
+ // the CLI in a long-running poll. The SDK schema already caps
2461
+ // `expires_in` at 3600s, but enforce the floor here too — the
2462
+ // SDK contract is what we own, and a future broadening must
2463
+ // still respect this local maximum.
2464
+ const PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC = (deps.deadlineMaxMs ?? 60 * 60 * 1000) / 1000;
2465
+ const expiresInSec = Math.min(start.expires_in, PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC);
2466
+ const deadline = now() + expiresInSec * 1000;
2467
+ const pollInterval = start.interval;
2468
+ // P1-1 (triple-review 2026-05-24): cancellable interval. If the
2469
+ // Ink host aborts (user pressed Esc), `sleep` resolves immediately
2470
+ // and the loop throws DeviceFlowPollAbortedError so the caller can
2471
+ // drain the rejection silently without waiting up to an hour.
2472
+ while (now() < deadline) {
2473
+ if (signal?.aborted)
2474
+ throw new DeviceFlowPollAbortedError();
2475
+ await sleepImpl(pollInterval * 1000, signal);
2476
+ if (signal?.aborted)
2477
+ throw new DeviceFlowPollAbortedError();
2478
+ const pollResult = await poll(apiUrl, start.device_code);
2479
+ if (signal?.aborted)
2480
+ throw new DeviceFlowPollAbortedError();
2481
+ if (pollResult.status !== 'ok') {
2482
+ const failure = describeDeviceFlowFailure(pollResult, 'poll');
2483
+ return {
2484
+ kind: 'failed',
2485
+ failure,
2486
+ code: 'code' in pollResult ? pollResult.code : undefined,
2487
+ };
2488
+ }
2489
+ const outcome = pollResult.response;
2490
+ if (outcome.status === 'authorized') {
2491
+ return { kind: 'authorized', accessToken: outcome.access_token };
2492
+ }
2493
+ if (outcome.status === 'denied')
2494
+ return { kind: 'denied' };
2495
+ if (outcome.status === 'expired')
2496
+ return { kind: 'expired' };
2497
+ if (outcome.status === 'redeemed')
2498
+ return { kind: 'redeemed' };
2499
+ // status === 'pending' — keep polling
2500
+ }
2501
+ // P1-2 (triple-review 2026-05-24): the loop hit the LOCAL deadline
2502
+ // without the server returning a terminal status. The legacy stdout
2503
+ // path distinguished this from server-side `expired` so operators
2504
+ // could tell a local-cap timeout from an actual server expiry; we
2505
+ // preserve that signal via this dedicated outcome kind.
2506
+ return { kind: 'timed_out_local' };
2507
+ }
2508
+ /**
2509
+ * Final writeOutput emission, identical shape to the pre-parity
2510
+ * stdout path so JSON consumers, scripts, and CI snapshots are not
2511
+ * affected by the TTY-vs-stdout branch.
2512
+ */
2513
+ function emitDeviceFlowTerminalToStdout(outcome, ctx) {
2514
+ if (outcome.kind === 'authorized') {
2515
+ const record = storeApiKey({
2516
+ apiUrl: ctx.apiUrl,
2517
+ apiKey: outcome.accessToken,
2518
+ label: ctx.label ?? undefined,
2519
+ source: 'device-flow',
2520
+ });
2521
+ // Defense-in-depth against the device-flow phishing class
2522
+ // (P0, 2026-05-22 review): decode the JWT we just received and
2523
+ // surface the principal identity to the user so they can confirm
2524
+ // we landed in the expected tenant.
2525
+ const principal = decodeJwtPrincipal(outcome.accessToken);
2526
+ writeOutput(ctx.flags, {
2527
+ status: 'logged_in',
2528
+ apiUrl: record.apiUrl,
2529
+ apiKeyMasked: maskApiKey(record.apiKey),
2530
+ label: record.label ?? null,
2531
+ createdAt: record.createdAt,
2532
+ via: 'device-flow',
2533
+ principal,
2534
+ }, [
2535
+ `Pugi logged in for ${record.apiUrl} via device flow`,
2536
+ `Token: ${maskApiKey(record.apiKey)}${record.label ? ` (${record.label})` : ''}`,
2537
+ principal
2538
+ ? `Authenticated as user=${principal.email ?? principal.sub} tenant=${principal.customerId ?? '(unknown)'}.`
2539
+ : 'Authenticated (JWT payload could not be decoded for principal display).',
2540
+ 'If this is NOT the user/tenant you expected, run `pugi logout` immediately and re-check the verification URL.',
2541
+ 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2542
+ ].join('\n'));
2543
+ return;
2544
+ }
2545
+ if (outcome.kind === 'denied') {
2546
+ writeOutput(ctx.flags, { status: 'denied' }, 'Pugi device flow denied. The cabinet user clicked Deny.');
2547
+ process.exitCode = 5;
2548
+ return;
2549
+ }
2550
+ if (outcome.kind === 'expired' || outcome.kind === 'redeemed') {
2551
+ writeOutput(ctx.flags, { status: outcome.kind }, outcome.kind === 'expired'
2552
+ ? 'Pugi device flow expired before approval. Run `pugi login` again.'
2553
+ : 'Pugi device flow already redeemed by another CLI. Run `pugi login` to start a fresh flow.');
2554
+ process.exitCode = 5;
2555
+ return;
2556
+ }
2557
+ if (outcome.kind === 'timed_out_local') {
2558
+ // P1-2 (triple-review 2026-05-24): the legacy stdout path
2559
+ // surfaced local-cap timeouts under a dedicated message so
2560
+ // operators could tell a 1-hour client cap apart from a server
2561
+ // expiry. The JSON shape uses `status: 'timed_out_local'` so
2562
+ // downstream scripts can branch on it; the human message stays
2563
+ // close to the pre-parity wording.
2564
+ writeOutput(ctx.flags, { status: 'timed_out_local' }, 'Pugi device flow timed out locally before the server returned a terminal status. Run `pugi login` again.');
2565
+ process.exitCode = 5;
2566
+ return;
2567
+ }
2568
+ // failed
2569
+ writeOutput(ctx.flags, {
2570
+ status: outcome.failure.status,
2571
+ code: outcome.code,
2572
+ message: outcome.failure.message,
2573
+ }, [outcome.failure.headline, outcome.failure.next ? `Next: ${outcome.failure.next}` : '']
2574
+ .filter(Boolean)
2575
+ .join('\n'));
2576
+ process.exitCode = outcome.failure.exitCode;
2577
+ }
2578
+ /**
2579
+ * Drives the Ink screen toward the success / failure frame based on
2580
+ * the terminal outcome. The principal label mirrors the stdout
2581
+ * "Authenticated as user=… tenant=…" line so the on-screen identity
2582
+ * the user confirms matches the verbose message the stdout path
2583
+ * still emits.
2584
+ */
2585
+ function applyDeviceFlowOutcomeToInk(outcome, handle) {
2586
+ if (outcome.kind === 'authorized') {
2587
+ const principal = decodeJwtPrincipal(outcome.accessToken);
2588
+ const label = principal
2589
+ ? `${principal.email ?? principal.sub ?? 'authenticated'}${principal.customerId ? ` (tenant: ${principal.customerId})` : ''}`
2590
+ : 'authenticated';
2591
+ handle.setStatus({ kind: 'success', principalLabel: label });
2592
+ return;
2593
+ }
2594
+ if (outcome.kind === 'denied') {
2595
+ handle.setStatus({
2596
+ kind: 'failure',
2597
+ reason: 'Login was denied in the browser.',
2598
+ hint: 'Run `pugi login` again to retry.',
2599
+ });
2600
+ return;
2601
+ }
2602
+ if (outcome.kind === 'expired') {
2603
+ handle.setStatus({
2604
+ kind: 'failure',
2605
+ reason: 'Login code expired before approval.',
2606
+ hint: 'Run `pugi login` again to retry.',
2607
+ });
2608
+ return;
2609
+ }
2610
+ if (outcome.kind === 'timed_out_local') {
2611
+ handle.setStatus({
2612
+ kind: 'failure',
2613
+ reason: 'Login timed out locally before the server returned a terminal status.',
2614
+ hint: 'Run `pugi login` again to retry.',
2615
+ });
2616
+ return;
2617
+ }
2618
+ if (outcome.kind === 'redeemed') {
2619
+ handle.setStatus({
2620
+ kind: 'failure',
2621
+ reason: 'Login code already redeemed by another CLI.',
2622
+ hint: 'Run `pugi login` again to start a fresh flow.',
2623
+ });
2624
+ return;
2625
+ }
2626
+ handle.setStatus({
2627
+ kind: 'failure',
2628
+ reason: outcome.failure.headline,
2629
+ hint: outcome.failure.next ?? undefined,
2630
+ });
2631
+ }
2632
+ function sleep(ms, signal) {
2633
+ return new Promise((resolve) => {
2634
+ if (signal?.aborted) {
2635
+ resolve();
2636
+ return;
2637
+ }
2638
+ const timer = setTimeout(() => {
2639
+ if (signal)
2640
+ signal.removeEventListener('abort', onAbort);
2641
+ resolve();
2642
+ }, ms);
2643
+ const onAbort = () => {
2644
+ clearTimeout(timer);
2645
+ resolve();
2646
+ };
2647
+ signal?.addEventListener('abort', onAbort, { once: true });
2648
+ });
2649
+ }
2650
+ /**
2651
+ * Best-effort JWT payload decode for principal display ONLY. Does
2652
+ * NOT verify the signature — that's not the CLI's role (the server
2653
+ * is the trust anchor for the JWT it just issued). The decoded
2654
+ * fields are surfaced to the user so they can confirm the resulting
2655
+ * authentication matches the tenant they expected.
2656
+ *
2657
+ * Returns `null` on any parse error so the caller falls back to a
2658
+ * generic "authenticated" message.
2659
+ */
2660
+ function decodeJwtPrincipal(token) {
2661
+ try {
2662
+ const parts = token.split('.');
2663
+ if (parts.length < 2)
2664
+ return null;
2665
+ const payload = parts[1];
2666
+ if (!payload)
2667
+ return null;
2668
+ // base64url → base64
2669
+ const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
2670
+ const json = Buffer.from(padded, 'base64').toString('utf8');
2671
+ const obj = JSON.parse(json);
2672
+ if (!obj || typeof obj !== 'object')
2673
+ return null;
2674
+ return {
2675
+ sub: typeof obj.sub === 'string' ? obj.sub : undefined,
2676
+ email: typeof obj.email === 'string' ? obj.email : undefined,
2677
+ customerId: typeof obj.customerId === 'string' ? obj.customerId : undefined,
2678
+ role: typeof obj.role === 'string' ? obj.role : undefined,
2679
+ via: typeof obj.via === 'string' ? obj.via : undefined,
2680
+ plan: typeof obj.plan === 'string' ? obj.plan : undefined,
2681
+ };
2682
+ }
2683
+ catch {
2684
+ return null;
2685
+ }
2686
+ }
2687
+ function describeDeviceFlowFailure(result, stage) {
2688
+ switch (result.status) {
2689
+ case 'endpoint_missing':
2690
+ return {
2691
+ status: 'endpoint_missing',
2692
+ headline: `Pugi device-flow ${stage} endpoint not deployed on this runtime.`,
2693
+ message: result.message,
2694
+ next: 'Use `pugi login --token <PAT>` until the operator deploys POST /api/auth/device/* on this Anvil instance.',
2695
+ exitCode: 6,
2696
+ };
2697
+ case 'failed':
2698
+ default:
2699
+ return {
2700
+ status: 'failed',
2701
+ headline: `Pugi device-flow ${stage} failed.`,
2702
+ message: result.message,
2703
+ next: 'Use `pugi login --token <PAT>` or check network connectivity.',
2704
+ exitCode: 8,
2705
+ };
2706
+ }
2707
+ }
2708
+ async function logout(args, flags, _session) {
2709
+ const all = args.includes('--all');
2710
+ if (all) {
2711
+ const removed = purgeAllCredentials();
2712
+ writeOutput(flags, { status: 'logged_out_all', removed }, removed ? 'All Pugi credentials cleared.' : 'No Pugi credentials were stored.');
2713
+ return;
2714
+ }
2715
+ const apiUrlOverride = extractApiUrlFlag(args);
2716
+ // Use the same resolution precedence as `whoami` so `pugi login --api-url X`
2717
+ // followed by `pugi logout` clears the self-hosted credential the user
2718
+ // just authenticated against. Falls back to DEFAULT_API_URL only when
2719
+ // neither env nor active record names a host.
2720
+ const file = readCredentialsFile();
2721
+ const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
2722
+ const removed = clearApiKey(apiUrl);
2723
+ writeOutput(flags, { status: removed ? 'logged_out' : 'noop', apiUrl, removed }, removed
2724
+ ? `Pugi logged out from ${apiUrl}.`
2725
+ : `No credential stored for ${apiUrl} — nothing to clear.`);
2726
+ }
2727
+ async function whoami(_args, flags, _session) {
2728
+ const credential = resolveActiveCredential();
2729
+ if (!credential) {
2730
+ // Surface the same activeApiUrl precedence the resolver uses so the
2731
+ // user sees which host they would be talking to before logging in.
2732
+ const file = readCredentialsFile();
2733
+ const apiUrl = normalizeApiUrl(process.env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
2734
+ writeOutput(flags, { status: 'anonymous', apiUrl }, [
2735
+ 'Pugi is not logged in.',
2736
+ `Active API endpoint: ${apiUrl}`,
2737
+ 'Run `pugi login` to start (interactive) or `pugi login --provider token --token <PAT>`.',
2738
+ ].join('\n'));
2739
+ process.exitCode = 5;
2740
+ return;
2741
+ }
2742
+ // Decode the JWT principal for display only — the server is the trust
2743
+ // anchor for signature verification, the CLI surfaces what it sees so
2744
+ // the user can spot a wrong-tenant credential before running a tool.
2745
+ const principal = decodeJwtPrincipal(credential.apiKey);
2746
+ const via = describeLoginMethod(credential);
2747
+ const plan = principal && 'plan' in principal ? String(principal.plan ?? '') : '';
2748
+ // Sprint 3A: fetch the current-month usage via /api/pugi/usage. Best
2749
+ // effort, 4 s timeout — a slow / unreachable admin-api never blocks
2750
+ // `pugi whoami` from rendering the local credential info. The usage
2751
+ // payload, when present, lets us render "Plan: Founder ($20/mo) —
2752
+ // 12/100 reviews used this month (resets in 18d)".
2753
+ const usage = await fetchUsageSafely(credential);
2754
+ const payload = {
2755
+ status: 'authenticated',
2756
+ apiUrl: credential.apiUrl,
2757
+ source: credential.source,
2758
+ via,
2759
+ label: credential.label ?? null,
2760
+ apiKeyMasked: maskApiKey(credential.apiKey),
2761
+ createdAt: credential.createdAt ?? null,
2762
+ lastUsedAt: credential.lastUsedAt ?? null,
2763
+ cliVersion: PUGI_CLI_VERSION,
2764
+ account: principal
2765
+ ? {
2766
+ email: principal.email ?? null,
2767
+ sub: principal.sub ?? null,
2768
+ role: principal.role ?? null,
2769
+ customerId: principal.customerId ?? null,
2770
+ plan: plan || null,
2771
+ }
2772
+ : null,
2773
+ usage: usage ?? null,
2774
+ };
2775
+ const text = [
2776
+ `Pugi authenticated against ${credential.apiUrl}`,
2777
+ `via: ${via}${credential.label ? ` (${credential.label})` : ''}`,
2778
+ `Token: ${maskApiKey(credential.apiKey)}`,
2779
+ principal?.email || principal?.sub
2780
+ ? `Account: ${principal.email ?? principal.sub}${principal.role ? ` (${principal.role})` : ''}`
2781
+ : null,
2782
+ principal?.customerId ? `Tenant: ${principal.customerId}` : null,
2783
+ formatPlanLine(usage, plan),
2784
+ formatUsageLine(usage),
2785
+ ]
2786
+ .filter((line) => Boolean(line))
2787
+ .join('\n');
2788
+ writeOutput(flags, payload, text);
2789
+ }
2790
+ /**
2791
+ * Map a tier slug to its public price tag for the `pugi whoami` plan
2792
+ * line. Mirrors the four-tier ladder in admin-api/billing/pricing.ts;
2793
+ * kept in sync via the pricing.spec.ts gate.
2794
+ */
2795
+ const TIER_PRICE_LABEL = {
2796
+ free: 'Free',
2797
+ founder: 'Founder ($20/mo)',
2798
+ builder: 'Builder ($99/mo)',
2799
+ team: 'Team ($199/mo)',
2800
+ };
2801
+ function fetchUsageSafely(credential) {
2802
+ const controller = new AbortController();
2803
+ const timer = setTimeout(() => controller.abort(), 4000);
2804
+ return (async () => {
2805
+ try {
2806
+ const res = await fetch(`${credential.apiUrl}/api/pugi/usage`, {
2807
+ method: 'GET',
2808
+ headers: {
2809
+ authorization: `Bearer ${credential.apiKey}`,
2810
+ accept: 'application/json',
2811
+ },
2812
+ signal: controller.signal,
2813
+ });
2814
+ if (!res.ok)
2815
+ return null;
2816
+ const body = (await res.json());
2817
+ // Defensive shape check — older admin-api versions may not yet
2818
+ // expose this surface, in which case we silently degrade.
2819
+ if (body &&
2820
+ typeof body === 'object' &&
2821
+ typeof body.tier === 'string' &&
2822
+ body.used &&
2823
+ body.quotas) {
2824
+ return body;
2825
+ }
2826
+ return null;
2827
+ }
2828
+ catch {
2829
+ return null;
2830
+ }
2831
+ finally {
2832
+ clearTimeout(timer);
2833
+ }
2834
+ })();
2835
+ }
2836
+ function formatPlanLine(usage, jwtPlan) {
2837
+ if (usage) {
2838
+ return `Plan: ${TIER_PRICE_LABEL[usage.tier] ?? usage.tier}`;
2839
+ }
2840
+ if (jwtPlan)
2841
+ return `Plan: ${jwtPlan}`;
2842
+ return null;
2843
+ }
2844
+ function formatUsageLine(usage) {
2845
+ if (!usage)
2846
+ return null;
2847
+ const r = formatDimension(usage.used.review, usage.quotas.review, 'reviews');
2848
+ const s = formatDimension(usage.used.sync, usage.quotas.sync, 'syncs');
2849
+ const e = formatDimension(usage.used.engine, usage.quotas.engine, 'engine turns');
2850
+ const resetSuffix = formatResetSuffix(usage.resetAt);
2851
+ return [`Usage: ${r} · ${s} · ${e}`, resetSuffix].filter(Boolean).join(' ');
2852
+ }
2853
+ function formatDimension(used, limit, label) {
2854
+ if (limit === null)
2855
+ return `${used} ${label} (unlimited)`;
2856
+ return `${used}/${limit} ${label}`;
2857
+ }
2858
+ function formatResetSuffix(resetAtIso) {
2859
+ const parsed = Date.parse(resetAtIso);
2860
+ if (!Number.isFinite(parsed))
2861
+ return '';
2862
+ const diffMs = parsed - Date.now();
2863
+ if (diffMs <= 0)
2864
+ return '(resetting now)';
2865
+ const days = Math.max(1, Math.round(diffMs / (24 * 60 * 60 * 1000)));
2866
+ return `(resets in ${days}d)`;
2867
+ }
2868
+ /**
2869
+ * Render the login-method label shown in `pugi whoami` and emitted in
2870
+ * the JSON envelope. Aliases the resolver's `source` discriminator (env
2871
+ * vs file) plus the stored `fileSource` (token vs device-flow vs env
2872
+ * promotion) into a single short word.
2873
+ */
2874
+ function describeLoginMethod(credential) {
2875
+ if (credential.source === 'env')
2876
+ return 'env';
2877
+ switch (credential.fileSource) {
2878
+ case 'device-flow':
2879
+ return 'device-flow';
2880
+ case 'token':
2881
+ return 'token';
2882
+ case 'env':
2883
+ return 'env-promoted';
2884
+ default:
2885
+ return 'token';
2886
+ }
2887
+ }
2888
+ /**
2889
+ * `pugi accounts <list|switch|remove>` — manage stored credentials
2890
+ * across multiple Pugi hosts (api.pugi.io, self-hosted Anvil, dev
2891
+ * cabinet, etc.).
2892
+ *
2893
+ * `accounts list` — show every stored record with masked key, label,
2894
+ * source, lastUsed, and whether it is the active one.
2895
+ * `accounts switch <label-or-url>` — re-point `activeApiUrl` so
2896
+ * subsequent commands authenticate against the chosen account.
2897
+ * `accounts remove <label-or-url>` — delete a stored credential.
2898
+ *
2899
+ * The sub-verb is a positional, matching `gh auth <verb>` ergonomics.
2900
+ */
2901
+ async function accounts(args, flags, _session) {
2902
+ const subCommand = args[0];
2903
+ if (!subCommand || subCommand === '--help' || subCommand === '-h') {
2904
+ writeOutput(flags, {
2905
+ command: 'accounts',
2906
+ usage: [
2907
+ 'pugi accounts list',
2908
+ 'pugi accounts switch <label-or-url>',
2909
+ 'pugi accounts remove <label-or-url>',
2910
+ ],
2911
+ }, [
2912
+ 'Usage:',
2913
+ ' pugi accounts list Show every stored credential.',
2914
+ ' pugi accounts switch <label|url> Set the active account.',
2915
+ ' pugi accounts remove <label|url> Delete a stored credential.',
2916
+ ].join('\n'));
2917
+ return;
2918
+ }
2919
+ const rest = args.slice(1);
2920
+ switch (subCommand) {
2921
+ case 'list':
2922
+ return accountsList(flags);
2923
+ case 'switch':
2924
+ return accountsSwitch(rest, flags);
2925
+ case 'remove':
2926
+ case 'rm':
2927
+ return accountsRemove(rest, flags);
2928
+ default:
2929
+ throw new Error(`Unknown sub-command "pugi accounts ${subCommand}". Expected list, switch, or remove.`);
2930
+ }
2931
+ }
2932
+ function accountsList(flags) {
2933
+ const records = listStoredCredentials();
2934
+ if (records.length === 0) {
2935
+ writeOutput(flags, { status: 'empty', accounts: [] }, 'No Pugi accounts stored. Run `pugi login` to add one.');
2936
+ return;
2937
+ }
2938
+ const payload = {
2939
+ status: 'ok',
2940
+ accounts: records.map((record) => ({
2941
+ apiUrl: record.apiUrl,
2942
+ label: record.label ?? null,
2943
+ apiKeyMasked: maskApiKey(record.apiKey),
2944
+ source: record.source ?? null,
2945
+ createdAt: record.createdAt,
2946
+ lastUsedAt: record.lastUsedAt ?? null,
2947
+ isActive: record.isActive,
2948
+ })),
2949
+ };
2950
+ const text = [
2951
+ 'Stored Pugi accounts:',
2952
+ '',
2953
+ ...records.map((record) => {
2954
+ const star = record.isActive ? '*' : ' ';
2955
+ const label = record.label ?? '(unlabelled)';
2956
+ const last = record.lastUsedAt ?? record.createdAt;
2957
+ const source = record.source ?? 'unknown';
2958
+ return `${star} ${label.padEnd(20)} ${record.apiUrl.padEnd(32)} ${maskApiKey(record.apiKey)} ${source.padEnd(12)} last:${last}`;
2959
+ }),
2960
+ '',
2961
+ 'Switch active account: pugi accounts switch <label>',
2962
+ ].join('\n');
2963
+ writeOutput(flags, payload, text);
2964
+ }
2965
+ function accountsSwitch(args, flags) {
2966
+ const selector = args[0];
2967
+ if (!selector) {
2968
+ throw new Error('pugi accounts switch requires a label or apiUrl. Run `pugi accounts list` to see candidates.');
2969
+ }
2970
+ const next = switchActiveAccount(selector);
2971
+ if (!next) {
2972
+ writeOutput(flags, { status: 'not_found', selector }, `No stored account matches "${selector}". Run \`pugi accounts list\` to see candidates.`);
2973
+ process.exitCode = 5;
2974
+ return;
2975
+ }
2976
+ writeOutput(flags, {
2977
+ status: 'switched',
2978
+ apiUrl: next.apiUrl,
2979
+ label: next.label ?? null,
2980
+ apiKeyMasked: maskApiKey(next.apiKey),
2981
+ }, [
2982
+ `Active account is now ${next.label ?? next.apiUrl}`,
2983
+ `Endpoint: ${next.apiUrl}`,
2984
+ `Token: ${maskApiKey(next.apiKey)}`,
2985
+ ].join('\n'));
2986
+ }
2987
+ function accountsRemove(args, flags) {
2988
+ const selector = args[0];
2989
+ if (!selector) {
2990
+ throw new Error('pugi accounts remove requires a label or apiUrl.');
2991
+ }
2992
+ // Map label → apiUrl when a label is supplied; clearApiKey takes a
2993
+ // canonicalised url.
2994
+ const records = listStoredCredentials();
2995
+ const lower = selector.toLowerCase();
2996
+ const target = records.find((record) => record.label && record.label.toLowerCase() === lower)?.apiUrl ??
2997
+ selector;
2998
+ const removed = clearApiKey(target);
2999
+ if (!removed) {
3000
+ writeOutput(flags, { status: 'not_found', selector }, `No stored account matches "${selector}".`);
3001
+ process.exitCode = 5;
3002
+ return;
3003
+ }
3004
+ writeOutput(flags, { status: 'removed', selector, apiUrl: normalizeApiUrl(target) }, `Pugi credential for ${normalizeApiUrl(target)} removed.`);
3005
+ }
3006
+ function extractNamedFlagValue(args, flagName) {
3007
+ const space = `--${flagName}`;
3008
+ const equals = `--${flagName}=`;
3009
+ for (let i = 0; i < args.length; i += 1) {
3010
+ const arg = args[i] ?? '';
3011
+ if (arg === space) {
3012
+ const next = args[i + 1];
3013
+ // Refuse the next arg if it looks like another flag — otherwise
3014
+ // `--token --api-url …` silently stores the literal string
3015
+ // "--api-url" as the token. `pugi login --token` (last arg)
3016
+ // returns undefined and falls through to PUGI_LOGIN_TOKEN.
3017
+ if (!next || next.startsWith('--'))
3018
+ return undefined;
3019
+ return next;
3020
+ }
3021
+ if (arg.startsWith(equals))
3022
+ return arg.slice(equals.length);
3023
+ }
3024
+ return undefined;
3025
+ }
3026
+ function extractTokenFlag(args) {
3027
+ return extractNamedFlagValue(args, 'token');
3028
+ }
3029
+ function extractApiUrlFlag(args) {
3030
+ return extractNamedFlagValue(args, 'api-url');
3031
+ }
3032
+ function extractLabelFlag(args) {
3033
+ return extractNamedFlagValue(args, 'label');
3034
+ }
3035
+ /**
3036
+ * `pugi jobs` — surface the persistent JobRegistry on the CLI.
3037
+ * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
3038
+ * (list/status/tail/kill) lives in `src/commands/jobs.ts`; this
3039
+ * handler is a thin shim so the existing command map dispatch
3040
+ * remains the single entry point.
3041
+ */
3042
+ async function jobs(args, flags, session) {
3043
+ const exitCode = await runJobsCommand(args, { json: flags.json }, {
3044
+ write: (text) => process.stdout.write(text),
3045
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
3046
+ }, session.id);
3047
+ if (exitCode !== 0) {
3048
+ process.exitCode = exitCode;
3049
+ }
3050
+ }
3051
+ function notImplemented(command) {
3052
+ return async (_args, flags) => {
3053
+ const payload = {
3054
+ command,
3055
+ status: 'not_implemented',
3056
+ message: `pugi ${command} is scaffolded but not implemented yet`,
3057
+ };
3058
+ writeOutput(flags, payload, payload.message);
3059
+ };
3060
+ }
3061
+ function ensurePugiGitIgnore(cwd, created, skipped) {
3062
+ const gitignorePath = resolve(cwd, '.gitignore');
3063
+ const marker = '.pugi/';
3064
+ if (!existsSync(gitignorePath)) {
3065
+ writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
3066
+ created.push(gitignorePath);
3067
+ return;
3068
+ }
3069
+ const current = readFileSync(gitignorePath, 'utf8');
3070
+ const lines = current.split('\n').map((line) => line.trim());
3071
+ if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
3072
+ skipped.push(gitignorePath);
3073
+ return;
3074
+ }
3075
+ const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
3076
+ writeFileSync(gitignorePath, next, { encoding: 'utf8' });
3077
+ created.push(`${gitignorePath} (+${marker})`);
3078
+ }
3079
+ /**
3080
+ * Compute the workspace label surfaced in the REPL header bar
3081
+ * (Sprint α5.7). We prefer the basename of the workspace root because
3082
+ * that is what the operator sees in their shell prompt — keeping the
3083
+ * REPL header in sync with `pwd` lets the operator orient at a glance.
3084
+ * Empty / pathological cwd values (a worktree resolved to `/`) fall
3085
+ * back to `workspace` so the header never collapses.
3086
+ */
3087
+ function workspaceLabel(cwd) {
3088
+ const segments = cwd.split('/').filter((s) => s.length > 0);
3089
+ const last = segments[segments.length - 1];
3090
+ if (!last || last.length === 0)
3091
+ return 'workspace';
3092
+ return last;
3093
+ }
3094
+ function ensureDir(path, created, skipped) {
3095
+ if (existsSync(path)) {
3096
+ skipped.push(path);
3097
+ return;
3098
+ }
3099
+ mkdirSync(path, { recursive: true });
3100
+ created.push(path);
3101
+ }
3102
+ function ensureInitialized(root) {
3103
+ if (!existsSync(resolve(root, '.pugi'))) {
3104
+ throw new Error('Run pugi init first');
3105
+ }
3106
+ }
3107
+ function createArtifactDir(root, seed) {
3108
+ const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
3109
+ const artifactDir = resolve(root, '.pugi', 'artifacts', id);
3110
+ mkdirSync(artifactDir, { recursive: true });
3111
+ return artifactDir;
3112
+ }
3113
+ function latestArtifactDir(root) {
3114
+ const artifactsDir = resolve(root, '.pugi', 'artifacts');
3115
+ if (!existsSync(artifactsDir))
3116
+ return null;
3117
+ const dirs = readdirSync(artifactsDir, { withFileTypes: true })
3118
+ .filter((entry) => entry.isDirectory())
3119
+ .map((entry) => resolve(artifactsDir, entry.name))
3120
+ .sort();
3121
+ return dirs.at(-1) ?? null;
3122
+ }
3123
+ function listArtifactSets(root) {
3124
+ const artifactsDir = resolve(root, '.pugi', 'artifacts');
3125
+ if (!existsSync(artifactsDir))
3126
+ return [];
3127
+ return readdirSync(artifactsDir, { withFileTypes: true })
3128
+ .filter((entry) => entry.isDirectory())
3129
+ .map((entry) => {
3130
+ const dir = resolve(artifactsDir, entry.name);
3131
+ const files = readdirSync(dir, { withFileTypes: true })
3132
+ .filter((file) => file.isFile())
3133
+ .map((file) => file.name)
3134
+ .sort();
3135
+ return {
3136
+ id: entry.name,
3137
+ path: relative(root, dir),
3138
+ files,
3139
+ updatedAt: statSync(dir).mtime.toISOString(),
3140
+ };
3141
+ })
3142
+ .sort((a, b) => b.id.localeCompare(a.id));
3143
+ }
3144
+ function listHandoffBundles(root) {
3145
+ const handoffDir = resolve(root, '.pugi', 'handoffs');
3146
+ if (!existsSync(handoffDir))
3147
+ return [];
3148
+ return readdirSync(handoffDir, { withFileTypes: true })
3149
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
3150
+ .map((entry) => {
3151
+ const path = resolve(handoffDir, entry.name);
3152
+ const parsed = safeReadJson(path);
3153
+ return {
3154
+ id: String(parsed?.id ?? entry.name.replace(/\.json$/, '')),
3155
+ path: relative(root, path),
3156
+ reason: String(parsed?.reason ?? 'unknown'),
3157
+ createdAt: String(parsed?.createdAt ?? statSync(path).mtime.toISOString()),
3158
+ };
3159
+ })
3160
+ .sort((a, b) => b.id.localeCompare(a.id));
3161
+ }
3162
+ function artifactIdFromDir(path) {
3163
+ return path.split('/').at(-1) ?? randomUUID();
3164
+ }
3165
+ function createHandoffBundle(root, session, reason, prompt) {
3166
+ const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(reason)}`;
3167
+ const handoffDir = resolve(root, '.pugi', 'handoffs');
3168
+ mkdirSync(handoffDir, { recursive: true });
3169
+ const bundlePath = resolve(handoffDir, `${id}.json`);
3170
+ const latest = latestArtifactDir(root);
3171
+ const payload = pugiHandoffBundleSchema.parse({
3172
+ schema: 1,
3173
+ id,
3174
+ reason,
3175
+ prompt,
3176
+ createdAt: new Date().toISOString(),
3177
+ workspace: {
3178
+ rootName: root.split('/').at(-1) ?? 'workspace',
3179
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
3180
+ gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
3181
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
3182
+ },
3183
+ session: {
3184
+ id: session.id,
3185
+ eventsPath: relative(root, session.eventsPath),
3186
+ },
3187
+ artifacts: {
3188
+ latest: latest ? relative(root, latest) : null,
3189
+ },
3190
+ privacy: {
3191
+ includesFileContents: false,
3192
+ includesSecrets: false,
3193
+ },
3194
+ });
3195
+ writeFileSync(bundlePath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
3196
+ registerArtifact(root, {
3197
+ id,
3198
+ kind: 'handoff',
3199
+ path: relative(root, bundlePath),
3200
+ sessionId: session.id,
3201
+ createdAt: payload.createdAt,
3202
+ files: [`${id}.json`],
3203
+ });
3204
+ return { status: 'created', id, reason, path: relative(root, bundlePath) };
3205
+ }
3206
+ function parsePrivacyMode(input) {
3207
+ return pugiSyncPrivacyModeSchema.parse(input);
3208
+ }
3209
+ function privacyModeFromSettings(mode) {
3210
+ if (mode === 'airgapped')
3211
+ return 'local-only';
3212
+ if (mode === 'embeddings-only')
3213
+ return 'summaries';
3214
+ return 'metadata';
3215
+ }
3216
+ function workspaceSnapshot(root) {
3217
+ return {
3218
+ rootName: root.split('/').at(-1) ?? 'workspace',
3219
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
3220
+ gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
3221
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
3222
+ };
3223
+ }
3224
+ function buildSyncDryRunItems(root, mode) {
3225
+ if (mode === 'local-only') {
3226
+ return [];
3227
+ }
3228
+ const items = [];
3229
+ for (const artifact of listArtifactSets(root).slice(0, 20)) {
3230
+ const summaryMode = mode === 'summaries';
3231
+ items.push({
3232
+ kind: summaryMode ? 'artifact_summary' : 'artifact_metadata',
3233
+ path: artifact.path,
3234
+ bytes: directoryFileBytes(resolve(root, artifact.path)),
3235
+ action: mode === 'selected-files' || mode === 'full-sync' ? 'exclude' : 'include',
3236
+ reason: mode === 'metadata'
3237
+ ? 'artifact metadata only; no raw file contents'
3238
+ : summaryMode
3239
+ ? 'curated artifact summaries are eligible for explicit continuation'
3240
+ : 'raw file sync requires a future explicit file picker',
3241
+ });
3242
+ }
3243
+ for (const handoffBundle of listHandoffBundles(root).slice(0, 20)) {
3244
+ items.push({
3245
+ kind: 'handoff_bundle',
3246
+ path: handoffBundle.path,
3247
+ bytes: fileBytes(resolve(root, handoffBundle.path)),
3248
+ action: 'include',
3249
+ reason: 'handoff bundles contain metadata and artifact references only',
3250
+ });
3251
+ }
3252
+ const eventsPath = resolve(root, '.pugi', 'events.jsonl');
3253
+ if (existsSync(eventsPath)) {
3254
+ items.push({
3255
+ kind: 'session_event_log',
3256
+ path: relative(root, eventsPath),
3257
+ bytes: fileBytes(eventsPath),
3258
+ action: mode === 'metadata' ? 'exclude' : 'include',
3259
+ reason: mode === 'metadata'
3260
+ ? 'session event logs excluded in metadata mode'
3261
+ : 'session timeline is eligible without raw repository contents',
3262
+ });
3263
+ }
3264
+ return items;
3265
+ }
3266
+ function directoryFileBytes(path) {
3267
+ if (!existsSync(path))
3268
+ return 0;
3269
+ return readdirSync(path, { withFileTypes: true })
3270
+ .filter((entry) => entry.isFile())
3271
+ .reduce((total, entry) => total + fileBytes(resolve(path, entry.name)), 0);
3272
+ }
3273
+ function fileBytes(path) {
3274
+ try {
3275
+ return statSync(path).size;
3276
+ }
3277
+ catch {
3278
+ return 0;
3279
+ }
3280
+ }
3281
+ function safeGit(root, args) {
3282
+ try {
3283
+ return execFileSync('git', args, {
3284
+ cwd: root,
3285
+ encoding: 'utf8',
3286
+ stdio: ['ignore', 'pipe', 'ignore'],
3287
+ // Default `maxBuffer` is 1 MB. Branch diffs blow past that on real
3288
+ // PRs and `execFileSync` throws ENOBUFS, which this helper used to
3289
+ // swallow as empty string — causing the triple-review request to
3290
+ // ship an empty `diffPatch` and get a false PASS. 64 MB matches
3291
+ // GitHub's typical PR diff cap.
3292
+ maxBuffer: 64 * 1024 * 1024,
3293
+ });
3294
+ }
3295
+ catch {
3296
+ return '';
3297
+ }
3298
+ }
3299
+ /**
3300
+ * Glob patterns excluded from triple-review `diffPatch` before egress.
3301
+ *
3302
+ * Mirrors `protectedBasenames` + `protectedSuffixes` from
3303
+ * `apps/pugi-cli/src/core/permission.ts`. If a developer tracks a
3304
+ * protected file (uncommon but possible) we still must not POST its
3305
+ * contents to Anvil — the runtime endpoint does not need to see secrets
3306
+ * to render a verdict.
3307
+ *
3308
+ * Format follows git's pathspec exclude syntax (`:!<pattern>`).
3309
+ */
3310
+ const PROTECTED_DIFF_EXCLUDES = [
3311
+ // Basename excludes apply at the repo root AND in any subdirectory
3312
+ // (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
3313
+ // `**/` prefix, git's literal pathspec syntax would only match the
3314
+ // repo root and silently let a subdir `.env` ship in the diff —
3315
+ // common pitfall in pnpm/turbo monorepos.
3316
+ ':(exclude,glob)**/.env',
3317
+ ':(exclude,glob)**/.env.*',
3318
+ ':(exclude,glob)**/.npmrc',
3319
+ ':(exclude,glob)**/.yarnrc',
3320
+ ':(exclude,glob)**/.pypirc',
3321
+ ':(exclude,glob)**/.gitconfig',
3322
+ ':(exclude,glob)**/id_rsa',
3323
+ ':(exclude,glob)**/id_ed25519',
3324
+ ':(exclude,glob)**/*.pem',
3325
+ ':(exclude,glob)**/*.key',
3326
+ ':(exclude,glob)**/*.crt',
3327
+ ':(exclude,glob)**/*.p12',
3328
+ ':(exclude,glob)**/*.dump',
3329
+ ':(exclude,glob)**/*.sql',
3330
+ ];
3331
+ function collectUntrackedSummary(root) {
3332
+ const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
3333
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
3334
+ const visible = [];
3335
+ let excluded = 0;
3336
+ for (const path of lines) {
3337
+ if (isProtectedPath(path)) {
3338
+ excluded += 1;
3339
+ continue;
3340
+ }
3341
+ visible.push(path);
3342
+ }
3343
+ return { paths: visible.slice(0, 50), excludedProtected: excluded };
3344
+ }
3345
+ function isProtectedPath(path) {
3346
+ const base = path.split('/').pop() ?? path;
3347
+ if (base === '.env' || base.startsWith('.env.'))
3348
+ return true;
3349
+ if (['.npmrc', '.yarnrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(base))
3350
+ return true;
3351
+ return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
3352
+ }
3353
+ function safeReadJson(path) {
3354
+ try {
3355
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
3356
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
3357
+ ? parsed
3358
+ : null;
3359
+ }
3360
+ catch {
3361
+ return null;
3362
+ }
3363
+ }
3364
+ function writeJsonIfMissing(path, value, created, skipped) {
3365
+ writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
3366
+ }
3367
+ function writeTextIfMissing(path, value, created, skipped) {
3368
+ if (existsSync(path)) {
3369
+ skipped.push(path);
3370
+ return;
3371
+ }
3372
+ writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
3373
+ created.push(path);
3374
+ }
3375
+ function slugify(input) {
3376
+ const slug = input
3377
+ .toLowerCase()
3378
+ .replace(/[^a-z0-9]+/g, '-')
3379
+ .replace(/^-+|-+$/g, '')
3380
+ .slice(0, 48);
3381
+ return slug || randomUUID().slice(0, 8);
3382
+ }
3383
+ function writeOutput(flags, payload, text) {
3384
+ if (flags.json) {
3385
+ console.log(JSON.stringify(payload, null, 2));
3386
+ }
3387
+ else {
3388
+ console.log(text);
3389
+ }
3390
+ }
3391
+ export function packageRoot() {
3392
+ return dirname(dirname(fileURLToPath(import.meta.url)));
3393
+ }
3394
+ /**
3395
+ * Test-only surface for the triple-review 2026-05-24 device-flow
3396
+ * fixes (P1-1 abort + P1-2 local-timeout distinction). Kept under an
3397
+ * explicit `__test__` namespace so consumers do not accidentally
3398
+ * import internals; the module's runtime contract is still the
3399
+ * `runCli` entry point above.
3400
+ */
3401
+ export const __test__ = {
3402
+ sleep,
3403
+ pollDeviceFlowUntilTerminal,
3404
+ };
3405
+ //# sourceMappingURL=cli.js.map