@pugi/cli 0.1.0-beta.22 → 0.1.0-beta.24

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.
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
5
6
  import { dirname, relative, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
@@ -22,17 +23,21 @@ import { PUGI_TAGLINE } from '@pugi/personas';
22
23
  import { resolveRoster, renderRosterTable } from './commands/roster.js';
23
24
  import { runDelegateCommand } from './commands/delegate.js';
24
25
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
26
+ import { resolveAndValidateEnvLogin, } from '../core/auth/env-provider.js';
25
27
  import { runDeployCommand } from '../commands/deploy.js';
26
28
  import { runJobsCommand } from '../commands/jobs.js';
27
29
  import { runConfigCommand } from './commands/config.js';
28
30
  import { runStyleCommand } from './commands/style.js';
31
+ import { runThemeCommand } from './commands/theme.js';
29
32
  import { runOnboardingCommand } from './commands/onboarding.js';
33
+ import { runVimCommand } from './commands/vim.js';
30
34
  import { isOnboarded } from '../core/onboarding/marker.js';
31
35
  import { runPrivacyCommand } from './commands/privacy.js';
32
36
  import { runReport } from './commands/report.js';
33
37
  import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
34
38
  import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
35
39
  import { runStickersCommand } from './commands/stickers.js';
40
+ import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
36
41
  import { runUndoCommand } from './commands/undo.js';
37
42
  import { runCompactCommand } from './commands/compact.js';
38
43
  import { runBudgetCommand } from './commands/budget.js';
@@ -104,6 +109,12 @@ const handlers = {
104
109
  plan: dispatchPlan,
105
110
  'plan-review': dispatchPlanReview,
106
111
  privacy: dispatchPrivacy,
112
+ // L24 (2026-05-27): `pugi release-notes` shows the bundled CHANGELOG
113
+ // diff between the operator's last-seen version + installed version.
114
+ // The slash counterpart `/release-notes` shares this handler via the
115
+ // shared `runReleaseNotesCommand` runner.
116
+ 'release-notes': releaseNotes,
117
+ releaseNotes,
107
118
  // PAVF-7 (2026-05-27): `pugi report --from-error` captures the
108
119
  // most-recent failed session as a redacted bundle so operators can
109
120
  // file clean bug reports without manual log-grepping.
@@ -122,17 +133,31 @@ const handlers = {
122
133
  feedback: dispatchFeedback,
123
134
  sync,
124
135
  style: dispatchStyle,
136
+ // Leak L30 (2026-05-27): `pugi theme` flips the local TUI color
137
+ // palette (orthogonal to `pugi style` — that one steers engine
138
+ // prose register). 4 presets: default / dark / light / colorblind.
139
+ theme: dispatchTheme,
125
140
  // Leak L25 (2026-05-27): `pugi onboarding` walks the new operator
126
141
  // through auth / mode / style / MCP / telemetry. Idempotent;
127
142
  // `--reset` clears the marker file so the bare-invocation hint
128
143
  // re-arms without nuking persisted defaults.
129
144
  onboarding: dispatchOnboarding,
145
+ // Leak L26 (2026-05-27): `pugi vim` toggles vim-style modal editing
146
+ // in the REPL input buffer. Bare invocation toggles, `on`/`off`
147
+ // sets explicitly; preference persists in ~/.pugi/config.json.
148
+ vim: dispatchVim,
130
149
  undo: dispatchUndo,
131
150
  compact: dispatchCompact,
132
151
  // L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
133
152
  // handler, same flags. Operators trained on Claude Code expect either
134
153
  // verb to surface the per-model token + USD table.
135
154
  usage: dispatchCost,
155
+ // Leak L27 (2026-05-27): `pugi update` — channel-aware npm registry
156
+ // probe + optional npm install shell-out. Same handler powers the
157
+ // in-REPL `/update` slash via the session module. R2 atomic swap
158
+ // deferred to Phase 2 per the sprint plan; npm is the single
159
+ // distribution channel today.
160
+ update: dispatchUpdate,
136
161
  version,
137
162
  web: dispatchWeb,
138
163
  whoami,
@@ -330,6 +355,29 @@ async function dispatchStyle(args, flags, _session) {
330
355
  if (rc !== 0)
331
356
  process.exitCode = rc;
332
357
  }
358
+ /**
359
+ * Leak L30 (2026-05-27) — `pugi theme` top-level dispatcher.
360
+ *
361
+ * Forwards to the shared `runThemeCommand` runner. The REPL `/theme`
362
+ * slash uses the same runner via a dynamic import inside
363
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
364
+ *
365
+ * Exit-code policy mirrors `dispatchStyle`:
366
+ * - 0 — show / switch / reset / list happy paths
367
+ * - 1 — unknown preset slug
368
+ * - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
369
+ *
370
+ * The runner returns the code; we attach it to `process.exitCode` so
371
+ * subsequent dispatch wrappers do not clobber it on success.
372
+ */
373
+ async function dispatchTheme(args, flags, _session) {
374
+ const rc = await runThemeCommand(args, {
375
+ workspaceRoot: process.cwd(),
376
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
377
+ });
378
+ if (rc !== 0)
379
+ process.exitCode = rc;
380
+ }
333
381
  /**
334
382
  * Leak L25 (2026-05-27) — `pugi onboarding` top-level dispatcher.
335
383
  *
@@ -360,6 +408,25 @@ async function dispatchOnboarding(args, flags, _session) {
360
408
  if (rc !== 0)
361
409
  process.exitCode = rc;
362
410
  }
411
+ /**
412
+ * Leak L26 (2026-05-27) — `pugi vim` top-level dispatcher.
413
+ *
414
+ * Forwards to the shared `runVimCommand` runner. The REPL `/vim` slash
415
+ * uses the same runner via a dynamic import inside
416
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
417
+ *
418
+ * Exit-code policy:
419
+ * - 0 — show / enable / disable / toggle happy paths
420
+ * - 2 — unknown subcommand (e.g. `pugi vim chaos`) or too many args
421
+ */
422
+ async function dispatchVim(args, flags, _session) {
423
+ const rc = await runVimCommand(args, {
424
+ env: process.env,
425
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
426
+ });
427
+ if (rc !== 0)
428
+ process.exitCode = rc;
429
+ }
363
430
  /**
364
431
  * PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
365
432
  * recent failed session into a redacted local report so operators can
@@ -924,6 +991,10 @@ function parseArgs(argv) {
924
991
  // interactive surface keeps its boxed renderer; opt-in via flag
925
992
  // for pipe / script use.
926
993
  asciiOnly: false,
994
+ // Leak L24 — `--reset` for `pugi release-notes`. Default off so a
995
+ // bare invocation only surfaces new sections. Opt-in to force the
996
+ // full bundled changelog к re-render (clears the on-disk marker).
997
+ reset: false,
927
998
  };
928
999
  const args = [];
929
1000
  // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
@@ -1010,6 +1081,14 @@ function parseArgs(argv) {
1010
1081
  // through to runStickersCommand without per-command argv slicing.
1011
1082
  flags.asciiOnly = true;
1012
1083
  }
1084
+ else if (arg === '--reset') {
1085
+ // Leak L24 — `pugi release-notes --reset` clears the on-disk
1086
+ // `~/.pugi/.last-seen-version` marker so the full bundled
1087
+ // changelog re-renders. Parsed globally for symmetry with the
1088
+ // rest of the flag grammar; `runReleaseNotesCommand` is the
1089
+ // single consumer today.
1090
+ flags.reset = true;
1091
+ }
1013
1092
  else if (arg === '--decompose') {
1014
1093
  // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
1015
1094
  // it. Parsed globally for symmetry with the rest of the flag
@@ -1368,7 +1447,8 @@ const COMMAND_HELP_BODIES = {
1368
1447
  'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
1369
1448
  ' --provider device Device-flow OAuth.',
1370
1449
  ' --provider token --token <jwt> Pass a JWT directly.',
1371
- ' --provider env --env PUGI_API_KEY Read from an env var.',
1450
+ ' --provider env Read PUGI_API_KEY (or --key) + verify via /api/pugi/health.',
1451
+ ' --provider env --key <value> --skip-validate Explicit key, no probe (CI bootstrap).',
1372
1452
  ],
1373
1453
  accounts: [
1374
1454
  'pugi accounts — manage stored credentials across endpoints.',
@@ -1430,6 +1510,32 @@ const COMMAND_HELP_BODIES = {
1430
1510
  'Useful in shell scripts that need a human-confirm before a destructive',
1431
1511
  'step. Exits 0 on yes, 1 on no, 2 on cancel.',
1432
1512
  ],
1513
+ update: [
1514
+ 'pugi update — channel-aware @pugi/cli update check + install.',
1515
+ '',
1516
+ 'Polls npm registry dist-tags for a newer @pugi/cli on the configured',
1517
+ 'channel (stable / beta / canary). Without flags, prints the install',
1518
+ 'command and exits. With --apply, shells out to `npm install -g …`.',
1519
+ '',
1520
+ ' --check Non-interactive probe + JSON envelope.',
1521
+ ' --channel <name> Switch channel (stable | beta | canary) and probe.',
1522
+ ' Persisted to ~/.pugi/config.json::updateChannel.',
1523
+ ' --apply Shell out to `npm install -g @pugi/cli@<tag>`',
1524
+ ' after a y/n confirmation.',
1525
+ ' --yes, -y Skip the confirmation prompt on --apply.',
1526
+ ' --json Force JSON envelope (auto-on with --check).',
1527
+ '',
1528
+ 'Channel mapping: stable -> npm `latest`, beta -> npm `beta`,',
1529
+ 'canary -> npm `next`. Default channel is `beta` (Pugi currently',
1530
+ 'ships beta releases only).',
1531
+ '',
1532
+ 'Also available as /update from inside the REPL — slash form NEVER',
1533
+ 'spawns npm (would corrupt the running binary); it only prints the',
1534
+ 'install command for the operator к run after exit.',
1535
+ '',
1536
+ 'R2 atomic swap (sprint plan L27) deferred к Phase 2 — npm is the',
1537
+ 'only distribution channel today.',
1538
+ ],
1433
1539
  stickers: [
1434
1540
  'pugi stickers — show a Pugi brand sticker (gimmick).',
1435
1541
  '',
@@ -1456,6 +1562,19 @@ const COMMAND_HELP_BODIES = {
1456
1562
  '',
1457
1563
  'Also available as /feedback from inside the REPL.',
1458
1564
  ],
1565
+ 'release-notes': [
1566
+ 'pugi release-notes — show what changed since you last upgraded.',
1567
+ '',
1568
+ 'Reads the bundled CHANGELOG.md, slices to sections strictly newer than',
1569
+ '~/.pugi/.last-seen-version, renders Markdown to stdout, then bumps the',
1570
+ 'last-seen marker to the installed CLI version. Re-running is a no-op',
1571
+ 'until you upgrade again.',
1572
+ '',
1573
+ ' --json Emit a structured envelope (sections + meta).',
1574
+ ' --reset Clear last-seen marker; re-render every section.',
1575
+ '',
1576
+ 'Also available as /release-notes from inside the REPL.',
1577
+ ],
1459
1578
  deploy: [
1460
1579
  'pugi deploy — trigger a vendor deployment from the bound Git source.',
1461
1580
  '',
@@ -1582,6 +1701,53 @@ async function doctor(_args, flags, _session) {
1582
1701
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
1583
1702
  });
1584
1703
  }
1704
+ /**
1705
+ * `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
1706
+ * probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
1707
+ *
1708
+ * Argument grammar:
1709
+ * pugi update -> probe + offer install command
1710
+ * pugi update --check -> probe + JSON envelope (scripted)
1711
+ * pugi update --channel <name> -> persist channel + probe
1712
+ * pugi update --apply [--yes] -> probe + shell out to npm
1713
+ * pugi update --json -> JSON envelope (any subcommand)
1714
+ *
1715
+ * The handler delegates to `runUpdateCommand` in
1716
+ * `runtime/commands/update.ts` so the in-REPL `/update` slash + the
1717
+ * top-level shell command share one channel-resolution + persistence
1718
+ * + probe surface. Exit codes:
1719
+ *
1720
+ * 0 — happy path (no update OR update completed OR probe-only)
1721
+ * 1 — install / probe failure with structured error
1722
+ * 2 — argument error (unknown flag, unknown channel)
1723
+ */
1724
+ async function dispatchUpdate(args, flags, _session) {
1725
+ const { parseUpdateArgs, runUpdateCommand, defaultSpawnInstaller } = await import('./commands/update.js');
1726
+ const parsed = parseUpdateArgs(args, { jsonDefault: flags.json });
1727
+ if ('error' in parsed) {
1728
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
1729
+ process.exitCode = 2;
1730
+ return;
1731
+ }
1732
+ const envelope = await runUpdateCommand({
1733
+ cwd: process.cwd(),
1734
+ home: homedir(),
1735
+ env: process.env,
1736
+ flags: parsed,
1737
+ promptConfirm: async (question) => {
1738
+ const answer = await readSingleChoice(`${question} `);
1739
+ return /^y(es)?$/i.test(answer.trim());
1740
+ },
1741
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1742
+ spawnInstaller: defaultSpawnInstaller,
1743
+ });
1744
+ if (!envelope.ok) {
1745
+ // `apply_cancelled_by_operator` is a benign decline; we still
1746
+ // surface a non-zero exit so scripted callers can detect that the
1747
+ // operator did not green-light the install.
1748
+ process.exitCode = 1;
1749
+ }
1750
+ }
1585
1751
  /**
1586
1752
  * `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
1587
1753
  * mirroring Claude Code's `/status`. Distinct from `pugi doctor`
@@ -1686,6 +1852,31 @@ async function dispatchFeedback(_args, flags, _session) {
1686
1852
  });
1687
1853
  writeOutput(flags, { ok: true, result }, renderFeedbackToast(result));
1688
1854
  }
1855
+ /**
1856
+ * `pugi release-notes` — Leak L24 (2026-05-27). Diff between the
1857
+ * last-seen + installed CLI versions, rendered from the bundled
1858
+ * `apps/pugi-cli/CHANGELOG.md`. Bumps `~/.pugi/.last-seen-version`
1859
+ * to the installed version on every successful render so the next
1860
+ * invocation is a no-op until the operator upgrades again.
1861
+ *
1862
+ * The handler stays thin: parser, slicer, and state I/O all live in
1863
+ * `core/release-notes/`. This wrapper just hands ambient state to
1864
+ * `runReleaseNotesCommand` so `--json` keeps producing the same
1865
+ * envelope from both the top-level shell + the in-REPL `/release-notes`
1866
+ * slash dispatcher.
1867
+ *
1868
+ * Always exits 0 — the command is informational, never a gate. Read
1869
+ * failures, missing CHANGELOG, and write failures all degrade to a
1870
+ * structured envelope with a human-readable footer.
1871
+ */
1872
+ async function releaseNotes(_args, flags, _session) {
1873
+ runReleaseNotesCommand({
1874
+ home: defaultReleaseNotesHome(),
1875
+ json: flags.json,
1876
+ reset: flags.reset,
1877
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1878
+ });
1879
+ }
1689
1880
  /**
1690
1881
  * Programmatic init scaffolder. Idempotent — every helper call is a
1691
1882
  * `*_IfMissing` write, so re-running over an existing .pugi/ workspace
@@ -4229,7 +4420,7 @@ async function login(args, flags, _session) {
4229
4420
  if (args.includes('--help') || args.includes('-h')) {
4230
4421
  writeOutput(flags, {
4231
4422
  command: 'login',
4232
- usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
4423
+ usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--key <value>] [--skip-validate] [--label <name>] [--api-url <url>]',
4233
4424
  }, [
4234
4425
  'Usage: pugi login [options]',
4235
4426
  '',
@@ -4241,19 +4432,27 @@ async function login(args, flags, _session) {
4241
4432
  'Non-interactive options:',
4242
4433
  ' --provider device Run the device-flow login (recommended).',
4243
4434
  ' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
4244
- ' --provider env Promote PUGI_API_KEY from the environment into the store.',
4435
+ ' --provider env Read PUGI_API_KEY (or --key) and verify it via /api/pugi/health.',
4245
4436
  ' --token <PAT> Inline API key (visible in `ps`).',
4246
4437
  ' --token-stdin Read API key from stdin (gh-CLI style).',
4438
+ ' --key <value> Explicit key for --provider env; beats PUGI_API_KEY.',
4439
+ ' --skip-validate Skip the /api/pugi/health probe for --provider env (CI bootstrap).',
4247
4440
  ' --label <name> Short label surfaced in `pugi accounts list`.',
4248
4441
  ' --api-url <url> Override the Anvil endpoint (self-hosted).',
4249
4442
  ' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
4250
4443
  '',
4444
+ 'Environment variables:',
4445
+ ' PUGI_API_KEY Read by --provider env. Pass --key to override.',
4446
+ ' PUGI_LOGIN_TOKEN Read by --provider token in non-interactive shells.',
4447
+ ' PUGI_API_URL Override the Anvil endpoint (same as --api-url).',
4448
+ '',
4251
4449
  'Examples:',
4252
4450
  ' pugi login # interactive picker on a TTY',
4253
4451
  ' pugi login --provider device # explicit browser OAuth',
4254
4452
  ' pugi login --provider token --token sk-xx # paste in a key',
4255
4453
  ' echo $TOKEN | pugi login --provider token --token-stdin',
4256
- ' PUGI_API_KEY=sk-xx pugi login --provider env',
4454
+ ' PUGI_API_KEY=pugi_xxx pugi login --provider env',
4455
+ ' pugi login --provider env --key pugi_xxx # explicit key beats env',
4257
4456
  ].join('\n'));
4258
4457
  return;
4259
4458
  }
@@ -4276,6 +4475,11 @@ async function login(args, flags, _session) {
4276
4475
  const apiUrlOverride = extractApiUrlFlag(args);
4277
4476
  const labelFlag = extractLabelFlag(args);
4278
4477
  const provider = parseProviderFlag(args);
4478
+ // Leak L35 (2026-05-27): `--key` is the explicit-arg path for
4479
+ // `--provider env`; `--skip-validate` bypasses the /api/pugi/health
4480
+ // probe (CI bootstrap before the network is up).
4481
+ const envExplicitKey = extractKeyFlag(args);
4482
+ const envSkipValidate = args.includes('--skip-validate');
4279
4483
  const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
4280
4484
  // Path 1: explicit --provider trumps everything else.
4281
4485
  if (provider) {
@@ -4286,6 +4490,8 @@ async function login(args, flags, _session) {
4286
4490
  explicitToken: tokenFromArgs,
4287
4491
  tokenStdinFlag,
4288
4492
  noDeviceFlow,
4493
+ envExplicitKey,
4494
+ envSkipValidate,
4289
4495
  });
4290
4496
  return;
4291
4497
  }
@@ -4333,6 +4539,8 @@ async function login(args, flags, _session) {
4333
4539
  flags,
4334
4540
  label: labelFlag,
4335
4541
  noDeviceFlow,
4542
+ envExplicitKey,
4543
+ envSkipValidate,
4336
4544
  });
4337
4545
  return;
4338
4546
  }
@@ -4551,16 +4759,28 @@ async function dispatchLoginProvider(provider, ctx) {
4551
4759
  return;
4552
4760
  }
4553
4761
  case 'env': {
4554
- const envKey = process.env.PUGI_API_KEY;
4555
- if (!envKey) {
4556
- throw new Error('pugi login --provider env requires PUGI_API_KEY to be exported in the current shell.');
4762
+ // Leak L35 (2026-05-27): resolve the env / --key candidate,
4763
+ // run the local format check, then probe `/api/pugi/health`
4764
+ // BEFORE persisting. A bad token never lands on disk so the
4765
+ // next `pugi <anything>` does not silently 401 against the
4766
+ // cabinet. `--skip-validate` opts out for CI bootstrap.
4767
+ const resolved = await resolveAndValidateEnvLogin({
4768
+ apiUrl: ctx.apiUrl,
4769
+ explicitKey: ctx.envExplicitKey,
4770
+ env: process.env,
4771
+ skipValidate: ctx.envSkipValidate ?? false,
4772
+ });
4773
+ if (resolved.kind !== 'ok') {
4774
+ reportEnvLoginFailure(resolved, ctx.flags);
4775
+ return;
4557
4776
  }
4558
4777
  storeAndAnnounceToken({
4559
4778
  apiUrl: ctx.apiUrl,
4560
- apiKey: envKey,
4779
+ apiKey: resolved.token,
4561
4780
  label: ctx.label,
4562
4781
  source: 'env',
4563
4782
  flags: ctx.flags,
4783
+ validatedLatencyMs: resolved.latencyMs > 0 ? resolved.latencyMs : undefined,
4564
4784
  });
4565
4785
  return;
4566
4786
  }
@@ -4579,6 +4799,15 @@ function storeAndAnnounceToken(input) {
4579
4799
  label: input.label,
4580
4800
  source: input.source,
4581
4801
  });
4802
+ const textLines = [
4803
+ `Pugi logged in for ${record.apiUrl}`,
4804
+ `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
4805
+ `Token: ${maskApiKey(record.apiKey)}`,
4806
+ ];
4807
+ if (typeof input.validatedLatencyMs === 'number') {
4808
+ textLines.push(`Verified via /api/pugi/health in ${input.validatedLatencyMs}ms`);
4809
+ }
4810
+ textLines.push('Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.');
4582
4811
  writeOutput(input.flags, {
4583
4812
  status: 'logged_in',
4584
4813
  apiUrl: record.apiUrl,
@@ -4586,12 +4815,55 @@ function storeAndAnnounceToken(input) {
4586
4815
  label: record.label ?? null,
4587
4816
  createdAt: record.createdAt,
4588
4817
  source: input.source,
4589
- }, [
4590
- `Pugi logged in for ${record.apiUrl}`,
4591
- `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
4592
- `Token: ${maskApiKey(record.apiKey)}`,
4593
- 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
4594
- ].join('\n'));
4818
+ ...(typeof input.validatedLatencyMs === 'number'
4819
+ ? { validatedLatencyMs: input.validatedLatencyMs }
4820
+ : {}),
4821
+ }, textLines.join('\n'));
4822
+ }
4823
+ /**
4824
+ * Render a typed `EnvLoginFailure` from `resolveAndValidateEnvLogin`
4825
+ * onto the surrounding CLI surface. Maps the failure kind to:
4826
+ * - an exit code (1 by default; 2 for invalid format so a CI step
4827
+ * can disambiguate "missing key" vs "key shape wrong" without
4828
+ * parsing stderr; 4 for network / server errors so retry logic
4829
+ * can distinguish transient failures from credential failures)
4830
+ * - a structured JSON payload for `--json` consumers
4831
+ * - a human-readable stderr line for the interactive path
4832
+ *
4833
+ * The token itself is never echoed — only the validator's own message
4834
+ * (which the env-provider module composed without the secret in it).
4835
+ */
4836
+ function reportEnvLoginFailure(failure, flags) {
4837
+ const exitCode = (() => {
4838
+ switch (failure.kind) {
4839
+ case 'missing':
4840
+ return 1;
4841
+ case 'invalid-format':
4842
+ return 2;
4843
+ case 'unauthorized':
4844
+ return 3;
4845
+ case 'network-error':
4846
+ case 'server-error':
4847
+ return 4;
4848
+ case 'unexpected-status':
4849
+ return 5;
4850
+ default: {
4851
+ const exhaustive = failure;
4852
+ return Number(exhaustive) || 1;
4853
+ }
4854
+ }
4855
+ })();
4856
+ const payload = {
4857
+ status: 'login_failed',
4858
+ kind: failure.kind,
4859
+ message: failure.message,
4860
+ };
4861
+ if ('status' in failure)
4862
+ payload.httpStatus = failure.status;
4863
+ if ('cause' in failure && failure.cause)
4864
+ payload.cause = failure.cause;
4865
+ writeOutput(flags, payload, failure.message);
4866
+ process.exitCode = exitCode;
4595
4867
  }
4596
4868
  /**
4597
4869
  * OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
@@ -5347,6 +5619,17 @@ function extractApiUrlFlag(args) {
5347
5619
  function extractLabelFlag(args) {
5348
5620
  return extractNamedFlagValue(args, 'label');
5349
5621
  }
5622
+ /**
5623
+ * `pugi login --provider env --key <value>` — explicit key arg that
5624
+ * beats `PUGI_API_KEY` env. Same precedence rule as `gh auth login
5625
+ * --with-token`, `aws configure set`, and `pugi config`: the most
5626
+ * specific operator intent (a typed flag) overrides the ambient
5627
+ * environment so an operator can override a stale `PUGI_API_KEY`
5628
+ * from their shell rc without unsetting it first.
5629
+ */
5630
+ function extractKeyFlag(args) {
5631
+ return extractNamedFlagValue(args, 'key');
5632
+ }
5350
5633
  /**
5351
5634
  * `pugi jobs` — surface the persistent JobRegistry on the CLI.
5352
5635
  * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
@@ -50,6 +50,7 @@ import { probeConfig } from '../../core/diagnostics/probes/config.js';
50
50
  import { probeSession } from '../../core/diagnostics/probes/session.js';
51
51
  import { probeDenialTracking } from '../../core/diagnostics/probes/denial-tracking.js';
52
52
  import { probeBareMode } from '../../core/diagnostics/probes/bare-mode.js';
53
+ import { probePugiMdHierarchy } from '../../core/diagnostics/probes/pugi-md.js';
53
54
  /**
54
55
  * Default API URL when no PUGI_API_URL env override is set. Mirrors
55
56
  * the constant in `core/credentials.ts` (kept local to avoid an
@@ -214,6 +215,18 @@ export function buildDefaultProbes(ctx, options = {}) {
214
215
  name: 'BARE MODE',
215
216
  run: async () => probeBareMode({ env: ctx.env }),
216
217
  },
218
+ // Leak L32 (2026-05-27): PUGI.md HIERARCHY row. Reports how many
219
+ // ambient `PUGI.md` / `CLAUDE.md` files the cwd → homedir walk
220
+ // discovered, and the closest path. `skipped` when bare mode is
221
+ // active (walk disabled) or zero files found.
222
+ {
223
+ name: 'PUGI.md HIERARCHY',
224
+ run: async () => probePugiMdHierarchy({
225
+ cwd: ctx.cwd,
226
+ homedir: ctx.home,
227
+ env: ctx.env,
228
+ }),
229
+ },
217
230
  ];
218
231
  return probes;
219
232
  }