@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.
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +100 -37
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/repl/session.js +34 -0
- package/dist/core/repl/slash-commands.js +9 -0
- package/dist/runtime/cli.js +24 -58
- package/dist/runtime/commands/doctor.js +357 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/package.json +2 -2
package/dist/core/file-cache.js
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session file-read cache + stale-read gate.
|
|
3
|
+
*
|
|
4
|
+
* Leak intel L1 (openclaude `FileEditTool.ts`, 2026-05-27 gap analysis
|
|
5
|
+
* §5.1): every FileEdit must validate the operator's last-known view of
|
|
6
|
+
* the file before mutating disk. The gate compares BOTH `mtimeMs` and
|
|
7
|
+
* `sha256(content)` of the file on disk against the record captured at
|
|
8
|
+
* read time:
|
|
9
|
+
*
|
|
10
|
+
* - mtimeMs is a cheap fast-path. If the inode mtime hasn't moved
|
|
11
|
+
* since the read, the content hash cannot have changed (barring a
|
|
12
|
+
* filesystem with hash-on-mtime-skew bugs) and we can short-circuit.
|
|
13
|
+
* - sha256 is the authoritative gate. A user editor that writes back
|
|
14
|
+
* identical content can leave mtime untouched on some filesystems
|
|
15
|
+
* (atomic-rename with preserved metadata), and conversely `touch`
|
|
16
|
+
* bumps mtime without changing content. Hash is the truth.
|
|
17
|
+
*
|
|
18
|
+
* Both signals must agree for the gate to PASS. Any divergence => STALE
|
|
19
|
+
* => refuse the edit, force the model to re-read.
|
|
20
|
+
*
|
|
21
|
+
* Cache lifetime: per-session. `FileReadCache.clear()` is called at
|
|
22
|
+
* session.end (see `core/session.ts`). The cache is intentionally NOT
|
|
23
|
+
* durable across sessions — a re-read after restart is cheap and stale
|
|
24
|
+
* cross-session entries would themselves be a soundness hazard.
|
|
25
|
+
*
|
|
26
|
+
* Exception: writeTool for create-new (path doesn't exist on disk) does
|
|
27
|
+
* not consult the cache. Creating a brand new file has no "last-known
|
|
28
|
+
* view" to invalidate.
|
|
29
|
+
*/
|
|
1
30
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { statSync } from 'node:fs';
|
|
31
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
32
|
import { resolve } from 'node:path';
|
|
33
|
+
export class StaleReadError extends Error {
|
|
34
|
+
reason;
|
|
35
|
+
path;
|
|
36
|
+
constructor(path, reason, detail) {
|
|
37
|
+
super(`stale_read: ${path} — ${detail}. Re-read the file before editing.`);
|
|
38
|
+
this.name = 'StaleReadError';
|
|
39
|
+
this.reason = reason;
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
4
43
|
export class FileReadCache {
|
|
5
44
|
records = new Map();
|
|
6
45
|
set(record) {
|
|
@@ -9,6 +48,70 @@ export class FileReadCache {
|
|
|
9
48
|
get(root, path) {
|
|
10
49
|
return this.records.get(resolve(root, path));
|
|
11
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a candidate edit against the cached read record. Returns
|
|
53
|
+
* a tagged-union: `{ stale: false }` when the edit may proceed, or
|
|
54
|
+
* `{ stale: true, reason, detail }` when the gate must refuse.
|
|
55
|
+
*
|
|
56
|
+
* Pure function over the cache + supplied `currentMtimeMs` /
|
|
57
|
+
* `currentContent` — does NOT touch disk. Callers (editTool /
|
|
58
|
+
* writeTool) do their own `statSync` + `readFileSync` because they
|
|
59
|
+
* also need the content for the diff/edit itself.
|
|
60
|
+
*
|
|
61
|
+
* @param root workspace root (used to resolve relative path)
|
|
62
|
+
* @param path workspace-relative file path
|
|
63
|
+
* @param currentMtimeMs `fs.statSync().mtimeMs` of the on-disk file
|
|
64
|
+
* @param currentContent UTF-8 contents of the on-disk file
|
|
65
|
+
*/
|
|
66
|
+
validate(root, path, currentMtimeMs, currentContent) {
|
|
67
|
+
const record = this.get(root, path);
|
|
68
|
+
if (!record) {
|
|
69
|
+
return {
|
|
70
|
+
stale: true,
|
|
71
|
+
reason: 'no_prior_read',
|
|
72
|
+
detail: 'file must be read first',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Fast-path: mtime hasn't moved. Hash check is redundant in the
|
|
76
|
+
// common case but cheap, so we still verify below. Skipping hash
|
|
77
|
+
// when mtime matches would allow a subtle bug class (in-place
|
|
78
|
+
// writers that preserve mtime) to slip through.
|
|
79
|
+
if (currentMtimeMs > record.mtimeMs) {
|
|
80
|
+
// mtime advanced — confirm with hash before flagging. A bump
|
|
81
|
+
// without a content change (e.g. `touch`) shouldn't fire stale.
|
|
82
|
+
const currentHash = hashContent(currentContent);
|
|
83
|
+
if (currentHash !== record.sha256) {
|
|
84
|
+
return {
|
|
85
|
+
stale: true,
|
|
86
|
+
reason: 'mtime_drift',
|
|
87
|
+
detail: `mtime advanced (${record.mtimeMs} → ${currentMtimeMs}) and content hash diverged`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// mtime bumped but content identical — treat as fresh. The cache
|
|
91
|
+
// entry's mtime is intentionally NOT refreshed here; the next
|
|
92
|
+
// edit will hit the same path and the gate will keep agreeing.
|
|
93
|
+
return { stale: false };
|
|
94
|
+
}
|
|
95
|
+
// mtime hasn't moved — hash MUST still match the record. A
|
|
96
|
+
// mismatch is a filesystem-level inconsistency or an in-place
|
|
97
|
+
// editor that preserves mtime; either way, refuse.
|
|
98
|
+
const currentHash = hashContent(currentContent);
|
|
99
|
+
if (currentHash !== record.sha256) {
|
|
100
|
+
return {
|
|
101
|
+
stale: true,
|
|
102
|
+
reason: 'hash_drift',
|
|
103
|
+
detail: 'content hash diverged from last read (mtime unchanged)',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { stale: false };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Drop every cached record. Called by session.end so a fresh REPL
|
|
110
|
+
* session never inherits stale cross-session entries.
|
|
111
|
+
*/
|
|
112
|
+
clear() {
|
|
113
|
+
this.records.clear();
|
|
114
|
+
}
|
|
12
115
|
}
|
|
13
116
|
export function hashContent(content) {
|
|
14
117
|
return createHash('sha256').update(content).digest('hex');
|
|
@@ -26,4 +129,13 @@ export function createReadRecord(root, path, content, source) {
|
|
|
26
129
|
source,
|
|
27
130
|
};
|
|
28
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Convenience helper: does this absolute path exist on disk? Wraps the
|
|
134
|
+
* existsSync import so file-tools.ts can decide between create-new
|
|
135
|
+
* (skip stale gate) and update-existing (apply stale gate) without
|
|
136
|
+
* pulling in another fs import.
|
|
137
|
+
*/
|
|
138
|
+
export function pathExists(absolutePath) {
|
|
139
|
+
return existsSync(absolutePath);
|
|
140
|
+
}
|
|
29
141
|
//# sourceMappingURL=file-cache.js.map
|
package/dist/core/mcp/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
/**
|
|
4
5
|
* Minimal JSON-RPC 2.0 over stdio MCP client for Pugi CLI M1.
|
|
@@ -79,6 +80,22 @@ export async function connect(serverName, config, options = {}) {
|
|
|
79
80
|
env: { ...process.env, ...config.env },
|
|
80
81
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
82
|
});
|
|
83
|
+
// L13: optional stderr → log file. We open the stream lazily so a bad
|
|
84
|
+
// path surfaces synchronously to the caller instead of getting buried
|
|
85
|
+
// in the child's stderr handler. `mkdir -p` of the parent dir is the
|
|
86
|
+
// caller's responsibility (the registry does it once per session).
|
|
87
|
+
let logStream;
|
|
88
|
+
if (options.logFile) {
|
|
89
|
+
try {
|
|
90
|
+
logStream = createWriteStream(options.logFile, { flags: 'a' });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Log directory missing or unwritable — degrade silently rather
|
|
94
|
+
// than refuse the handshake. The operator still gets the same
|
|
95
|
+
// surface they had pre-L13 (stderr dropped on the floor).
|
|
96
|
+
logStream = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
82
99
|
const connection = {
|
|
83
100
|
serverName,
|
|
84
101
|
child,
|
|
@@ -86,6 +103,8 @@ export async function connect(serverName, config, options = {}) {
|
|
|
86
103
|
pending: new Map(),
|
|
87
104
|
nextId: 1,
|
|
88
105
|
closed: false,
|
|
106
|
+
...(logStream ? { logStream } : {}),
|
|
107
|
+
startedAt: Date.now(),
|
|
89
108
|
};
|
|
90
109
|
// Attach the spawn-error handler BEFORE the reader: ENOENT and similar
|
|
91
110
|
// are emitted asynchronously on the next tick, and without this
|
|
@@ -193,11 +212,25 @@ export async function callTool(connection, name, args, options = {}) {
|
|
|
193
212
|
* SIGKILL if the child has not exited. Safe to call multiple times.
|
|
194
213
|
*/
|
|
195
214
|
export async function disconnect(connection) {
|
|
196
|
-
|
|
215
|
+
const closeLogStream = () => {
|
|
216
|
+
const stream = connection.logStream;
|
|
217
|
+
if (!stream)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
stream.end();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Best-effort — stream may already be destroyed.
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
if (connection.closed) {
|
|
227
|
+
closeLogStream();
|
|
197
228
|
return;
|
|
229
|
+
}
|
|
198
230
|
const { child } = connection;
|
|
199
231
|
if (child.exitCode !== null || child.killed) {
|
|
200
232
|
connection.closed = true;
|
|
233
|
+
closeLogStream();
|
|
201
234
|
return;
|
|
202
235
|
}
|
|
203
236
|
return new Promise((resolveDone) => {
|
|
@@ -207,6 +240,7 @@ export async function disconnect(connection) {
|
|
|
207
240
|
return;
|
|
208
241
|
settled = true;
|
|
209
242
|
connection.closed = true;
|
|
243
|
+
closeLogStream();
|
|
210
244
|
resolveDone();
|
|
211
245
|
};
|
|
212
246
|
child.once('exit', settle);
|
|
@@ -232,6 +266,22 @@ export async function disconnect(connection) {
|
|
|
232
266
|
}, SHUTDOWN_GRACE_MS);
|
|
233
267
|
});
|
|
234
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* L13: cheap liveness probe for `pugi mcp doctor`. True when the child
|
|
271
|
+
* process is still running AND the connection has not been torn down.
|
|
272
|
+
* Does NOT issue a JSON-RPC call — that would race with in-flight tool
|
|
273
|
+
* dispatch and the doctor surface is read-only.
|
|
274
|
+
*/
|
|
275
|
+
export function isAlive(connection) {
|
|
276
|
+
if (connection.closed)
|
|
277
|
+
return false;
|
|
278
|
+
const { child } = connection;
|
|
279
|
+
if (child.killed)
|
|
280
|
+
return false;
|
|
281
|
+
if (child.exitCode !== null)
|
|
282
|
+
return false;
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
235
285
|
function writeFrame(connection, message) {
|
|
236
286
|
if (connection.closed) {
|
|
237
287
|
throw new Error(`mcp server "${connection.serverName}" connection is closed`);
|
|
@@ -283,11 +333,21 @@ function attachReader(connection) {
|
|
|
283
333
|
newlineIndex = buffer.indexOf('\n');
|
|
284
334
|
}
|
|
285
335
|
});
|
|
286
|
-
// stderr is captured
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
connection.child.stderr.on('data', () => {
|
|
336
|
+
// stderr is captured and (when `connect({ logFile })` was set) mirrored
|
|
337
|
+
// to a per-server log file under `.pugi/logs/`. Operators can tail the
|
|
338
|
+
// file via `pugi mcp logs <server>`. Default sink remains "drop on the
|
|
339
|
+
// floor" so the CLI stdout stays pure JSON envelope output.
|
|
340
|
+
connection.child.stderr.on('data', (chunk) => {
|
|
341
|
+
const stream = connection.logStream;
|
|
342
|
+
if (!stream || stream.destroyed)
|
|
343
|
+
return;
|
|
344
|
+
try {
|
|
345
|
+
stream.write(typeof chunk === 'string' ? chunk : chunk);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Disk full / fd revoked — drop silently. Mirroring is best-effort.
|
|
349
|
+
}
|
|
350
|
+
});
|
|
291
351
|
}
|
|
292
352
|
function handleLine(connection, line) {
|
|
293
353
|
let parsed;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
@@ -38,6 +38,13 @@ import { getMcpTrust } from './trust.js';
|
|
|
38
38
|
const mcpFileSchema = z.object({
|
|
39
39
|
servers: z.record(mcpServerConfigSchema).default({}),
|
|
40
40
|
});
|
|
41
|
+
/**
|
|
42
|
+
* L13: workspace-relative path for per-server log files. Surfaces in
|
|
43
|
+
* `pugi mcp logs <name>` and is mkdir -p'd before the first connect.
|
|
44
|
+
*/
|
|
45
|
+
export function mcpLogPath(workspaceRoot, serverName) {
|
|
46
|
+
return resolve(workspaceRoot, '.pugi/logs', `mcp-${serverName}.log`);
|
|
47
|
+
}
|
|
41
48
|
/**
|
|
42
49
|
* Load and (optionally) connect every approved MCP server defined in the
|
|
43
50
|
* workspace + user configs. Pending and denied servers stay in the
|
|
@@ -45,6 +52,7 @@ const mcpFileSchema = z.object({
|
|
|
45
52
|
*/
|
|
46
53
|
export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
47
54
|
const shouldConnect = options.connect !== false;
|
|
55
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5_000;
|
|
48
56
|
const userConfig = readMcpFile(resolve(userHomeDir(), 'mcp.json'));
|
|
49
57
|
const workspaceConfig = readMcpFile(resolve(workspaceRoot, '.pugi/mcp.json'));
|
|
50
58
|
const merged = new Map();
|
|
@@ -52,6 +60,17 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
|
52
60
|
merged.set(name, config);
|
|
53
61
|
for (const [name, config] of Object.entries(workspaceConfig))
|
|
54
62
|
merged.set(name, config);
|
|
63
|
+
// L13: ensure the log dir exists once per session so per-server log
|
|
64
|
+
// streams can `append` without each one having to mkdir -p.
|
|
65
|
+
if (shouldConnect && merged.size > 0) {
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(resolve(workspaceRoot, '.pugi/logs'), { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Workspace may be read-only (CI sandbox). Log routing degrades
|
|
71
|
+
// silently in that case — see `client.ts::connect`.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
55
74
|
const servers = new Map();
|
|
56
75
|
for (const [name, config] of merged) {
|
|
57
76
|
const ledgerTrust = await getMcpTrust(name);
|
|
@@ -70,7 +89,10 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
|
|
|
70
89
|
};
|
|
71
90
|
if (shouldConnect && trust === 'trusted') {
|
|
72
91
|
try {
|
|
73
|
-
const connection = await connect(name, config
|
|
92
|
+
const connection = await connect(name, config, {
|
|
93
|
+
timeoutMs: handshakeTimeoutMs,
|
|
94
|
+
logFile: mcpLogPath(workspaceRoot, name),
|
|
95
|
+
});
|
|
74
96
|
state.connection = connection;
|
|
75
97
|
state.surfacedTools = await listTools(connection);
|
|
76
98
|
}
|
|
@@ -765,6 +765,40 @@ export class ReplSession {
|
|
|
765
765
|
}
|
|
766
766
|
return verdict;
|
|
767
767
|
}
|
|
768
|
+
case 'doctor': {
|
|
769
|
+
// L17 (2026-05-27): run the doctor probe sweep inline. We
|
|
770
|
+
// dynamic-import the runtime/commands/doctor module so the
|
|
771
|
+
// slash dispatcher does not pull the diagnostics graph
|
|
772
|
+
// (execFileSync + fs probes) into every keystroke. The
|
|
773
|
+
// module's output is captured into local lines so we can
|
|
774
|
+
// render it as system entries in the conversation pane;
|
|
775
|
+
// an Ink-rendered table inside the REPL frame is a follow-up.
|
|
776
|
+
try {
|
|
777
|
+
const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
|
|
778
|
+
const lines = [];
|
|
779
|
+
await runDoctorCommand({
|
|
780
|
+
cwd: process.cwd(),
|
|
781
|
+
home: defaultHome(),
|
|
782
|
+
env: process.env,
|
|
783
|
+
json: false,
|
|
784
|
+
writeOutput: (_payload, text) => {
|
|
785
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
786
|
+
if (trimmed.length > 0)
|
|
787
|
+
lines.push(trimmed);
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
for (const line of lines)
|
|
791
|
+
this.appendSystemLine(line);
|
|
792
|
+
if (lines.length === 0) {
|
|
793
|
+
this.appendSystemLine('/doctor: no output.');
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
798
|
+
this.appendSystemLine(`/doctor failed: ${message}`);
|
|
799
|
+
}
|
|
800
|
+
return verdict;
|
|
801
|
+
}
|
|
768
802
|
case 'stub': {
|
|
769
803
|
this.appendSystemLine(verdict.message);
|
|
770
804
|
return verdict;
|
|
@@ -81,6 +81,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
81
81
|
// Meta
|
|
82
82
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
83
83
|
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
84
|
+
{ name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
|
|
84
85
|
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
85
86
|
]);
|
|
86
87
|
/**
|
|
@@ -271,6 +272,14 @@ export function parseSlashCommand(input) {
|
|
|
271
272
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
272
273
|
return { kind: 'mcp', args: tokens };
|
|
273
274
|
}
|
|
275
|
+
case 'doctor':
|
|
276
|
+
case 'health': {
|
|
277
|
+
// L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
|
|
278
|
+
// the doctor command has no operator-facing arguments (every
|
|
279
|
+
// probe runs unconditionally; per-probe disable lives on the CLI
|
|
280
|
+
// shell surface, not the slash one).
|
|
281
|
+
return { kind: 'doctor' };
|
|
282
|
+
}
|
|
274
283
|
case 'compact':
|
|
275
284
|
case 'memory':
|
|
276
285
|
case 'config':
|
package/dist/runtime/cli.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
@@ -30,6 +27,7 @@ import { runJobsCommand } from '../commands/jobs.js';
|
|
|
30
27
|
import { runConfigCommand } from './commands/config.js';
|
|
31
28
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
32
29
|
import { runReport } from './commands/report.js';
|
|
30
|
+
import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
|
|
33
31
|
import { runUndoCommand } from './commands/undo.js';
|
|
34
32
|
import { runBudgetCommand } from './commands/budget.js';
|
|
35
33
|
import { runSkillsCommand } from './commands/skills.js';
|
|
@@ -1111,61 +1109,29 @@ async function help(args, flags, _session) {
|
|
|
1111
1109
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
1112
1110
|
].join('\n'));
|
|
1113
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
|
+
*/
|
|
1114
1127
|
async function doctor(_args, flags, _session) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
return {
|
|
1123
|
-
stop: 'error',
|
|
1124
|
-
code: 'failed',
|
|
1125
|
-
message: 'doctor: inert client',
|
|
1126
|
-
};
|
|
1127
|
-
},
|
|
1128
|
-
};
|
|
1129
|
-
const adapters = [
|
|
1130
|
-
new NoopEngineAdapter(),
|
|
1131
|
-
new NativePugiEngineAdapter({ client: inertClient }),
|
|
1132
|
-
];
|
|
1133
|
-
const capabilities = await Promise.all(adapters.map(async (adapter) => ({
|
|
1134
|
-
name: adapter.name,
|
|
1135
|
-
capabilities: await adapter.capabilities(),
|
|
1136
|
-
})));
|
|
1137
|
-
const payload = {
|
|
1138
|
-
cliVersion: PUGI_CLI_VERSION,
|
|
1139
|
-
nodeVersion: process.version,
|
|
1140
|
-
workspaceRoot: cwd,
|
|
1141
|
-
pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
|
|
1142
|
-
pugiDir: existsSync(resolve(cwd, '.pugi')),
|
|
1143
|
-
eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
|
|
1144
|
-
permissionMode: settings.permissions.mode,
|
|
1145
|
-
approvals: settings.workflow.approvals,
|
|
1146
|
-
notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
|
|
1147
|
-
protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
|
|
1148
|
-
protectedFileSafety: 'configured-in-m1',
|
|
1149
|
-
mcpTrust: 'not-configured',
|
|
1150
|
-
releaseGuard: 'scaffolded',
|
|
1151
|
-
tools: toolRegistry,
|
|
1152
|
-
engineAdapters: capabilities,
|
|
1153
|
-
schemaBundleHash: createHash('sha256')
|
|
1154
|
-
.update(toolSchemaBundleHashInput())
|
|
1155
|
-
.digest('hex'),
|
|
1156
|
-
};
|
|
1157
|
-
writeOutput(flags, payload, [
|
|
1158
|
-
'Pugi doctor',
|
|
1159
|
-
`CLI: ${payload.cliVersion}`,
|
|
1160
|
-
`Node: ${payload.nodeVersion}`,
|
|
1161
|
-
`Workspace: ${payload.workspaceRoot}`,
|
|
1162
|
-
`Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
|
|
1163
|
-
`Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
|
|
1164
|
-
`Event log: ${payload.eventLog ? 'present' : 'missing'}`,
|
|
1165
|
-
`Permission mode: ${payload.permissionMode}`,
|
|
1166
|
-
`Approvals: ${payload.approvals}`,
|
|
1167
|
-
`Release guard: ${payload.releaseGuard}`,
|
|
1168
|
-
].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
|
+
});
|
|
1169
1135
|
}
|
|
1170
1136
|
/**
|
|
1171
1137
|
* Programmatic init scaffolder. Idempotent — every helper call is a
|