@nerviq/cli 1.7.1 → 1.8.1
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/README.md +4 -0
- package/bin/cli.js +109 -4
- package/package.json +1 -1
- package/src/audit.js +56 -3
- package/src/claudex-sync.json +2 -2
- package/src/dashboard.js +471 -0
- package/src/init.js +184 -0
package/README.md
CHANGED
|
@@ -323,6 +323,10 @@ Every write command supports `--snapshot` for automatic backup before changes.
|
|
|
323
323
|
- **Website**: [nerviq.net](https://nerviq.net)
|
|
324
324
|
- **Discord**: [Join the community](https://discord.gg/nerviq)
|
|
325
325
|
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
If Nerviq helped you, consider giving it a ⭐ on [GitHub](https://github.com/nerviq/nerviq) — it helps others discover the project.
|
|
329
|
+
|
|
326
330
|
## What Nerviq Is — and Isn't
|
|
327
331
|
|
|
328
332
|
**Strongest at:** Agent configuration, workflow governance, repo policy hygiene, cross-platform alignment, and setup standardization.
|
package/bin/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ const COMMAND_ALIASES = {
|
|
|
26
26
|
gov: 'governance',
|
|
27
27
|
outcome: 'feedback',
|
|
28
28
|
};
|
|
29
|
-
const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'rollback', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'check-health', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
|
|
29
|
+
const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'init', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'rollback', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'check-health', 'dashboard', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
|
|
30
30
|
|
|
31
31
|
function levenshtein(a, b) {
|
|
32
32
|
const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
@@ -88,6 +88,7 @@ function parseArgs(rawArgs) {
|
|
|
88
88
|
let migrateTo = null;
|
|
89
89
|
let checkVersion = null;
|
|
90
90
|
let external = null;
|
|
91
|
+
let repos = [];
|
|
91
92
|
|
|
92
93
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
93
94
|
const arg = rawArgs[i];
|
|
@@ -128,6 +129,22 @@ function parseArgs(rawArgs) {
|
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
if (arg === '--repos') {
|
|
133
|
+
// Collect all following non-flag args as repo paths (supports comma-separated too)
|
|
134
|
+
while (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('--')) {
|
|
135
|
+
i++;
|
|
136
|
+
repos.push(...rawArgs[i].split(',').map(s => s.trim()).filter(Boolean));
|
|
137
|
+
}
|
|
138
|
+
if (repos.length === 0) throw new Error('--repos requires at least one path');
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (arg.startsWith('--repos=')) {
|
|
143
|
+
repos = arg.split('=').slice(1).join('=').split(',').map(s => s.trim()).filter(Boolean);
|
|
144
|
+
if (repos.length === 0) throw new Error('--repos requires at least one path');
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
if (arg.startsWith('--require=')) {
|
|
132
149
|
requireChecks = arg.split('=').slice(1).join('=').split(',').map(item => item.trim()).filter(Boolean);
|
|
133
150
|
continue;
|
|
@@ -233,7 +250,7 @@ function parseArgs(rawArgs) {
|
|
|
233
250
|
|
|
234
251
|
const normalizedCommand = COMMAND_ALIASES[command] || command;
|
|
235
252
|
|
|
236
|
-
return { flags, command, normalizedCommand, threshold, out, planFile, only, profile, mcpPacks, requireChecks, feedbackKey, feedbackStatus, feedbackEffect, feedbackNotes, feedbackSource, feedbackScoreDelta, platform, format, port, workspace, extraArgs, convertFrom, convertTo, migrateFrom, migrateTo, checkVersion, webhookUrl, external };
|
|
253
|
+
return { flags, command, normalizedCommand, threshold, out, planFile, only, profile, mcpPacks, requireChecks, feedbackKey, feedbackStatus, feedbackEffect, feedbackNotes, feedbackSource, feedbackScoreDelta, platform, format, port, workspace, extraArgs, convertFrom, convertTo, migrateFrom, migrateTo, checkVersion, webhookUrl, external, repos };
|
|
237
254
|
}
|
|
238
255
|
|
|
239
256
|
function printWorkspaceSummary(summary, options) {
|
|
@@ -313,6 +330,7 @@ const HELP = `
|
|
|
313
330
|
nerviq fix <key> Auto-fix a specific check (with score impact)
|
|
314
331
|
nerviq fix --all-critical Fix all critical issues at once
|
|
315
332
|
nerviq fix --dry-run Preview fixes without writing
|
|
333
|
+
nerviq fix --auto Apply fixes without confirmation prompt
|
|
316
334
|
nerviq rollback Undo the most recent apply (delete created files)
|
|
317
335
|
nerviq rollback --list Show available rollback points
|
|
318
336
|
nerviq rollback --dry-run Preview what would be deleted
|
|
@@ -345,6 +363,9 @@ const HELP = `
|
|
|
345
363
|
nerviq migrate --platform cursor --from v2 --to v3
|
|
346
364
|
|
|
347
365
|
MONITOR
|
|
366
|
+
nerviq dashboard Generate static HTML dashboard report
|
|
367
|
+
nerviq dashboard --out F Save dashboard to custom file
|
|
368
|
+
nerviq dashboard --open Open dashboard in browser after generating
|
|
348
369
|
nerviq watch Live config monitoring (re-audits on file change)
|
|
349
370
|
nerviq history Score history from saved snapshots
|
|
350
371
|
nerviq compare Latest vs previous snapshot diff
|
|
@@ -380,6 +401,7 @@ const HELP = `
|
|
|
380
401
|
--dry-run Preview changes without writing files
|
|
381
402
|
--config-only Only write config files (.claude/, rules, hooks) — never source code
|
|
382
403
|
--verbose Full audit + medium-priority recommendations
|
|
404
|
+
--show-deprecated Show deprecated checks (excluded from scoring)
|
|
383
405
|
--json Output as JSON
|
|
384
406
|
--auto Apply all generated files without prompting
|
|
385
407
|
--key NAME Feedback: recommendation key (e.g. permissionDeny)
|
|
@@ -439,6 +461,7 @@ async function main() {
|
|
|
439
461
|
auto: flags.includes('--auto'),
|
|
440
462
|
lite: flags.includes('--full') || flags.includes('--verbose') ? false : true,
|
|
441
463
|
full: flags.includes('--full'),
|
|
464
|
+
showDeprecated: flags.includes('--show-deprecated'),
|
|
442
465
|
snapshot: flags.includes('--snapshot'),
|
|
443
466
|
feedback: flags.includes('--feedback'),
|
|
444
467
|
fix: flags.includes('--fix'),
|
|
@@ -1165,6 +1188,21 @@ async function main() {
|
|
|
1165
1188
|
console.log(`\n Use --json for full output or --out <file> to save.\n`);
|
|
1166
1189
|
}
|
|
1167
1190
|
process.exit(0);
|
|
1191
|
+
} else if (normalizedCommand === 'dashboard') {
|
|
1192
|
+
const dashFlags = {
|
|
1193
|
+
out: options.out,
|
|
1194
|
+
open: flags.includes('--open'),
|
|
1195
|
+
json: options.json,
|
|
1196
|
+
platform: options.platform,
|
|
1197
|
+
};
|
|
1198
|
+
if (parsed.repos && parsed.repos.length > 0) {
|
|
1199
|
+
const { generatePortfolioDashboard } = require('../src/dashboard');
|
|
1200
|
+
await generatePortfolioDashboard(parsed.repos, dashFlags);
|
|
1201
|
+
} else {
|
|
1202
|
+
const { generateDashboard } = require('../src/dashboard');
|
|
1203
|
+
await generateDashboard(options.dir, dashFlags);
|
|
1204
|
+
}
|
|
1205
|
+
process.exit(0);
|
|
1168
1206
|
} else if (normalizedCommand === 'check-health') {
|
|
1169
1207
|
const { checkHealth, formatCheckHealth } = require('../src/activity');
|
|
1170
1208
|
const report = checkHealth(options.dir);
|
|
@@ -1246,9 +1284,10 @@ async function main() {
|
|
|
1246
1284
|
console.log(output);
|
|
1247
1285
|
process.exit(0);
|
|
1248
1286
|
} else if (normalizedCommand === 'fix') {
|
|
1249
|
-
// nerviq fix [key] [--all-critical] [--dry-run]
|
|
1287
|
+
// nerviq fix [key] [--all-critical] [--dry-run] [--auto]
|
|
1250
1288
|
const fixKey = parsed.extraArgs[0] || null;
|
|
1251
1289
|
const allCritical = flags.includes('--all-critical');
|
|
1290
|
+
const autoApply = options.auto || options.dryRun;
|
|
1252
1291
|
|
|
1253
1292
|
// Step 1: Run silent audit to find failed checks (only actual failures, not skipped/null)
|
|
1254
1293
|
const auditResult = await audit({ dir: options.dir, silent: true, platform: options.platform });
|
|
@@ -1351,8 +1390,70 @@ async function main() {
|
|
|
1351
1390
|
process.exit(0);
|
|
1352
1391
|
}
|
|
1353
1392
|
|
|
1354
|
-
// Step
|
|
1393
|
+
// Step 2.5: Predict impact and show preview before applying
|
|
1394
|
+
const IMPACT_WEIGHTS = { critical: 15, high: 10, medium: 5, low: 2 };
|
|
1355
1395
|
const preScore = auditResult.score;
|
|
1396
|
+
const applicableResults = (auditResult.results || []).filter(r => r.passed !== null);
|
|
1397
|
+
const maxScore = applicableResults.reduce((sum, r) => sum + (IMPACT_WEIGHTS[r.impact] || 5), 0);
|
|
1398
|
+
|
|
1399
|
+
// Compute predicted score by simulating target fixes as passing
|
|
1400
|
+
const targetKeySet = new Set(targetKeys);
|
|
1401
|
+
const INLINE_FIX_KEYS = new Set(Object.keys(INLINE_FIXERS));
|
|
1402
|
+
const fixableTargets = targetKeys.filter(k => {
|
|
1403
|
+
const tech = TECHNIQUES[k];
|
|
1404
|
+
return (tech && tech.template) || INLINE_FIX_KEYS.has(k);
|
|
1405
|
+
});
|
|
1406
|
+
const fixableTargetSet = new Set(fixableTargets);
|
|
1407
|
+
const simulatedEarned = applicableResults.reduce((sum, r) => {
|
|
1408
|
+
const w = IMPACT_WEIGHTS[r.impact] || 5;
|
|
1409
|
+
if (r.passed) return sum + w;
|
|
1410
|
+
if (fixableTargetSet.has(r.key)) return sum + w;
|
|
1411
|
+
return sum;
|
|
1412
|
+
}, 0);
|
|
1413
|
+
const predictedScore = maxScore > 0 ? Math.round((simulatedEarned / maxScore) * 100) : 0;
|
|
1414
|
+
const predictedDelta = predictedScore - preScore;
|
|
1415
|
+
|
|
1416
|
+
if (!autoApply) {
|
|
1417
|
+
console.log('');
|
|
1418
|
+
if (allCritical && fixableTargets.length > 1) {
|
|
1419
|
+
// Multi-fix summary
|
|
1420
|
+
console.log(` ${fixableTargets.length} critical fixes available:`);
|
|
1421
|
+
let runningEarned = applicableResults.reduce((s, r) => s + (r.passed ? (IMPACT_WEIGHTS[r.impact] || 5) : 0), 0);
|
|
1422
|
+
let runningScore = maxScore > 0 ? Math.round((runningEarned / maxScore) * 100) : 0;
|
|
1423
|
+
fixableTargets.forEach((k, idx) => {
|
|
1424
|
+
const r = failedResults.find(fr => fr.key === k);
|
|
1425
|
+
const w = IMPACT_WEIGHTS[r.impact] || 5;
|
|
1426
|
+
const nextEarned = runningEarned + w;
|
|
1427
|
+
const nextScore = maxScore > 0 ? Math.round((nextEarned / maxScore) * 100) : 0;
|
|
1428
|
+
const d = nextScore - runningScore;
|
|
1429
|
+
console.log(` ${idx + 1}. ${(r.key).padEnd(18)} ${runningScore} → ${nextScore} (+${d})`);
|
|
1430
|
+
runningEarned = nextEarned;
|
|
1431
|
+
runningScore = nextScore;
|
|
1432
|
+
});
|
|
1433
|
+
console.log('');
|
|
1434
|
+
console.log(` Total: ${preScore} → ${predictedScore} (+${predictedDelta})`);
|
|
1435
|
+
} else {
|
|
1436
|
+
// Single fix preview
|
|
1437
|
+
const targetCheck = failedResults.find(r => r.key === fixableTargets[0]) || failedResults.find(r => r.key === targetKeys[0]);
|
|
1438
|
+
if (targetCheck) {
|
|
1439
|
+
console.log(` Predicted impact: ${preScore} → ${predictedScore} (+${predictedDelta})`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Prompt for confirmation
|
|
1444
|
+
const readline = require('readline');
|
|
1445
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1446
|
+
const answer = await new Promise(resolve => {
|
|
1447
|
+
rl.question(' Apply? (Y/n) ', resolve);
|
|
1448
|
+
});
|
|
1449
|
+
rl.close();
|
|
1450
|
+
if (answer && answer.trim().toLowerCase() === 'n') {
|
|
1451
|
+
console.log('\n Aborted.\n');
|
|
1452
|
+
process.exit(0);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Step 3: For each target, either use template, inline fix, or show manual instructions
|
|
1356
1457
|
let fixed = 0;
|
|
1357
1458
|
let manual = 0;
|
|
1358
1459
|
|
|
@@ -1400,6 +1501,10 @@ async function main() {
|
|
|
1400
1501
|
|
|
1401
1502
|
console.log(`\n ${fixed} fixed, ${manual} need manual action.\n`);
|
|
1402
1503
|
|
|
1504
|
+
} else if (normalizedCommand === 'init') {
|
|
1505
|
+
const { runInit } = require('../src/init');
|
|
1506
|
+
await runInit(options.dir, flags);
|
|
1507
|
+
process.exit(0);
|
|
1403
1508
|
} else if (normalizedCommand === 'setup') {
|
|
1404
1509
|
await setup(options);
|
|
1405
1510
|
if (options.snapshot) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerviq/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "The intelligent nervous system for AI coding agents — 2,431 checks across 8 platforms, 10 languages, and 62 domain packs. Audit, align, and amplify.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/audit.js
CHANGED
|
@@ -882,6 +882,31 @@ function printLiteAudit(result, dir) {
|
|
|
882
882
|
}
|
|
883
883
|
console.log('');
|
|
884
884
|
console.log(` Score: ${colorize(`${result.score}/100`, 'bold')} (${result.passed}/${result.passed + result.failed} checks passing)`);
|
|
885
|
+
|
|
886
|
+
// Score explanation line (lite mode only)
|
|
887
|
+
const _critCount = (result.results || []).filter(r => r.passed === false && r.impact === 'critical').length;
|
|
888
|
+
const _highCount = (result.results || []).filter(r => r.passed === false && r.impact === 'high').length;
|
|
889
|
+
let scoreExplanation;
|
|
890
|
+
if (result.score >= 90) {
|
|
891
|
+
scoreExplanation = 'Excellent setup — production-ready governance';
|
|
892
|
+
} else if (result.score >= 70) {
|
|
893
|
+
scoreExplanation = `Strong setup — ${_critCount} critical items to address`;
|
|
894
|
+
} else if (result.score >= 50) {
|
|
895
|
+
scoreExplanation = `Good foundation — ${_critCount + _highCount} items need attention`;
|
|
896
|
+
} else if (result.score >= 30) {
|
|
897
|
+
// Find weakest category (most failures)
|
|
898
|
+
const catFailures = {};
|
|
899
|
+
(result.results || []).filter(r => r.passed === false).forEach(r => {
|
|
900
|
+
const cat = r.category || 'unknown';
|
|
901
|
+
catFailures[cat] = (catFailures[cat] || 0) + 1;
|
|
902
|
+
});
|
|
903
|
+
const weakestCategory = Object.keys(catFailures).sort((a, b) => catFailures[b] - catFailures[a])[0] || 'config';
|
|
904
|
+
scoreExplanation = `Basic setup — significant gaps in ${weakestCategory}`;
|
|
905
|
+
} else {
|
|
906
|
+
scoreExplanation = 'Early stage — run `nerviq setup` to bootstrap your config';
|
|
907
|
+
}
|
|
908
|
+
console.log(colorize(` ${scoreExplanation}`, 'dim'));
|
|
909
|
+
|
|
885
910
|
if (result.platformScopeNote) {
|
|
886
911
|
console.log(colorize(` Scope: ${result.platformScopeNote.message}`, 'dim'));
|
|
887
912
|
}
|
|
@@ -908,6 +933,7 @@ function printLiteAudit(result, dir) {
|
|
|
908
933
|
if (result.platform === 'codex') {
|
|
909
934
|
console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
|
|
910
935
|
}
|
|
936
|
+
console.log(colorize(' Star: github.com/nerviq/nerviq | Discord: discord.gg/nerviq', 'dim'));
|
|
911
937
|
console.log('');
|
|
912
938
|
return;
|
|
913
939
|
}
|
|
@@ -938,6 +964,7 @@ function printLiteAudit(result, dir) {
|
|
|
938
964
|
console.log(colorize(' Note: Codex now supports no-write advisory flows via augment and suggest-only before setup/apply.', 'dim'));
|
|
939
965
|
}
|
|
940
966
|
console.log(colorize(` See all ${result.failed} failed checks: ${colorize('nerviq audit --full', 'bold')}`, 'dim'));
|
|
967
|
+
console.log(colorize(' Star: github.com/nerviq/nerviq | Discord: discord.gg/nerviq', 'dim'));
|
|
941
968
|
console.log('');
|
|
942
969
|
}
|
|
943
970
|
|
|
@@ -949,6 +976,7 @@ function printLiteAudit(result, dir) {
|
|
|
949
976
|
* @param {boolean} [options.json] - Output result as JSON.
|
|
950
977
|
* @param {boolean} [options.lite] - Show short top-3 quick scan.
|
|
951
978
|
* @param {boolean} [options.verbose] - Show all recommendations including medium-impact.
|
|
979
|
+
* @param {boolean} [options.showDeprecated] - Include deprecated checks in output.
|
|
952
980
|
* @returns {Promise<Object>} Audit result with score, passed/failed counts, quickWins, and topNextActions.
|
|
953
981
|
*/
|
|
954
982
|
async function audit(options) {
|
|
@@ -1033,9 +1061,14 @@ async function audit(options) {
|
|
|
1033
1061
|
});
|
|
1034
1062
|
}
|
|
1035
1063
|
|
|
1064
|
+
// Separate deprecated checks from active checks.
|
|
1065
|
+
// Deprecated checks are excluded from scoring but preserved for display.
|
|
1066
|
+
const deprecated = results.filter(r => r.deprecated === true);
|
|
1067
|
+
const activeResults = results.filter(r => r.deprecated !== true);
|
|
1068
|
+
|
|
1036
1069
|
// null = not applicable (skip), true = pass, false = fail
|
|
1037
|
-
const applicable =
|
|
1038
|
-
const skipped =
|
|
1070
|
+
const applicable = activeResults.filter(r => r.passed !== null);
|
|
1071
|
+
const skipped = activeResults.filter(r => r.passed === null);
|
|
1039
1072
|
const passed = applicable.filter(r => r.passed);
|
|
1040
1073
|
const failed = applicable.filter(r => !r.passed);
|
|
1041
1074
|
const critical = failed.filter(r => r.impact === 'critical');
|
|
@@ -1115,9 +1148,17 @@ async function audit(options) {
|
|
|
1115
1148
|
passed: passed.length,
|
|
1116
1149
|
failed: failed.length,
|
|
1117
1150
|
skipped: skipped.length,
|
|
1151
|
+
deprecated: deprecated.length,
|
|
1118
1152
|
checkCount: applicable.length,
|
|
1119
1153
|
stacks,
|
|
1120
1154
|
results,
|
|
1155
|
+
deprecatedChecks: deprecated.map(r => ({
|
|
1156
|
+
key: r.key,
|
|
1157
|
+
name: r.name,
|
|
1158
|
+
category: r.category,
|
|
1159
|
+
deprecatedReason: r.deprecatedReason || null,
|
|
1160
|
+
sunsetDate: r.sunsetDate || null,
|
|
1161
|
+
})),
|
|
1121
1162
|
categoryScores,
|
|
1122
1163
|
quickWins: quickWins.map(({ key, name, impact, fix, category, sourceUrl }) => ({ key, name, impact, category, fix, sourceUrl })),
|
|
1123
1164
|
topNextActions,
|
|
@@ -1264,6 +1305,17 @@ async function audit(options) {
|
|
|
1264
1305
|
console.log('');
|
|
1265
1306
|
}
|
|
1266
1307
|
|
|
1308
|
+
// Deprecated checks (shown with --show-deprecated or --full)
|
|
1309
|
+
if (deprecated.length > 0 && (options.showDeprecated || options.full)) {
|
|
1310
|
+
console.log(colorize(` ⏳ Deprecated (${deprecated.length} checks excluded from scoring)`, 'dim'));
|
|
1311
|
+
for (const r of deprecated) {
|
|
1312
|
+
const reason = r.deprecatedReason ? ` — ${r.deprecatedReason}` : '';
|
|
1313
|
+
const sunset = r.sunsetDate ? ` (sunset: ${r.sunsetDate})` : '';
|
|
1314
|
+
console.log(colorize(` [DEPRECATED] ${r.name}${reason}${sunset}`, 'dim'));
|
|
1315
|
+
}
|
|
1316
|
+
console.log('');
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1267
1319
|
// Failed - by priority
|
|
1268
1320
|
if (critical.length > 0) {
|
|
1269
1321
|
console.log(colorize(' 🔴 Critical (fix immediately)', 'red'));
|
|
@@ -1331,7 +1383,8 @@ async function audit(options) {
|
|
|
1331
1383
|
|
|
1332
1384
|
// Summary
|
|
1333
1385
|
console.log(colorize(' ─────────────────────────────────────', 'dim'));
|
|
1334
|
-
|
|
1386
|
+
const deprecatedNote = deprecated.length > 0 ? colorize(`, ${deprecated.length} deprecated`, 'dim') : '';
|
|
1387
|
+
console.log(` ${colorize(`${passed.length}/${applicable.length}`, 'bold')} checks passing${skipped.length > 0 ? colorize(` (${skipped.length} not applicable${deprecatedNote})`, 'dim') : (deprecatedNote ? colorize(` (${deprecatedNote})`, 'dim') : '')}`);
|
|
1335
1388
|
|
|
1336
1389
|
if (failed.length > 0) {
|
|
1337
1390
|
console.log(` Next command: ${colorize(result.suggestedNextCommand, 'bold')}`);
|
package/src/claudex-sync.json
CHANGED
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard generator — produces a self-contained HTML report from audit snapshots.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { version } = require('../package.json');
|
|
8
|
+
const { readSnapshotIndex, getHistory, loadSnapshotPayload } = require('./activity');
|
|
9
|
+
|
|
10
|
+
const COLORS = {
|
|
11
|
+
bg: '#0a0a0a',
|
|
12
|
+
surface: '#18181b',
|
|
13
|
+
border: '#27272a',
|
|
14
|
+
text: '#e4e4e7',
|
|
15
|
+
textDim: '#a1a1aa',
|
|
16
|
+
green: '#22c55e',
|
|
17
|
+
red: '#ef4444',
|
|
18
|
+
yellow: '#eab308',
|
|
19
|
+
blue: '#3b82f6',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function scoreColor(score) {
|
|
23
|
+
if (score >= 70) return COLORS.green;
|
|
24
|
+
if (score >= 40) return COLORS.yellow;
|
|
25
|
+
return COLORS.red;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeHtml(str) {
|
|
29
|
+
return String(str)
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildScoreOverTimeSvg(history) {
|
|
37
|
+
if (!history || history.length < 2) return '';
|
|
38
|
+
const entries = history.slice().reverse(); // oldest first
|
|
39
|
+
const w = 600, h = 200, pad = 40;
|
|
40
|
+
const plotW = w - pad * 2, plotH = h - pad * 2;
|
|
41
|
+
const n = entries.length;
|
|
42
|
+
const step = n > 1 ? plotW / (n - 1) : 0;
|
|
43
|
+
|
|
44
|
+
const points = entries.map((e, i) => {
|
|
45
|
+
const x = pad + i * step;
|
|
46
|
+
const score = e.summary?.score ?? 0;
|
|
47
|
+
const y = pad + plotH - (score / 100) * plotH;
|
|
48
|
+
return { x, y, score, date: (e.createdAt || '').split('T')[0] };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const polyline = points.map(p => `${p.x},${p.y}`).join(' ');
|
|
52
|
+
const dots = points.map(p =>
|
|
53
|
+
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="${scoreColor(p.score)}"/>`
|
|
54
|
+
+ `<title>${p.date}: ${p.score}/100</title>`
|
|
55
|
+
).join('\n ');
|
|
56
|
+
|
|
57
|
+
// Y-axis labels
|
|
58
|
+
const yLabels = [0, 25, 50, 75, 100].map(v => {
|
|
59
|
+
const y = pad + plotH - (v / 100) * plotH;
|
|
60
|
+
return `<text x="${pad - 8}" y="${y + 4}" text-anchor="end" fill="${COLORS.textDim}" font-size="11">${v}</text>`
|
|
61
|
+
+ `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${COLORS.border}" stroke-dasharray="4"/>`;
|
|
62
|
+
}).join('\n ');
|
|
63
|
+
|
|
64
|
+
// X-axis: first and last date
|
|
65
|
+
const first = points[0], last = points[points.length - 1];
|
|
66
|
+
const xLabels = `<text x="${first.x}" y="${h - 8}" text-anchor="start" fill="${COLORS.textDim}" font-size="11">${first.date}</text>`
|
|
67
|
+
+ `<text x="${last.x}" y="${h - 8}" text-anchor="end" fill="${COLORS.textDim}" font-size="11">${last.date}</text>`;
|
|
68
|
+
|
|
69
|
+
return `
|
|
70
|
+
<div class="card">
|
|
71
|
+
<h2>Score Over Time</h2>
|
|
72
|
+
<svg viewBox="0 0 ${w} ${h}" width="100%" style="max-width:${w}px">
|
|
73
|
+
${yLabels}
|
|
74
|
+
<polyline points="${polyline}" fill="none" stroke="${COLORS.blue}" stroke-width="2"/>
|
|
75
|
+
${dots}
|
|
76
|
+
${xLabels}
|
|
77
|
+
</svg>
|
|
78
|
+
</div>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildCategoryBreakdownSvg(results) {
|
|
82
|
+
if (!results || results.length === 0) return '';
|
|
83
|
+
const cats = {};
|
|
84
|
+
for (const r of results) {
|
|
85
|
+
if (r.passed === null) continue;
|
|
86
|
+
const cat = r.category || 'other';
|
|
87
|
+
if (!cats[cat]) cats[cat] = { pass: 0, total: 0 };
|
|
88
|
+
cats[cat].total++;
|
|
89
|
+
if (r.passed) cats[cat].pass++;
|
|
90
|
+
}
|
|
91
|
+
const sorted = Object.entries(cats).sort((a, b) => {
|
|
92
|
+
const rateA = a[1].total > 0 ? a[1].pass / a[1].total : 0;
|
|
93
|
+
const rateB = b[1].total > 0 ? b[1].pass / b[1].total : 0;
|
|
94
|
+
return rateA - rateB;
|
|
95
|
+
});
|
|
96
|
+
if (sorted.length === 0) return '';
|
|
97
|
+
|
|
98
|
+
const barH = 28, gap = 6, labelW = 160, barMaxW = 360, padR = 60;
|
|
99
|
+
const svgH = sorted.length * (barH + gap) + 10;
|
|
100
|
+
const svgW = labelW + barMaxW + padR;
|
|
101
|
+
|
|
102
|
+
const bars = sorted.map(([cat, data], i) => {
|
|
103
|
+
const rate = data.total > 0 ? data.pass / data.total : 0;
|
|
104
|
+
const pct = Math.round(rate * 100);
|
|
105
|
+
const barW = Math.max(2, rate * barMaxW);
|
|
106
|
+
const y = i * (barH + gap) + 4;
|
|
107
|
+
const color = pct >= 70 ? COLORS.green : pct >= 40 ? COLORS.yellow : COLORS.red;
|
|
108
|
+
return `<text x="${labelW - 8}" y="${y + barH / 2 + 4}" text-anchor="end" fill="${COLORS.text}" font-size="13">${escapeHtml(cat)}</text>`
|
|
109
|
+
+ `<rect x="${labelW}" y="${y}" width="${barW}" height="${barH}" rx="4" fill="${color}" opacity="0.85"/>`
|
|
110
|
+
+ `<text x="${labelW + barW + 8}" y="${y + barH / 2 + 4}" fill="${COLORS.textDim}" font-size="12">${pct}% (${data.pass}/${data.total})</text>`;
|
|
111
|
+
}).join('\n ');
|
|
112
|
+
|
|
113
|
+
return `
|
|
114
|
+
<div class="card">
|
|
115
|
+
<h2>Category Breakdown</h2>
|
|
116
|
+
<svg viewBox="0 0 ${svgW} ${svgH}" width="100%" style="max-width:${svgW}px">
|
|
117
|
+
${bars}
|
|
118
|
+
</svg>
|
|
119
|
+
</div>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildHtml(projectName, auditPayload, history) {
|
|
123
|
+
const score = auditPayload.score ?? 0;
|
|
124
|
+
const platform = auditPayload.platform || 'unknown';
|
|
125
|
+
const results = auditPayload.results || [];
|
|
126
|
+
const timestamp = new Date().toISOString();
|
|
127
|
+
|
|
128
|
+
// Top 5 failed checks sorted by impact severity
|
|
129
|
+
const impactOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
130
|
+
const failed = results
|
|
131
|
+
.filter(r => r.passed === false)
|
|
132
|
+
.sort((a, b) => (impactOrder[a.impact] ?? 9) - (impactOrder[b.impact] ?? 9))
|
|
133
|
+
.slice(0, 5);
|
|
134
|
+
|
|
135
|
+
const failedRows = failed.length > 0
|
|
136
|
+
? failed.map(r =>
|
|
137
|
+
`<tr><td>${escapeHtml(r.name || r.key)}</td><td class="impact-${r.impact || 'medium'}">${escapeHtml(r.impact || '-')}</td><td>${escapeHtml(r.category || '-')}</td></tr>`
|
|
138
|
+
).join('\n ')
|
|
139
|
+
: '<tr><td colspan="3" style="text-align:center;color:' + COLORS.green + '">All checks passing!</td></tr>';
|
|
140
|
+
|
|
141
|
+
const scoreOverTime = buildScoreOverTimeSvg(history);
|
|
142
|
+
const categoryBreakdown = buildCategoryBreakdownSvg(results);
|
|
143
|
+
const drifts = detectDrifts(history);
|
|
144
|
+
const driftAlerts = buildDriftAlertsHtml(drifts);
|
|
145
|
+
|
|
146
|
+
const detectedPlatforms = auditPayload.detectedPlatforms
|
|
147
|
+
|| (auditPayload.platform ? [auditPayload.platform] : ['unknown']);
|
|
148
|
+
const platformList = (Array.isArray(detectedPlatforms) ? detectedPlatforms : [detectedPlatforms])
|
|
149
|
+
.map(p => `<span class="badge">${escapeHtml(p)}</span>`).join(' ');
|
|
150
|
+
|
|
151
|
+
return `<!DOCTYPE html>
|
|
152
|
+
<html lang="en">
|
|
153
|
+
<head>
|
|
154
|
+
<meta charset="utf-8"/>
|
|
155
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
156
|
+
<title>Nerviq Dashboard — ${escapeHtml(projectName)}</title>
|
|
157
|
+
<style>
|
|
158
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
159
|
+
body{background:${COLORS.bg};color:${COLORS.text};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:2rem;max-width:900px;margin:0 auto}
|
|
160
|
+
h1{font-size:1.6rem;margin-bottom:.3rem}
|
|
161
|
+
h2{font-size:1.1rem;margin-bottom:1rem;color:${COLORS.textDim}}
|
|
162
|
+
.timestamp{color:${COLORS.textDim};font-size:.85rem;margin-bottom:2rem}
|
|
163
|
+
.card{background:${COLORS.surface};border:1px solid ${COLORS.border};border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
|
|
164
|
+
.score-card{text-align:center;padding:2rem}
|
|
165
|
+
.score-number{font-size:4rem;font-weight:800;line-height:1}
|
|
166
|
+
.score-label{color:${COLORS.textDim};font-size:1rem;margin-top:.5rem}
|
|
167
|
+
.badge{display:inline-block;background:${COLORS.border};padding:3px 10px;border-radius:6px;font-size:.85rem;margin:2px}
|
|
168
|
+
table{width:100%;border-collapse:collapse}
|
|
169
|
+
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid ${COLORS.border}}
|
|
170
|
+
th{color:${COLORS.textDim};font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.03em}
|
|
171
|
+
.impact-critical{color:${COLORS.red};font-weight:700}
|
|
172
|
+
.impact-high{color:${COLORS.red}}
|
|
173
|
+
.impact-medium{color:${COLORS.yellow}}
|
|
174
|
+
.impact-low{color:${COLORS.textDim}}
|
|
175
|
+
.footer{text-align:center;color:${COLORS.textDim};font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid ${COLORS.border}}
|
|
176
|
+
.footer a{color:${COLORS.blue};text-decoration:none}
|
|
177
|
+
.footer a:hover{text-decoration:underline}
|
|
178
|
+
svg text{font-family:inherit}
|
|
179
|
+
</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<h1>Nerviq Dashboard — ${escapeHtml(projectName)}</h1>
|
|
183
|
+
<div class="timestamp">Generated ${timestamp}</div>
|
|
184
|
+
|
|
185
|
+
<div class="card score-card">
|
|
186
|
+
<div class="score-number" style="color:${scoreColor(score)}">${score}</div>
|
|
187
|
+
<div class="score-label">out of 100</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="card">
|
|
191
|
+
<h2>Platforms Detected</h2>
|
|
192
|
+
${platformList}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="card">
|
|
196
|
+
<h2>Top Failed Checks</h2>
|
|
197
|
+
<table>
|
|
198
|
+
<thead><tr><th>Check</th><th>Impact</th><th>Category</th></tr></thead>
|
|
199
|
+
<tbody>
|
|
200
|
+
${failedRows}
|
|
201
|
+
</tbody>
|
|
202
|
+
</table>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
${scoreOverTime}
|
|
206
|
+
${driftAlerts}
|
|
207
|
+
${categoryBreakdown}
|
|
208
|
+
|
|
209
|
+
<div class="footer">
|
|
210
|
+
Generated by <a href="https://github.com/nerviq/cli">Nerviq v${version}</a>
|
|
211
|
+
</div>
|
|
212
|
+
</body>
|
|
213
|
+
</html>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Generate a static HTML dashboard report.
|
|
218
|
+
* @param {string} dir - Project root directory.
|
|
219
|
+
* @param {Object} flags - CLI flags (--out, --open, --json, etc).
|
|
220
|
+
*/
|
|
221
|
+
async function generateDashboard(dir, flags = {}) {
|
|
222
|
+
const outputFile = flags.out || 'nerviq-dashboard.html';
|
|
223
|
+
const outputPath = path.isAbsolute(outputFile) ? outputFile : path.join(dir, outputFile);
|
|
224
|
+
const projectName = path.basename(dir);
|
|
225
|
+
|
|
226
|
+
// Collect audit history from snapshots
|
|
227
|
+
const history = getHistory(dir, 50);
|
|
228
|
+
let auditPayload = null;
|
|
229
|
+
|
|
230
|
+
if (history.length > 0) {
|
|
231
|
+
// Load the most recent audit snapshot
|
|
232
|
+
auditPayload = loadSnapshotPayload(dir, history[0]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!auditPayload) {
|
|
236
|
+
// No snapshots — run a fresh audit
|
|
237
|
+
const { audit } = require('./audit');
|
|
238
|
+
auditPayload = await audit({ dir, silent: true, platform: flags.platform || 'claude' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const html = buildHtml(projectName, auditPayload, history);
|
|
242
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
243
|
+
fs.writeFileSync(outputPath, html, 'utf8');
|
|
244
|
+
|
|
245
|
+
const relPath = path.relative(dir, outputPath);
|
|
246
|
+
if (!flags.json) {
|
|
247
|
+
console.log('');
|
|
248
|
+
console.log(' nerviq dashboard');
|
|
249
|
+
console.log(' \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');
|
|
250
|
+
console.log(` Score: ${auditPayload.score ?? '?'}/100`);
|
|
251
|
+
console.log(` Snapshots: ${history.length}`);
|
|
252
|
+
console.log(` Output: ${relPath}`);
|
|
253
|
+
console.log('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (flags.open) {
|
|
257
|
+
const { exec } = require('child_process');
|
|
258
|
+
const cmd = process.platform === 'win32' ? `start "" "${outputPath}"`
|
|
259
|
+
: process.platform === 'darwin' ? `open "${outputPath}"`
|
|
260
|
+
: `xdg-open "${outputPath}"`;
|
|
261
|
+
exec(cmd);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { outputPath, relativePath: relPath, score: auditPayload.score };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Detect score drift between recent snapshots.
|
|
269
|
+
* Returns array of { from, to, delta, date } for drifts > threshold.
|
|
270
|
+
*/
|
|
271
|
+
function detectDrifts(history, threshold = 5) {
|
|
272
|
+
if (!history || history.length < 2) return [];
|
|
273
|
+
const drifts = [];
|
|
274
|
+
for (let i = 0; i < history.length - 1; i++) {
|
|
275
|
+
const current = history[i];
|
|
276
|
+
const previous = history[i + 1];
|
|
277
|
+
if (current.score != null && previous.score != null) {
|
|
278
|
+
const delta = current.score - previous.score;
|
|
279
|
+
if (Math.abs(delta) >= threshold) {
|
|
280
|
+
drifts.push({
|
|
281
|
+
date: current.date || current.timestamp,
|
|
282
|
+
from: previous.score,
|
|
283
|
+
to: current.score,
|
|
284
|
+
delta,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return drifts;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build drift alerts HTML section for the dashboard.
|
|
294
|
+
*/
|
|
295
|
+
function buildDriftAlertsHtml(drifts) {
|
|
296
|
+
if (!drifts.length) return '';
|
|
297
|
+
const rows = drifts.map(d => {
|
|
298
|
+
const color = d.delta < 0 ? COLORS.red : COLORS.green;
|
|
299
|
+
const arrow = d.delta < 0 ? '▼' : '▲';
|
|
300
|
+
const sign = d.delta > 0 ? '+' : '';
|
|
301
|
+
return `<tr>
|
|
302
|
+
<td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border}">${escapeHtml(d.date)}</td>
|
|
303
|
+
<td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border}">${d.from} → ${d.to}</td>
|
|
304
|
+
<td style="padding:8px 12px;border-bottom:1px solid ${COLORS.border};color:${color};font-weight:bold">${arrow} ${sign}${d.delta}</td>
|
|
305
|
+
</tr>`;
|
|
306
|
+
}).join('');
|
|
307
|
+
|
|
308
|
+
return `
|
|
309
|
+
<div style="margin-top:32px">
|
|
310
|
+
<h2 style="color:${COLORS.text};font-size:18px;margin-bottom:12px">⚠ Score Drift Alerts</h2>
|
|
311
|
+
<p style="color:${COLORS.textDim};font-size:13px;margin-bottom:12px">Changes of 5+ points between consecutive snapshots</p>
|
|
312
|
+
<table style="width:100%;border-collapse:collapse;background:${COLORS.surface};border-radius:8px;overflow:hidden">
|
|
313
|
+
<thead><tr style="background:${COLORS.border}">
|
|
314
|
+
<th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Date</th>
|
|
315
|
+
<th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Score Change</th>
|
|
316
|
+
<th style="padding:8px 12px;text-align:left;color:${COLORS.textDim};font-size:12px">Delta</th>
|
|
317
|
+
</tr></thead>
|
|
318
|
+
<tbody>${rows}</tbody>
|
|
319
|
+
</table>
|
|
320
|
+
</div>`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build a portfolio HTML page summarizing multiple repos.
|
|
325
|
+
*/
|
|
326
|
+
function buildPortfolioHtml(repoResults) {
|
|
327
|
+
const timestamp = new Date().toISOString();
|
|
328
|
+
const scores = repoResults.map(r => r.score);
|
|
329
|
+
const avgScore = repoResults.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
|
330
|
+
const weakest = repoResults.reduce((a, b) => a.score <= b.score ? a : b, repoResults[0]);
|
|
331
|
+
const strongest = repoResults.reduce((a, b) => a.score >= b.score ? a : b, repoResults[0]);
|
|
332
|
+
|
|
333
|
+
const rows = repoResults.map(r => {
|
|
334
|
+
const indicator = r.score >= 70 ? '\u{1F7E2}' : r.score >= 40 ? '\u{1F7E1}' : '\u{1F534}';
|
|
335
|
+
const platforms = (r.platforms || ['unknown']).map(p => `<span class="badge">${escapeHtml(p)}</span>`).join(' ');
|
|
336
|
+
const isWeak = r.name === weakest.name && repoResults.length > 1;
|
|
337
|
+
const isStrong = r.name === strongest.name && repoResults.length > 1;
|
|
338
|
+
const highlight = isWeak ? ' style="border-left:3px solid ' + COLORS.red + '"'
|
|
339
|
+
: isStrong ? ' style="border-left:3px solid ' + COLORS.green + '"' : '';
|
|
340
|
+
return `<tr${highlight}><td>${escapeHtml(r.name)}</td><td>${platforms}</td><td style="color:${scoreColor(r.score)};font-weight:700">${r.score}</td><td>${r.critical}</td><td>${r.high}</td><td>${indicator}</td></tr>`;
|
|
341
|
+
}).join('\n ');
|
|
342
|
+
|
|
343
|
+
return `<!DOCTYPE html>
|
|
344
|
+
<html lang="en">
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8"/>
|
|
347
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
348
|
+
<title>Nerviq Portfolio Dashboard</title>
|
|
349
|
+
<style>
|
|
350
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
351
|
+
body{background:${COLORS.bg};color:${COLORS.text};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:2rem;max-width:1000px;margin:0 auto}
|
|
352
|
+
h1{font-size:1.6rem;margin-bottom:.3rem}
|
|
353
|
+
h2{font-size:1.1rem;margin-bottom:1rem;color:${COLORS.textDim}}
|
|
354
|
+
.timestamp{color:${COLORS.textDim};font-size:.85rem;margin-bottom:2rem}
|
|
355
|
+
.card{background:${COLORS.surface};border:1px solid ${COLORS.border};border-radius:12px;padding:1.5rem;margin-bottom:1.5rem}
|
|
356
|
+
.score-card{text-align:center;padding:2rem}
|
|
357
|
+
.score-number{font-size:4rem;font-weight:800;line-height:1}
|
|
358
|
+
.score-label{color:${COLORS.textDim};font-size:1rem;margin-top:.5rem}
|
|
359
|
+
.badge{display:inline-block;background:${COLORS.border};padding:3px 10px;border-radius:6px;font-size:.85rem;margin:2px}
|
|
360
|
+
.highlights{display:flex;gap:1.5rem;margin-bottom:1.5rem}
|
|
361
|
+
.highlights .card{flex:1;text-align:center}
|
|
362
|
+
.highlights .label{color:${COLORS.textDim};font-size:.85rem;margin-bottom:.5rem}
|
|
363
|
+
.highlights .value{font-size:1.3rem;font-weight:700}
|
|
364
|
+
table{width:100%;border-collapse:collapse}
|
|
365
|
+
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid ${COLORS.border}}
|
|
366
|
+
th{color:${COLORS.textDim};font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.03em}
|
|
367
|
+
.footer{text-align:center;color:${COLORS.textDim};font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid ${COLORS.border}}
|
|
368
|
+
.footer a{color:${COLORS.blue};text-decoration:none}
|
|
369
|
+
.footer a:hover{text-decoration:underline}
|
|
370
|
+
</style>
|
|
371
|
+
</head>
|
|
372
|
+
<body>
|
|
373
|
+
<h1>Nerviq Portfolio Dashboard</h1>
|
|
374
|
+
<div class="timestamp">${repoResults.length} repos — Generated ${timestamp}</div>
|
|
375
|
+
|
|
376
|
+
<div class="card score-card">
|
|
377
|
+
<div class="score-number" style="color:${scoreColor(avgScore)}">${avgScore}</div>
|
|
378
|
+
<div class="score-label">average score across ${repoResults.length} repos</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div class="highlights">
|
|
382
|
+
<div class="card">
|
|
383
|
+
<div class="label">Strongest Repo</div>
|
|
384
|
+
<div class="value" style="color:${COLORS.green}">${escapeHtml(strongest.name)} (${strongest.score})</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="card">
|
|
387
|
+
<div class="label">Weakest Repo</div>
|
|
388
|
+
<div class="value" style="color:${COLORS.red}">${escapeHtml(weakest.name)} (${weakest.score})</div>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<div class="card">
|
|
393
|
+
<h2>Repository Summary</h2>
|
|
394
|
+
<table>
|
|
395
|
+
<thead><tr><th>Repo</th><th>Platform(s)</th><th>Score</th><th>Critical</th><th>High</th><th>Status</th></tr></thead>
|
|
396
|
+
<tbody>
|
|
397
|
+
${rows}
|
|
398
|
+
</tbody>
|
|
399
|
+
</table>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div class="footer">
|
|
403
|
+
Generated by <a href="https://github.com/nerviq/cli">Nerviq v${version}</a>
|
|
404
|
+
</div>
|
|
405
|
+
</body>
|
|
406
|
+
</html>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Generate a portfolio dashboard across multiple repos.
|
|
411
|
+
* @param {string[]} repoPaths - Array of repo directory paths.
|
|
412
|
+
* @param {Object} flags - CLI flags (--out, --open, --json, --platform).
|
|
413
|
+
*/
|
|
414
|
+
async function generatePortfolioDashboard(repoPaths, flags = {}) {
|
|
415
|
+
const { audit } = require('./audit');
|
|
416
|
+
const repoResults = [];
|
|
417
|
+
|
|
418
|
+
for (const repoPath of repoPaths) {
|
|
419
|
+
const absPath = path.isAbsolute(repoPath) ? repoPath : path.resolve(repoPath);
|
|
420
|
+
if (!fs.existsSync(absPath)) {
|
|
421
|
+
console.error(` Warning: skipping ${repoPath} (not found)`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const name = path.basename(absPath);
|
|
425
|
+
try {
|
|
426
|
+
const result = await audit({ dir: absPath, silent: true, platform: flags.platform || 'claude' });
|
|
427
|
+
const results = result.results || [];
|
|
428
|
+
const critical = results.filter(r => !r.passed && r.impact === 'critical').length;
|
|
429
|
+
const high = results.filter(r => !r.passed && r.impact === 'high').length;
|
|
430
|
+
const platforms = result.detectedPlatforms || (result.platform ? [result.platform] : ['unknown']);
|
|
431
|
+
repoResults.push({ name, score: result.score ?? 0, platforms, critical, high });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(` Warning: audit failed for ${name}: ${err.message}`);
|
|
434
|
+
repoResults.push({ name, score: 0, platforms: ['error'], critical: 0, high: 0 });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (repoResults.length === 0) {
|
|
439
|
+
console.error('\n Error: no valid repos found.\n');
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const outputFile = flags.out || 'nerviq-portfolio.html';
|
|
444
|
+
const outputPath = path.isAbsolute(outputFile) ? outputFile : path.resolve(outputFile);
|
|
445
|
+
const html = buildPortfolioHtml(repoResults);
|
|
446
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
447
|
+
fs.writeFileSync(outputPath, html, 'utf8');
|
|
448
|
+
|
|
449
|
+
const avgScore = Math.round(repoResults.reduce((s, r) => s + r.score, 0) / repoResults.length);
|
|
450
|
+
if (!flags.json) {
|
|
451
|
+
console.log('');
|
|
452
|
+
console.log(' nerviq portfolio dashboard');
|
|
453
|
+
console.log(' \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550');
|
|
454
|
+
console.log(` Repos: ${repoResults.length}`);
|
|
455
|
+
console.log(` Average score: ${avgScore}/100`);
|
|
456
|
+
console.log(` Output: ${outputPath}`);
|
|
457
|
+
console.log('');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (flags.open) {
|
|
461
|
+
const { exec } = require('child_process');
|
|
462
|
+
const cmd = process.platform === 'win32' ? `start "" "${outputPath}"`
|
|
463
|
+
: process.platform === 'darwin' ? `open "${outputPath}"`
|
|
464
|
+
: `xdg-open "${outputPath}"`;
|
|
465
|
+
exec(cmd);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { outputPath, repoCount: repoResults.length, avgScore, repos: repoResults };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = { generateDashboard, generatePortfolioDashboard, detectDrifts, buildDriftAlertsHtml };
|
package/src/init.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const { detectPlatforms } = require('./public-api');
|
|
5
|
+
const { audit } = require('./audit');
|
|
6
|
+
const { setup } = require('./setup');
|
|
7
|
+
const { ProjectContext } = require('./context');
|
|
8
|
+
const { STACKS } = require('./techniques');
|
|
9
|
+
|
|
10
|
+
const PLATFORM_LABELS = {
|
|
11
|
+
claude: 'Claude Code',
|
|
12
|
+
codex: 'Codex',
|
|
13
|
+
gemini: 'Gemini CLI',
|
|
14
|
+
copilot: 'GitHub Copilot',
|
|
15
|
+
cursor: 'Cursor',
|
|
16
|
+
windsurf: 'Windsurf',
|
|
17
|
+
aider: 'Aider',
|
|
18
|
+
opencode: 'OpenCode',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ALL_PLATFORMS = Object.keys(PLATFORM_LABELS);
|
|
22
|
+
|
|
23
|
+
const TEAM_SIZES = ['solo', 'small', 'team', 'enterprise'];
|
|
24
|
+
const TEAM_LABELS = {
|
|
25
|
+
solo: 'Solo developer',
|
|
26
|
+
small: 'Small team (2-5)',
|
|
27
|
+
team: 'Team (6-20)',
|
|
28
|
+
enterprise: 'Enterprise (20+)',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function prompt(rl, question) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
rl.question(question, (answer) => resolve(answer.trim()));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function detectStacks(dir) {
|
|
38
|
+
const ctx = new ProjectContext(dir);
|
|
39
|
+
return ctx.detectStacks(STACKS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function labelPlatforms(platforms) {
|
|
43
|
+
return platforms.map((p) => PLATFORM_LABELS[p] || p).join(', ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parsePlatforms(input) {
|
|
47
|
+
return input
|
|
48
|
+
.split(/[,\s]+/)
|
|
49
|
+
.map((s) => s.trim().toLowerCase())
|
|
50
|
+
.filter((s) => ALL_PLATFORMS.includes(s));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runInit(dir, flags) {
|
|
54
|
+
const rl = readline.createInterface({
|
|
55
|
+
input: process.stdin,
|
|
56
|
+
output: process.stdout,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const dim = '\x1b[2m';
|
|
60
|
+
const bold = '\x1b[1m';
|
|
61
|
+
const cyan = '\x1b[36m';
|
|
62
|
+
const green = '\x1b[32m';
|
|
63
|
+
const reset = '\x1b[0m';
|
|
64
|
+
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log(`${bold} Welcome to Nerviq${reset} — let's set up your AI coding agent governance.`);
|
|
67
|
+
console.log('');
|
|
68
|
+
|
|
69
|
+
// --- Question 1: Platforms ---
|
|
70
|
+
const detected = detectPlatforms(dir);
|
|
71
|
+
const detectedLabel = detected.length > 0
|
|
72
|
+
? `${dim}[auto-detected: ${labelPlatforms(detected)}]${reset}`
|
|
73
|
+
: `${dim}[no platforms detected]${reset}`;
|
|
74
|
+
console.log(` ${bold}1.${reset} Which platform(s) do you use?`);
|
|
75
|
+
console.log(` ${detectedLabel}`);
|
|
76
|
+
const platformInput = await prompt(
|
|
77
|
+
rl,
|
|
78
|
+
` ${dim}> Press Enter to confirm, or type: ${ALL_PLATFORMS.join(',')}${reset}\n > `,
|
|
79
|
+
);
|
|
80
|
+
const platforms = platformInput === ''
|
|
81
|
+
? (detected.length > 0 ? detected : ['claude'])
|
|
82
|
+
: parsePlatforms(platformInput);
|
|
83
|
+
if (platforms.length === 0) platforms.push('claude');
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
|
|
87
|
+
// --- Question 2: Stack ---
|
|
88
|
+
const stacks = detectStacks(dir);
|
|
89
|
+
const stackLabels = stacks.map((s) => s.label);
|
|
90
|
+
const stackDetectedLabel = stackLabels.length > 0
|
|
91
|
+
? `${dim}[auto-detected: ${stackLabels.join(', ')}]${reset}`
|
|
92
|
+
: `${dim}[no stack detected]${reset}`;
|
|
93
|
+
console.log(` ${bold}2.${reset} What's your primary stack?`);
|
|
94
|
+
console.log(` ${stackDetectedLabel}`);
|
|
95
|
+
const stackInput = await prompt(
|
|
96
|
+
rl,
|
|
97
|
+
` ${dim}> Press Enter to confirm, or type your stack${reset}\n > `,
|
|
98
|
+
);
|
|
99
|
+
const stackDisplay = stackInput === ''
|
|
100
|
+
? (stackLabels.length > 0 ? stackLabels.join(', ') : 'General')
|
|
101
|
+
: stackInput;
|
|
102
|
+
|
|
103
|
+
console.log('');
|
|
104
|
+
|
|
105
|
+
// --- Question 3: Team size ---
|
|
106
|
+
console.log(` ${bold}3.${reset} What's your team size?`);
|
|
107
|
+
const teamInput = await prompt(
|
|
108
|
+
rl,
|
|
109
|
+
` ${dim}> solo / small (2-5) / team (6-20) / enterprise (20+)${reset}\n > `,
|
|
110
|
+
);
|
|
111
|
+
const teamKey = TEAM_SIZES.find((t) => teamInput.toLowerCase().startsWith(t)) || 'solo';
|
|
112
|
+
const teamLabel = TEAM_LABELS[teamKey];
|
|
113
|
+
|
|
114
|
+
rl.close();
|
|
115
|
+
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(` ${cyan}Setting up for: ${labelPlatforms(platforms)} | ${stackDisplay} | ${teamLabel}${reset}`);
|
|
118
|
+
console.log('');
|
|
119
|
+
|
|
120
|
+
// --- Run audit (before) ---
|
|
121
|
+
const primaryPlatform = platforms[0];
|
|
122
|
+
console.log(` ${dim}Running audit...${reset}`);
|
|
123
|
+
const preResult = await audit({ dir, silent: true, platform: primaryPlatform });
|
|
124
|
+
const preScore = preResult.score;
|
|
125
|
+
console.log(` Score: ${bold}${preScore}/100${reset}`);
|
|
126
|
+
console.log('');
|
|
127
|
+
|
|
128
|
+
// --- Run setup ---
|
|
129
|
+
console.log(` ${dim}Running setup...${reset}`);
|
|
130
|
+
const setupResult = await setup({
|
|
131
|
+
dir,
|
|
132
|
+
platform: primaryPlatform,
|
|
133
|
+
silent: true,
|
|
134
|
+
profile: 'safe-write',
|
|
135
|
+
mcpPacks: [],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const f of setupResult.writtenFiles) {
|
|
139
|
+
console.log(` ${green}✅${reset} Created ${f}`);
|
|
140
|
+
}
|
|
141
|
+
for (const f of setupResult.preservedFiles) {
|
|
142
|
+
console.log(` ${dim}⏭️ Kept ${f} (already exists)${reset}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Run additional platform setups ---
|
|
146
|
+
for (const plat of platforms.slice(1)) {
|
|
147
|
+
try {
|
|
148
|
+
const extraResult = await setup({
|
|
149
|
+
dir,
|
|
150
|
+
platform: plat,
|
|
151
|
+
silent: true,
|
|
152
|
+
profile: 'safe-write',
|
|
153
|
+
mcpPacks: [],
|
|
154
|
+
});
|
|
155
|
+
for (const f of extraResult.writtenFiles) {
|
|
156
|
+
console.log(` ${green}✅${reset} Created ${f}`);
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Platform setup not available, skip
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Run audit (after) ---
|
|
164
|
+
const postResult = await audit({ dir, silent: true, platform: primaryPlatform });
|
|
165
|
+
const postScore = postResult.score;
|
|
166
|
+
const delta = postScore - preScore;
|
|
167
|
+
|
|
168
|
+
if (delta > 0) {
|
|
169
|
+
console.log(` Score: ${bold}${postScore}/100${reset} (${green}+${delta}${reset})`);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(` Score: ${bold}${postScore}/100${reset}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log('');
|
|
175
|
+
console.log(` ${bold}Next steps:${reset}`);
|
|
176
|
+
console.log(` - Review: ${cyan}nerviq audit --full${reset}`);
|
|
177
|
+
if (platforms.length > 1) {
|
|
178
|
+
console.log(` - Cross-platform: ${cyan}nerviq harmony-audit${reset}`);
|
|
179
|
+
}
|
|
180
|
+
console.log(` - Customize: ${cyan}nerviq augment${reset}`);
|
|
181
|
+
console.log('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { runInit };
|