@pugi/cli 0.1.0-beta.17 → 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 (32) hide show
  1. package/dist/core/diagnostics/probe-runner.js +93 -0
  2. package/dist/core/diagnostics/probes/api.js +46 -0
  3. package/dist/core/diagnostics/probes/auth.js +86 -0
  4. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  5. package/dist/core/diagnostics/probes/config.js +72 -0
  6. package/dist/core/diagnostics/probes/disk.js +81 -0
  7. package/dist/core/diagnostics/probes/git.js +65 -0
  8. package/dist/core/diagnostics/probes/mcp.js +75 -0
  9. package/dist/core/diagnostics/probes/node.js +59 -0
  10. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  11. package/dist/core/diagnostics/probes/session.js +74 -0
  12. package/dist/core/diagnostics/probes/workspace.js +63 -0
  13. package/dist/core/diagnostics/types.js +70 -0
  14. package/dist/core/engine/strip-internal-fields.js +124 -0
  15. package/dist/core/engine/tool-bridge.js +100 -37
  16. package/dist/core/file-cache.js +113 -1
  17. package/dist/core/mcp/client.js +66 -6
  18. package/dist/core/mcp/registry.js +24 -2
  19. package/dist/core/repl/session.js +34 -0
  20. package/dist/core/repl/slash-commands.js +9 -0
  21. package/dist/runtime/cli.js +24 -58
  22. package/dist/runtime/commands/doctor.js +357 -0
  23. package/dist/runtime/commands/mcp.js +290 -3
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/tools/agent-tool.js +18 -4
  26. package/dist/tools/ask-user-question.js +213 -0
  27. package/dist/tools/file-tools.js +57 -14
  28. package/dist/tools/registry.js +7 -0
  29. package/dist/tui/ask-user-question-prompt.js +192 -0
  30. package/dist/tui/conversation-pane.js +68 -7
  31. package/dist/tui/doctor-table.js +31 -0
  32. package/package.json +2 -2
@@ -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
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { FileReadCache } from '../../core/file-cache.js';
6
6
  import { openSession } from '../../core/session.js';
7
7
  import { loadSettings } from '../../core/settings.js';
8
- import { loadMcpRegistry } from '../../core/mcp/registry.js';
8
+ import { isAlive } from '../../core/mcp/client.js';
9
+ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
9
10
  import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
10
11
  import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
11
12
  import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
@@ -22,6 +23,15 @@ export async function runMcpCommand(args, ctx) {
22
23
  return runMcpFlip(args.slice(1), ctx, 'denied');
23
24
  case 'install':
24
25
  return runMcpInstall(args.slice(1), ctx);
26
+ case 'remove':
27
+ case 'uninstall':
28
+ return runMcpRemove(args.slice(1), ctx);
29
+ case 'doctor':
30
+ return runMcpDoctor(args.slice(1), ctx);
31
+ case 'logs':
32
+ return runMcpLogs(args.slice(1), ctx);
33
+ case 'restart':
34
+ return runMcpRestart(args.slice(1), ctx);
25
35
  case 'serve':
26
36
  return runMcpServe(args.slice(1), ctx);
27
37
  case 'perms':
@@ -32,7 +42,7 @@ export async function runMcpCommand(args, ctx) {
32
42
  ctx.writeOutput({ command: 'mcp', usage: USAGE_LINES }, USAGE_LINES.join('\n'));
33
43
  return;
34
44
  default:
35
- throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, serve, perms.`);
45
+ throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, remove, doctor, logs, restart, serve, perms.`);
36
46
  }
37
47
  }
