@rune-kit/rune 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +118 -0
  6. package/compiler/__tests__/hooks-cursor.test.js +139 -0
  7. package/compiler/__tests__/hooks-install.test.js +305 -0
  8. package/compiler/__tests__/hooks-merge.test.js +204 -0
  9. package/compiler/__tests__/hooks-tiers.test.js +519 -0
  10. package/compiler/__tests__/hooks-windsurf.test.js +115 -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 +221 -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 +194 -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,221 @@
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 } from 'node:fs';
20
+ import { readFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+
23
+ /** Known tier env vars. Adding a new tier = add its env var here. */
24
+ export const TIER_ENV_VARS = Object.freeze({
25
+ pro: 'RUNE_PRO_ROOT',
26
+ business: 'RUNE_BUSINESS_ROOT',
27
+ });
28
+
29
+ /** Valid event names a manifest entry may declare. */
30
+ const VALID_EVENTS = new Set(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'statusLine']);
31
+
32
+ /** Tier names must be simple lowercase identifiers. Blocks `../../etc` and similar traversal. */
33
+ const TIER_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
34
+
35
+ /**
36
+ * Assert a tier name is safe to use as a path segment.
37
+ * @param {string} tier
38
+ */
39
+ function assertSafeTierName(tier) {
40
+ if (typeof tier !== 'string' || !TIER_NAME_RE.test(tier)) {
41
+ throw new Error(
42
+ `Invalid tier name: ${JSON.stringify(tier)}. Tier must be a lowercase identifier (a-z, 0-9, dash).`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Locate a tier manifest by tier name.
49
+ *
50
+ * @param {string} tier — 'pro' | 'business' | custom
51
+ * @param {string} projectRoot — the user project root (used for monorepo fallback)
52
+ * @returns {string|null} absolute path if found, else null
53
+ */
54
+ export function locateTierManifest(tier, projectRoot) {
55
+ assertSafeTierName(tier);
56
+ const envVar = TIER_ENV_VARS[tier];
57
+ if (envVar && process.env[envVar]) {
58
+ // Resolve + re-anchor so that env var values can't traverse out via `..`.
59
+ const root = path.resolve(process.env[envVar]);
60
+ const candidate = path.join(root, 'hooks', 'manifest.json');
61
+ if (existsSync(candidate)) return candidate;
62
+ }
63
+ // Monorepo fallback: <projectRoot>/../<Tier>/hooks/manifest.json (capitalized tier).
64
+ // tier is pre-validated against TIER_NAME_RE so no traversal is possible here.
65
+ const capitalized = tier.charAt(0).toUpperCase() + tier.slice(1);
66
+ const fallback = path.resolve(projectRoot, '..', capitalized, 'hooks', 'manifest.json');
67
+ if (existsSync(fallback)) return fallback;
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Load + validate a tier manifest JSON file.
73
+ *
74
+ * @param {string} manifestPath
75
+ * @returns {Promise<TierManifest>}
76
+ */
77
+ export async function loadTierManifest(manifestPath) {
78
+ if (!existsSync(manifestPath)) {
79
+ throw new Error(`Tier manifest not found: ${manifestPath}`);
80
+ }
81
+ const raw = await readFile(manifestPath, 'utf-8');
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch (err) {
86
+ throw new Error(`Tier manifest ${manifestPath} is not valid JSON: ${err.message}`);
87
+ }
88
+ return validateManifest(parsed, manifestPath);
89
+ }
90
+
91
+ /**
92
+ * Validate manifest shape + normalize to internal form.
93
+ * Throws with a specific error on any violation so users get actionable output.
94
+ */
95
+ export function validateManifest(manifest, source = '<memory>') {
96
+ if (!manifest || typeof manifest !== 'object') {
97
+ throw new Error(`Manifest ${source}: expected top-level object`);
98
+ }
99
+ if (typeof manifest.tier !== 'string' || !manifest.tier) {
100
+ throw new Error(`Manifest ${source}: missing required string field 'tier'`);
101
+ }
102
+ if (!Array.isArray(manifest.entries)) {
103
+ throw new Error(`Manifest ${source}: 'entries' must be an array`);
104
+ }
105
+
106
+ const seenIds = new Set();
107
+ const entries = manifest.entries.map((raw, i) => {
108
+ const where = `${source} entries[${i}]`;
109
+ if (!raw || typeof raw !== 'object') {
110
+ throw new Error(`${where}: expected object`);
111
+ }
112
+ if (typeof raw.id !== 'string' || !raw.id) {
113
+ throw new Error(`${where}: missing required string 'id'`);
114
+ }
115
+ if (seenIds.has(raw.id)) {
116
+ throw new Error(`${where}: duplicate entry id '${raw.id}'`);
117
+ }
118
+ seenIds.add(raw.id);
119
+ if (typeof raw.event !== 'string' || !VALID_EVENTS.has(raw.event)) {
120
+ throw new Error(
121
+ `${where}: 'event' must be one of ${[...VALID_EVENTS].join(', ')} (got ${JSON.stringify(raw.event)})`,
122
+ );
123
+ }
124
+ if (typeof raw.command !== 'string' || !raw.command) {
125
+ throw new Error(`${where}: missing required string 'command'`);
126
+ }
127
+ if (raw.matcher !== undefined && typeof raw.matcher !== 'string') {
128
+ throw new Error(`${where}: 'matcher' must be a string if present`);
129
+ }
130
+ if (raw.event !== 'statusLine' && raw.event !== 'Stop' && !raw.matcher) {
131
+ throw new Error(`${where}: '${raw.event}' requires a 'matcher' string (e.g. 'Edit|Write' or '.*')`);
132
+ }
133
+ if (raw.globs !== undefined && !Array.isArray(raw.globs)) {
134
+ throw new Error(`${where}: 'globs' must be an array of strings if present`);
135
+ }
136
+ return {
137
+ id: raw.id,
138
+ skill: typeof raw.skill === 'string' && raw.skill ? raw.skill : raw.id,
139
+ event: raw.event,
140
+ matcher: raw.matcher ?? null,
141
+ command: raw.command,
142
+ async: raw.async === true,
143
+ padding: typeof raw.padding === 'number' ? raw.padding : undefined,
144
+ claudeOnly: raw.claudeOnly === true,
145
+ description: typeof raw.description === 'string' ? raw.description : '',
146
+ globs: Array.isArray(raw.globs) ? [...raw.globs] : null,
147
+ platforms: raw.platforms && typeof raw.platforms === 'object' ? { ...raw.platforms } : {},
148
+ };
149
+ });
150
+
151
+ return {
152
+ name: typeof manifest.name === 'string' ? manifest.name : `Rune ${manifest.tier} Hooks`,
153
+ description: typeof manifest.description === 'string' ? manifest.description : '',
154
+ tier: manifest.tier,
155
+ version: typeof manifest.version === 'string' ? manifest.version : '0.0.0',
156
+ requires: Array.isArray(manifest.requires) ? [...manifest.requires] : [],
157
+ entries,
158
+ overrides: manifest.overrides && typeof manifest.overrides === 'object' ? { ...manifest.overrides } : {},
159
+ source,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Check whether a manifest's `requires` list is satisfied by current env.
165
+ * Returns { ok, missing }.
166
+ */
167
+ export function checkManifestRequires(manifest) {
168
+ const missing = (manifest.requires || []).filter((name) => !process.env[name]);
169
+ return { ok: missing.length === 0, missing };
170
+ }
171
+
172
+ /**
173
+ * Resolve a tier request (`--tier pro`) into a loaded, validated manifest,
174
+ * or throw with a helpful upgrade/missing-env message.
175
+ *
176
+ * @param {string} tier
177
+ * @param {string} projectRoot
178
+ * @returns {Promise<TierManifest>}
179
+ */
180
+ export async function resolveTier(tier, projectRoot) {
181
+ const manifestPath = locateTierManifest(tier, projectRoot);
182
+ if (!manifestPath) {
183
+ const envHint = TIER_ENV_VARS[tier] ? ` Set $${TIER_ENV_VARS[tier]} to your ${tier} install dir.` : '';
184
+ throw new Error(
185
+ `Could not locate '${tier}' tier manifest.${envHint}` +
186
+ ` See https://rune.dev/docs/hooks#tiers for install instructions.`,
187
+ );
188
+ }
189
+ const manifest = await loadTierManifest(manifestPath);
190
+ if (manifest.tier !== tier) {
191
+ throw new Error(`Manifest at ${manifestPath} declares tier='${manifest.tier}' but was requested as '${tier}'`);
192
+ }
193
+ return manifest;
194
+ }
195
+
196
+ /**
197
+ * @typedef TierManifestEntry
198
+ * @property {string} id
199
+ * @property {string} skill
200
+ * @property {'UserPromptSubmit'|'PreToolUse'|'PostToolUse'|'Stop'|'statusLine'} event
201
+ * @property {string|null} matcher
202
+ * @property {string} command
203
+ * @property {boolean} async
204
+ * @property {number|undefined} padding
205
+ * @property {boolean} claudeOnly
206
+ * @property {string} description
207
+ * @property {string[]|null} globs
208
+ * @property {Record<string, string>} platforms
209
+ */
210
+
211
+ /**
212
+ * @typedef TierManifest
213
+ * @property {string} name
214
+ * @property {string} description
215
+ * @property {string} tier
216
+ * @property {string} version
217
+ * @property {string[]} requires
218
+ * @property {TierManifestEntry[]} entries
219
+ * @property {Record<string, string>} overrides
220
+ * @property {string} source
221
+ */
@@ -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
+ }