@nerviq/cli 0.9.2 → 0.9.4
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/bin/cli.js +64 -3
- package/package.json +3 -2
- package/src/aider/techniques.js +85 -11
- package/src/audit.js +3 -2
- package/src/codex/techniques.js +3 -0
- package/src/convert.js +336 -0
- package/src/copilot/techniques.js +125 -11
- package/src/cursor/techniques.js +93 -10
- package/src/doctor.js +253 -0
- package/src/feedback.js +173 -0
- package/src/freshness.js +177 -0
- package/src/gemini/techniques.js +177 -23
- package/src/mcp-server.js +373 -0
- package/src/migrate.js +354 -0
- package/src/opencode/techniques.js +73 -99
- package/src/source-urls.js +219 -0
- package/src/techniques.js +3 -0
- package/src/windsurf/techniques.js +214 -138
package/src/cursor/techniques.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cursor techniques module — CHECK CATALOG
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* v0.1 (40): A. Rules(9), B. Config(
|
|
6
|
-
* v0.5 (55): G. Background Agents(5), H. Automations(
|
|
4
|
+
* 88 checks across 16 categories:
|
|
5
|
+
* v0.1 (40): A. Rules(9), B. Config(8), C. Trust & Safety(11), D. Agent Mode(5), E. MCP(5), F. Instructions Quality(5)
|
|
6
|
+
* v0.5 (55): G. Background Agents(5), H. Automations(6), I. Enterprise(5)
|
|
7
7
|
* v1.0 (70): J. BugBot & Code Review(4), K. Cross-Surface(4), L. Quality Deep(7)
|
|
8
8
|
* CP-08 (82): M. Advisory(4), N. Pack(4), O. Repeat(3), P. Freshness(3)
|
|
9
|
+
* Exp-fixes (88): +4 new checks from experiment findings
|
|
9
10
|
*
|
|
10
11
|
* Each check: { id, name, check(ctx), impact, rating, category, fix, template, file(), line() }
|
|
11
12
|
*/
|
|
@@ -14,6 +15,7 @@ const os = require('os');
|
|
|
14
15
|
const path = require('path');
|
|
15
16
|
const { CursorProjectContext } = require('./context');
|
|
16
17
|
const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
|
|
18
|
+
const { attachSourceUrls } = require('../source-urls');
|
|
17
19
|
const { validateMdcFrontmatter, validateMcpEnvVars } = require('./config-parser');
|
|
18
20
|
|
|
19
21
|
// ─── Shared helpers ─────────────────────────────────────────────────────────
|
|
@@ -180,7 +182,7 @@ const CURSOR_TECHNIQUES = {
|
|
|
180
182
|
impact: 'critical',
|
|
181
183
|
rating: 5,
|
|
182
184
|
category: 'rules',
|
|
183
|
-
fix: 'Migrate .cursorrules to .cursor/rules/*.mdc with
|
|
185
|
+
fix: 'Migrate .cursorrules to .cursor/rules/*.mdc with alwaysApply: true. AGENT MODE COMPLETELY IGNORES .cursorrules (confirmed by direct observation). 82% of projects have broken rules because of this — cursor-doctor audit.',
|
|
184
186
|
template: 'cursor-legacy-migration',
|
|
185
187
|
file: () => '.cursorrules',
|
|
186
188
|
line: () => 1,
|
|
@@ -215,10 +217,10 @@ const CURSOR_TECHNIQUES = {
|
|
|
215
217
|
return validation.valid;
|
|
216
218
|
});
|
|
217
219
|
},
|
|
218
|
-
impact: '
|
|
219
|
-
rating:
|
|
220
|
+
impact: 'critical',
|
|
221
|
+
rating: 5,
|
|
220
222
|
category: 'rules',
|
|
221
|
-
fix: 'Fix YAML frontmatter in .mdc files.
|
|
223
|
+
fix: 'Fix YAML frontmatter in .mdc files. Invalid YAML silently skips the entire rule file — no error, no warning. Only 3 fields recognized: description, globs, alwaysApply. 82% of audited projects have broken rules from this issue.',
|
|
222
224
|
template: null,
|
|
223
225
|
file: () => '.cursor/rules/',
|
|
224
226
|
line: () => 1,
|
|
@@ -476,8 +478,28 @@ const CURSOR_TECHNIQUES = {
|
|
|
476
478
|
line: () => null,
|
|
477
479
|
},
|
|
478
480
|
|
|
481
|
+
cursorMcpServersRootKey: {
|
|
482
|
+
id: 'CU-B08',
|
|
483
|
+
name: 'MCP config has required mcpServers root key',
|
|
484
|
+
check: (ctx) => {
|
|
485
|
+
const raw = mcpJsonRaw(ctx);
|
|
486
|
+
if (!raw) return null;
|
|
487
|
+
const data = mcpJsonData(ctx);
|
|
488
|
+
if (!data) return null;
|
|
489
|
+
// Must have mcpServers key at root — any other key causes silent failure
|
|
490
|
+
return Object.prototype.hasOwnProperty.call(data, 'mcpServers');
|
|
491
|
+
},
|
|
492
|
+
impact: 'critical',
|
|
493
|
+
rating: 5,
|
|
494
|
+
category: 'config',
|
|
495
|
+
fix: 'Ensure .cursor/mcp.json has the "mcpServers" root key. Using "servers" or any other key causes silent failure — zero tools load with no error shown (confirmed by experiment).',
|
|
496
|
+
template: null,
|
|
497
|
+
file: () => '.cursor/mcp.json',
|
|
498
|
+
line: () => 1,
|
|
499
|
+
},
|
|
500
|
+
|
|
479
501
|
// =============================================
|
|
480
|
-
// C. Trust & Safety (
|
|
502
|
+
// C. Trust & Safety (11 checks) — CU-C01..CU-C11
|
|
481
503
|
// =============================================
|
|
482
504
|
|
|
483
505
|
cursorPrivacyMode: {
|
|
@@ -489,10 +511,10 @@ const CURSOR_TECHNIQUES = {
|
|
|
489
511
|
const docs = docsBundle(ctx);
|
|
490
512
|
return /privacy mode|zero.?retention|data retention|privacy.*enabled/i.test(docs);
|
|
491
513
|
},
|
|
492
|
-
impact: '
|
|
514
|
+
impact: 'critical',
|
|
493
515
|
rating: 5,
|
|
494
516
|
category: 'trust',
|
|
495
|
-
fix: '
|
|
517
|
+
fix: 'Privacy Mode is OFF by default — code is sent to all third-party providers (OpenAI, Anthropic, etc.) unless explicitly enabled. Enable in Cursor Settings → Privacy → Privacy Mode, or document the deliberate decision to keep it off.',
|
|
496
518
|
template: null,
|
|
497
519
|
file: () => '.cursor/rules/',
|
|
498
520
|
line: () => null,
|
|
@@ -656,6 +678,44 @@ const CURSOR_TECHNIQUES = {
|
|
|
656
678
|
line: () => null,
|
|
657
679
|
},
|
|
658
680
|
|
|
681
|
+
cursorBackgroundAgentHomeDir: {
|
|
682
|
+
id: 'CU-C10',
|
|
683
|
+
name: 'Background agent home directory exposure documented',
|
|
684
|
+
check: (ctx) => {
|
|
685
|
+
const env = envJsonData(ctx);
|
|
686
|
+
if (!env) return null;
|
|
687
|
+
// If background agents are configured, check that the security risk is documented
|
|
688
|
+
const docs = docsBundle(ctx);
|
|
689
|
+
return /home.?dir|npmrc|aws.?credentials|ssh.*key|credential.*exposure|home.*access/i.test(docs);
|
|
690
|
+
},
|
|
691
|
+
impact: 'critical',
|
|
692
|
+
rating: 5,
|
|
693
|
+
category: 'trust',
|
|
694
|
+
fix: 'Background agents have FULL READ access to ~/.npmrc, ~/.aws/credentials, ~/.ssh/ (open security issue since Nov 2025). Document this risk and remove sensitive credentials from home directory before using background agents, or use environment variable references instead.',
|
|
695
|
+
template: null,
|
|
696
|
+
file: () => '.cursor/environment.json',
|
|
697
|
+
line: () => null,
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
cursorCursorignoreShellBypass: {
|
|
701
|
+
id: 'CU-C11',
|
|
702
|
+
name: '.cursorignore does not protect against shell command access',
|
|
703
|
+
check: (ctx) => {
|
|
704
|
+
const hasIgnore = Boolean(ctx.fileContent('.cursorignore'));
|
|
705
|
+
if (!hasIgnore) return null;
|
|
706
|
+
// If .cursorignore exists, check that docs acknowledge shell bypass gap
|
|
707
|
+
const docs = docsBundle(ctx);
|
|
708
|
+
return /cursorignore.*shell|shell.*bypass|terminal.*ignore|ignore.*terminal/i.test(docs);
|
|
709
|
+
},
|
|
710
|
+
impact: 'high',
|
|
711
|
+
rating: 4,
|
|
712
|
+
category: 'trust',
|
|
713
|
+
fix: '.cursorignore only protects files from @Codebase direct reads — agents can still access ignored files via terminal commands (cat, head, etc.). Do not rely on .cursorignore for security. Use proper OS-level file permissions for truly sensitive files.',
|
|
714
|
+
template: null,
|
|
715
|
+
file: () => '.cursorignore',
|
|
716
|
+
line: () => null,
|
|
717
|
+
},
|
|
718
|
+
|
|
659
719
|
// =============================================
|
|
660
720
|
// D. Agent Mode (5 checks) — CU-D01..CU-D05
|
|
661
721
|
// =============================================
|
|
@@ -1140,6 +1200,27 @@ const CURSOR_TECHNIQUES = {
|
|
|
1140
1200
|
line: () => null,
|
|
1141
1201
|
},
|
|
1142
1202
|
|
|
1203
|
+
cursorAutomationFileSaveDebounce: {
|
|
1204
|
+
id: 'CU-H06',
|
|
1205
|
+
name: 'file_save automation triggers have debounce_ms set',
|
|
1206
|
+
check: (ctx) => {
|
|
1207
|
+
const configs = ctx.automationsConfig ? ctx.automationsConfig() : [];
|
|
1208
|
+
if (configs.length === 0) return null;
|
|
1209
|
+
const combined = configs.map(c => c.content).join('\n');
|
|
1210
|
+
// Only relevant if file_save trigger is used
|
|
1211
|
+
if (!/type:\s*file_save|file[_-]save/i.test(combined)) return null;
|
|
1212
|
+
// Must have debounce_ms set to avoid infinite loop
|
|
1213
|
+
return /debounce_ms|debounce-ms/i.test(combined);
|
|
1214
|
+
},
|
|
1215
|
+
impact: 'critical',
|
|
1216
|
+
rating: 5,
|
|
1217
|
+
category: 'automations',
|
|
1218
|
+
fix: 'Add debounce_ms: 30000 (minimum) to all file_save automation triggers. Without debounce, the automation saves a file → triggers itself → infinite loop that consumes your entire automation quota.',
|
|
1219
|
+
template: null,
|
|
1220
|
+
file: () => '.cursor/automations/',
|
|
1221
|
+
line: () => null,
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1143
1224
|
// =============================================
|
|
1144
1225
|
// I. Enterprise (5 checks) — CU-I01..CU-I05
|
|
1145
1226
|
// =============================================
|
|
@@ -1781,6 +1862,8 @@ const CURSOR_TECHNIQUES = {
|
|
|
1781
1862
|
},
|
|
1782
1863
|
};
|
|
1783
1864
|
|
|
1865
|
+
attachSourceUrls('cursor', CURSOR_TECHNIQUES);
|
|
1866
|
+
|
|
1784
1867
|
module.exports = {
|
|
1785
1868
|
CURSOR_TECHNIQUES,
|
|
1786
1869
|
};
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nerviq Doctor
|
|
3
|
+
*
|
|
4
|
+
* Self-diagnostics for the nerviq CLI and the current project environment.
|
|
5
|
+
* Checks: Node version, dependencies, platform detection, freshness gates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { version } = require('../package.json');
|
|
13
|
+
|
|
14
|
+
const COLORS = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[36m',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function c(text, color) {
|
|
25
|
+
return `${COLORS[color] || ''}${text}${COLORS.reset}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PLATFORM_SIGNALS = {
|
|
29
|
+
claude: ['CLAUDE.md', '.claude/CLAUDE.md', '.claude/settings.json'],
|
|
30
|
+
codex: ['AGENTS.md', '.codex/', '.codex/config.toml'],
|
|
31
|
+
cursor: ['.cursor/rules/', '.cursor/mcp.json', '.cursorrules'],
|
|
32
|
+
copilot: ['.github/copilot-instructions.md', '.github/'],
|
|
33
|
+
gemini: ['GEMINI.md', '.gemini/', '.gemini/settings.json'],
|
|
34
|
+
windsurf: ['.windsurf/', '.windsurfrules', '.windsurf/rules/'],
|
|
35
|
+
aider: ['.aider.conf.yml', '.aider.model.settings.yml'],
|
|
36
|
+
opencode: ['opencode.json', '.opencode/'],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const FRESHNESS_MODULES = {
|
|
40
|
+
claude: './freshness',
|
|
41
|
+
codex: './codex/freshness',
|
|
42
|
+
cursor: './cursor/freshness',
|
|
43
|
+
copilot: './copilot/freshness',
|
|
44
|
+
gemini: './gemini/freshness',
|
|
45
|
+
windsurf: './windsurf/freshness',
|
|
46
|
+
aider: './aider/freshness',
|
|
47
|
+
opencode: './opencode/freshness',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ─── Individual checks ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function checkNodeVersion() {
|
|
53
|
+
const raw = process.version.replace('v', '');
|
|
54
|
+
const [major] = raw.split('.').map(Number);
|
|
55
|
+
const ok = major >= 18;
|
|
56
|
+
return {
|
|
57
|
+
label: 'Node.js version',
|
|
58
|
+
status: ok ? 'pass' : 'fail',
|
|
59
|
+
detail: `${process.version} (${ok ? 'meets' : 'below'} minimum v18)`,
|
|
60
|
+
fix: ok ? null : 'Upgrade Node.js to v18 or later: https://nodejs.org',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkDeps() {
|
|
65
|
+
const pkgPath = path.join(__dirname, '..', 'node_modules');
|
|
66
|
+
const exists = fs.existsSync(pkgPath);
|
|
67
|
+
return {
|
|
68
|
+
label: 'node_modules installed',
|
|
69
|
+
status: exists ? 'pass' : 'fail',
|
|
70
|
+
detail: exists ? `${pkgPath}` : 'node_modules missing',
|
|
71
|
+
fix: exists ? null : 'Run: npm install',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkJestInstalled() {
|
|
76
|
+
const jestPath = path.join(__dirname, '..', 'node_modules', 'jest', 'package.json');
|
|
77
|
+
const exists = fs.existsSync(jestPath);
|
|
78
|
+
let jestVersion = null;
|
|
79
|
+
if (exists) {
|
|
80
|
+
try {
|
|
81
|
+
jestVersion = require(jestPath).version;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
label: 'Jest test runner',
|
|
86
|
+
status: exists ? 'pass' : 'warn',
|
|
87
|
+
detail: exists ? `jest@${jestVersion}` : 'jest not found in node_modules',
|
|
88
|
+
fix: exists ? null : 'Run: npm install --save-dev jest',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkPlatformDetection(dir) {
|
|
93
|
+
const detected = [];
|
|
94
|
+
for (const [platform, signals] of Object.entries(PLATFORM_SIGNALS)) {
|
|
95
|
+
for (const signal of signals) {
|
|
96
|
+
const signalPath = path.join(dir, signal);
|
|
97
|
+
if (fs.existsSync(signalPath)) {
|
|
98
|
+
detected.push(platform);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
label: 'Platform detection',
|
|
106
|
+
status: detected.length > 0 ? 'pass' : 'warn',
|
|
107
|
+
detail: detected.length > 0
|
|
108
|
+
? `Detected: ${detected.join(', ')}`
|
|
109
|
+
: 'No platform config files found in current directory',
|
|
110
|
+
detected,
|
|
111
|
+
fix: detected.length === 0
|
|
112
|
+
? 'Run `nerviq setup` to generate baseline config files for your platform'
|
|
113
|
+
: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function checkFreshnessGates() {
|
|
118
|
+
const results = [];
|
|
119
|
+
for (const [platform, modulePath] of Object.entries(FRESHNESS_MODULES)) {
|
|
120
|
+
try {
|
|
121
|
+
const freshness = require(modulePath);
|
|
122
|
+
const gate = freshness.checkReleaseGate({});
|
|
123
|
+
results.push({
|
|
124
|
+
platform,
|
|
125
|
+
status: gate.stale.length === 0 ? 'pass' : 'warn',
|
|
126
|
+
fresh: gate.fresh.length,
|
|
127
|
+
total: gate.results.length,
|
|
128
|
+
stale: gate.stale.length,
|
|
129
|
+
detail: gate.stale.length === 0
|
|
130
|
+
? `All ${gate.results.length} P0 sources fresh`
|
|
131
|
+
: `${gate.stale.length}/${gate.results.length} P0 sources unverified/stale`,
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
results.push({ platform, status: 'error', detail: e.message });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function checkCliPermissions() {
|
|
141
|
+
const cliBin = path.join(__dirname, '..', 'bin', 'cli.js');
|
|
142
|
+
const exists = fs.existsSync(cliBin);
|
|
143
|
+
if (!exists) {
|
|
144
|
+
return { label: 'CLI binary (bin/cli.js)', status: 'fail', detail: 'bin/cli.js not found', fix: null };
|
|
145
|
+
}
|
|
146
|
+
return { label: 'CLI binary (bin/cli.js)', status: 'pass', detail: cliBin, fix: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkGitRepo(dir) {
|
|
150
|
+
const gitPath = path.join(dir, '.git');
|
|
151
|
+
const exists = fs.existsSync(gitPath);
|
|
152
|
+
return {
|
|
153
|
+
label: 'Git repository',
|
|
154
|
+
status: exists ? 'pass' : 'warn',
|
|
155
|
+
detail: exists ? '.git/ found' : 'Not a git repository',
|
|
156
|
+
fix: exists ? null : 'Run: git init (recommended for safety)',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Main doctor function ────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function runDoctor({ dir = process.cwd(), json = false, verbose = false } = {}) {
|
|
163
|
+
const startMs = Date.now();
|
|
164
|
+
|
|
165
|
+
const checks = [
|
|
166
|
+
checkNodeVersion(),
|
|
167
|
+
checkDeps(),
|
|
168
|
+
checkJestInstalled(),
|
|
169
|
+
checkCliPermissions(),
|
|
170
|
+
checkGitRepo(dir),
|
|
171
|
+
checkPlatformDetection(dir),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
const freshnessChecks = checkFreshnessGates();
|
|
175
|
+
|
|
176
|
+
const totalPass = checks.filter(c => c.status === 'pass').length;
|
|
177
|
+
const totalWarn = checks.filter(c => c.status === 'warn').length;
|
|
178
|
+
const totalFail = checks.filter(c => c.status === 'fail').length;
|
|
179
|
+
|
|
180
|
+
const freshPass = freshnessChecks.filter(f => f.status === 'pass').length;
|
|
181
|
+
const freshWarn = freshnessChecks.filter(f => f.status !== 'pass').length;
|
|
182
|
+
|
|
183
|
+
const overallOk = totalFail === 0;
|
|
184
|
+
const elapsed = Date.now() - startMs;
|
|
185
|
+
|
|
186
|
+
if (json) {
|
|
187
|
+
return JSON.stringify({
|
|
188
|
+
nerviq: version,
|
|
189
|
+
node: process.version,
|
|
190
|
+
dir,
|
|
191
|
+
overallOk,
|
|
192
|
+
checks,
|
|
193
|
+
freshnessChecks,
|
|
194
|
+
totalPass,
|
|
195
|
+
totalWarn,
|
|
196
|
+
totalFail,
|
|
197
|
+
freshPass,
|
|
198
|
+
freshWarn,
|
|
199
|
+
elapsed,
|
|
200
|
+
}, null, 2);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const lines = [''];
|
|
204
|
+
lines.push(c(` nerviq doctor v${version}`, 'bold'));
|
|
205
|
+
lines.push(c(' ═══════════════════════════════════════', 'dim'));
|
|
206
|
+
lines.push('');
|
|
207
|
+
|
|
208
|
+
// Environment checks
|
|
209
|
+
lines.push(c(' Environment', 'bold'));
|
|
210
|
+
for (const chk of checks) {
|
|
211
|
+
const icon = chk.status === 'pass' ? c('✓', 'green') : chk.status === 'warn' ? c('⚠', 'yellow') : c('✗', 'red');
|
|
212
|
+
lines.push(` ${icon} ${chk.label.padEnd(32)} ${c(chk.detail, chk.status === 'pass' ? 'dim' : 'reset')}`);
|
|
213
|
+
if (chk.fix && (verbose || chk.status === 'fail')) {
|
|
214
|
+
lines.push(c(` Fix: ${chk.fix}`, 'yellow'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Platform detection detail
|
|
219
|
+
const detectedPlatforms = (checks.find(c => c.detected) || {}).detected || [];
|
|
220
|
+
if (detectedPlatforms.length > 0) {
|
|
221
|
+
lines.push('');
|
|
222
|
+
lines.push(c(' Detected Platforms', 'bold'));
|
|
223
|
+
for (const p of detectedPlatforms) {
|
|
224
|
+
lines.push(` ${c('✓', 'green')} ${p}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Freshness
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(c(' Freshness Gates', 'bold'));
|
|
231
|
+
for (const f of freshnessChecks) {
|
|
232
|
+
const icon = f.status === 'pass' ? c('✓', 'green') : c('⚠', 'yellow');
|
|
233
|
+
const label = f.platform.padEnd(12);
|
|
234
|
+
lines.push(` ${icon} ${label} ${c(f.detail || f.status, f.status === 'pass' ? 'dim' : 'yellow')}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push(c(' Summary', 'bold'));
|
|
239
|
+
lines.push(` Checks: ${c(String(totalPass), 'green')} pass ${totalWarn > 0 ? c(String(totalWarn), 'yellow') + ' warn ' : ''}${totalFail > 0 ? c(String(totalFail), 'red') + ' fail' : ''}`);
|
|
240
|
+
lines.push(` Freshness: ${c(String(freshPass), 'green')} fresh ${freshWarn > 0 ? c(String(freshWarn), 'yellow') + ' stale/unverified' : ''}`);
|
|
241
|
+
lines.push(` Status: ${overallOk ? c('✓ Healthy', 'green') : c('✗ Issues found', 'red')}`);
|
|
242
|
+
lines.push(` Duration: ${elapsed}ms`);
|
|
243
|
+
lines.push('');
|
|
244
|
+
|
|
245
|
+
if (!overallOk) {
|
|
246
|
+
lines.push(c(' Run with --verbose for fix suggestions.', 'dim'));
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return lines.join('\n');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { runDoctor };
|
package/src/feedback.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
let lastTimestamp = '';
|
|
6
|
+
let counter = 0;
|
|
7
|
+
|
|
8
|
+
function timestampId() {
|
|
9
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
10
|
+
if (ts === lastTimestamp) {
|
|
11
|
+
counter += 1;
|
|
12
|
+
return `${ts}-${counter}`;
|
|
13
|
+
}
|
|
14
|
+
lastTimestamp = ts;
|
|
15
|
+
counter = 0;
|
|
16
|
+
return ts;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureFeedbackDir(dir) {
|
|
20
|
+
const feedbackDir = path.join(dir, '.claude', 'claudex-setup', 'feedback');
|
|
21
|
+
fs.mkdirSync(feedbackDir, { recursive: true });
|
|
22
|
+
return feedbackDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeJson(filePath, payload) {
|
|
26
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveFeedback(dir, payload) {
|
|
31
|
+
const feedbackDir = ensureFeedbackDir(dir);
|
|
32
|
+
const id = timestampId();
|
|
33
|
+
const keySlug = String(payload.key || 'finding').replace(/[^a-z0-9_-]+/gi, '-').toLowerCase();
|
|
34
|
+
const filePath = path.join(feedbackDir, `${id}-${keySlug}.json`);
|
|
35
|
+
const envelope = {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
id,
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
...payload,
|
|
40
|
+
};
|
|
41
|
+
writeJson(filePath, envelope);
|
|
42
|
+
return {
|
|
43
|
+
...envelope,
|
|
44
|
+
filePath,
|
|
45
|
+
relativePath: path.relative(dir, filePath),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getFeedbackSummary(dir) {
|
|
50
|
+
const feedbackDir = ensureFeedbackDir(dir);
|
|
51
|
+
const files = fs.readdirSync(feedbackDir).filter((name) => name.endsWith('.json'));
|
|
52
|
+
const entries = [];
|
|
53
|
+
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const filePath = path.join(feedbackDir, file);
|
|
56
|
+
try {
|
|
57
|
+
entries.push(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore malformed artifacts so one bad file does not break the summary.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const summary = {
|
|
64
|
+
totalEntries: entries.length,
|
|
65
|
+
helpful: 0,
|
|
66
|
+
unhelpful: 0,
|
|
67
|
+
byKey: {},
|
|
68
|
+
relativeDir: path.relative(dir, feedbackDir),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const helpful = entry.helpful === true;
|
|
73
|
+
if (helpful) {
|
|
74
|
+
summary.helpful += 1;
|
|
75
|
+
} else if (entry.helpful === false) {
|
|
76
|
+
summary.unhelpful += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const bucket = summary.byKey[entry.key] || { total: 0, helpful: 0, unhelpful: 0 };
|
|
80
|
+
bucket.total += 1;
|
|
81
|
+
if (helpful) {
|
|
82
|
+
bucket.helpful += 1;
|
|
83
|
+
} else if (entry.helpful === false) {
|
|
84
|
+
bucket.unhelpful += 1;
|
|
85
|
+
}
|
|
86
|
+
summary.byKey[entry.key] = bucket;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return summary;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function askQuestion(rl, prompt) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
rl.question(prompt, resolve);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function collectFeedback(dir, options = {}) {
|
|
99
|
+
const findings = Array.isArray(options.findings) ? options.findings : [];
|
|
100
|
+
const stdin = options.stdin || process.stdin;
|
|
101
|
+
const stdout = options.stdout || process.stdout;
|
|
102
|
+
|
|
103
|
+
if (findings.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
saved: 0,
|
|
106
|
+
skipped: 0,
|
|
107
|
+
helpful: 0,
|
|
108
|
+
unhelpful: 0,
|
|
109
|
+
entries: [],
|
|
110
|
+
relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!(stdin.isTTY && stdout.isTTY)) {
|
|
115
|
+
return {
|
|
116
|
+
mode: 'skipped-noninteractive',
|
|
117
|
+
saved: 0,
|
|
118
|
+
skipped: findings.length,
|
|
119
|
+
helpful: 0,
|
|
120
|
+
unhelpful: 0,
|
|
121
|
+
entries: [],
|
|
122
|
+
relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
127
|
+
const entries = [];
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
for (const finding of findings) {
|
|
131
|
+
stdout.write(`\n Feedback for ${finding.name} (${finding.key})\n`);
|
|
132
|
+
let answer = await askQuestion(rl, ' Was this helpful? (y/n) ');
|
|
133
|
+
answer = String(answer || '').trim().toLowerCase();
|
|
134
|
+
|
|
135
|
+
if (!['y', 'yes', 'n', 'no'].includes(answer)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
entries.push(saveFeedback(dir, {
|
|
140
|
+
key: finding.key,
|
|
141
|
+
name: finding.name,
|
|
142
|
+
helpful: answer === 'y' || answer === 'yes',
|
|
143
|
+
platform: options.platform || null,
|
|
144
|
+
sourceCommand: options.sourceCommand || 'audit',
|
|
145
|
+
sourceUrl: finding.sourceUrl || null,
|
|
146
|
+
impact: finding.impact || null,
|
|
147
|
+
category: finding.category || null,
|
|
148
|
+
score: Number.isFinite(options.score) ? options.score : null,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
rl.close();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const helpful = entries.filter((entry) => entry.helpful).length;
|
|
156
|
+
const unhelpful = entries.filter((entry) => entry.helpful === false).length;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
saved: entries.length,
|
|
160
|
+
skipped: findings.length - entries.length,
|
|
161
|
+
helpful,
|
|
162
|
+
unhelpful,
|
|
163
|
+
entries,
|
|
164
|
+
relativeDir: path.relative(dir, ensureFeedbackDir(dir)),
|
|
165
|
+
summary: getFeedbackSummary(dir),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
collectFeedback,
|
|
171
|
+
saveFeedback,
|
|
172
|
+
getFeedbackSummary,
|
|
173
|
+
};
|