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/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
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "scripts": {
40
40
  "example": "node examples/demo.mjs",
41
- "test": "node --test"
41
+ "test": "node --test --test-concurrency=1"
42
42
  },
43
43
  "type": "module",
44
- "version": "0.1.7"
44
+ "version": "0.2.0"
45
45
  }
File without changes
File without changes