@pugi/cli 0.1.0-beta.16 → 0.1.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/commands/jobs-watch.js +201 -0
  2. package/dist/commands/jobs.js +15 -0
  3. package/dist/core/agent-progress/cleanup.js +134 -0
  4. package/dist/core/agent-progress/schema.js +144 -0
  5. package/dist/core/agent-progress/writer.js +101 -0
  6. package/dist/core/diagnostics/probe-runner.js +93 -0
  7. package/dist/core/diagnostics/probes/api.js +46 -0
  8. package/dist/core/diagnostics/probes/auth.js +86 -0
  9. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  10. package/dist/core/diagnostics/probes/config.js +72 -0
  11. package/dist/core/diagnostics/probes/disk.js +81 -0
  12. package/dist/core/diagnostics/probes/git.js +65 -0
  13. package/dist/core/diagnostics/probes/mcp.js +75 -0
  14. package/dist/core/diagnostics/probes/node.js +59 -0
  15. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  16. package/dist/core/diagnostics/probes/session.js +74 -0
  17. package/dist/core/diagnostics/probes/workspace.js +63 -0
  18. package/dist/core/diagnostics/types.js +70 -0
  19. package/dist/core/engine/strip-internal-fields.js +124 -0
  20. package/dist/core/engine/tool-bridge.js +96 -27
  21. package/dist/core/file-cache.js +113 -1
  22. package/dist/core/mcp/client.js +66 -6
  23. package/dist/core/mcp/registry.js +24 -2
  24. package/dist/core/repl/session.js +64 -5
  25. package/dist/core/repl/slash-commands.js +9 -0
  26. package/dist/runtime/cli.js +153 -64
  27. package/dist/runtime/commands/doctor.js +357 -0
  28. package/dist/runtime/commands/mcp.js +290 -3
  29. package/dist/runtime/version.js +1 -1
  30. package/dist/tools/agent-tool.js +18 -4
  31. package/dist/tools/ask-user-question.js +213 -0
  32. package/dist/tools/file-tools.js +85 -14
  33. package/dist/tools/registry.js +7 -0
  34. package/dist/tui/agent-progress-card.js +111 -0
  35. package/dist/tui/ask-user-question-prompt.js +192 -0
  36. package/dist/tui/conversation-pane.js +68 -7
  37. package/dist/tui/doctor-table.js +31 -0
  38. package/dist/tui/tool-stream-pane.js +7 -0
  39. package/package.json +2 -2
@@ -1,13 +1,11 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ 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
5
  import { dirname, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
8
- import { NoopEngineAdapter } from '../core/engine/noop.js';
9
8
  import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
- import { decidePermission } from '../core/permission.js';
11
9
  import { loadMcpRegistry } from '../core/mcp/registry.js';
12
10
  import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
13
11
  import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
@@ -16,7 +14,6 @@ import { loadSettings } from '../core/settings.js';
16
14
  import { FileReadCache } from '../core/file-cache.js';
17
15
  import { resolveWorkspacePath } from '../core/path-security.js';
18
16
  import { globTool, grepTool, readTool } from '../tools/file-tools.js';
19
- import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
20
17
  import { webFetchTool } from '../tools/web-fetch.js';
21
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
22
19
  import { signatureForPlanReview } from '../core/repl/ask.js';
@@ -29,6 +26,8 @@ import { runDeployCommand } from '../commands/deploy.js';
29
26
  import { runJobsCommand } from '../commands/jobs.js';
30
27
  import { runConfigCommand } from './commands/config.js';
31
28
  import { runPrivacyCommand } from './commands/privacy.js';
29
+ import { runReport } from './commands/report.js';
30
+ import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
32
31
  import { runUndoCommand } from './commands/undo.js';
33
32
  import { runBudgetCommand } from './commands/budget.js';
34
33
  import { runSkillsCommand } from './commands/skills.js';
@@ -90,6 +89,10 @@ const handlers = {
90
89
  plan: runEngineTask('plan'),
91
90
  'plan-review': dispatchPlanReview,
92
91
  privacy: dispatchPrivacy,
92
+ // PAVF-7 (2026-05-27): `pugi report --from-error` captures the
93
+ // most-recent failed session as a redacted bundle so operators can
94
+ // file clean bug reports without manual log-grepping.
95
+ report: dispatchReport,
93
96
  review,
94
97
  resume,
95
98
  roster: dispatchRoster,
@@ -271,6 +274,25 @@ async function dispatchPrivacy(args, flags, _session) {
271
274
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
272
275
  });
273
276
  }