38
48
  const USAGE_LINES = [
@@ -44,6 +54,15 @@ const USAGE_LINES = [
44
54
  ' install <name> <command...> Add a server to .pugi/mcp.json (workspace scope).',
45
55
  ' <command> must be an absolute path OR a binary',
46
56
  ' resolvable via `which` on the operator PATH.',
57
+ ' remove <name> Remove a server from .pugi/mcp.json (workspace scope).',
58
+ ' Trust ledger entry is preserved — re-install reuses it.',
59
+ ' doctor [--connect] Print per-server health (handshake, tool count, last error).',
60
+ ' --connect actually spawns the children (slow, ~5s/server).',
61
+ ' Without --connect, reports the declared state only.',
62
+ ' logs <name> [--tail N] Tail the per-server log file at .pugi/logs/mcp-<name>.log.',
63
+ ' Default tail is 40 lines.',
64
+ ' restart <name> Bounce a server: tear down the connection, reload the',
65
+ ' config, re-handshake. Surfaces the new tool count.',
47
66
  ' serve [options] Run Pugi as an MCP server',
48
67
  ' --http :<port> HTTP+SSE transport (default: stdio)',
49
68
  ' --host <ip> HTTP bind host (default: 127.0.0.1)',
@@ -219,6 +238,274 @@ function containsShellMetachar(value) {
219
238
  // a shell pipeline.
220
239
  return /[;|&`$<>(){}\n\r]/.test(value);
221
240
  }
241
+ /* ---------- remove ---------------------------------------------------- */
242
+ async function runMcpRemove(args, ctx) {
243
+ const name = args[0];
244
+ if (!name) {
245
+ throw new Error('Usage: pugi mcp remove <name>');
246
+ }
247
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
248
+ throw new Error(`pugi mcp remove: server name "${name}" must be [a-zA-Z0-9_-]+`);
249
+ }
250
+ const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
251
+ if (!existsSync(mcpJsonPath)) {
252
+ throw new Error(`pugi mcp remove: no .pugi/mcp.json at ${mcpJsonPath}. Nothing to remove.`);
253
+ }
254
+ let existing;
255
+ try {
256
+ const raw = readFileSync(mcpJsonPath, 'utf8');
257
+ if (raw.trim().length === 0) {
258
+ existing = { servers: {} };
259
+ }
260
+ else {
261
+ const parsed = JSON.parse(raw);
262
+ existing = { servers: parsed.servers ?? {} };
263
+ }
264
+ }
265
+ catch (error) {
266
+ throw new Error(`pugi mcp remove: cannot parse .pugi/mcp.json: ${error.message}. ` +
267
+ `Fix the file by hand or delete it and re-run.`);
268
+ }
269
+ if (!existing.servers[name]) {
270
+ throw new Error(`pugi mcp remove: server "${name}" not declared in ${mcpJsonPath}. ` +
271
+ `Run \`pugi mcp list\` to see declared servers.`);
272
+ }
273
+ // Preserve the trust ledger entry on purpose — a re-install of the same
274
+ // server name should land back at its old trust state so the operator
275
+ // does not have to re-approve it. To wipe trust as well, run
276
+ // `pugi mcp deny <name>` (or edit ~/.pugi/trust-mcp.json by hand).
277
+ delete existing.servers[name];
278
+ writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
279
+ ctx.writeOutput({
280
+ command: 'mcp.remove',
281
+ name,
282
+ configPath: mcpJsonPath,
283
+ remaining: Object.keys(existing.servers),
284
+ }, [
285
+ `Removed MCP server "${name}" from ${mcpJsonPath}.`,
286
+ `Trust ledger entry preserved — re-install reuses it.`,
287
+ `Remaining servers: ${Object.keys(existing.servers).length === 0 ? '(none)' : Object.keys(existing.servers).join(', ')}.`,
288
+ ].join('\n'));
289
+ }
290
+ /* ---------- doctor ---------------------------------------------------- */
291
+ async function runMcpDoctor(args, ctx) {
292
+ // `--connect` forces the doctor to actually spawn children + handshake.
293
+ // Default behaviour is dry-run (config + trust ledger only) so a routine
294
+ // `pugi doctor` does not block on a misbehaving server's 5s timeout per
295
+ // entry. Operators investigating an outage pass `--connect` explicitly.
296
+ const wantsConnect = args.includes('--connect');
297
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: wantsConnect });
298
+ const ledger = await listMcpTrust();
299
+ const rows = [];
300
+ for (const state of registry.servers.values()) {
301
+ const conn = state.connection;
302
+ let handshake;
303
+ if (!wantsConnect) {
304
+ handshake = 'not-attempted';
305
+ }
306
+ else if (state.lastError) {
307
+ handshake = 'failed';
308
+ }
309
+ else if (conn && isAlive(conn)) {
310
+ handshake = 'ok';
311
+ }
312
+ else {
313
+ handshake = 'failed';
314
+ }
315
+ rows.push({
316
+ name: state.name,
317
+ trust: state.trust,
318
+ handshake,
319
+ pid: conn?.child.pid ?? null,
320
+ tools: state.surfacedTools.length,
321
+ uptimeMs: conn ? Date.now() - conn.startedAt : null,
322
+ lastError: state.lastError ?? null,
323
+ logFile: mcpLogPath(ctx.workspaceRoot, state.name),
324
+ });
325
+ }
326
+ rows.sort((a, b) => a.name.localeCompare(b.name));
327
+ await registry.shutdown();
328
+ if (rows.length === 0) {
329
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
330
+ return;
331
+ }
332
+ const headerLines = [
333
+ `MCP doctor (${wantsConnect ? 'live handshake' : 'declared state only — pass --connect for live probe'}):`,
334
+ '',
335
+ ` ${'NAME'.padEnd(20)} ${'TRUST'.padEnd(8)} ${'HANDSHAKE'.padEnd(14)} ${'TOOLS'.padEnd(6)} ${'PID'.padEnd(7)} NOTE`,
336
+ ];
337
+ for (const row of rows) {
338
+ const note = row.lastError
339
+ ? `error: ${truncate(row.lastError, 60)}`
340
+ : row.handshake === 'ok'
341
+ ? `uptime ${formatUptime(row.uptimeMs ?? 0)}`
342
+ : row.handshake === 'failed'
343
+ ? 'see log file'
344
+ : '';
345
+ headerLines.push(` ${row.name.padEnd(20)} ${row.trust.padEnd(8)} ${row.handshake.padEnd(14)} ${String(row.tools).padEnd(6)} ${String(row.pid ?? '-').padEnd(7)} ${note}`);
346
+ }
347
+ headerLines.push('', `Log dir: ${resolve(ctx.workspaceRoot, '.pugi/logs')}`);
348
+ if (!wantsConnect) {
349
+ headerLines.push('Hint: pass --connect to actually spawn the children (slow, ~5s budget/server).');
350
+ }
351
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, headerLines.join('\n'));
352
+ }
353
+ function truncate(value, max) {
354
+ if (value.length <= max)
355
+ return value;
356
+ return `${value.slice(0, max - 1)}…`;
357
+ }
358
+ function formatUptime(ms) {
359
+ if (ms < 1000)
360
+ return `${ms}ms`;
361
+ const sec = Math.floor(ms / 1000);
362
+ if (sec < 60)
363
+ return `${sec}s`;
364
+ const min = Math.floor(sec / 60);
365
+ if (min < 60)
366
+ return `${min}m${sec % 60}s`;
367
+ const hr = Math.floor(min / 60);
368
+ return `${hr}h${min % 60}m`;
369
+ }
370
+ /* ---------- logs ------------------------------------------------------ */
371
+ async function runMcpLogs(args, ctx) {
372
+ const name = args[0];
373
+ if (!name || name.startsWith('--')) {
374
+ throw new Error('Usage: pugi mcp logs <name> [--tail N]');
375
+ }
376
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
377
+ throw new Error(`pugi mcp logs: server name "${name}" must be [a-zA-Z0-9_-]+`);
378
+ }
379
+ // `--tail N` and `--tail=N` both supported. Default 40 lines — matches
380
+ // typical `tail -n 40` muscle memory.
381
+ let tail = 40;
382
+ for (let i = 1; i < args.length; i += 1) {
383
+ const arg = args[i] ?? '';
384
+ if (arg === '--tail') {
385
+ const next = args[i + 1];
386
+ if (!next)
387
+ throw new Error('pugi mcp logs: --tail requires a value');
388
+ const n = Number.parseInt(next, 10);
389
+ if (!Number.isInteger(n) || n <= 0) {
390
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${next}")`);
391
+ }
392
+ tail = n;
393
+ i += 1;
394
+ }
395
+ else if (arg.startsWith('--tail=')) {
396
+ const n = Number.parseInt(arg.slice('--tail='.length), 10);
397
+ if (!Number.isInteger(n) || n <= 0) {
398
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${arg}")`);
399
+ }
400
+ tail = n;
401
+ }
402
+ else {
403
+ throw new Error(`pugi mcp logs: unknown flag "${arg}"`);
404
+ }
405
+ }
406
+ const path = mcpLogPath(ctx.workspaceRoot, name);
407
+ if (!existsSync(path)) {
408
+ ctx.writeOutput({ command: 'mcp.logs', name, path, tail, lines: [] }, `No log file at ${path}. The server has not produced stderr output yet (or has never been started).`);
409
+ return;
410
+ }
411
+ let raw;
412
+ try {
413
+ raw = readFileSync(path, 'utf8');
414
+ }
415
+ catch (error) {
416
+ throw new Error(`pugi mcp logs: cannot read ${path}: ${error.message}.`);
417
+ }
418
+ const allLines = raw.split('\n');
419
+ // `split('\n')` of a trailing-newline file yields an empty last element.
420
+ // Drop it so the displayed tail matches `wc -l` expectations.
421
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
422
+ allLines.pop();
423
+ }
424
+ const tailed = allLines.slice(Math.max(0, allLines.length - tail));
425
+ const sizeBytes = (() => {
426
+ try {
427
+ return statSync(path).size;
428
+ }
429
+ catch {
430
+ return 0;
431
+ }
432
+ })();
433
+ ctx.writeOutput({
434
+ command: 'mcp.logs',
435
+ name,
436
+ path,
437
+ tail,
438
+ totalLines: allLines.length,
439
+ sizeBytes,
440
+ lines: tailed,
441
+ }, [
442
+ `pugi mcp logs ${name} (${path}, ${sizeBytes} bytes, ${allLines.length} total lines, showing last ${Math.min(tail, allLines.length)}):`,
443
+ ...tailed,
444
+ ].join('\n'));
445
+ }
446
+ /* ---------- restart --------------------------------------------------- */
447
+ async function runMcpRestart(args, ctx) {
448
+ const name = args[0];
449
+ if (!name) {
450
+ throw new Error('Usage: pugi mcp restart <name>');
451
+ }
452
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
453
+ throw new Error(`pugi mcp restart: server name "${name}" must be [a-zA-Z0-9_-]+`);
454
+ }
455
+ // `pugi mcp restart` is stateless from the CLI's perspective — the CLI
456
+ // process has no long-lived MCP registry of its own (the REPL owns it
457
+ // and tears it down on exit). What we DO here is: load the config,
458
+ // refuse if the server is not declared or not trusted, then probe-spawn
459
+ // the server with the live handshake. This proves it is reachable +
460
+ // surfaces the tool count for the operator. The REPL picks up the
461
+ // change on its next `loadMcpRegistry` cycle.
462
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
463
+ const state = registry.servers.get(name);
464
+ if (!state) {
465
+ await registry.shutdown();
466
+ throw new Error(`pugi mcp restart: server "${name}" not declared. Run \`pugi mcp list\` to see declared servers.`);
467
+ }
468
+ if (state.trust !== 'trusted') {
469
+ await registry.shutdown();
470
+ throw new Error(`pugi mcp restart: server "${name}" trust state is "${state.trust}". ` +
471
+ `Run \`pugi mcp trust ${name}\` first.`);
472
+ }
473
+ await registry.shutdown();
474
+ // Re-load WITH connect=true but scoped to a single-server probe via
475
+ // handshakeTimeoutMs (5s default keeps the CLI snappy). We use the same
476
+ // loadMcpRegistry path so log routing + error capture stay consistent.
477
+ const probe = await loadMcpRegistry(ctx.workspaceRoot, { connect: true });
478
+ const probed = probe.servers.get(name);
479
+ const lastError = probed?.lastError ?? null;
480
+ const toolCount = probed?.surfacedTools.length ?? 0;
481
+ const pid = probed?.connection?.child.pid ?? null;
482
+ await probe.shutdown();
483
+ if (lastError) {
484
+ ctx.writeOutput({
485
+ command: 'mcp.restart',
486
+ name,
487
+ ok: false,
488
+ error: lastError,
489
+ logFile: mcpLogPath(ctx.workspaceRoot, name),
490
+ }, [
491
+ `pugi mcp restart ${name}: FAILED`,
492
+ ` error: ${lastError}`,
493
+ ` log file: ${mcpLogPath(ctx.workspaceRoot, name)}`,
494
+ ].join('\n'));
495
+ return;
496
+ }
497
+ ctx.writeOutput({
498
+ command: 'mcp.restart',
499
+ name,
500
+ ok: true,
501
+ pid,
502
+ surfacedTools: toolCount,
503
+ }, [
504
+ `pugi mcp restart ${name}: OK`,
505
+ ` pid: ${pid ?? '-'}`,
506
+ ` surfaced tools: ${toolCount}`,
507
+ ].join('\n'));
508
+ }
222
509
  async function runMcpServe(args, ctx) {
223
510
  const flags = parseServeFlags(args);
224
511
  const session = openSession(ctx.workspaceRoot);