@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.
Files changed (60) hide show
  1. package/README.md +58 -1
  2. package/compiler/__tests__/detect-invariants.test.js +136 -0
  3. package/compiler/__tests__/doctor-mesh.test.js +229 -0
  4. package/compiler/__tests__/hook-dispatch.test.js +91 -0
  5. package/compiler/__tests__/hooks-antigravity.test.js +120 -0
  6. package/compiler/__tests__/hooks-cursor.test.js +141 -0
  7. package/compiler/__tests__/hooks-install.test.js +307 -0
  8. package/compiler/__tests__/hooks-merge.test.js +204 -0
  9. package/compiler/__tests__/hooks-tiers.test.js +620 -0
  10. package/compiler/__tests__/hooks-windsurf.test.js +117 -0
  11. package/compiler/__tests__/inject-claude-md.test.js +152 -0
  12. package/compiler/__tests__/load-invariants.test.js +408 -0
  13. package/compiler/__tests__/onboard-invariants.test.js +240 -0
  14. package/compiler/adapters/hooks/antigravity.js +140 -0
  15. package/compiler/adapters/hooks/claude.js +166 -0
  16. package/compiler/adapters/hooks/cursor.js +191 -0
  17. package/compiler/adapters/hooks/index.js +82 -0
  18. package/compiler/adapters/hooks/tier-emitter.js +182 -0
  19. package/compiler/adapters/hooks/windsurf.js +202 -0
  20. package/compiler/bin/rune.js +196 -6
  21. package/compiler/commands/hook-dispatch.js +87 -0
  22. package/compiler/commands/hooks/install.js +120 -0
  23. package/compiler/commands/hooks/merge.js +211 -0
  24. package/compiler/commands/hooks/presets.js +116 -0
  25. package/compiler/commands/hooks/status.js +112 -0
  26. package/compiler/commands/hooks/tiers.js +323 -0
  27. package/compiler/commands/hooks/uninstall.js +94 -0
  28. package/compiler/doctor.js +236 -0
  29. package/package.json +2 -2
  30. package/skills/ba/SKILL.md +85 -1
  31. package/skills/brainstorm/SKILL.md +39 -1
  32. package/skills/browser-pilot/SKILL.md +1 -0
  33. package/skills/context-engine/SKILL.md +6 -2
  34. package/skills/design/SKILL.md +1 -0
  35. package/skills/docs-seeker/SKILL.md +1 -0
  36. package/skills/fix/SKILL.md +4 -2
  37. package/skills/hallucination-guard/SKILL.md +1 -0
  38. package/skills/journal/SKILL.md +1 -0
  39. package/skills/logic-guardian/SKILL.md +22 -4
  40. package/skills/marketing/SKILL.md +62 -1
  41. package/skills/neural-memory/SKILL.md +13 -16
  42. package/skills/onboard/SKILL.md +30 -2
  43. package/skills/onboard/references/invariants-template.md +76 -0
  44. package/skills/onboard/scripts/detect-invariants.js +439 -0
  45. package/skills/onboard/scripts/inject-claude-md.js +150 -0
  46. package/skills/onboard/scripts/onboard-invariants.js +196 -0
  47. package/skills/perf/SKILL.md +1 -0
  48. package/skills/plan/SKILL.md +2 -0
  49. package/skills/preflight/SKILL.md +1 -1
  50. package/skills/research/SKILL.md +4 -0
  51. package/skills/review/SKILL.md +4 -2
  52. package/skills/scope-guard/SKILL.md +4 -1
  53. package/skills/scout/SKILL.md +6 -0
  54. package/skills/sentinel/SKILL.md +2 -0
  55. package/skills/session-bridge/SKILL.md +53 -1
  56. package/skills/session-bridge/scripts/load-invariants.js +397 -0
  57. package/skills/slides/SKILL.md +19 -0
  58. package/skills/team/SKILL.md +2 -1
  59. package/skills/test/SKILL.md +6 -0
  60. package/skills/verification/SKILL.md +8 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Hook preset definitions for `rune hooks install`.