277
+ /**
278
+ * PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
279
+ * recent failed session into a redacted local report so operators can
280
+ * file clean bug tickets without manual log-grepping. v1 is local-only
281
+ * (no auto-upload — see commands/report.ts header for the rationale).
282
+ */
283
+ async function dispatchReport(args, flags, _session) {
284
+ const rc = runReport(args, {
285
+ cwd: process.cwd(),
286
+ json: flags.json,
287
+ emit: (line) => {
288
+ if (!flags.json)
289
+ process.stdout.write(line);
290
+ },
291
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
292
+ });
293
+ if (rc !== 0)
294
+ process.exitCode = rc;
295
+ }
274
296
  /**
275
297
  * `pugi roster` - α7.5 Phase 1.
276
298
  *
@@ -973,6 +995,16 @@ const COMMAND_HELP_BODIES = {
973
995
  'event log, settings), permission mode, and the capability matrix per',
974
996
  'engine adapter. Safe to run anywhere; no network calls.',
975
997
  ],
998
+ report: [
999
+ 'pugi report — capture a bug report from the most-recent session.',
1000
+ '',
1001
+ ' --from-error Bundle the most-recent failed session as a',
1002
+ ' redacted local report (default + only mode in v1).',
1003
+ '',
1004
+ 'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
1005
+ 'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
1006
+ 'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
1007
+ ],
976
1008
  ask: [
977
1009
  'pugi ask "<question>" — surface a yes/no question modal locally.',
978
1010
  '',
@@ -1077,61 +1109,29 @@ async function help(args, flags, _session) {
1077
1109
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
1078
1110
  ].join('\n'));
1079
1111
  }
1112
+ /**
1113
+ * `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
1114
+ * probe runner in `runtime/commands/doctor.ts`. The handler stays
1115
+ * thin so the probe surface stays single-sourced between the CLI
1116
+ * shell command, the `pnpm run doctor --json` package script, and
1117
+ * the in-REPL `/doctor` slash command.
1118
+ *
1119
+ * Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
1120
+ * 2 = at least one error probe). The pre-L17 minimal doctor surface
1121
+ * (adapter capabilities + schema bundle hash) is preserved under
1122
+ * `payload.meta.legacy` so any operator scripts that grep the JSON
1123
+ * keep working through the transition; the field is marked for
1124
+ * removal in a follow-up sprint once the new shape is the
1125
+ * documented contract.
1126
+ */
1080
1127
  async function doctor(_args, flags, _session) {
1081
- const cwd = process.cwd();
1082
- const settings = loadSettings(cwd);
1083
- // `doctor` reports adapter capabilities only; we pass a no-op client
1084
- // so we do not require an Anvil endpoint to run `pugi doctor`. The
1085
- // adapter never invokes `client.send()` from inside `capabilities()`.
1086
- const inertClient = {
1087
- async send() {
1088
- return {
1089
- stop: 'error',
1090
- code: 'failed',
1091
- message: 'doctor: inert client',
1092
- };
1093
- },
1094
- };
1095
- const adapters = [
1096
- new NoopEngineAdapter(),
1097
- new NativePugiEngineAdapter({ client: inertClient }),
1098
- ];
1099
- const capabilities = await Promise.all(adapters.map(async (adapter) => ({
1100
- name: adapter.name,
1101
- capabilities: await adapter.capabilities(),
1102
- })));
1103
- const payload = {
1104
- cliVersion: PUGI_CLI_VERSION,
1105
- nodeVersion: process.version,
1106
- workspaceRoot: cwd,
1107
- pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
1108
- pugiDir: existsSync(resolve(cwd, '.pugi')),
1109
- eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
1110
- permissionMode: settings.permissions.mode,
1111
- approvals: settings.workflow.approvals,
1112
- notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
1113
- protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
1114
- protectedFileSafety: 'configured-in-m1',
1115
- mcpTrust: 'not-configured',
1116
- releaseGuard: 'scaffolded',
1117
- tools: toolRegistry,
1118
- engineAdapters: capabilities,
1119
- schemaBundleHash: createHash('sha256')
1120
- .update(toolSchemaBundleHashInput())
1121
- .digest('hex'),
1122
- };
1123
- writeOutput(flags, payload, [
1124
- 'Pugi doctor',
1125
- `CLI: ${payload.cliVersion}`,
1126
- `Node: ${payload.nodeVersion}`,
1127
- `Workspace: ${payload.workspaceRoot}`,
1128
- `Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
1129
- `Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
1130
- `Event log: ${payload.eventLog ? 'present' : 'missing'}`,
1131
- `Permission mode: ${payload.permissionMode}`,
1132
- `Approvals: ${payload.approvals}`,
1133
- `Release guard: ${payload.releaseGuard}`,
1134
- ].join('\n'));
1128
+ await runDoctorCommand({
1129
+ cwd: process.cwd(),
1130
+ home: defaultDoctorHome(),
1131
+ env: process.env,
1132
+ json: flags.json,
1133
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1134
+ });
1135
1135
  }
