dev-diagnosis-cli 0.1.7 → 0.2.0
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/CHANGELOG.md +113 -1
- package/README.md +127 -17
- package/bin/dev-diagnosis.js +239 -182
- package/lib/checks.mjs +280 -0
- package/lib/context.mjs +199 -11
- package/lib/mcp.mjs +271 -0
- package/lib/report.mjs +12 -0
- package/lib/setup.mjs +249 -0
- package/lib/skill.mjs +81 -0
- package/package.json +2 -2
- /package/examples/runs/{run-1 → run-01}/results.xml +0 -0
- /package/examples/runs/{run-2 → run-02}/results.xml +0 -0
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server mode: `dev-diagnosis --mcp`.
|
|
3
|
+
*
|
|
4
|
+
* A stdio MCP server in the standard library only — the same code the
|
|
5
|
+
* CLI runs, served over JSON-RPC 2.0, one message per line. Agents
|
|
6
|
+
* connect with the usual config:
|
|
7
|
+
*
|
|
8
|
+
* { "mcpServers": { "dev-diagnosis": { "command": "dev-diagnosis", "args": ["--mcp"] } } }
|
|
9
|
+
*
|
|
10
|
+
* Protocol: `initialize` (2024-11-05), `notifications/initialized`,
|
|
11
|
+
* `tools/list`, `tools/call`, `ping`. Each tool returns the same JSON
|
|
12
|
+
* the CLI's --json output produces, so an agent that already knows the
|
|
13
|
+
* CLI output knows the tool output too.
|
|
14
|
+
*
|
|
15
|
+
* Sandbox: `--sandbox <dir>` (alias `--root`) restricts every path the
|
|
16
|
+
* tools touch to that directory. A path outside it is rejected with a
|
|
17
|
+
* plain error before anything runs. The `path` tool takes only a
|
|
18
|
+
* command name, so it has nothing to sandbox and is always allowed.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import {
|
|
23
|
+
UsageError, allJson, applyContextFixAndRescan, rescanContext, runAll,
|
|
24
|
+
runCacheCheck, runContextCheck, runFlakesCheck, runPathCheck,
|
|
25
|
+
stripCacheStdout,
|
|
26
|
+
} from './checks.mjs';
|
|
27
|
+
|
|
28
|
+
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
29
|
+
|
|
30
|
+
const TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'diagnose_context',
|
|
33
|
+
description: 'Find the files an AI coding agent should not read in a repo: build output, lockfiles, logs and oversized files. Returns a 0-100 score, the noise found, the token estimate, and the .aiignore suggestions that would fix it.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
path: { type: 'string', description: 'Repo to scan (default: the sandbox root, or the current directory without one).' },
|
|
38
|
+
fix: { type: 'boolean', description: 'Write the .aiignore suggestions into <path>/.aiignore.' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'diagnose_path',
|
|
44
|
+
description: 'Diagnose why a CLI command is missing: one row per shell context (current, login, non-login, daemon-like), shadowed copies, toolchain installs, and a copy-paste fix.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
command: { type: 'string', description: 'Command to look up, for example "node".' },
|
|
49
|
+
},
|
|
50
|
+
required: ['command'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'diagnose_cache',
|
|
55
|
+
description: 'Time one build command cold (cache moved aside) and warm (cache present) and say whether the cache pays. The cache directory is restored afterwards.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
path: { type: 'string', description: 'Project directory to run the build in (default: the sandbox root, or the current directory without one).' },
|
|
60
|
+
command: { type: 'array', items: { type: 'string' }, description: 'Build command as an argv array, for example ["node", "build.mjs"].' },
|
|
61
|
+
cacheDir: { type: 'string', description: 'Cache directory to measure (default: auto-detected under the project).' },
|
|
62
|
+
},
|
|
63
|
+
required: ['command'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'diagnose_flakes',
|
|
68
|
+
description: 'Rank flaky tests from JUnit XML across CI runs, including passed-on-retry. Each subdirectory of the runs directory is one run; each *.xml file directly under it is a run of its own.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: 'string', description: 'Runs directory to scan.' },
|
|
73
|
+
top: { type: 'integer', description: 'Show only the N highest-ranked flaky tests.' },
|
|
74
|
+
},
|
|
75
|
+
required: ['path'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'diagnose_all',
|
|
80
|
+
description: 'Run every check that has what it needs and print one unified report: context always runs, path runs with a command name, flakes with a runs directory, cache with a build command. Checks without their input are listed as skipped.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
path: { type: 'string', description: 'Repo to diagnose (default: the sandbox root, or the current directory without one).' },
|
|
85
|
+
command: { type: 'string', description: 'Command name for the path check, for example "node".' },
|
|
86
|
+
flakesDir: { type: 'string', description: 'JUnit runs directory for the flakes check.' },
|
|
87
|
+
buildCommand: { type: 'array', items: { type: 'string' }, description: 'Build command as an argv array for the cache check.' },
|
|
88
|
+
cacheDir: { type: 'string', description: 'Cache directory to measure (default: auto-detected).' },
|
|
89
|
+
fix: { type: 'boolean', description: 'Write the context check .aiignore suggestions.' },
|
|
90
|
+
top: { type: 'integer', description: 'Show only the N highest-ranked flaky tests.' },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Resolve a tool argument to an absolute path inside the sandbox, or throw. */
|
|
97
|
+
export function resolveInSandbox(sandbox, value, label) {
|
|
98
|
+
if (value == null || value === '') return sandbox || process.cwd();
|
|
99
|
+
const resolved = path.resolve(sandbox || process.cwd(), value);
|
|
100
|
+
if (!sandbox) return resolved;
|
|
101
|
+
const root = path.resolve(sandbox);
|
|
102
|
+
if (resolved === root) return resolved;
|
|
103
|
+
const rel = path.relative(root, resolved);
|
|
104
|
+
if (rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) {
|
|
105
|
+
throw new Error(`${label} is outside the sandbox (${sandbox}): ${value}`);
|
|
106
|
+
}
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function textResult(payload) {
|
|
111
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function errorResult(message) {
|
|
115
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function callTool(name, args, sandbox) {
|
|
119
|
+
const input = args && typeof args === 'object' ? args : {};
|
|
120
|
+
switch (name) {
|
|
121
|
+
case 'diagnose_context': {
|
|
122
|
+
const target = resolveInSandbox(sandbox, input.path, 'path');
|
|
123
|
+
const check = runContextCheck(target);
|
|
124
|
+
// With fix, the tool answers with the post-fix scan — an agent
|
|
125
|
+
// reading the result needs to know whether the repo is fixed
|
|
126
|
+
// now, not what it scored before the fix.
|
|
127
|
+
if (input.fix) return textResult(applyContextFixAndRescan(check).result);
|
|
128
|
+
return textResult(check.result);
|
|
129
|
+
}
|
|
130
|
+
case 'diagnose_path': {
|
|
131
|
+
if (!input.command) throw new UsageError('diagnose_path needs a command');
|
|
132
|
+
return textResult(runPathCheck(input.command).result);
|
|
133
|
+
}
|
|
134
|
+
case 'diagnose_cache': {
|
|
135
|
+
if (!Array.isArray(input.command) || input.command.length === 0) {
|
|
136
|
+
throw new UsageError('diagnose_cache needs a build command array');
|
|
137
|
+
}
|
|
138
|
+
const root = resolveInSandbox(sandbox, input.path, 'path');
|
|
139
|
+
const cacheDir = input.cacheDir
|
|
140
|
+
? resolveInSandbox(sandbox, path.resolve(root, input.cacheDir), 'cacheDir')
|
|
141
|
+
: undefined;
|
|
142
|
+
const check = runCacheCheck({ buildCommand: input.command, cacheDir, cwd: root });
|
|
143
|
+
return textResult(stripCacheStdout(check.result));
|
|
144
|
+
}
|
|
145
|
+
case 'diagnose_flakes': {
|
|
146
|
+
if (!input.path) throw new UsageError('diagnose_flakes needs a runs directory');
|
|
147
|
+
const dir = resolveInSandbox(sandbox, input.path, 'path');
|
|
148
|
+
const check = runFlakesCheck(dir);
|
|
149
|
+
const out = input.top != null ? { ...check.result, tests: check.result.tests.slice(0, input.top) } : check.result;
|
|
150
|
+
return textResult(out);
|
|
151
|
+
}
|
|
152
|
+
case 'diagnose_all': {
|
|
153
|
+
const root = resolveInSandbox(sandbox, input.path, 'path');
|
|
154
|
+
// flakesDir is relative to the sandbox (or cwd), like the CLI's
|
|
155
|
+
// --flakes is relative to the process cwd — not to the repo root.
|
|
156
|
+
const flakesDir = input.flakesDir
|
|
157
|
+
? resolveInSandbox(sandbox, input.flakesDir, 'flakesDir')
|
|
158
|
+
: null;
|
|
159
|
+
// cacheDir is relative to the repo root, like the CLI's
|
|
160
|
+
// --cache-dir.
|
|
161
|
+
const cacheDir = input.cacheDir
|
|
162
|
+
? resolveInSandbox(sandbox, path.resolve(root, input.cacheDir), 'cacheDir')
|
|
163
|
+
: null;
|
|
164
|
+
const run = runAll({
|
|
165
|
+
root,
|
|
166
|
+
commandName: input.command || null,
|
|
167
|
+
flakesDir,
|
|
168
|
+
buildCommand: Array.isArray(input.buildCommand) && input.buildCommand.length > 0 ? input.buildCommand : null,
|
|
169
|
+
cacheDir,
|
|
170
|
+
});
|
|
171
|
+
if (input.fix) {
|
|
172
|
+
run.applyContextFix();
|
|
173
|
+
rescanContext(run);
|
|
174
|
+
}
|
|
175
|
+
return textResult(allJson(run, input.top != null ? input.top : null));
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`unknown tool: ${name}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function handleMessage(message, sandbox, version) {
|
|
183
|
+
if (message == null || typeof message !== 'object' || Array.isArray(message)) {
|
|
184
|
+
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } };
|
|
185
|
+
}
|
|
186
|
+
const { method, id, params } = message;
|
|
187
|
+
// A notification (no id) never gets a response.
|
|
188
|
+
const isNotification = id === undefined;
|
|
189
|
+
if (typeof method !== 'string') {
|
|
190
|
+
return isNotification ? null : { jsonrpc: '2.0', id: id ?? null, error: { code: -32600, message: 'Invalid Request' } };
|
|
191
|
+
}
|
|
192
|
+
if (method.startsWith('notifications/')) return null;
|
|
193
|
+
if (isNotification) return null;
|
|
194
|
+
|
|
195
|
+
const respond = (result) => ({ jsonrpc: '2.0', id, result });
|
|
196
|
+
const fail = (code, text) => ({ jsonrpc: '2.0', id, error: { code, message: text } });
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
switch (method) {
|
|
200
|
+
case 'initialize':
|
|
201
|
+
return respond({
|
|
202
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
203
|
+
capabilities: { tools: {} },
|
|
204
|
+
serverInfo: { name: 'dev-diagnosis', version },
|
|
205
|
+
});
|
|
206
|
+
case 'ping':
|
|
207
|
+
return respond({});
|
|
208
|
+
case 'tools/list':
|
|
209
|
+
return respond({ tools: TOOLS });
|
|
210
|
+
case 'tools/call': {
|
|
211
|
+
if (!params || typeof params.name !== 'string') return fail(-32602, 'tools/call needs a tool name');
|
|
212
|
+
try {
|
|
213
|
+
return respond(callTool(params.name, params.arguments, sandbox));
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// A tool failure is a tool result with isError, not a
|
|
216
|
+
// JSON-RPC error: the call itself worked, the check did not.
|
|
217
|
+
return respond(errorResult(error.message));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
default:
|
|
221
|
+
return fail(-32601, `Method not found: ${method}`);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return fail(-32603, error.message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Serve MCP over stdio until stdin closes. One JSON message per line;
|
|
230
|
+
* a malformed line gets a -32700 parse error and the server keeps going.
|
|
231
|
+
* Resolves when stdin ends; never throws for a bad message.
|
|
232
|
+
*/
|
|
233
|
+
export function runMcpServer(options = {}) {
|
|
234
|
+
const sandbox = options.sandbox ? path.resolve(options.sandbox) : null;
|
|
235
|
+
const version = options.version || '0.2.0';
|
|
236
|
+
if (sandbox) {
|
|
237
|
+
let stat;
|
|
238
|
+
try { stat = fs.statSync(sandbox); } catch { stat = null; }
|
|
239
|
+
if (!stat || !stat.isDirectory()) throw new Error(`sandbox is not a directory: ${sandbox}`);
|
|
240
|
+
}
|
|
241
|
+
let buffer = '';
|
|
242
|
+
const write = (payload) => process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
243
|
+
process.stdin.setEncoding('utf8');
|
|
244
|
+
process.stdin.on('data', (chunk) => {
|
|
245
|
+
buffer += chunk;
|
|
246
|
+
let newline = buffer.indexOf('\n');
|
|
247
|
+
while (newline !== -1) {
|
|
248
|
+
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
|
249
|
+
buffer = buffer.slice(newline + 1);
|
|
250
|
+
if (line.trim() === '') {
|
|
251
|
+
newline = buffer.indexOf('\n');
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let message;
|
|
255
|
+
try {
|
|
256
|
+
message = JSON.parse(line);
|
|
257
|
+
} catch {
|
|
258
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
259
|
+
newline = buffer.indexOf('\n');
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const response = handleMessage(message, sandbox, version);
|
|
263
|
+
if (response) write(response);
|
|
264
|
+
newline = buffer.indexOf('\n');
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
process.stdin.on('end', resolve);
|
|
269
|
+
process.stdin.resume();
|
|
270
|
+
});
|
|
271
|
+
}
|
package/lib/report.mjs
CHANGED
|
@@ -16,3 +16,15 @@ export function formatMs(ms) {
|
|
|
16
16
|
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
17
17
|
return `${(ms / 1000).toFixed(2)} s`;
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Short form for a token estimate: 420, 4.2k, 1.2M. */
|
|
21
|
+
export function formatTokens(tokens) {
|
|
22
|
+
if (tokens == null) return '—';
|
|
23
|
+
if (tokens < 1000) return String(tokens);
|
|
24
|
+
if (tokens < 1000000) {
|
|
25
|
+
const value = tokens / 1000;
|
|
26
|
+
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}k`;
|
|
27
|
+
}
|
|
28
|
+
const value = tokens / 1000000;
|
|
29
|
+
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}M`;
|
|
30
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-command install: `dev-diagnosis setup`.
|
|
3
|
+
*
|
|
4
|
+
* Detects which agents are installed on this machine and writes the MCP
|
|
5
|
+
* config that wires dev-diagnosis into each of them — Claude Code,
|
|
6
|
+
* Codex CLI, Cursor and VS Code. The command always shows a preview of
|
|
7
|
+
* exactly what would be written before it writes anything, and it
|
|
8
|
+
* never silently overwrites:
|
|
9
|
+
*
|
|
10
|
+
* - a config that already mentions dev-diagnosis is reported as
|
|
11
|
+
* already wired and left alone;
|
|
12
|
+
* - a config that exists without it is merged (other entries kept) and
|
|
13
|
+
* the change is reported;
|
|
14
|
+
* - a missing config is created with just the dev-diagnosis entry.
|
|
15
|
+
*
|
|
16
|
+
* A config file that cannot be parsed is left alone and reported as an
|
|
17
|
+
* error for that agent — the tool never rewrites a file it cannot read.
|
|
18
|
+
* TOML (Codex) is appended to, never rewritten: only the
|
|
19
|
+
* `[mcp_servers.dev-diagnosis]` section is added when it is absent.
|
|
20
|
+
*
|
|
21
|
+
* Detection is by config file or by the agent's CLI being on PATH, so
|
|
22
|
+
* an agent with no config yet still gets one. The MCP entry itself is
|
|
23
|
+
* the same shape the README documents: command `dev-diagnosis`, args
|
|
24
|
+
* `["--mcp"]`. If `dev-diagnosis` is not on PATH, setup warns that the
|
|
25
|
+
* agents will not be able to run it until it is installed globally.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { lookupCommand } from './pathcheck.mjs';
|
|
31
|
+
|
|
32
|
+
export const MCP_COMMAND = 'dev-diagnosis';
|
|
33
|
+
export const MCP_ARGS = ['--mcp'];
|
|
34
|
+
export const MCP_ENTRY = { command: MCP_COMMAND, args: MCP_ARGS };
|
|
35
|
+
|
|
36
|
+
export function mcpEntryJson() {
|
|
37
|
+
return { mcpServers: { 'dev-diagnosis': MCP_ENTRY } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function vscodeSettingsPath(home) {
|
|
41
|
+
if (process.platform === 'darwin') {
|
|
42
|
+
return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json');
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
46
|
+
return path.join(appData, 'Code', 'User', 'settings.json');
|
|
47
|
+
}
|
|
48
|
+
return path.join(home, '.config', 'Code', 'User', 'settings.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The agents setup knows about. Each entry describes where its MCP
|
|
53
|
+
* config lives and how it is stored. `configPath` is a function of
|
|
54
|
+
* the home directory so tests can point it at a temp dir.
|
|
55
|
+
*/
|
|
56
|
+
export function agentDefs(home) {
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
id: 'claude-code',
|
|
60
|
+
name: 'Claude Code',
|
|
61
|
+
binaries: ['claude'],
|
|
62
|
+
format: 'json',
|
|
63
|
+
configPath: path.join(home, '.claude.json'),
|
|
64
|
+
mergeKey: ['mcpServers'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'codex',
|
|
68
|
+
name: 'Codex CLI',
|
|
69
|
+
binaries: ['codex'],
|
|
70
|
+
format: 'toml',
|
|
71
|
+
configPath: path.join(home, '.codex', 'config.toml'),
|
|
72
|
+
mergeKey: null,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'cursor',
|
|
76
|
+
name: 'Cursor',
|
|
77
|
+
binaries: ['cursor'],
|
|
78
|
+
format: 'json',
|
|
79
|
+
configPath: path.join(home, '.cursor', 'mcp.json'),
|
|
80
|
+
mergeKey: ['mcpServers'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'vscode',
|
|
84
|
+
name: 'VS Code',
|
|
85
|
+
binaries: ['code'],
|
|
86
|
+
format: 'json',
|
|
87
|
+
configPath: vscodeSettingsPath(home),
|
|
88
|
+
mergeKey: ['mcp', 'servers'],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function binaryOnPath(name, pathEnv) {
|
|
94
|
+
return lookupCommand(name, pathEnv || process.env.PATH || '').resolved;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJsonFile(file) {
|
|
98
|
+
try {
|
|
99
|
+
return { data: JSON.parse(fs.readFileSync(file, 'utf8')), error: null };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { data: null, error: error.message };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nestedGet(obj, keys) {
|
|
106
|
+
let current = obj;
|
|
107
|
+
for (const key of keys) {
|
|
108
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
109
|
+
current = current[key];
|
|
110
|
+
}
|
|
111
|
+
return current;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function nestedSet(obj, keys, value) {
|
|
115
|
+
let current = obj;
|
|
116
|
+
for (let i = 0; i < keys.length - 1; i += 1) {
|
|
117
|
+
if (current[keys[i]] == null || typeof current[keys[i]] !== 'object') current[keys[i]] = {};
|
|
118
|
+
current = current[keys[i]];
|
|
119
|
+
}
|
|
120
|
+
current[keys[keys.length - 1]] = value;
|
|
121
|
+
return obj;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tomlSection() {
|
|
125
|
+
return '[mcp_servers.dev-diagnosis]\ncommand = "dev-diagnosis"\nargs = ["--mcp"]\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasTomlEntry(content) {
|
|
129
|
+
return /^\s*\[mcp_servers\.dev-diagnosis\]/m.test(content);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detect installed agents. `options` takes { home, pathEnv } so tests
|
|
134
|
+
* can point detection at a temp HOME without touching the real one.
|
|
135
|
+
* An agent counts as installed when its config file exists or its CLI
|
|
136
|
+
* is on PATH. Returns one row per agent: { id, name, format,
|
|
137
|
+
* configPath, detected, via } where via is 'config', 'binary' or
|
|
138
|
+
* 'config+binary'.
|
|
139
|
+
*/
|
|
140
|
+
export function detectAgents(options = {}) {
|
|
141
|
+
const home = options.home || os.homedir();
|
|
142
|
+
const pathEnv = options.pathEnv !== undefined ? options.pathEnv : process.env.PATH || '';
|
|
143
|
+
return agentDefs(home).map((def) => {
|
|
144
|
+
const hasConfig = fs.existsSync(def.configPath);
|
|
145
|
+
const hasBinary = def.binaries.some((name) => binaryOnPath(name, pathEnv));
|
|
146
|
+
const via = hasConfig && hasBinary ? 'config+binary' : hasConfig ? 'config' : hasBinary ? 'binary' : null;
|
|
147
|
+
return { ...def, detected: hasConfig || hasBinary, via };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Work out what setup would do for each detected agent, without
|
|
153
|
+
* writing anything. Returns { agents, commandOnPath } where each agent
|
|
154
|
+
* row carries:
|
|
155
|
+
* status: 'already-wired' | 'merge' | 'create' | 'unreadable'
|
|
156
|
+
* preview: the exact text that would be written or added (for
|
|
157
|
+
* 'merge' on JSON, the added entry; for 'create', the full file)
|
|
158
|
+
* note: human-readable explanation for unreadable configs.
|
|
159
|
+
*/
|
|
160
|
+
export function planSetup(options = {}) {
|
|
161
|
+
const home = options.home || os.homedir();
|
|
162
|
+
const agents = detectAgents({ home, pathEnv: options.pathEnv }).filter((agent) => agent.detected);
|
|
163
|
+
const commandOnPath = binaryOnPath(MCP_COMMAND, options.pathEnv !== undefined ? options.pathEnv : process.env.PATH || '');
|
|
164
|
+
const planned = agents.map((agent) => {
|
|
165
|
+
const row = { id: agent.id, name: agent.name, format: agent.format, configPath: agent.configPath, mergeKey: agent.mergeKey };
|
|
166
|
+
if (agent.format === 'toml') {
|
|
167
|
+
if (!fs.existsSync(agent.configPath)) {
|
|
168
|
+
return { ...row, status: 'create', preview: tomlSection() };
|
|
169
|
+
}
|
|
170
|
+
const content = fs.readFileSync(agent.configPath, 'utf8');
|
|
171
|
+
if (hasTomlEntry(content)) return { ...row, status: 'already-wired', preview: null };
|
|
172
|
+
return { ...row, status: 'merge', preview: tomlSection() };
|
|
173
|
+
}
|
|
174
|
+
// JSON formats: merge under mergeKey, never touch other entries.
|
|
175
|
+
if (!fs.existsSync(agent.configPath)) {
|
|
176
|
+
const data = nestedSet({}, agent.mergeKey, { 'dev-diagnosis': MCP_ENTRY });
|
|
177
|
+
return { ...row, status: 'create', preview: `${JSON.stringify(data, null, 2)}\n` };
|
|
178
|
+
}
|
|
179
|
+
const { data, error } = readJsonFile(agent.configPath);
|
|
180
|
+
if (error) {
|
|
181
|
+
return { ...row, status: 'unreadable', preview: null, note: `could not parse ${agent.configPath}: ${error} — left alone` };
|
|
182
|
+
}
|
|
183
|
+
const existing = nestedGet(data, agent.mergeKey);
|
|
184
|
+
if (existing && typeof existing === 'object' && existing['dev-diagnosis'] !== undefined) {
|
|
185
|
+
return { ...row, status: 'already-wired', preview: null };
|
|
186
|
+
}
|
|
187
|
+
if (existing !== undefined && (existing === null || typeof existing !== 'object')) {
|
|
188
|
+
return { ...row, status: 'unreadable', preview: null, note: `${agent.mergeKey.join('.')} in ${agent.configPath} is not an object — left alone` };
|
|
189
|
+
}
|
|
190
|
+
return { ...row, status: 'merge', preview: JSON.stringify({ 'dev-diagnosis': MCP_ENTRY }, null, 2) };
|
|
191
|
+
});
|
|
192
|
+
return { agents: planned, commandOnPath };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Carry out a plan from planSetup(). Writes only 'merge' and 'create'
|
|
197
|
+
* rows; 'already-wired' and 'unreadable' rows are reported and left
|
|
198
|
+
* alone. Returns one result per agent: { id, name, configPath,
|
|
199
|
+
* action: 'already-wired' | 'merged' | 'created' | 'unreadable',
|
|
200
|
+
* detail }.
|
|
201
|
+
*/
|
|
202
|
+
export function applySetup(plan) {
|
|
203
|
+
return plan.agents.map((row) => {
|
|
204
|
+
const base = { id: row.id, name: row.name, configPath: row.configPath };
|
|
205
|
+
if (row.status === 'already-wired') return { ...base, action: 'already-wired', detail: 'dev-diagnosis is already in this config' };
|
|
206
|
+
if (row.status === 'unreadable') return { ...base, action: 'unreadable', detail: row.note };
|
|
207
|
+
fs.mkdirSync(path.dirname(row.configPath), { recursive: true });
|
|
208
|
+
if (row.format === 'toml') {
|
|
209
|
+
if (row.status === 'create') {
|
|
210
|
+
fs.writeFileSync(row.configPath, row.preview);
|
|
211
|
+
return { ...base, action: 'created', detail: 'created with the dev-diagnosis MCP server entry' };
|
|
212
|
+
}
|
|
213
|
+
const existing = fs.readFileSync(row.configPath, 'utf8');
|
|
214
|
+
const joined = existing.endsWith('\n') || existing === '' ? `${existing}${row.preview}` : `${existing}\n${row.preview}`;
|
|
215
|
+
fs.writeFileSync(row.configPath, joined);
|
|
216
|
+
return { ...base, action: 'merged', detail: 'appended [mcp_servers.dev-diagnosis] to the existing config' };
|
|
217
|
+
}
|
|
218
|
+
if (row.status === 'create') {
|
|
219
|
+
fs.writeFileSync(row.configPath, row.preview);
|
|
220
|
+
return { ...base, action: 'created', detail: 'created with the dev-diagnosis MCP server entry' };
|
|
221
|
+
}
|
|
222
|
+
const { data } = readJsonFile(row.configPath);
|
|
223
|
+
nestedSet(data, [...row.mergeKey, 'dev-diagnosis'], MCP_ENTRY);
|
|
224
|
+
fs.writeFileSync(row.configPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
225
|
+
return { ...base, action: 'merged', detail: 'added dev-diagnosis under the existing MCP servers, other entries kept' };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Report which detected agents are wired and which are not. Returns
|
|
231
|
+
* { rows, detected, allWired } where each row is { id, name,
|
|
232
|
+
* configPath, wired }. An agent with an unreadable config counts as
|
|
233
|
+
* not wired.
|
|
234
|
+
*/
|
|
235
|
+
export function checkSetup(options = {}) {
|
|
236
|
+
const plan = planSetup(options);
|
|
237
|
+
const rows = plan.agents.map((row) => ({
|
|
238
|
+
id: row.id,
|
|
239
|
+
name: row.name,
|
|
240
|
+
configPath: row.configPath,
|
|
241
|
+
wired: row.status === 'already-wired',
|
|
242
|
+
status: row.status,
|
|
243
|
+
}));
|
|
244
|
+
return {
|
|
245
|
+
rows,
|
|
246
|
+
detected: rows.length,
|
|
247
|
+
allWired: rows.length > 0 && rows.every((row) => row.wired),
|
|
248
|
+
};
|
|
249
|
+
}
|
package/lib/skill.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Skills generation: `dev-diagnosis --skill-generate`.
|
|
3
|
+
*
|
|
4
|
+
* Writes a Claude Agent Skills SKILL.md that tells an agent to run
|
|
5
|
+
* dev-diagnosis when it starts work in a repo, read the verdicts, fix
|
|
6
|
+
* what fails, and re-run to confirm. Default scope is --project
|
|
7
|
+
* (./.claude/skills/dev-diagnosis/SKILL.md); --global writes
|
|
8
|
+
* ~/.claude/skills/dev-diagnosis/SKILL.md instead.
|
|
9
|
+
*
|
|
10
|
+
* Never overwrites: if the SKILL.md already exists, generation stops
|
|
11
|
+
* with a plain message and the caller exits 1 — the file may hold the
|
|
12
|
+
* user's own edits.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const SKILL_DIR_NAME = 'dev-diagnosis';
|
|
19
|
+
|
|
20
|
+
export const SKILL_MD = `---
|
|
21
|
+
name: dev-diagnosis
|
|
22
|
+
description: Diagnose a repo before working in it — context noise that wastes tokens, missing commands, build-cache value, flaky tests. Run when starting work in a repository, after a build or test failure, or when an agent session feels slow.
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# dev-diagnosis
|
|
26
|
+
|
|
27
|
+
Run this before reading a repo, and again after fixing what it finds.
|
|
28
|
+
|
|
29
|
+
## When to use it
|
|
30
|
+
|
|
31
|
+
- Starting work in a repository you have not diagnosed yet.
|
|
32
|
+
- An agent session feels slow, or the context looks full of files it should not be reading.
|
|
33
|
+
- A build is slow, a command is missing, or tests keep failing on retry.
|
|
34
|
+
|
|
35
|
+
## Run it
|
|
36
|
+
|
|
37
|
+
\`\`\`sh
|
|
38
|
+
dev-diagnosis all
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
If the command is not installed, use \`npx dev-diagnosis-cli\` instead. Run it in the repo directory; pass a path to diagnose another one (\`dev-diagnosis all ./other-repo\`).
|
|
42
|
+
|
|
43
|
+
## Read the verdicts
|
|
44
|
+
|
|
45
|
+
- **context FAIL** — the repo has noise an AI agent should not read (build output, lockfiles, logs, oversized files). The report lists the files and the token estimate. Fix it with \`dev-diagnosis context --fix\`, which writes the suggestions into \`.aiignore\`, then re-run \`dev-diagnosis all\` to confirm the score is 100/100.
|
|
46
|
+
- **path FAIL** — a command is missing in the current shell. The report says which shell contexts have it and prints a copy-paste fix; apply the fix, then re-run.
|
|
47
|
+
- **flakes FAIL** — the report ranks the flaky tests. Look at the top-ranked test first; a test marked \`new\` broke recently, \`chronic\` has been unstable for a while.
|
|
48
|
+
- **cache FAIL** — the verdict says whether the build cache pays. If it costs more than it saves, the cache setup is the problem, not the build.
|
|
49
|
+
|
|
50
|
+
Skipped checks are not failures: they just need their input
|
|
51
|
+
(\`--command <name>\` for path, \`--flakes <runs-dir>\` for flakes,
|
|
52
|
+
\`-- <build-command>\` for cache).
|
|
53
|
+
|
|
54
|
+
## Rules
|
|
55
|
+
|
|
56
|
+
- Fix what FAILs before starting the real work; a 75/100 repo costs real tokens on every turn.
|
|
57
|
+
- Always re-run \`dev-diagnosis all\` after a fix and confirm it passes.
|
|
58
|
+
- Never edit \`.aiignore\` by hand to hide a failure you have not read — the file is the record of what the agent agreed not to read.
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export function skillPath(scope = 'project', base = process.cwd()) {
|
|
62
|
+
if (scope === 'global') {
|
|
63
|
+
return path.join(os.homedir(), '.claude', 'skills', SKILL_DIR_NAME, 'SKILL.md');
|
|
64
|
+
}
|
|
65
|
+
return path.join(path.resolve(base), '.claude', 'skills', SKILL_DIR_NAME, 'SKILL.md');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write the SKILL.md. Returns `{ path, created: true }` on success,
|
|
70
|
+
* or `{ path, created: false, reason }` when the file already exists —
|
|
71
|
+
* the caller prints the reason and exits 1.
|
|
72
|
+
*/
|
|
73
|
+
export function generateSkill(scope = 'project', base = process.cwd()) {
|
|
74
|
+
const target = skillPath(scope, base);
|
|
75
|
+
if (fs.existsSync(target)) {
|
|
76
|
+
return { path: target, created: false, reason: `already exists: ${target}` };
|
|
77
|
+
}
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
79
|
+
fs.writeFileSync(target, SKILL_MD);
|
|
80
|
+
return { path: target, created: true };
|
|
81
|
+
}
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|