3
+ *
4
+ * Presets:
5
+ * - strict — dispatcher blocks on BLOCK verdict (returns non-zero exit)
6
+ * - gentle — dispatcher warns only (always exits 0), adds --gentle flag
7
+ * - off — no hooks installed (uninstall semantics)
8
+ *
9
+ * Each hook command is `rune hook-dispatch <skill>` so the dispatcher owns
10
+ * skill→command mapping. Commands carry the RUNE_MANAGED signature so we can
11
+ * detect and cleanly replace them without comment markers (settings.json is JSON).
12
+ */
13
+
14
+ export const RUNE_MANAGED_SIGNATURE = 'rune hook-dispatch';
15
+
16
+ /** Shared relative path to avoid per-file duplication. */
17
+ export const SETTINGS_REL_PATH = '.claude/settings.json';
18
+
19
+ const DISPATCH_CMD = 'npx --yes @rune-kit/rune hook-dispatch';
20
+
21
+ /**
22
+ * Regex that matches the exact dispatch invocation Rune writes.
23
+ * Matches: `npx [--yes] @rune-kit/rune hook-dispatch` or
24
+ * `node ... @rune-kit/rune hook-dispatch` as word boundary.
25
+ * Does NOT match arbitrary strings that merely contain those words.
26
+ */
27
+ const RUNE_DISPATCH_RE = /(^|\s)npx(\s+--yes)?\s+@rune-kit\/rune\s+hook-dispatch\b/;
28
+
29
+ /**
30
+ * Regex that matches tier-emitted commands — they substitute a tier env var
31
+ * (e.g. `${RUNE_PRO_ROOT}`, `${RUNE_BUSINESS_ROOT}`). These are Rune-managed
32
+ * because only `rune hooks install --tier <name>` writes them.
33
+ */
34
+ const RUNE_TIER_RE = /\$\{RUNE_[A-Z][A-Z0-9_]*_ROOT\}/;
35
+
36
+ /**
37
+ * Build a preset hooks block for merging into `.claude/settings.json`.
38
+ *
39
+ * @param {'strict'|'gentle'} preset
40
+ * @returns {Object} — { hooks: { PreToolUse: [...], PostToolUse: [...], Stop: [...] } }
41
+ */
42
+ export function buildPreset(preset) {
43
+ if (preset !== 'strict' && preset !== 'gentle') {
44
+ throw new Error(`Unknown preset: ${preset}. Use 'strict' or 'gentle'.`);
45
+ }
46
+
47
+ const flag = preset === 'gentle' ? ' --gentle' : '';
48
+
49
+ return {
50
+ hooks: {
51
+ PreToolUse: [
52
+ {
53
+ matcher: 'Edit|Write',
54
+ hooks: [
55
+ {
56
+ type: 'command',
57
+ command: `${DISPATCH_CMD} preflight${flag}`,
58
+ async: preset === 'gentle',
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ matcher: 'Bash',
64
+ hooks: [
65
+ {
66
+ type: 'command',
67
+ command: `${DISPATCH_CMD} sentinel${flag}`,
68
+ async: false,
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ PostToolUse: [
74
+ {
75
+ matcher: 'Edit|Write',
76
+ hooks: [
77
+ {
78
+ type: 'command',
79
+ command: `${DISPATCH_CMD} dependency-doctor${flag}`,
80
+ async: true,
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ Stop: [
86
+ {
87
+ matcher: '.*',
88
+ hooks: [
89
+ {
90
+ type: 'command',
91
+ command: `${DISPATCH_CMD} completion-gate${flag}`,
92
+ async: false,
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Skills wired by presets — used by `rune hooks status` to verify skill existence.
103
+ */
104
+ export const WIRED_SKILLS = ['preflight', 'sentinel', 'dependency-doctor', 'completion-gate'];
105
+
106
+ /**
107
+ * Detect if a hook command entry is Rune-managed.
108
+ * Matches only the exact `npx [--yes] @rune-kit/rune hook-dispatch` invocation
109
+ * to avoid false-positives on user commands that merely contain those words.
110
+ *
111
+ * @param {Object} entry — single hook entry { type, command, ... }
112
+ */
113
+ export function isRuneManaged(entry) {
114
+ if (!entry || typeof entry.command !== 'string') return false;
115
+ return RUNE_DISPATCH_RE.test(entry.command) || RUNE_TIER_RE.test(entry.command);
116
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `rune hooks status [--platform <name>|all]`
3
+ *
4
+ * For each detected (or requested) platform, reports:
5
+ * - installed (boolean)
6
+ * - preset (gentle | strict | mixed | null)
7
+ * - wired skills
8
+ * - missing skills (present-in-project-but-not-wired)
9
+ * - per-platform notes
10
+ *
11
+ * Claude Code adapter additionally returns `events` for hook-level detail.
12
+ */
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { ADAPTERS, CAPABILITIES, detectPlatforms, getAdapter, PLATFORM_KEYS } from '../../adapters/hooks/index.js';
17
+ import { WIRED_SKILLS } from './presets.js';
18
+ import { checkManifestRequires, locateTierManifest, resolveTier } from './tiers.js';
19
+
20
+ /**
21
+ * @param {string} projectRoot
22
+ * @param {string} runeRoot
23
+ * @param {{platform?: string|string[], tier?: string|string[]}} args
24
+ */
25
+ export async function hookStatus(projectRoot, runeRoot, args = {}) {
26
+ const platforms = resolvePlatforms(projectRoot, args.platform);
27
+ const missingInRepo = findMissingSkills(runeRoot, WIRED_SKILLS);
28
+ const tiers = await resolveRequestedTiers(projectRoot, args.tier);
29
+
30
+ if (platforms.length === 0) {
31
+ return {
32
+ platforms: [],
33
+ results: [],
34
+ tiers,
35
+ missingInRepo,
36
+ notes: [
37
+ 'No target platform detected. Create `.claude/`, `.cursor/`, `.windsurf/`, or `.antigravity/` first, or pass `--platform <name>`.',
38
+ ],
39
+ };
40
+ }
41
+
42
+ const results = [];
43
+ for (const id of platforms) {
44
+ const adapter = getAdapter(id);
45
+ const info = await adapter.status(projectRoot);
46
+ results.push({
47
+ platform: id,
48
+ capability: CAPABILITIES[id] ?? null,
49
+ ...info,
50
+ });
51
+ }
52
+
53
+ return { platforms, results, tiers, missingInRepo, notes: [] };
54
+ }
55
+
56
+ async function resolveRequestedTiers(projectRoot, requested) {
57
+ if (!requested) return [];
58
+ const list = Array.isArray(requested) ? requested : [requested];
59
+ const out = [];
60
+ for (const tier of Array.from(new Set(list))) {
61
+ const loc = locateTierManifest(tier, projectRoot);
62
+ if (!loc) {
63
+ out.push({ tier, found: false, manifestPath: null, requiresOk: false, requiresMissing: [], entries: 0 });
64
+ continue;
65
+ }
66
+ try {
67
+ const manifest = await resolveTier(tier, projectRoot);
68
+ const req = checkManifestRequires(manifest);
69
+ out.push({
70
+ tier,
71
+ found: true,
72
+ manifestPath: loc,
73
+ requiresOk: req.ok,
74
+ requiresMissing: req.missing,
75
+ entries: manifest.entries.length,
76
+ version: manifest.version,
77
+ });
78
+ } catch (err) {
79
+ out.push({
80
+ tier,
81
+ found: false,
82
+ manifestPath: loc,
83
+ error: err.message,
84
+ requiresOk: false,
85
+ requiresMissing: [],
86
+ entries: 0,
87
+ });
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function resolvePlatforms(projectRoot, requested) {
94
+ if (!requested) return detectPlatforms(projectRoot);
95
+ const list = Array.isArray(requested) ? requested : [requested];
96
+ const expanded = [];
97
+ for (const item of list) {
98
+ if (item === 'all') {
99
+ expanded.push(...PLATFORM_KEYS);
100
+ } else if (ADAPTERS[item]) {
101
+ expanded.push(item);
102
+ } else {
103
+ throw new Error(`Unknown platform: ${item}. Choose from: ${PLATFORM_KEYS.join(', ')}, all`);
104
+ }
105
+ }
106
+ return Array.from(new Set(expanded));
107
+ }
108
+
109
+ function findMissingSkills(runeRoot, skills) {
110
+ const skillsDir = path.join(runeRoot, 'skills');
111
+ return skills.filter((skill) => !existsSync(path.join(skillsDir, skill, 'SKILL.md')));
112
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tier manifest loader for `rune hooks install/uninstall/status`.
3
+ *
4
+ * The Free compiler is tier-agnostic. Tiers (Pro / Business / custom) ship
5
+ * their own `hooks/manifest.json` files following the schema described in
6
+ * `docs/HOOKS.md`. This module resolves a `--tier` flag (or explicit path)
7
+ * into a loaded manifest.
8
+ *
9
+ * Resolution order for `--tier pro`:
10
+ * 1. `$RUNE_PRO_ROOT/hooks/manifest.json` (env var — primary)
11
+ * 2. `<projectRoot>/../Pro/hooks/manifest.json` (monorepo sibling)
12
+ * 3. Fails with a helpful upgrade message.
13
+ *
14
+ * For `--tier-manifest <path>` we load the file directly. This keeps the
15
+ * compiler side free of hardcoded tier logic — any future tier (Business,
16
+ * third-party) plugs in by shipping a manifest at a known path.
17
+ */
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { readFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ /** Known tier env vars. Adding a new tier = add its env var here. */
25
+ export const TIER_ENV_VARS = Object.freeze({
26
+ pro: 'RUNE_PRO_ROOT',
27
+ business: 'RUNE_BUSINESS_ROOT',
28
+ });
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ /**
33
+ * Read Free compiler version from the bundled package.json. Cached after first read.
34
+ * @returns {string} semver string, e.g. "2.12.1"
35
+ */
36
+ let _freeVersionCache = null;
37
+ export function getFreeVersion() {
38
+ if (_freeVersionCache) return _freeVersionCache;
39
+ const pkgPath = path.resolve(__dirname, '..', '..', '..', 'package.json');
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
42
+ _freeVersionCache = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
43
+ } catch {
44
+ _freeVersionCache = '0.0.0';
45
+ }
46
+ return _freeVersionCache;
47
+ }
48
+
49
+ /** For test/override scenarios — resets the cache. */
50
+ export function _resetFreeVersionCache() {
51
+ _freeVersionCache = null;
52
+ }
53
+
54
+ /**
55
+ * Parse a semver `x.y.z` (ignores pre-release/build) into a tuple. Returns null on malformed input.
56
+ * @param {string} v
57
+ * @returns {[number,number,number]|null}
58
+ */
59
+ export function parseSemver(v) {
60
+ if (typeof v !== 'string') return null;
61
+ const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
62
+ if (!m) return null;
63
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
64
+ }
65
+
66
+ /**
67
+ * Compare two semver strings. Returns -1, 0, 1 or null if either is malformed.
68
+ */
69
+ export function compareSemver(a, b) {
70
+ const pa = parseSemver(a);
71
+ const pb = parseSemver(b);
72
+ if (!pa || !pb) return null;
73
+ for (let i = 0; i < 3; i++) {
74
+ if (pa[i] < pb[i]) return -1;
75
+ if (pa[i] > pb[i]) return 1;
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ /** Valid event names a manifest entry may declare. */
81
+ const VALID_EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'statusLine']);
82
+
83
+ /** Tier names must be simple lowercase identifiers. Blocks `../../etc` and similar traversal. */
84
+ const TIER_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
85
+
86
+ /**
87
+ * Assert a tier name is safe to use as a path segment.
88
+ * @param {string} tier
89
+ */
90
+ function assertSafeTierName(tier) {
91
+ if (typeof tier !== 'string' || !TIER_NAME_RE.test(tier)) {
92
+ throw new Error(
93
+ `Invalid tier name: ${JSON.stringify(tier)}. Tier must be a lowercase identifier (a-z, 0-9, dash).`,
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Locate a tier manifest by tier name.
100
+ *
101
+ * @param {string} tier — 'pro' | 'business' | custom
102
+ * @param {string} projectRoot — the user project root (used for monorepo fallback)
103
+ * @returns {string|null} absolute path if found, else null
104
+ */
105
+ export function locateTierManifest(tier, projectRoot) {
106
+ assertSafeTierName(tier);
107
+ const envVar = TIER_ENV_VARS[tier];
108
+ if (envVar && process.env[envVar]) {
109
+ // Resolve + re-anchor so that env var values can't traverse out via `..`.
110
+ const root = path.resolve(process.env[envVar]);
111
+ const candidate = path.join(root, 'hooks', 'manifest.json');
112
+ if (existsSync(candidate)) return candidate;
113
+ }
114
+ // Monorepo fallback: <projectRoot>/../<Tier>/hooks/manifest.json (capitalized tier).
115
+ // tier is pre-validated against TIER_NAME_RE so no traversal is possible here.
116
+ const capitalized = tier.charAt(0).toUpperCase() + tier.slice(1);
117
+ const fallback = path.resolve(projectRoot, '..', capitalized, 'hooks', 'manifest.json');
118
+ if (existsSync(fallback)) return fallback;
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Load + validate a tier manifest JSON file.
124
+ *
125
+ * @param {string} manifestPath
126
+ * @returns {Promise<TierManifest>}
127
+ */
128
+ export async function loadTierManifest(manifestPath) {
129
+ if (!existsSync(manifestPath)) {
130
+ throw new Error(`Tier manifest not found: ${manifestPath}`);
131
+ }
132
+ const raw = await readFile(manifestPath, 'utf-8');
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(raw);
136
+ } catch (err) {
137
+ throw new Error(`Tier manifest ${manifestPath} is not valid JSON: ${err.message}`);
138
+ }
139
+ return validateManifest(parsed, manifestPath);
140
+ }
141
+
142
+ /**
143
+ * Validate manifest shape + normalize to internal form.
144
+ * Throws with a specific error on any violation so users get actionable output.
145
+ */
146
+ export function validateManifest(manifest, source = '<memory>') {
147
+ if (!manifest || typeof manifest !== 'object') {
148
+ throw new Error(`Manifest ${source}: expected top-level object`);
149
+ }
150
+ if (typeof manifest.tier !== 'string' || !manifest.tier) {
151
+ throw new Error(`Manifest ${source}: missing required string field 'tier'`);
152
+ }
153
+ if (!Array.isArray(manifest.entries)) {
154
+ throw new Error(`Manifest ${source}: 'entries' must be an array`);
155
+ }
156
+
157
+ const seenIds = new Set();
158
+ const entries = manifest.entries.map((raw, i) => {
159
+ const where = `${source} entries[${i}]`;
160
+ if (!raw || typeof raw !== 'object') {
161
+ throw new Error(`${where}: expected object`);
162
+ }
163
+ if (typeof raw.id !== 'string' || !raw.id) {
164
+ throw new Error(`${where}: missing required string 'id'`);
165
+ }
166
+ if (seenIds.has(raw.id)) {
167
+ throw new Error(`${where}: duplicate entry id '${raw.id}'`);
168
+ }
169
+ seenIds.add(raw.id);
170
+ if (typeof raw.event !== 'string' || !VALID_EVENTS.has(raw.event)) {
171
+ throw new Error(
172
+ `${where}: 'event' must be one of ${[...VALID_EVENTS].join(', ')} (got ${JSON.stringify(raw.event)})`,
173
+ );
174
+ }
175
+ if (typeof raw.command !== 'string' || !raw.command) {
176
+ throw new Error(`${where}: missing required string 'command'`);
177
+ }
178
+ if (raw.matcher !== undefined && typeof raw.matcher !== 'string') {
179
+ throw new Error(`${where}: 'matcher' must be a string if present`);
180
+ }
181
+ if (raw.event !== 'statusLine' && raw.event !== 'Stop' && !raw.matcher) {
182
+ throw new Error(`${where}: '${raw.event}' requires a 'matcher' string (e.g. 'Edit|Write' or '.*')`);
183
+ }
184
+ if (raw.globs !== undefined && !Array.isArray(raw.globs)) {
185
+ throw new Error(`${where}: 'globs' must be an array of strings if present`);
186
+ }
187
+ return {
188
+ id: raw.id,
189
+ skill: typeof raw.skill === 'string' && raw.skill ? raw.skill : raw.id,
190
+ event: raw.event,
191
+ matcher: raw.matcher ?? null,
192
+ command: raw.command,
193
+ async: raw.async === true,
194
+ padding: typeof raw.padding === 'number' ? raw.padding : undefined,
195
+ claudeOnly: raw.claudeOnly === true,
196
+ description: typeof raw.description === 'string' ? raw.description : '',
197
+ globs: Array.isArray(raw.globs) ? [...raw.globs] : null,
198
+ platforms: raw.platforms && typeof raw.platforms === 'object' ? { ...raw.platforms } : {},
199
+ };
200
+ });
201
+
202
+ const minFreeVersion =
203
+ typeof manifest.minFreeVersion === 'string' && manifest.minFreeVersion.length > 0 ? manifest.minFreeVersion : null;
204
+ if (manifest.minFreeVersion !== undefined && minFreeVersion === null) {
205
+ throw new Error(`Manifest ${source}: 'minFreeVersion' must be a non-empty string if present`);
206
+ }
207
+ if (minFreeVersion && !parseSemver(minFreeVersion)) {
208
+ throw new Error(
209
+ `Manifest ${source}: 'minFreeVersion' must be semver x.y.z (got ${JSON.stringify(minFreeVersion)})`,
210
+ );
211
+ }
212
+
213
+ return {
214
+ name: typeof manifest.name === 'string' ? manifest.name : `Rune ${manifest.tier} Hooks`,
215
+ description: typeof manifest.description === 'string' ? manifest.description : '',
216
+ tier: manifest.tier,
217
+ version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
218
+ minFreeVersion,
219
+ requires: Array.isArray(manifest.requires) ? [...manifest.requires] : [],
220
+ entries,
221
+ overrides: manifest.overrides && typeof manifest.overrides === 'object' ? { ...manifest.overrides } : {},
222
+ source,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Assert the current Free compiler satisfies a manifest's `minFreeVersion`.
228
+ * Throws with an actionable upgrade message when the local Free is too old.
229
+ *
230
+ * @param {import('./tiers.js').TierManifest} manifest
231
+ * @param {string} [currentFreeVersion] — defaults to `getFreeVersion()`. Override for tests.
232
+ */
233
+ export function assertFreeVersionCompat(manifest, currentFreeVersion) {
234
+ if (!manifest || !manifest.minFreeVersion) return;
235
+ const current = currentFreeVersion ?? getFreeVersion();
236
+ const cmp = compareSemver(current, manifest.minFreeVersion);
237
+ if (cmp === null) {
238
+ // Malformed input — surface but don't block (defensive).
239
+ return;
240
+ }
241
+ if (cmp < 0) {
242
+ throw new Error(
243
+ `Tier '${manifest.tier}' requires Rune Free >= ${manifest.minFreeVersion} but the installed compiler is ${current}. ` +
244
+ `Upgrade Free first: \`npm i -g @rune-kit/rune@latest\` (or \`npx @rune-kit/rune@latest hooks install --tier ${manifest.tier}\`).`,
245
+ );
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Check whether a manifest's `requires` list is satisfied by current env.
251
+ * Returns { ok, missing }.
252
+ */
253
+ export function checkManifestRequires(manifest) {
254
+ const missing = (manifest.requires || []).filter((name) => !process.env[name]);
255
+ return { ok: missing.length === 0, missing };
256
+ }
257
+
258
+ /**
259
+ * Resolve a tier request (`--tier pro`) into a loaded, validated manifest,
260
+ * or throw with a helpful upgrade/missing-env message.
261
+ *
262
+ * @param {string} tier
263
+ * @param {string} projectRoot
264
+ * @returns {Promise<TierManifest>}
265
+ */
266
+ export async function resolveTier(tier, projectRoot) {
267
+ const manifestPath = locateTierManifest(tier, projectRoot);
268
+ if (!manifestPath) {
269
+ const envVar = TIER_ENV_VARS[tier];
270
+ const capitalized = tier.charAt(0).toUpperCase() + tier.slice(1);
271
+ const siblingPath = path.resolve(projectRoot, '..', capitalized, 'hooks', 'manifest.json');
272
+ const lines = [`Could not locate '${tier}' tier manifest. Rune looked in:`];
273
+ if (envVar) {
274
+ lines.push(` 1. $${envVar}/hooks/manifest.json (env var — set this if ${capitalized} is installed elsewhere)`);
275
+ lines.push(` 2. ${siblingPath} (monorepo sibling fallback)`);
276
+ } else {
277
+ lines.push(` • ${siblingPath} (monorepo sibling fallback)`);
278
+ }
279
+ lines.push('');
280
+ lines.push(`Fix one of:`);
281
+ if (envVar) {
282
+ lines.push(` • export ${envVar}=/path/to/${capitalized} # point at your ${tier} install`);
283
+ }
284
+ lines.push(` • Clone ${capitalized} next to Free so the sibling path resolves`);
285
+ lines.push(` • Drop --tier ${tier} to install Free-only hooks`);
286
+ lines.push('');
287
+ lines.push(`See https://rune.dev/docs/hooks#tiers for details.`);
288
+ throw new Error(lines.join('\n'));
289
+ }
290
+ const manifest = await loadTierManifest(manifestPath);
291
+ if (manifest.tier !== tier) {
292
+ throw new Error(`Manifest at ${manifestPath} declares tier='${manifest.tier}' but was requested as '${tier}'`);
293
+ }
294
+ assertFreeVersionCompat(manifest);
295
+ return manifest;
296
+ }
297
+
298
+ /**
299
+ * @typedef TierManifestEntry
300
+ * @property {string} id
301
+ * @property {string} skill
302
+ * @property {'UserPromptSubmit'|'PreToolUse'|'PostToolUse'|'Stop'|'statusLine'} event
303
+ * @property {string|null} matcher
304
+ * @property {string} command
305
+ * @property {boolean} async
306
+ * @property {number|undefined} padding
307
+ * @property {boolean} claudeOnly
308
+ * @property {string} description
309
+ * @property {string[]|null} globs
310
+ * @property {Record<string, string>} platforms
311
+ */
312
+
313
+ /**
314
+ * @typedef TierManifest
315
+ * @property {string} name
316
+ * @property {string} description
317
+ * @property {string} tier
318
+ * @property {string} version
319
+ * @property {string[]} requires
320
+ * @property {TierManifestEntry[]} entries
321
+ * @property {Record<string, string>} overrides
322
+ * @property {string} source
323
+ */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `rune hooks uninstall [--platform <name>|all]`
3
+ *
4
+ * Removes all Rune-managed hook/rule/workflow entries for one or more platforms,
5
+ * leaving user entries intact. If no platform artifacts exist, no-op.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { unlink, writeFile } from 'node:fs/promises';
10
+ import { ADAPTERS, detectPlatforms, getAdapter, PLATFORM_KEYS } from '../../adapters/hooks/index.js';
11
+ import { resolveTier } from './tiers.js';
12
+
13
+ /**
14
+ * @param {string} projectRoot
15
+ * @param {{dry?: boolean, platform?: string|string[], tier?: string|string[]}} args
16
+ */
17
+ export async function uninstallHooks(projectRoot, args = {}) {
18
+ const platforms = resolvePlatforms(projectRoot, args.platform);
19
+ const tiers = await resolveTierManifests(projectRoot, args.tier);
20
+ if (platforms.length === 0) {
21
+ return {
22
+ platforms: [],
23
+ results: [],
24
+ tiers: tiers.map((m) => m.tier),
25
+ written: false,
26
+ notes: ['no target platform detected'],
27
+ };
28
+ }
29
+
30
+ const results = [];
31
+ let totalWrites = 0;
32
+ for (const id of platforms) {
33
+ const adapter = getAdapter(id);
34
+ const plan = await adapter.uninstall({ projectRoot, tierManifests: tiers });
35
+ let platformWrites = 0;
36
+ if (!args.dry) {
37
+ for (const file of plan.files) {
38
+ if (file.content === null) {
39
+ if (existsSync(file.path)) {
40
+ await unlink(file.path);
41
+ platformWrites += 1;
42
+ }
43
+ } else {
44
+ await writeFile(file.path, file.content, 'utf-8');
45
+ platformWrites += 1;
46
+ }
47
+ }
48
+ }
49
+ totalWrites += platformWrites;
50
+ results.push({
51
+ platform: id,
52
+ files: plan.files.map((f) => ({ path: f.path, deleted: f.content === null })),
53
+ notes: plan.notes,
54
+ writes: platformWrites,
55
+ });
56
+ }
57
+
58
+ return {
59
+ platforms,
60
+ tiers: tiers.map((m) => m.tier),
61
+ results,
62
+ written: !args.dry && totalWrites > 0,
63
+ notes: totalWrites === 0 && !args.dry ? ['no Rune-managed entries found'] : [],
64
+ };
65
+ }
66
+
67
+ async function resolveTierManifests(projectRoot, requested) {
68
+ if (!requested) return [];
69
+ const list = Array.isArray(requested) ? requested : [requested];
70
+ const unique = Array.from(new Set(list.filter((t) => typeof t === 'string' && t.length > 0)));
71
+ const manifests = [];
72
+ for (const tier of unique) {
73
+ manifests.push(await resolveTier(tier, projectRoot));
74
+ }
75
+ return manifests;
76
+ }
77
+
78
+ function resolvePlatforms(projectRoot, requested) {
79
+ if (!requested) return detectPlatforms(projectRoot);
80
+ const list = Array.isArray(requested) ? requested : [requested];
81
+ const expanded = [];
82
+ for (const item of list) {
83
+ if (item === 'all') {
84
+ // Uninstall `all` walks detected platforms only. Named platforms still work
85
+ // even with no directory — adapter uninstall() returns empty for missing dirs.
86
+ expanded.push(...detectPlatforms(projectRoot));
87
+ } else if (ADAPTERS[item]) {
88
+ expanded.push(item);
89
+ } else {
90
+ throw new Error(`Unknown platform: ${item}. Choose from: ${PLATFORM_KEYS.join(', ')}, all`);
91
+ }
92
+ }
93
+ return Array.from(new Set(expanded));
94
+ }