1136
1136
  /**
1137
1137
  * Programmatic init scaffolder. Idempotent — every helper call is a
@@ -2140,12 +2140,45 @@ async function performTripleProviderReview(root, session, flags, prompt) {
2140
2140
  `Refusing to submit an empty diff for review.`);
2141
2141
  }
2142
2142
  const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
2143
- const mergeBase = safeGit(root, ['merge-base', baseRef, commitRef]).trim() || '';
2143
+ // merge-base is intentionally a PROBE: an empty result is a valid
2144
+ // signal (orphan branch, shallow clone, moved tag) that the dispatch
2145
+ // path handles by falling back к range-notation. Use the legacy
2146
+ // `safeGit` (probe semantics) explicitly rather than the strict
2147
+ // variant.
2148
+ const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
2149
+ // 2026-05-27 (Claude review followup #489): when merge-base returns empty
2150
+ // (orphan branch, shallow clone, moved tag), we MUST NOT pass the
2151
+ // `<range> <commitRef>` two-arg form to `git diff` — that combo is
2152
+ // invalid syntax, git exits 129, `safeGit` swallows the error, and the
2153
+ // diff payload ships empty. An empty diff is then classified as
2154
+ // `'code'` server-side, dispatched to reviewers who emit a trivial
2155
+ // `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
2156
+ // nobody actually examined. Branch on `mergeBase` так что:
2157
+ // - mergeBase present → `git diff <mergeBase> <commitRef> --`
2158
+ // (both endpoints explicit, only-uncommitted-against-base ignored
2159
+ // because commitRef is a SHA, not HEAD).
2160
+ // - mergeBase empty → `git diff <baseRef>..<commitRef> --`
2161
+ // (range form encodes both endpoints; do NOT append commitRef
2162
+ // again or git rejects the args).
2144
2163
  const diffRange = mergeBase || `${baseRef}..${commitRef}`;
2145
- const diffArgs = ['diff', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2146
- const diffStatArgs = ['diff', '--shortstat', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2147
- const diffPatch = safeGit(root, diffArgs);
2148
- const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
2164
+ const diffArgs = mergeBase
2165
+ ? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
2166
+ : ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2167
+ const diffStatArgs = mergeBase
2168
+ ? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
2169
+ : ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2170
+ // Use the strict variant — a non-empty diffPatch is load-bearing for
2171
+ // the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
2172
+ // permission), we'd rather surface a hard error than ship a green
2173
+ // review on nothing. The `--shortstat` companion uses the same
2174
+ // helper so the throw is symmetric.
2175
+ const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
2176
+ const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
2177
+ if (diffPatch.trim() === '') {
2178
+ throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
2179
+ `Refusing to dispatch a review for zero changes — check the refs ` +
2180
+ `or commit your changes before running.`);
2181
+ }
2149
2182
  const requestBody = pugiTripleReviewRequestSchema.parse({
2150
2183
  schema: 1,
2151
2184
  workspace: {
@@ -5039,7 +5072,31 @@ function fileBytes(path) {
5039
5072
  return 0;
5040
5073
  }
5041
5074
  }
5042
- function safeGit(root, args) {
5075
+ /**
5076
+ * Git invocation helpers — probe vs required semantics.
5077
+ *
5078
+ * 2026-05-27 (Claude review followup #489): the historical `safeGit`
5079
+ * collapsed BOTH "tell me the branch name if you can" probes AND
5080
+ * "give me the diff or fail" hard requirements into a single helper
5081
+ * that swallowed every error as an empty string. That's the correct
5082
+ * shape for the probe case (branch / status / dirty flag — empty
5083
+ * result is a valid signal) but catastrophically wrong for the diff
5084
+ * case (empty result === false PASS on a commit nobody reviewed).
5085
+ *
5086
+ * The split:
5087
+ * - `safeGitProbe` — best-effort. Returns '' on any error. Use for
5088
+ * branch name lookups, status probes, opt-in dirty detection.
5089
+ * - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
5090
+ * Use for diff, merge-base resolution, anything whose empty
5091
+ * output would silently corrupt downstream behaviour.
5092
+ *
5093
+ * Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
5094
+ * so existing call-sites (branch detection, status, etc.) keep their
5095
+ * tolerant semantics until they are individually migrated. Diff /
5096
+ * merge-base / rev-parse-verify call-sites are migrated к
5097
+ * `safeGitRequired` in this same patch.
5098
+ */
5099
+ export function safeGitProbe(root, args) {
5043
5100
  try {
5044
5101
  return execFileSync('git', args, {
5045
5102
  cwd: root,
@@ -5057,6 +5114,38 @@ function safeGit(root, args) {
5057
5114
  return '';
5058
5115
  }
5059
5116
  }
5117
+ /**
5118
+ * Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
5119
+ * failure. The thrown error carries the operation context so the
5120
+ * caller (triple-review dispatch, etc.) can fail loud rather than
5121
+ * ship an empty diff to a remote reviewer.
5122
+ */
5123
+ export function safeGitRequired(root, args, context) {
5124
+ try {
5125
+ return execFileSync('git', args, {
5126
+ cwd: root,
5127
+ encoding: 'utf8',
5128
+ stdio: ['ignore', 'pipe', 'pipe'],
5129
+ maxBuffer: 64 * 1024 * 1024,
5130
+ });
5131
+ }
5132
+ catch (err) {
5133
+ const cause = err instanceof Error ? err.message : String(err);
5134
+ throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
5135
+ `Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
5136
+ }
5137
+ }
5138
+ /**
5139
+ * Deprecated alias preserved for diff / status / branch probes that
5140
+ * legitimately want a tolerant empty-string-on-error shape. New call
5141
+ * sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
5142
+ *
5143
+ * @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
5144
+ * `safeGitRequired` (strict, throws).
5145
+ */
5146
+ function safeGit(root, args) {
5147
+ return safeGitProbe(root, args);
5148
+ }
5060
5149
  /**
5061
5150
  * Glob patterns excluded from triple-review `diffPatch` before egress.
5062
5151
  *
@@ -0,0 +1,357 @@
1
+ /**
2
+ * `pugi doctor` — environment health report (Leak L17, 2026-05-27).
3
+ *
4
+ * Parity command with Claude Code's `/doctor` (gap doc:
5
+ * docs/research/2026-05-27-pugi-gap-analysis-3-repos.md §6). Probes
6
+ * auth, API reachability, CLI version, workspace state, disk space,
7
+ * Node version, pnpm, git, MCP servers, config file, and session
8
+ * activity. Emits either a human-readable table OR a structured JSON
9
+ * envelope depending on `--json`.
10
+ *
11
+ * Module contract:
12
+ *
13
+ * - This file owns the WIRING from CLI flags + workspace context to
14
+ * the probe runner. The probes themselves live in
15
+ * `core/diagnostics/probes/*.ts` and have NO module-level coupling
16
+ * to the CLI dispatch surface.
17
+ *
18
+ * - `runDoctorCommand` is the single entry point. Both the top-level
19
+ * `pugi doctor` handler in `runtime/cli.ts` AND the in-REPL
20
+ * `/doctor` slash command call it. The function returns the
21
+ * `DoctorReport` so the REPL can render via the Ink table without
22
+ * re-running the probes.
23
+ *
24
+ * - Exit codes are derived from `exitCodeFor(overall)` in
25
+ * `core/diagnostics/types.ts` and bubble up via `process.exitCode`
26
+ * (matches the convention of every other CLI handler in cli.ts).
27
+ *
28
+ * - The MCP probe is opportunistic: if `core/mcp/registry.js` is
29
+ * unavailable for any reason (e.g. sibling L13 not yet landed,
30
+ * unexpected schema change), the probe degrades to a graceful
31
+ * `skipped` result so the rest of the table still renders.
32
+ */
33
+ import { execFileSync } from 'node:child_process';
34
+ import { constants as fsConstants, existsSync, accessSync, readFileSync, statSync } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { resolveActiveCredential } from '../../core/credentials.js';
37
+ import { PUGI_CLI_VERSION } from '../version.js';
38
+ import { runProbes, } from '../../core/diagnostics/probe-runner.js';
39
+ import { computeOverall, countProbes, exitCodeFor, } from '../../core/diagnostics/types.js';
40
+ import { probeAuth } from '../../core/diagnostics/probes/auth.js';
41
+ import { probeApi } from '../../core/diagnostics/probes/api.js';
42
+ import { probeCliVersion } from '../../core/diagnostics/probes/cli-version.js';
43
+ import { probeWorkspace } from '../../core/diagnostics/probes/workspace.js';
44
+ import { probeDisk } from '../../core/diagnostics/probes/disk.js';
45
+ import { probeNode } from '../../core/diagnostics/probes/node.js';
46
+ import { probePnpm } from '../../core/diagnostics/probes/pnpm.js';
47
+ import { probeGit } from '../../core/diagnostics/probes/git.js';
48
+ import { probeMcp } from '../../core/diagnostics/probes/mcp.js';
49
+ import { probeConfig } from '../../core/diagnostics/probes/config.js';
50
+ import { probeSession } from '../../core/diagnostics/probes/session.js';
51
+ /**
52
+ * Default API URL when no PUGI_API_URL env override is set. Mirrors
53
+ * the constant in `core/credentials.ts` (kept local to avoid an
54
+ * extra named export from that module).
55
+ */
56
+ const DEFAULT_API_URL = 'https://api.pugi.io';
57
+ /**
58
+ * Build the standard probe set with production dependencies. Exported
59
+ * for the spec so the test can construct the same suite with stub
60
+ * deps + assert per-probe ordering + fail-isolation in isolation.
61
+ */
62
+ export function buildDefaultProbes(ctx, options = {}) {
63
+ const fetchImpl = ctx.fetchImpl ?? globalThis.fetch.bind(globalThis);
64
+ const now = Date.now;
65
+ const probes = [
66
+ {
67
+ name: 'AUTH',
68
+ run: () => probeAuth(ctx, {
69
+ resolveCredential: (env, home) => {
70
+ const credential = resolveActiveCredential(env, home);
71
+ if (!credential)
72
+ return null;
73
+ return { apiUrl: credential.apiUrl, apiKey: credential.apiKey };
74
+ },
75
+ fetchImpl,
76
+ now,
77
+ }),
78
+ timeoutMs: 4_000,
79
+ },
80
+ {
81
+ name: 'API',
82
+ run: () => probeApi(ctx, {
83
+ resolveApiUrl: (env) => {
84
+ return env.PUGI_API_URL ?? DEFAULT_API_URL;
85
+ },
86
+ fetchImpl,
87
+ now,
88
+ }),
89
+ timeoutMs: 4_000,
90
+ },
91
+ {
92
+ name: 'CLI VERSION',
93
+ run: () => probeCliVersion({
94
+ localVersion: options.localCliVersion ?? PUGI_CLI_VERSION,
95
+ fetchImpl,
96
+ now,
97
+ }),
98
+ timeoutMs: 4_000,
99
+ },
100
+ {
101
+ name: 'WORKSPACE',
102
+ run: async () => probeWorkspace(ctx, {
103
+ existsSync,
104
+ statSync,
105
+ accessSync,
106
+ W_OK: fsConstants.W_OK,
107
+ }),
108
+ },
109
+ {
110
+ name: 'DISK',
111
+ run: async () => probeDisk(ctx, {
112
+ getFreeBytes: (home) => getFreeBytesViaDf(home),
113
+ }),
114
+ },
115
+ {
116
+ name: 'NODE',
117
+ run: async () => probeNode({ version: process.version }),
118
+ },
119
+ {
120
+ name: 'PNPM',
121
+ run: async () => probePnpm({
122
+ resolveVersion: () => execFileSync('pnpm', ['--version'], {
123
+ encoding: 'utf8',
124
+ timeout: 2_000,
125
+ stdio: ['ignore', 'pipe', 'ignore'],
126
+ }).trim(),
127
+ }),
128
+ },
129
+ {
130
+ name: 'GIT',
131
+ run: async () => probeGit(ctx, {
132
+ resolveVersion: () => execFileSync('git', ['--version'], {
133
+ encoding: 'utf8',
134
+ timeout: 2_000,
135
+ stdio: ['ignore', 'pipe', 'ignore'],
136
+ }).trim(),
137
+ isInWorkTree: (cwd) => {
138
+ try {
139
+ const result = execFileSync('git', ['-C', cwd, 'rev-parse', '--is-inside-work-tree'], {
140
+ encoding: 'utf8',
141
+ timeout: 2_000,
142
+ stdio: ['ignore', 'pipe', 'ignore'],
143
+ }).trim();
144
+ return result === 'true';
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ },
150
+ resolveHeadSha: (cwd) => {
151
+ try {
152
+ return execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], {
153
+ encoding: 'utf8',
154
+ timeout: 2_000,
155
+ stdio: ['ignore', 'pipe', 'ignore'],
156
+ }).trim();
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ },
162
+ resolveRoot: (cwd) => {
163
+ try {
164
+ return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
165
+ encoding: 'utf8',
166
+ timeout: 2_000,
167
+ stdio: ['ignore', 'pipe', 'ignore'],
168
+ }).trim();
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ },
174
+ }),
175
+ },
176
+ {
177
+ name: 'MCP SERVERS',
178
+ run: async () => probeMcpSafely(ctx),
179
+ },
180
+ {
181
+ name: 'CONFIG',
182
+ run: async () => probeConfig(ctx, {
183
+ existsSync,
184
+ readFileSync: (p, encoding) => readFileSync(p, encoding),
185
+ }),
186
+ },
187
+ {
188
+ name: 'SESSION',
189
+ run: async () => probeSession(ctx, {
190
+ existsSync,
191
+ statSync,
192
+ readFileSync: (p, encoding) => readFileSync(p, encoding),
193
+ }, {
194
+ now,
195
+ ...(options.liveSessionId ? { liveSessionId: options.liveSessionId } : {}),
196
+ }),
197
+ },
198
+ ];
199
+ return probes;
200
+ }
201
+ /**
202
+ * Run the full doctor sweep + emit the output via the supplied
203
+ * writeOutput sink. Returns the report so REPL callers can route it
204
+ * к the Ink renderer instead of the plain-text fallback.
205
+ */
206
+ export async function runDoctorCommand(ctx) {
207
+ const probeCtx = {
208
+ cwd: ctx.cwd,
209
+ home: ctx.home,
210
+ env: ctx.env,
211
+ };
212
+ const probes = buildDefaultProbes(probeCtx, {
213
+ ...(ctx.liveSessionId ? { liveSessionId: ctx.liveSessionId } : {}),
214
+ });
215
+ const report = await runProbes(probes);
216
+ // Defensive recompute: even though runProbes already computed the
217
+ // overall + counts, recomputing here documents the invariant for the
218
+ // reader and gives the JSON envelope a single source of truth.
219
+ const overall = computeOverall(report.probes);
220
+ const counts = countProbes(report.probes);
221
+ const envelope = {
222
+ command: 'doctor',
223
+ overall,
224
+ counts,
225
+ durationMs: report.durationMs,
226
+ probes: report.probes,
227
+ meta: {
228
+ cliVersion: PUGI_CLI_VERSION,
229
+ nodeVersion: process.version,
230
+ cwd: ctx.cwd,
231
+ },
232
+ };
233
+ const text = renderDoctorTable(envelope);
234
+ ctx.writeOutput(envelope, text);
235
+ process.exitCode = exitCodeFor(overall);
236
+ return { ...report, overall, counts };
237
+ }
238
+ /**
239
+ * Plain-text table renderer. Mirrors the layout from the leak-parity
240
+ * spec but is intentionally column-light (3 columns: NAME / STATUS /
241
+ * DETAIL) so it composes well in narrow terminals without dragging
242
+ * a layout library into the CLI hot path. The Ink TUI renderer in
243
+ * `tui/doctor-table.tsx` is the colour-aware variant used inside the
244
+ * REPL.
245
+ */
246
+ export function renderDoctorTable(envelope) {
247
+ const NAME_WIDTH = Math.max('NAME'.length, ...envelope.probes.map((row) => row.name.length));
248
+ const STATUS_WIDTH = Math.max('STATUS'.length, ...envelope.probes.map((row) => row.status.length));
249
+ const lines = [];
250
+ lines.push('Pugi Doctor — environment health report');
251
+ lines.push('='.repeat(50));
252
+ lines.push('');
253
+ for (const row of envelope.probes) {
254
+ const namePart = row.name.padEnd(NAME_WIDTH, ' ');
255
+ const statusPart = row.status.toUpperCase().padEnd(STATUS_WIDTH, ' ');
256
+ const latencyPart = typeof row.latencyMs === 'number' ? ` (${row.latencyMs}ms)` : '';
257
+ lines.push(`${namePart} ${statusPart} ${row.detail}${latencyPart}`);
258
+ if (row.remediation && (row.status === 'warn' || row.status === 'error')) {
259
+ lines.push(`${' '.repeat(NAME_WIDTH + STATUS_WIDTH + 4)}→ ${row.remediation}`);
260
+ }
261
+ }
262
+ lines.push('');
263
+ const { ok, warn, error: errorCount, skipped } = envelope.counts;
264
+ const summary = envelope.overall === 'healthy'
265
+ ? 'HEALTHY'
266
+ : envelope.overall === 'warning'
267
+ ? 'WARNINGS'
268
+ : 'ERRORS';
269
+ lines.push(`${errorCount} error(s), ${warn} warning(s), ${ok} ok, ${skipped} skipped. Overall: ${summary}`);
270
+ lines.push(`CLI ${envelope.meta.cliVersion} Node ${envelope.meta.nodeVersion} cwd ${envelope.meta.cwd}`);
271
+ return lines.join('\n');
272
+ }
273
+ /**
274
+ * Wrap the MCP probe in a dynamic import + try/catch so a missing
275
+ * sibling L13 surface (or a schema mismatch in `core/mcp/registry`)
276
+ * degrades the row к `skipped` instead of breaking the entire sweep.
277
+ * The probe-runner already isolates throws into `error` rows; this
278
+ * wrapper additionally distinguishes "feature not available" from
279
+ * "feature crashed".
280
+ */
281
+ async function probeMcpSafely(ctx) {
282
+ try {
283
+ const mod = await import('../../core/mcp/registry.js');
284
+ if (typeof mod.loadMcpRegistry !== 'function') {
285
+ return {
286
+ name: 'MCP SERVERS',
287
+ status: 'skipped',
288
+ detail: 'MCP integration not exported by this build',
289
+ };
290
+ }
291
+ return await probeMcp(ctx, {
292
+ loadRegistry: (cwd, options) => mod.loadMcpRegistry(cwd, { connect: options.connect ?? false }),
293
+ });
294
+ }
295
+ catch (error) {
296
+ const message = error instanceof Error ? error.message : String(error);
297
+ return {
298
+ name: 'MCP SERVERS',
299
+ status: 'skipped',
300
+ detail: 'MCP integration not available',
301
+ remediation: `Inspection failed: ${message}`,
302
+ };
303
+ }
304
+ }
305
+ /**
306
+ * Best-effort free-bytes lookup via `df -k <home>`. Parses the second
307
+ * line (header + one data row) and returns the `Available` column ×
308
+ * 1024. Throws on parse failure so the probe surfaces a `warn`
309
+ * instead of a misleading 0-bytes-free verdict.
310
+ *
311
+ * Exported for the spec so we can drive it through a stubbed
312
+ * execFileSync without spawning a real subprocess.
313
+ */
314
+ export function getFreeBytesViaDf(home) {
315
+ const out = execFileSync('df', ['-k', home], {
316
+ encoding: 'utf8',
317
+ timeout: 2_000,
318
+ stdio: ['ignore', 'pipe', 'ignore'],
319
+ });
320
+ return parseDfOutput(out);
321
+ }
322
+ /**
323
+ * Parse the textual output of `df -k`. Handles both BSD and GNU
324
+ * variants — both emit a `Available` column at index 3 of the data
325
+ * row, with one quirk: long device names wrap к the next line on
326
+ * GNU, so we collapse whitespace + tab newlines first.
327
+ */
328
+ export function parseDfOutput(out) {
329
+ // Collapse multi-line device-name wraps into a single logical row.
330
+ const collapsed = out.replace(/\n\s+/g, ' ');
331
+ const lines = collapsed
332
+ .split('\n')
333
+ .map((line) => line.trim())
334
+ .filter((line) => line.length > 0);
335
+ if (lines.length < 2) {
336
+ throw new Error(`df output too short: ${JSON.stringify(out.slice(0, 64))}`);
337
+ }
338
+ const data = lines[1].split(/\s+/);
339
+ // Schema: Filesystem 1K-blocks Used Available Capacity Mounted-on
340
+ const availableField = data[3];
341
+ if (!availableField) {
342
+ throw new Error(`df output missing Available column: ${JSON.stringify(lines[1])}`);
343
+ }
344
+ const value = Number(availableField);
345
+ if (!Number.isFinite(value) || value < 0) {
346
+ throw new Error(`df Available column not numeric: ${availableField}`);
347
+ }
348
+ return value * 1024;
349
+ }
350
+ /**
351
+ * Default home dir resolver. Centralised so the CLI handler can call
352
+ * `runDoctorCommand` without re-importing `os.homedir` everywhere.
353
+ */
354
+ export function defaultHome() {
355
+ return homedir();
356
+ }
357
+ //# sourceMappingURL=doctor.js.map