@pugi/cli 0.1.0-alpha.3

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