@rune-kit/rune 2.11.0 → 2.12.2
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 +58 -1
- package/compiler/__tests__/detect-invariants.test.js +136 -0
- package/compiler/__tests__/doctor-mesh.test.js +229 -0
- package/compiler/__tests__/hook-dispatch.test.js +91 -0
- package/compiler/__tests__/hooks-antigravity.test.js +120 -0
- package/compiler/__tests__/hooks-cursor.test.js +141 -0
- package/compiler/__tests__/hooks-install.test.js +307 -0
- package/compiler/__tests__/hooks-merge.test.js +204 -0
- package/compiler/__tests__/hooks-tiers.test.js +620 -0
- package/compiler/__tests__/hooks-windsurf.test.js +117 -0
- package/compiler/__tests__/inject-claude-md.test.js +152 -0
- package/compiler/__tests__/load-invariants.test.js +408 -0
- package/compiler/__tests__/onboard-invariants.test.js +240 -0
- package/compiler/adapters/hooks/antigravity.js +140 -0
- package/compiler/adapters/hooks/claude.js +166 -0
- package/compiler/adapters/hooks/cursor.js +191 -0
- package/compiler/adapters/hooks/index.js +82 -0
- package/compiler/adapters/hooks/tier-emitter.js +182 -0
- package/compiler/adapters/hooks/windsurf.js +202 -0
- package/compiler/bin/rune.js +196 -6
- package/compiler/commands/hook-dispatch.js +87 -0
- package/compiler/commands/hooks/install.js +120 -0
- package/compiler/commands/hooks/merge.js +211 -0
- package/compiler/commands/hooks/presets.js +116 -0
- package/compiler/commands/hooks/status.js +112 -0
- package/compiler/commands/hooks/tiers.js +323 -0
- package/compiler/commands/hooks/uninstall.js +94 -0
- package/compiler/doctor.js +236 -0
- package/package.json +2 -2
- package/skills/ba/SKILL.md +85 -1
- package/skills/brainstorm/SKILL.md +39 -1
- package/skills/browser-pilot/SKILL.md +1 -0
- package/skills/context-engine/SKILL.md +6 -2
- package/skills/design/SKILL.md +1 -0
- package/skills/docs-seeker/SKILL.md +1 -0
- package/skills/fix/SKILL.md +4 -2
- package/skills/hallucination-guard/SKILL.md +1 -0
- package/skills/journal/SKILL.md +1 -0
- package/skills/logic-guardian/SKILL.md +22 -4
- package/skills/marketing/SKILL.md +62 -1
- package/skills/neural-memory/SKILL.md +13 -16
- package/skills/onboard/SKILL.md +30 -2
- package/skills/onboard/references/invariants-template.md +76 -0
- package/skills/onboard/scripts/detect-invariants.js +439 -0
- package/skills/onboard/scripts/inject-claude-md.js +150 -0
- package/skills/onboard/scripts/onboard-invariants.js +196 -0
- package/skills/perf/SKILL.md +1 -0
- package/skills/plan/SKILL.md +2 -0
- package/skills/preflight/SKILL.md +1 -1
- package/skills/research/SKILL.md +4 -0
- package/skills/review/SKILL.md +4 -2
- package/skills/scope-guard/SKILL.md +4 -1
- package/skills/scout/SKILL.md +6 -0
- package/skills/sentinel/SKILL.md +2 -0
- package/skills/session-bridge/SKILL.md +53 -1
- package/skills/session-bridge/scripts/load-invariants.js +397 -0
- package/skills/slides/SKILL.md +19 -0
- package/skills/team/SKILL.md +2 -1
- package/skills/test/SKILL.md +6 -0
- package/skills/verification/SKILL.md +8 -0
package/compiler/bin/rune.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Commands:
|
|
7
7
|
* rune init — Interactive setup for a new project
|
|
8
8
|
* rune build — Compile skills for the configured platform
|
|
9
|
-
* rune doctor — Validate compiled output
|
|
9
|
+
* rune doctor — Validate compiled output + mesh integrity (--mesh for mesh only)
|
|
10
10
|
* rune status — Project dashboard (neofetch-style)
|
|
11
11
|
* rune visualize — Interactive mesh graph
|
|
12
12
|
*/
|
|
@@ -18,8 +18,12 @@ import { createInterface } from 'node:readline';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { getAdapter, listPlatforms } from '../adapters/index.js';
|
|
20
20
|
import { getAllAnalytics } from '../analytics.js';
|
|
21
|
+
import { dispatchHook } from '../commands/hook-dispatch.js';
|
|
22
|
+
import { installHooks } from '../commands/hooks/install.js';
|
|
23
|
+
import { hookStatus } from '../commands/hooks/status.js';
|
|
24
|
+
import { uninstallHooks } from '../commands/hooks/uninstall.js';
|
|
21
25
|
import { generateDashboardHTML } from '../dashboard.js';
|
|
22
|
-
import { formatDoctorResults, runDoctor } from '../doctor.js';
|
|
26
|
+
import { checkMeshIntegrity, formatDoctorResults, formatMeshResults, runDoctor } from '../doctor.js';
|
|
23
27
|
import { buildAll } from '../emitter.js';
|
|
24
28
|
import { collectStats, renderStatus, renderStatusJson } from '../status.js';
|
|
25
29
|
import { collectGraphData, generateMeshHTML } from '../visualizer.js';
|
|
@@ -237,6 +241,17 @@ async function cmdBuild(projectRoot, args) {
|
|
|
237
241
|
async function cmdDoctor(projectRoot, args) {
|
|
238
242
|
const config = await readConfig(projectRoot);
|
|
239
243
|
|
|
244
|
+
// --mesh flag: run mesh integrity check only
|
|
245
|
+
if (args.mesh) {
|
|
246
|
+
log('');
|
|
247
|
+
const meshResults = await checkMeshIntegrity(RUNE_ROOT);
|
|
248
|
+
log(formatMeshResults(meshResults));
|
|
249
|
+
if (meshResults.errors.length > 0) process.exit(1);
|
|
250
|
+
// Exit with warning code if there are warnings (for CI awareness)
|
|
251
|
+
if (args.strict && meshResults.warnings.length > 0) process.exit(1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
240
255
|
if (!config) {
|
|
241
256
|
// No config = CI or fresh clone. Run source-only checks (split packs).
|
|
242
257
|
log('');
|
|
@@ -248,6 +263,12 @@ async function cmdDoctor(projectRoot, args) {
|
|
|
248
263
|
runeRoot: RUNE_ROOT,
|
|
249
264
|
});
|
|
250
265
|
log(formatDoctorResults(results));
|
|
266
|
+
|
|
267
|
+
// Also run mesh check in source-only mode
|
|
268
|
+
log('');
|
|
269
|
+
const meshResults = await checkMeshIntegrity(RUNE_ROOT);
|
|
270
|
+
log(formatMeshResults(meshResults));
|
|
271
|
+
|
|
251
272
|
if (!results.healthy) process.exit(1);
|
|
252
273
|
return;
|
|
253
274
|
}
|
|
@@ -265,6 +286,11 @@ async function cmdDoctor(projectRoot, args) {
|
|
|
265
286
|
|
|
266
287
|
log(formatDoctorResults(results));
|
|
267
288
|
|
|
289
|
+
// Also run mesh check
|
|
290
|
+
log('');
|
|
291
|
+
const meshResults = await checkMeshIntegrity(runeRoot);
|
|
292
|
+
log(formatMeshResults(meshResults));
|
|
293
|
+
|
|
268
294
|
if (!results.healthy) process.exit(1);
|
|
269
295
|
}
|
|
270
296
|
|
|
@@ -403,10 +429,155 @@ async function cmdAnalytics(projectRoot, args) {
|
|
|
403
429
|
}
|
|
404
430
|
}
|
|
405
431
|
|
|
432
|
+
// ─── Hook Commands ───
|
|
433
|
+
|
|
434
|
+
async function cmdHooks(projectRoot, args, subcommand) {
|
|
435
|
+
if (!subcommand) {
|
|
436
|
+
log('');
|
|
437
|
+
log(' rune hooks — Auto-discipline entry point for AI IDEs');
|
|
438
|
+
log('');
|
|
439
|
+
log(' Subcommands:');
|
|
440
|
+
log(' install [--preset strict|gentle|off] [--platform <name>|all] [--tier pro|business]');
|
|
441
|
+
log(' Wire Rune hooks / rules / workflows');
|
|
442
|
+
log(
|
|
443
|
+
' uninstall [--platform <name>|all] Remove Rune-managed entries (keeps user entries)',
|
|
444
|
+
);
|
|
445
|
+
log(
|
|
446
|
+
' status [--platform <name>|all] [--tier pro|business] Show active preset, wired skills, tier coverage',
|
|
447
|
+
);
|
|
448
|
+
log('');
|
|
449
|
+
log(' Platforms: claude, cursor, windsurf, antigravity (auto-detected if omitted)');
|
|
450
|
+
log(' Tiers: pro, business — requires $RUNE_PRO_ROOT / $RUNE_BUSINESS_ROOT env var or monorepo sibling.');
|
|
451
|
+
log(' Options:');
|
|
452
|
+
log(' --dry Preview changes without writing');
|
|
453
|
+
log(' --tier <name>[,<name>] Overlay tier manifest(s) — pass multiple tiers comma-separated.');
|
|
454
|
+
log('');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
switch (subcommand) {
|
|
459
|
+
case 'install': {
|
|
460
|
+
const tierArg = parseTierFlag(args.tier);
|
|
461
|
+
const result = await installHooks(projectRoot, {
|
|
462
|
+
preset: args.preset,
|
|
463
|
+
dry: args.dry,
|
|
464
|
+
platform: args.platform,
|
|
465
|
+
tier: tierArg,
|
|
466
|
+
});
|
|
467
|
+
log('');
|
|
468
|
+
if (result.platforms.length === 0) {
|
|
469
|
+
for (const note of result.notes) logStep('·', note);
|
|
470
|
+
log('');
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
const tierSuffix = result.tiers?.length ? ` + tier(s): ${result.tiers.join(', ')}` : '';
|
|
474
|
+
if (result.written) {
|
|
475
|
+
logStep('✓', `Installed preset "${result.preset}"${tierSuffix} across: ${result.platforms.join(', ')}`);
|
|
476
|
+
} else if (args.dry) {
|
|
477
|
+
logStep('◎', `Dry-run — no changes written (platforms: ${result.platforms.join(', ')}${tierSuffix})`);
|
|
478
|
+
}
|
|
479
|
+
for (const r of result.results) {
|
|
480
|
+
log('');
|
|
481
|
+
log(` [${r.platform}]`);
|
|
482
|
+
for (const file of r.files) {
|
|
483
|
+
const rel = path.relative(projectRoot, file.path);
|
|
484
|
+
logStep(file.deleted ? '−' : '→', rel);
|
|
485
|
+
}
|
|
486
|
+
for (const note of r.notes) logStep('·', note);
|
|
487
|
+
}
|
|
488
|
+
log('');
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case 'uninstall': {
|
|
492
|
+
const result = await uninstallHooks(projectRoot, { dry: args.dry, platform: args.platform });
|
|
493
|
+
log('');
|
|
494
|
+
if (result.platforms.length === 0) {
|
|
495
|
+
for (const note of result.notes) logStep('·', note);
|
|
496
|
+
log('');
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
if (result.written) {
|
|
500
|
+
logStep('✓', `Uninstalled Rune entries across: ${result.platforms.join(', ')}`);
|
|
501
|
+
} else if (args.dry) {
|
|
502
|
+
logStep('◎', `Dry-run — would uninstall across: ${result.platforms.join(', ')}`);
|
|
503
|
+
}
|
|
504
|
+
for (const r of result.results) {
|
|
505
|
+
log('');
|
|
506
|
+
log(` [${r.platform}]`);
|
|
507
|
+
for (const file of r.files) {
|
|
508
|
+
const rel = path.relative(projectRoot, file.path);
|
|
509
|
+
logStep(file.deleted ? '−' : '→', rel);
|
|
510
|
+
}
|
|
511
|
+
for (const note of r.notes) logStep('·', note);
|
|
512
|
+
}
|
|
513
|
+
log('');
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'status': {
|
|
517
|
+
const tierArg = parseTierFlag(args.tier);
|
|
518
|
+
const result = await hookStatus(projectRoot, RUNE_ROOT, { platform: args.platform, tier: tierArg });
|
|
519
|
+
log('');
|
|
520
|
+
if (result.platforms.length === 0) {
|
|
521
|
+
for (const note of result.notes) logStep('·', note);
|
|
522
|
+
log('');
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
for (const r of result.results) {
|
|
526
|
+
log(` [${r.platform}]${r.capability ? ` (${r.capability.maturity})` : ''}`);
|
|
527
|
+
log(` installed: ${r.installed ? 'yes' : 'no'}`);
|
|
528
|
+
log(` preset: ${r.preset ?? 'none'}`);
|
|
529
|
+
if (r.wired.length > 0) log(` wired: ${r.wired.join(', ')}`);
|
|
530
|
+
if (r.missing.length > 0) logStep('⚠', `missing: ${r.missing.join(', ')}`);
|
|
531
|
+
if (r.events) {
|
|
532
|
+
for (const [event, skills] of Object.entries(r.events)) {
|
|
533
|
+
log(` ${event}: ${skills.join(', ')}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
for (const note of r.notes) logStep('·', note);
|
|
537
|
+
log('');
|
|
538
|
+
}
|
|
539
|
+
if (result.tiers && result.tiers.length > 0) {
|
|
540
|
+
log(' Tiers:');
|
|
541
|
+
for (const t of result.tiers) {
|
|
542
|
+
if (!t.found) {
|
|
543
|
+
logStep('⚠', `${t.tier}: manifest not found${t.error ? ` (${t.error})` : ''}`);
|
|
544
|
+
} else {
|
|
545
|
+
const req = t.requiresOk ? 'env OK' : `missing env: ${t.requiresMissing.join(', ')}`;
|
|
546
|
+
logStep('·', `${t.tier} v${t.version} — ${t.entries} entries — ${req}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
log('');
|
|
550
|
+
}
|
|
551
|
+
if (result.missingInRepo.length > 0) {
|
|
552
|
+
logStep('⚠', `Skills referenced by presets but not found in repo: ${result.missingInRepo.join(', ')}`);
|
|
553
|
+
log('');
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
default:
|
|
558
|
+
log(` ✗ Unknown hooks subcommand: ${subcommand}. Run \`rune hooks\` for help.`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
406
563
|
// ─── Arg Parsing ───
|
|
407
564
|
|
|
408
565
|
// Flags that require a string value (not boolean)
|
|
409
|
-
const VALUE_REQUIRED_FLAGS = new Set(['platform', 'output', 'disable', 'extensions', 'days']);
|
|
566
|
+
const VALUE_REQUIRED_FLAGS = new Set(['platform', 'output', 'disable', 'extensions', 'days', 'preset', 'tier']);
|
|
567
|
+
|
|
568
|
+
function parseTierFlag(raw) {
|
|
569
|
+
if (!raw) return undefined;
|
|
570
|
+
if (Array.isArray(raw)) {
|
|
571
|
+
return raw
|
|
572
|
+
.flatMap((v) => String(v).split(','))
|
|
573
|
+
.map((s) => s.trim())
|
|
574
|
+
.filter(Boolean);
|
|
575
|
+
}
|
|
576
|
+
return String(raw)
|
|
577
|
+
.split(',')
|
|
578
|
+
.map((s) => s.trim())
|
|
579
|
+
.filter(Boolean);
|
|
580
|
+
}
|
|
410
581
|
|
|
411
582
|
function parseArgs(argv) {
|
|
412
583
|
const args = {};
|
|
@@ -431,13 +602,21 @@ function parseArgs(argv) {
|
|
|
431
602
|
}
|
|
432
603
|
}
|
|
433
604
|
|
|
434
|
-
return { command: positional[0], args };
|
|
605
|
+
return { command: positional[0], subcommand: positional[1], args, positional };
|
|
435
606
|
}
|
|
436
607
|
|
|
437
608
|
// ─── Main ───
|
|
438
609
|
|
|
439
610
|
async function main() {
|
|
440
|
-
const
|
|
611
|
+
const rawArgv = process.argv.slice(2);
|
|
612
|
+
|
|
613
|
+
// hook-dispatch bypasses parseArgs so flags like --gentle reach the dispatcher verbatim
|
|
614
|
+
if (rawArgv[0] === 'hook-dispatch') {
|
|
615
|
+
const exitCode = await dispatchHook(rawArgv.slice(1));
|
|
616
|
+
process.exit(exitCode);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const { command, subcommand, args } = parseArgs(rawArgv);
|
|
441
620
|
const projectRoot = process.cwd();
|
|
442
621
|
|
|
443
622
|
// Handle --version / -v as flag (not positional command)
|
|
@@ -468,6 +647,9 @@ async function main() {
|
|
|
468
647
|
case 'dash':
|
|
469
648
|
await cmdAnalytics(projectRoot, args);
|
|
470
649
|
break;
|
|
650
|
+
case 'hooks':
|
|
651
|
+
await cmdHooks(projectRoot, args, subcommand);
|
|
652
|
+
break;
|
|
471
653
|
case 'version':
|
|
472
654
|
case '--version':
|
|
473
655
|
case '-v': {
|
|
@@ -484,10 +666,18 @@ async function main() {
|
|
|
484
666
|
log(' Commands:');
|
|
485
667
|
log(' init Interactive setup (auto-detects platform)');
|
|
486
668
|
log(' build Compile skills for configured platform');
|
|
487
|
-
log(' doctor Validate compiled output');
|
|
669
|
+
log(' doctor Validate compiled output + mesh integrity');
|
|
670
|
+
log(' --mesh Mesh integrity only (reciprocals, versions, sections)');
|
|
671
|
+
log(' --strict Fail on warnings (for CI)');
|
|
488
672
|
log(' status Project dashboard (skills, signals, packs, health)');
|
|
489
673
|
log(' visualize Interactive mesh graph (opens in browser)');
|
|
490
674
|
log(' analytics Usage analytics dashboard (Business tier)');
|
|
675
|
+
log(' hooks Install/uninstall/status for multi-platform auto-discipline');
|
|
676
|
+
log(
|
|
677
|
+
' hooks install [--preset gentle|strict|off] [--platform claude|cursor|windsurf|antigravity|all]',
|
|
678
|
+
);
|
|
679
|
+
log(' hooks uninstall [--platform <name>|all]');
|
|
680
|
+
log(' hooks status [--platform <name>|all]');
|
|
491
681
|
log('');
|
|
492
682
|
log(' Options:');
|
|
493
683
|
log(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rune hook-dispatch <skill> [--gentle]`
|
|
3
|
+
*
|
|
4
|
+
* Runtime dispatcher invoked by Claude Code hooks. Reads event JSON from stdin,
|
|
5
|
+
* validates the skill name against an allowlist, then forwards to the skill's
|
|
6
|
+
* execution (currently a no-op placeholder that emits a verdict line — skill
|
|
7
|
+
* invocation wiring happens when skills gain a headless mode).
|
|
8
|
+
*
|
|
9
|
+
* In gentle mode: always exits 0, prints advisory line.
|
|
10
|
+
* In strict mode: exits 2 on BLOCK verdict (Claude Code blocks the tool call).
|
|
11
|
+
*
|
|
12
|
+
* Security:
|
|
13
|
+
* - Skill name MUST match the allowlist (no arbitrary shell injection)
|
|
14
|
+
* - Event payload is parsed but never passed to shell commands
|
|
15
|
+
* - Unknown skills fail closed (error in strict, warn in gentle)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { WIRED_SKILLS } from './hooks/presets.js';
|
|
19
|
+
|
|
20
|
+
const ALLOWLIST = new Set(WIRED_SKILLS);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string[]} argv — positional args after `hook-dispatch`
|
|
24
|
+
* @param {{stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream}} io
|
|
25
|
+
* @returns {Promise<number>} exit code
|
|
26
|
+
*/
|
|
27
|
+
export async function dispatchHook(argv, io = {}) {
|
|
28
|
+
const stdout = io.stdout || process.stdout;
|
|
29
|
+
const stderr = io.stderr || process.stderr;
|
|
30
|
+
const stdin = io.stdin || process.stdin;
|
|
31
|
+
|
|
32
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
33
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
34
|
+
const skill = positional[0];
|
|
35
|
+
const gentle = flags.has('--gentle');
|
|
36
|
+
|
|
37
|
+
if (!skill) {
|
|
38
|
+
stderr.write('rune hook-dispatch: missing skill name\n');
|
|
39
|
+
return gentle ? 0 : 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!ALLOWLIST.has(skill)) {
|
|
43
|
+
stderr.write(`rune hook-dispatch: unknown skill "${skill}"\n`);
|
|
44
|
+
return gentle ? 0 : 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read event JSON from stdin (best-effort; hooks may pass empty stdin)
|
|
48
|
+
let eventJson = {};
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readStdin(stdin);
|
|
51
|
+
if (raw.trim()) eventJson = JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
// Non-JSON stdin is tolerable — skills may not need the payload
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Skill invocation placeholder — skills don't yet expose a headless API.
|
|
57
|
+
// For v1: emit advisory line, pass through. This unblocks hook installation
|
|
58
|
+
// while skill-forge adds `--hook-mode` to preflight/sentinel/etc.
|
|
59
|
+
const mode = gentle ? 'advisory' : 'enforcing';
|
|
60
|
+
stdout.write(`rune-hook: ${skill} [${mode}] — tool=${eventJson?.tool_name || 'unknown'}\n`);
|
|
61
|
+
|
|
62
|
+
// Until skills expose headless verdicts, dispatcher returns neutral success.
|
|
63
|
+
// When skills add `--hook-mode`, extend this to run them and propagate exit codes.
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readStdin(stream) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
if (stream.isTTY) {
|
|
70
|
+
resolve('');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let buf = '';
|
|
74
|
+
stream.setEncoding('utf-8');
|
|
75
|
+
stream.on('data', (chunk) => {
|
|
76
|
+
buf += chunk;
|
|
77
|
+
if (buf.length > 1_000_000) {
|
|
78
|
+
stream.destroy();
|
|
79
|
+
reject(new Error('stdin too large'));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
stream.on('end', () => resolve(buf));
|
|
83
|
+
stream.on('error', reject);
|
|
84
|
+
// Safety timeout — don't block Claude Code indefinitely
|
|
85
|
+
setTimeout(() => resolve(buf), 500);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rune hooks install [--preset strict|gentle|off] [--platform claude|cursor|windsurf|antigravity|all]`
|
|
3
|
+
*
|
|
4
|
+
* Writes Rune-managed hook/rule/workflow entries for one or more platforms.
|
|
5
|
+
* Idempotent: re-running replaces existing Rune entries, preserves user entries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { ADAPTERS, detectPlatforms, getAdapter, PLATFORM_KEYS } from '../../adapters/hooks/index.js';
|
|
12
|
+
import { checkManifestRequires, resolveTier } from './tiers.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} projectRoot
|
|
16
|
+
* @param {{preset?: string, dry?: boolean, platform?: string|string[], tier?: string|string[]}} args
|
|
17
|
+
*/
|
|
18
|
+
export async function installHooks(projectRoot, args = {}) {
|
|
19
|
+
const preset = args.preset || 'gentle';
|
|
20
|
+
if (preset !== 'strict' && preset !== 'gentle' && preset !== 'off') {
|
|
21
|
+
throw new Error(`Invalid preset: ${preset}. Choose from: strict | gentle | off`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tierManifests = await resolveTierManifests(projectRoot, args.tier);
|
|
25
|
+
const tierNotes = [];
|
|
26
|
+
for (const manifest of tierManifests) {
|
|
27
|
+
const { ok, missing } = checkManifestRequires(manifest);
|
|
28
|
+
if (!ok) {
|
|
29
|
+
tierNotes.push(
|
|
30
|
+
`${manifest.tier}: missing env var${missing.length === 1 ? '' : 's'} (${missing.join(', ')}) — hooks are written with literal \${${missing[0]}} and will FAIL at runtime until you \`export ${missing.join('=… && export ')}=…\` in your shell.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const platforms = resolvePlatforms(projectRoot, args.platform);
|
|
36
|
+
if (platforms.length === 0) {
|
|
37
|
+
return {
|
|
38
|
+
preset,
|
|
39
|
+
tiers: tierManifests.map((m) => m.tier),
|
|
40
|
+
platforms: [],
|
|
41
|
+
results: [],
|
|
42
|
+
written: false,
|
|
43
|
+
notes: [
|
|
44
|
+
'No target platform detected. Create `.claude/`, `.cursor/`, `.windsurf/`, or `.antigravity/` first, or pass `--platform <name>`.',
|
|
45
|
+
...tierNotes,
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const results = [];
|
|
51
|
+
let totalWrites = 0;
|
|
52
|
+
for (const id of platforms) {
|
|
53
|
+
const adapter = getAdapter(id);
|
|
54
|
+
const plan = await adapter.emit({ preset, projectRoot, tierManifests });
|
|
55
|
+
let platformWrites = 0;
|
|
56
|
+
if (!args.dry) {
|
|
57
|
+
for (const file of plan.files) {
|
|
58
|
+
if (file.content === null) {
|
|
59
|
+
if (existsSync(file.path)) {
|
|
60
|
+
await unlink(file.path);
|
|
61
|
+
platformWrites += 1;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
await mkdir(path.dirname(file.path), { recursive: true });
|
|
65
|
+
await writeFile(file.path, file.content, 'utf-8');
|
|
66
|
+
platformWrites += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
totalWrites += platformWrites;
|
|
71
|
+
results.push({
|
|
72
|
+
platform: id,
|
|
73
|
+
files: plan.files.map((f) => ({ path: f.path, deleted: f.content === null })),
|
|
74
|
+
notes: plan.notes,
|
|
75
|
+
writes: platformWrites,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
preset,
|
|
81
|
+
tiers: tierManifests.map((m) => m.tier),
|
|
82
|
+
platforms,
|
|
83
|
+
results,
|
|
84
|
+
written: !args.dry && totalWrites > 0,
|
|
85
|
+
notes: [...(totalWrites === 0 && !args.dry ? ['no changes to apply (already clean)'] : []), ...tierNotes],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve requested tiers to loaded manifests.
|
|
91
|
+
* @returns {Promise<import('./tiers.js').TierManifest[]>}
|
|
92
|
+
*/
|
|
93
|
+
async function resolveTierManifests(projectRoot, requested) {
|
|
94
|
+
if (!requested) return [];
|
|
95
|
+
const list = Array.isArray(requested) ? requested : [requested];
|
|
96
|
+
const unique = Array.from(new Set(list.filter((t) => typeof t === 'string' && t.length > 0)));
|
|
97
|
+
const manifests = [];
|
|
98
|
+
for (const tier of unique) {
|
|
99
|
+
manifests.push(await resolveTier(tier, projectRoot));
|
|
100
|
+
}
|
|
101
|
+
return manifests;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolvePlatforms(projectRoot, requested) {
|
|
105
|
+
if (!requested) return detectPlatforms(projectRoot);
|
|
106
|
+
const list = Array.isArray(requested) ? requested : [requested];
|
|
107
|
+
const expanded = [];
|
|
108
|
+
for (const item of list) {
|
|
109
|
+
if (item === 'all') {
|
|
110
|
+
// `all` = every *detected* platform, never force-creates unrelated platform dirs.
|
|
111
|
+
// To install into a platform with no existing directory, pass `--platform <name>` explicitly.
|
|
112
|
+
expanded.push(...detectPlatforms(projectRoot));
|
|
113
|
+
} else if (ADAPTERS[item]) {
|
|
114
|
+
expanded.push(item);
|
|
115
|
+
} else {
|
|
116
|
+
throw new Error(`Unknown platform: ${item}. Choose from: ${PLATFORM_KEYS.join(', ')}, all`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(new Set(expanded));
|
|
120
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.json merger for `rune hooks install/uninstall`.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: detect Rune-managed hook entries by command signature (not JSON
|
|
5
|
+
* comments — settings.json is strict JSON). Remove all Rune entries, then
|
|
6
|
+
* inject new ones from the preset. User-authored hooks in the same event
|
|
7
|
+
* arrays are preserved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isRuneManaged } from './presets.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip all Rune-managed entries from a settings.json object.
|
|
14
|
+
* Returns a new settings object (no mutation).
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} settings — parsed .claude/settings.json content
|
|
17
|
+
* @returns {Object} settings with Rune hooks removed
|
|
18
|
+
*/
|
|
19
|
+
export function stripRuneHooks(settings) {
|
|
20
|
+
if (!settings || typeof settings !== 'object') return {};
|
|
21
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') return settings;
|
|
22
|
+
|
|
23
|
+
const cleanedHooks = {};
|
|
24
|
+
for (const [eventName, matcherGroups] of Object.entries(settings.hooks)) {
|
|
25
|
+
if (!Array.isArray(matcherGroups)) {
|
|
26
|
+
cleanedHooks[eventName] = matcherGroups;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cleanedGroups = matcherGroups
|
|
31
|
+
.map((group) => {
|
|
32
|
+
if (!group || !Array.isArray(group.hooks)) return group;
|
|
33
|
+
const cleanedEntries = group.hooks.filter((entry) => !isRuneManaged(entry));
|
|
34
|
+
return { ...group, hooks: cleanedEntries };
|
|
35
|
+
})
|
|
36
|
+
.filter((group) => {
|
|
37
|
+
// Drop groups that became empty after stripping Rune entries
|
|
38
|
+
if (!group || !Array.isArray(group.hooks)) return true;
|
|
39
|
+
return group.hooks.length > 0;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (cleanedGroups.length > 0) {
|
|
43
|
+
cleanedHooks[eventName] = cleanedGroups;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = { ...settings };
|
|
48
|
+
if (Object.keys(cleanedHooks).length > 0) {
|
|
49
|
+
result.hooks = cleanedHooks;
|
|
50
|
+
} else {
|
|
51
|
+
delete result.hooks;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge a preset hook block into existing settings. Existing Rune entries are
|
|
58
|
+
* replaced; user entries in the same events are preserved.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} settings — existing .claude/settings.json content (or {})
|
|
61
|
+
* @param {Object} preset — output of buildPreset()
|
|
62
|
+
* @returns {Object} merged settings
|
|
63
|
+
*/
|
|
64
|
+
export function mergePreset(settings, preset) {
|
|
65
|
+
const stripped = stripRuneHooks(settings || {});
|
|
66
|
+
return appendHookBlock(stripped, preset);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Append a hook block to pre-stripped settings — does NOT strip Rune entries
|
|
71
|
+
* first. Use this when layering multiple Rune blocks (e.g. preset + tier)
|
|
72
|
+
* after a single upstream `stripRuneHooks` call, to avoid each additional
|
|
73
|
+
* layer wiping the previous one.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} settings — already-stripped settings (or {})
|
|
76
|
+
* @param {Object} block — { hooks: {...}, ... }
|
|
77
|
+
* @returns {Object} merged settings
|
|
78
|
+
*/
|
|
79
|
+
export function appendHookBlock(settings, block) {
|
|
80
|
+
const base = settings && typeof settings === 'object' ? settings : {};
|
|
81
|
+
const existingHooks = base.hooks && typeof base.hooks === 'object' ? base.hooks : {};
|
|
82
|
+
const merged = { ...existingHooks };
|
|
83
|
+
|
|
84
|
+
for (const [eventName, matcherGroups] of Object.entries(block?.hooks || {})) {
|
|
85
|
+
const existing = Array.isArray(merged[eventName]) ? merged[eventName] : [];
|
|
86
|
+
merged[eventName] = mergeEventGroups(existing, matcherGroups);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ...base, hooks: merged };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merge preset matcher groups into existing groups for a single event.
|
|
94
|
+
* If a matcher string already has a user group, append Rune entries into it
|
|
95
|
+
* rather than duplicating the group.
|
|
96
|
+
*/
|
|
97
|
+
function mergeEventGroups(existingGroups, presetGroups) {
|
|
98
|
+
const result = existingGroups.map((g) => ({ ...g, hooks: [...(g.hooks || [])] }));
|
|
99
|
+
|
|
100
|
+
for (const presetGroup of presetGroups) {
|
|
101
|
+
const existingIdx = result.findIndex((g) => g.matcher === presetGroup.matcher);
|
|
102
|
+
if (existingIdx >= 0) {
|
|
103
|
+
result[existingIdx].hooks.push(...presetGroup.hooks);
|
|
104
|
+
} else {
|
|
105
|
+
result.push({ ...presetGroup, hooks: [...presetGroup.hooks] });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Summarize Rune hook entries currently in settings — for `rune hooks status`.
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} settings
|
|
116
|
+
* @returns {{events: Record<string, string[]>, total: number}}
|
|
117
|
+
*/
|
|
118
|
+
export function summarizeRuneHooks(settings) {
|
|
119
|
+
const events = {};
|
|
120
|
+
let total = 0;
|
|
121
|
+
if (!settings?.hooks) return { events, total };
|
|
122
|
+
|
|
123
|
+
for (const [eventName, matcherGroups] of Object.entries(settings.hooks)) {
|
|
124
|
+
if (!Array.isArray(matcherGroups)) continue;
|
|
125
|
+
for (const group of matcherGroups) {
|
|
126
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
127
|
+
for (const entry of group.hooks) {
|
|
128
|
+
if (!isRuneManaged(entry)) continue;
|
|
129
|
+
total += 1;
|
|
130
|
+
const skill = extractSkillFromCommand(entry.command);
|
|
131
|
+
if (!events[eventName]) events[eventName] = [];
|
|
132
|
+
events[eventName].push(skill);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { events, total };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractSkillFromCommand(command) {
|
|
140
|
+
// Expected shapes:
|
|
141
|
+
// "npx --yes @rune-kit/rune hook-dispatch <skill> [--gentle]" (Free preset / dispatch)
|
|
142
|
+
// "node \"${RUNE_*_ROOT}/hooks/run-hook.cjs\" <skill>" (tier entry)
|
|
143
|
+
const dispatch = command.match(/hook-dispatch\s+(\S+)/);
|
|
144
|
+
if (dispatch) return dispatch[1];
|
|
145
|
+
const tier = command.match(/run-hook\.cjs(?:"|')?\s+(\S+)/);
|
|
146
|
+
if (tier) return tier[1];
|
|
147
|
+
return 'unknown';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Strip entries whose extracted skill name appears in `skillIds` from an
|
|
152
|
+
* already-parsed settings object. Used to honor manifest `overrides`:
|
|
153
|
+
* e.g., Pro's `context-sense` replaces an older `context-watch` entry.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} settings
|
|
156
|
+
* @param {Iterable<string>} skillIds — skill names to remove
|
|
157
|
+
* @returns {Object} new settings
|
|
158
|
+
*/
|
|
159
|
+
export function stripHooksBySkill(settings, skillIds) {
|
|
160
|
+
const targets = new Set(skillIds);
|
|
161
|
+
if (!settings?.hooks || targets.size === 0) return settings;
|
|
162
|
+
|
|
163
|
+
const cleanedHooks = {};
|
|
164
|
+
for (const [eventName, matcherGroups] of Object.entries(settings.hooks)) {
|
|
165
|
+
if (!Array.isArray(matcherGroups)) {
|
|
166
|
+
cleanedHooks[eventName] = matcherGroups;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const cleanedGroups = matcherGroups
|
|
170
|
+
.map((group) => {
|
|
171
|
+
if (!group || !Array.isArray(group.hooks)) return group;
|
|
172
|
+
const filtered = group.hooks.filter((entry) => {
|
|
173
|
+
if (!entry || typeof entry.command !== 'string') return true;
|
|
174
|
+
return !targets.has(extractSkillFromCommand(entry.command));
|
|
175
|
+
});
|
|
176
|
+
return { ...group, hooks: filtered };
|
|
177
|
+
})
|
|
178
|
+
.filter((group) => !group || !Array.isArray(group.hooks) || group.hooks.length > 0);
|
|
179
|
+
if (cleanedGroups.length > 0) cleanedHooks[eventName] = cleanedGroups;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = { ...settings };
|
|
183
|
+
if (Object.keys(cleanedHooks).length > 0) result.hooks = cleanedHooks;
|
|
184
|
+
else delete result.hooks;
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Detect active preset by examining Rune commands in settings.
|
|
190
|
+
* Returns 'gentle' | 'strict' | 'mixed' | 'none'.
|
|
191
|
+
*/
|
|
192
|
+
export function detectPreset(settings) {
|
|
193
|
+
if (!settings?.hooks) return 'none';
|
|
194
|
+
let hasGentle = false;
|
|
195
|
+
let hasStrict = false;
|
|
196
|
+
for (const matcherGroups of Object.values(settings.hooks)) {
|
|
197
|
+
if (!Array.isArray(matcherGroups)) continue;
|
|
198
|
+
for (const group of matcherGroups) {
|
|
199
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
200
|
+
for (const entry of group.hooks) {
|
|
201
|
+
if (!isRuneManaged(entry)) continue;
|
|
202
|
+
if (entry.command.includes('--gentle')) hasGentle = true;
|
|
203
|
+
else hasStrict = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (hasGentle && hasStrict) return 'mixed';
|
|
208
|
+
if (hasGentle) return 'gentle';
|
|
209
|
+
if (hasStrict) return 'strict';
|
|
210
|
+
return 'none';
|
|
211
|
+
}
|