@rune-kit/rune 2.17.1 → 2.18.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  <p align="center">
6
6
  <strong>Less skills. Deeper connections.</strong><br>
7
7
  A lean, interconnected skill ecosystem for AI coding assistants.<br>
8
- 64 skills · 203 connections · 40 signals · 8 platforms · MIT
8
+ 64 skills · 203 connections · 40 signals · 13 platforms · MIT
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -16,7 +16,7 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <strong>Claude Code</strong> (native plugin) · <strong>Cursor</strong> · <strong>Windsurf</strong> · <strong>Google Antigravity</strong> · <strong>OpenAI Codex</strong> · <strong>OpenCode</strong> · any AI IDE
19
+ <strong>Claude Code</strong> (native plugin) · <strong>Cursor</strong> · <strong>Windsurf</strong> · <strong>Google Antigravity</strong> · <strong>OpenAI Codex</strong> · <strong>OpenCode</strong> · <strong>Aider</strong> · <strong>GitHub Copilot CLI</strong> · <strong>Gemini CLI</strong> · <strong>Qoder</strong> · <strong>Qwen Coder</strong> · any AI IDE
20
20
  </p>
21
21
 
22
22
  ## Why Rune?
@@ -83,7 +83,15 @@ _Methodology: Claude Code CLI headless mode (`claude -p --output-format json`),
83
83
 
84
84
  ---
85
85
 
86
- ## What's New (v2.17.1 — One-Command Setup Wizard)
86
+ ## What's New (v2.18.1 — Setup Installs Tier Skills, Not Just Hooks)
87
+
88
+ > **v2.18.1 (2026-05-17):** Bug fix — `rune setup --tier pro|business` now copies the tier's `skills/` directories into the Free plugin's `skills/` folder. Before this fix, paid tiers shipped hooks only, so `rune:autopilot` (Pro) returned `Unknown skill: rune:autopilot` because the SKILL.md was at `Pro/skills/autopilot/` but invisible to the Claude Code plugin runtime. New `installTierSkills` in `compiler/commands/setup.js` runs after `installHooks`, copies each `<tierRoot>/skills/<name>/` into `<runeRoot>/skills/`, idempotent (skips existing — protects Free skills from clobber and user-edited Pro skills from stomp), with path-traversal guard + symlink rejection + partial-copy cleanup + version-drift detection. Paired with Pro `autopilot-v1.5.0` (Step 0 LOAD now reads user-message context for plan path — same pattern cook uses). 25/26 setup tests pass (1 skipped on Windows — symlink test needs admin/dev-mode). Full CI 1444 tests.
89
+
90
+ ### Previous (v2.18.0 — Cross-Platform Reach + Discipline Tightening)
91
+
92
+ > **v2.18.0 (2026-05-15):** Compiler grew from 8 → 13 platforms with five new adapters: **Aider** (per-skill `aider/rules/` + auto-generated `.aider.conf.yml` `read:` array), **GitHub Copilot CLI** (`.github/instructions/*.instructions.md` w/ documented `applyTo` YAML), **Gemini CLI** (bundled `GEMINI.md` for single-file context), **Qoder** (`.qoder/rules/` + AGENTS.md), **Qwen Coder** (`qwen/skills/` + `QWEN.md` with `@import`). New `adapter.generateExtraFiles()` hook with path-traversal guard + frozen stats snapshot — replaces ad-hoc adapter special-cases (codex AGENTS.md migrated). Discipline tightening: `design` v0.6.0 adds Step 2.9 Rules 4/5/6 (measurable constraints, no #000/#fff/lorem ipsum, CJK-first font stack); `skill-forge` v1.9.0 adds soft `examples/` convention for output-format skills; `sentinel-env` v0.4.0 expands Tier 8 binary detection (Bun, Cargo, Deno, Volta, asdf, proto). New `CONTRIBUTING.md` "What we don't accept" non-goals section. Source: graft from `nexu-io/html-anything` (Apache-2.0). CI 1435/1435.
93
+
94
+ ### Previous (v2.17.1 — One-Command Setup Wizard)
87
95
 
88
96
  > **v2.17.1 (2026-05-06):** New `rune setup` interactive wizard collapses the multi-step `cd <project> && export RUNE_PRO_ROOT && rune hooks install --preset gentle --tier pro` workflow into one command — auto-detects Pro/Business tiers across env var / sibling / well-known paths, asks for scope (current project / global) + preset, installs hooks. New `--global` flag on `rune hooks install` writes to `~/.claude/settings.json` (every Claude Code session, regardless of project). Non-interactive mode via `--here` / `--global` / `--tier` / `--preset` / `--dry` flags. Anti-paywall — wizard ships in Free, NOT Pro/Business (tier-agnostic infrastructure UX). Doc sweep: README "One-Command Setup", HOOKS.md restructure, agent skill-routing row for "set up rune". CI 1376/1376.
89
97
 
@@ -243,7 +251,7 @@ The wizard auto-detects what you have:
243
251
  ```
244
252
  Rune Setup Wizard
245
253
  ──────────────────
246
- Free version: 2.17.1 (cached)
254
+ Free version: 2.18.0 (cached)
247
255
  Pro detected: sibling (../Pro) (v1.1.0)
248
256
  Business: not detected
249
257
 
@@ -131,6 +131,86 @@ describe('Anthropic-native adapters are no-op for model field', () => {
131
131
  });
132
132
  });
133
133
 
134
+ describe('qoder adapter model mapping (provider-agnostic)', () => {
135
+ const qoder = getAdapter('qoder');
136
+
137
+ test('opus translates to tier:heavy', () => {
138
+ const header = qoder.generateHeader({ ...baseSkill, model: 'opus' });
139
+ assert.match(header, /model: tier:heavy/);
140
+ });
141
+
142
+ test('omitted model produces no model line', () => {
143
+ const header = qoder.generateHeader({ ...baseSkill });
144
+ assert.doesNotMatch(header, /model:/);
145
+ });
146
+ });
147
+
148
+ describe('copilot adapter model mapping (tier-hint comment)', () => {
149
+ const copilot = getAdapter('copilot');
150
+
151
+ test('opus emits tier-hint:tier:heavy as comment (Copilot ignores model field)', () => {
152
+ const header = copilot.generateHeader({ ...baseSkill, model: 'opus' });
153
+ assert.match(header, /tier-hint: tier:heavy/);
154
+ });
155
+
156
+ test('omitted model produces no tier-hint line', () => {
157
+ const header = copilot.generateHeader({ ...baseSkill });
158
+ assert.doesNotMatch(header, /tier-hint/);
159
+ });
160
+ });
161
+
162
+ describe('aider adapter model mapping (inline header)', () => {
163
+ const aider = getAdapter('aider');
164
+
165
+ test('opus appears as tier:heavy inline', () => {
166
+ const header = aider.generateHeader({ ...baseSkill, model: 'opus' });
167
+ assert.match(header, /tier:heavy/);
168
+ });
169
+
170
+ test('omitted model produces no tier line', () => {
171
+ const header = aider.generateHeader({ ...baseSkill });
172
+ assert.doesNotMatch(header, /tier:/);
173
+ });
174
+ });
175
+
176
+ describe('qwen adapter model mapping (Qwen family)', () => {
177
+ const qwen = getAdapter('qwen');
178
+
179
+ test('opus translates to qwen3-coder-plus', () => {
180
+ const header = qwen.generateHeader({ ...baseSkill, model: 'opus' });
181
+ assert.match(header, /qwen3-coder-plus/);
182
+ });
183
+
184
+ test('sonnet translates to qwen3-coder', () => {
185
+ const header = qwen.generateHeader({ ...baseSkill, model: 'sonnet' });
186
+ assert.match(header, /qwen3-coder(?!-)/);
187
+ });
188
+
189
+ test('haiku translates to qwen3-coder-flash', () => {
190
+ const header = qwen.generateHeader({ ...baseSkill, model: 'haiku' });
191
+ assert.match(header, /qwen3-coder-flash/);
192
+ });
193
+ });
194
+
195
+ describe('gemini adapter model mapping (Gemini family)', () => {
196
+ const gemini = getAdapter('gemini');
197
+
198
+ test('opus translates to gemini-2.5-pro', () => {
199
+ const header = gemini.generateHeader({ ...baseSkill, model: 'opus' });
200
+ assert.match(header, /gemini-2\.5-pro/);
201
+ });
202
+
203
+ test('sonnet translates to gemini-2.5-flash', () => {
204
+ const header = gemini.generateHeader({ ...baseSkill, model: 'sonnet' });
205
+ assert.match(header, /gemini-2\.5-flash/);
206
+ });
207
+
208
+ test('haiku translates to gemini-2.0-flash-lite', () => {
209
+ const header = gemini.generateHeader({ ...baseSkill, model: 'haiku' });
210
+ assert.match(header, /gemini-2\.0-flash-lite/);
211
+ });
212
+ });
213
+
134
214
  describe('cross-adapter consistency', () => {
135
215
  test('all 5 non-Anthropic adapters emit a model line for opus skills', () => {
136
216
  const adapterNames = ['codex', 'antigravity', 'opencode', 'openclaw', 'generic'];
@@ -4,9 +4,23 @@ import { getAdapter, listPlatforms } from '../adapters/index.js';
4
4
 
5
5
  // --- Adapter registry ---
6
6
 
7
- test('listPlatforms returns all 8 platform adapters', () => {
7
+ test('listPlatforms returns all 13 platform adapters', () => {
8
8
  const adapters = listPlatforms();
9
- const expected = ['claude', 'cursor', 'windsurf', 'antigravity', 'codex', 'opencode', 'openclaw', 'generic'];
9
+ const expected = [
10
+ 'claude',
11
+ 'cursor',
12
+ 'windsurf',
13
+ 'antigravity',
14
+ 'codex',
15
+ 'opencode',
16
+ 'openclaw',
17
+ 'generic',
18
+ 'aider',
19
+ 'copilot',
20
+ 'gemini',
21
+ 'qoder',
22
+ 'qwen',
23
+ ];
10
24
  for (const name of expected) {
11
25
  assert.ok(adapters.includes(name), `missing adapter: ${name}`);
12
26
  }
@@ -35,7 +49,19 @@ const REQUIRED_METHODS = [
35
49
 
36
50
  const REQUIRED_PROPS = ['name', 'outputDir', 'fileExtension', 'skillPrefix', 'skillSuffix'];
37
51
 
38
- const ADAPTER_NAMES = ['cursor', 'windsurf', 'antigravity', 'codex', 'opencode', 'generic'];
52
+ const ADAPTER_NAMES = [
53
+ 'cursor',
54
+ 'windsurf',
55
+ 'antigravity',
56
+ 'codex',
57
+ 'opencode',
58
+ 'generic',
59
+ 'aider',
60
+ 'copilot',
61
+ 'gemini',
62
+ 'qoder',
63
+ 'qwen',
64
+ ];
39
65
 
40
66
  for (const adapterName of ADAPTER_NAMES) {
41
67
  describe(`${adapterName} adapter contract`, () => {
@@ -107,3 +133,89 @@ test('codex adapter uses skill directories', () => {
107
133
  assert.strictEqual(codex.useSkillDirectories, true);
108
134
  assert.strictEqual(codex.skillFileName, 'SKILL.md');
109
135
  });
136
+
137
+ // --- New v2.18 adapters: shape + generateExtraFiles contract ---
138
+
139
+ test('qoder adapter targets .qoder/rules and emits AGENTS.md', async () => {
140
+ const qoder = getAdapter('qoder');
141
+ assert.strictEqual(qoder.outputDir, '.qoder/rules');
142
+ assert.strictEqual(qoder.useSkillDirectories, false);
143
+ assert.strictEqual(typeof qoder.generateExtraFiles, 'function');
144
+ const extras = await qoder.generateExtraFiles({ stats: { skillCount: 5, packCount: 1, files: [] } });
145
+ assert.ok(extras.some((e) => e.path === 'AGENTS.md' && e.content.includes('Rune')));
146
+ });
147
+
148
+ test('copilot adapter targets .github/instructions with .instructions.md ext', async () => {
149
+ const copilot = getAdapter('copilot');
150
+ assert.strictEqual(copilot.outputDir, '.github/instructions');
151
+ assert.strictEqual(copilot.fileExtension, '.instructions.md');
152
+ const header = copilot.generateHeader({ name: 'cook', layer: 'L1', group: 'orchestrator', description: 'Test' });
153
+ // Per docs.github.com Copilot CLI custom-instructions spec, only `applyTo` is a
154
+ // documented frontmatter field. Description and tier-hint must live in the body.
155
+ assert.ok(header.includes('applyTo: "**"'));
156
+ assert.ok(!/^description:/m.test(header.split('---')[1] || ''), 'description must NOT appear in frontmatter');
157
+ const extras = await copilot.generateExtraFiles({ stats: { skillCount: 5, packCount: 1, files: [] } });
158
+ assert.ok(extras.some((e) => e.path === '.github/copilot-instructions.md'));
159
+ assert.ok(extras.some((e) => e.path === 'AGENTS.md'));
160
+ });
161
+
162
+ test('codex adapter migrated to generateExtraFiles for AGENTS.md (no longer special-cased in emitter)', async () => {
163
+ const codex = getAdapter('codex');
164
+ assert.strictEqual(typeof codex.generateExtraFiles, 'function');
165
+ const extras = await codex.generateExtraFiles({
166
+ stats: { skillCount: 64, packCount: 14, crossRefsResolved: 287, files: [] },
167
+ });
168
+ const agentsMd = extras.find((e) => e.path === 'AGENTS.md');
169
+ assert.ok(agentsMd, 'codex must emit AGENTS.md via generateExtraFiles');
170
+ assert.ok(agentsMd.content.includes('Rune'));
171
+ assert.ok(agentsMd.content.includes('64 core skills'));
172
+ });
173
+
174
+ test('emitter rejects absolute paths from generateExtraFiles (path-traversal guard)', async () => {
175
+ // Smoke: verify both qoder and gemini return relative paths only (no absolute).
176
+ for (const name of ['qoder', 'copilot', 'aider', 'qwen', 'gemini', 'codex']) {
177
+ const adapter = getAdapter(name);
178
+ const extras = await adapter.generateExtraFiles({
179
+ stats: { skillCount: 1, packCount: 0, crossRefsResolved: 0, files: ['rune-cook.md'] },
180
+ outputDir: '/tmp/test',
181
+ });
182
+ for (const extra of extras || []) {
183
+ assert.ok(!extra.path.startsWith('/'), `${name} returned absolute path: ${extra.path}`);
184
+ assert.ok(!/^[A-Z]:[\\/]/.test(extra.path), `${name} returned Windows absolute path: ${extra.path}`);
185
+ }
186
+ }
187
+ });
188
+
189
+ test('aider adapter emits .aider.conf.yml with read array', async () => {
190
+ const aider = getAdapter('aider');
191
+ assert.strictEqual(aider.outputDir, 'aider/rules');
192
+ const extras = await aider.generateExtraFiles({
193
+ stats: { skillCount: 2, packCount: 0, files: ['rune-cook.md', 'rune-fix.md', 'index.md'] },
194
+ });
195
+ const conf = extras.find((e) => e.path === '.aider.conf.yml');
196
+ assert.ok(conf);
197
+ assert.ok(conf.content.includes('read:'));
198
+ assert.ok(conf.content.includes('aider/rules/rune-cook.md'));
199
+ assert.ok(conf.content.includes('aider/rules/rune-fix.md'));
200
+ // index.md must NOT be in the read array
201
+ assert.ok(!conf.content.includes('aider/rules/index.md'));
202
+ });
203
+
204
+ test('qwen adapter emits QWEN.md with @import lines', async () => {
205
+ const qwen = getAdapter('qwen');
206
+ assert.strictEqual(qwen.outputDir, 'qwen/skills');
207
+ const extras = await qwen.generateExtraFiles({
208
+ stats: { skillCount: 2, packCount: 0, files: ['rune-cook.md', 'rune-fix.md'] },
209
+ });
210
+ const qwenMd = extras.find((e) => e.path === 'QWEN.md');
211
+ assert.ok(qwenMd);
212
+ assert.ok(qwenMd.content.includes('@qwen/skills/rune-cook.md'));
213
+ assert.ok(qwenMd.content.includes('@qwen/skills/rune-fix.md'));
214
+ });
215
+
216
+ test('gemini adapter declares generateExtraFiles for bundled GEMINI.md', () => {
217
+ const gemini = getAdapter('gemini');
218
+ assert.strictEqual(gemini.outputDir, 'gemini/skills');
219
+ assert.strictEqual(typeof gemini.generateExtraFiles, 'function');
220
+ // Full bundle test happens in pipeline test (needs filesystem) — contract checked here.
221
+ });
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { platform as osPlatform, tmpdir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
6
- import { detectTiers, formatSetupResult, runSetup } from '../commands/setup.js';
7
+ import { resolveTier } from '../commands/hooks/tiers.js';
8
+ import { detectTiers, formatSetupResult, installTierSkills, runSetup } from '../commands/setup.js';
7
9
 
8
10
  let tmpRoot;
9
11
 
@@ -11,11 +13,11 @@ async function seedClaude(root) {
11
13
  await mkdir(path.join(root, '.claude'), { recursive: true });
12
14
  }
13
15
 
14
- async function seedTier(root, tier) {
15
- const dir = path.join(root, '..', tier === 'pro' ? 'Pro' : 'Business', 'hooks');
16
- await mkdir(dir, { recursive: true });
16
+ async function seedTier(root, tier, opts = {}) {
17
+ const tierRoot = path.join(root, '..', tier === 'pro' ? 'Pro' : 'Business');
18
+ await mkdir(path.join(tierRoot, 'hooks'), { recursive: true });
17
19
  await writeFile(
18
- path.join(dir, 'manifest.json'),
20
+ path.join(tierRoot, 'hooks', 'manifest.json'),
19
21
  JSON.stringify({
20
22
  tier,
21
23
  version: '1.0.0',
@@ -24,6 +26,19 @@ async function seedTier(root, tier) {
24
26
  entries: [],
25
27
  }),
26
28
  );
29
+ if (Array.isArray(opts.skills)) {
30
+ for (const skill of opts.skills) {
31
+ const skillDir = path.join(tierRoot, 'skills', skill);
32
+ await mkdir(skillDir, { recursive: true });
33
+ await writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: ${skill}\n---\n# ${skill}\n`);
34
+ }
35
+ }
36
+ }
37
+
38
+ async function seedFakeRuneRoot(root) {
39
+ const runeRoot = path.join(root, 'fake-rune');
40
+ await mkdir(path.join(runeRoot, 'skills'), { recursive: true });
41
+ return runeRoot;
27
42
  }
28
43
 
29
44
  beforeEach(async () => {
@@ -120,6 +135,256 @@ describe('runSetup (non-interactive)', () => {
120
135
  });
121
136
  });
122
137
 
138
+ describe('installTierSkills', () => {
139
+ test('copies Pro skill directories into runeRoot/skills', async () => {
140
+ const projectRoot = path.join(tmpRoot, 'project');
141
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
142
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot', 'context-inject'] });
143
+ const manifest = await resolveTier('pro', projectRoot);
144
+
145
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
146
+
147
+ assert.strictEqual(result.tier, 'pro');
148
+ assert.deepStrictEqual(result.installed.sort(), ['autopilot', 'context-inject']);
149
+ assert.deepStrictEqual(result.skipped, []);
150
+ assert.strictEqual(result.reason, null);
151
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
152
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'context-inject', 'SKILL.md')));
153
+ });
154
+
155
+ test('skips skills already present (no Free clobber, no edit stomp)', async () => {
156
+ const projectRoot = path.join(tmpRoot, 'project');
157
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
158
+ // Pre-existing skill at target (e.g. Free skill with same name, or prior install)
159
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
160
+ await writeFile(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), '# existing — must not be overwritten');
161
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
162
+ const manifest = await resolveTier('pro', projectRoot);
163
+
164
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
165
+
166
+ assert.deepStrictEqual(result.installed, []);
167
+ assert.strictEqual(result.skipped.length, 1);
168
+ assert.strictEqual(result.skipped[0].skill, 'autopilot');
169
+ const preserved = readFileSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), 'utf-8');
170
+ assert.match(preserved, /existing — must not be overwritten/);
171
+ });
172
+
173
+ test('dry mode reports installs without writing', async () => {
174
+ const projectRoot = path.join(tmpRoot, 'project');
175
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
176
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
177
+ const manifest = await resolveTier('pro', projectRoot);
178
+
179
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot, dry: true });
180
+
181
+ assert.deepStrictEqual(result.installed, ['autopilot']);
182
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'autopilot')));
183
+ });
184
+
185
+ test('returns reason when tier has no skills/ dir', async () => {
186
+ const projectRoot = path.join(tmpRoot, 'project');
187
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
188
+ await seedTier(projectRoot, 'pro'); // no skills option
189
+ const manifest = await resolveTier('pro', projectRoot);
190
+
191
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
192
+
193
+ assert.deepStrictEqual(result.installed, []);
194
+ assert.match(result.reason, /no skills\//);
195
+ });
196
+
197
+ test('returns reason when runeRoot/skills target missing', async () => {
198
+ const projectRoot = path.join(tmpRoot, 'project');
199
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
200
+ const manifest = await resolveTier('pro', projectRoot);
201
+ const runeRoot = path.join(tmpRoot, 'missing-rune-root');
202
+
203
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
204
+
205
+ assert.deepStrictEqual(result.installed, []);
206
+ assert.match(result.reason, /target skills\/ missing/);
207
+ });
208
+
209
+ test('rejects adversarial skill directory names (path traversal)', async () => {
210
+ // The OS won't let us mkdir literal '..' or names with '/', so the basename
211
+ // guard is unit-tested at the path.basename level. The runtime guard inside
212
+ // installTierSkills uses the same path.basename(name) !== name check.
213
+ assert.strictEqual(path.basename('normal'), 'normal');
214
+ assert.notStrictEqual(path.basename('../escape'), '../escape');
215
+ assert.notStrictEqual(path.basename('a/b'), 'a/b');
216
+ // Integration smoke: a clean install still works after the guard was added
217
+ const projectRoot = path.join(tmpRoot, 'project');
218
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
219
+ await seedTier(projectRoot, 'pro', { skills: ['normal'] });
220
+ const manifest = await resolveTier('pro', projectRoot);
221
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
222
+ assert.deepStrictEqual(result.installed, ['normal']);
223
+ });
224
+
225
+ test('rejects relative tierManifest.source path', async () => {
226
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
227
+ const fakeManifest = {
228
+ tier: 'pro',
229
+ source: './relative/manifest.json', // not absolute
230
+ entries: [],
231
+ };
232
+
233
+ const result = await installTierSkills({ tier: 'pro', tierManifest: fakeManifest, runeRoot });
234
+
235
+ assert.deepStrictEqual(result.installed, []);
236
+ assert.match(result.reason, /must be absolute/);
237
+ });
238
+
239
+ test('skips non-directory entries in skills/ (e.g. stray README.md)', async () => {
240
+ const projectRoot = path.join(tmpRoot, 'project');
241
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
242
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
243
+ // Stray file at sibling of skill dirs — must NOT be installed
244
+ await writeFile(path.join(tmpRoot, 'Pro', 'skills', 'README.md'), '# Pro skills\n');
245
+ const manifest = await resolveTier('pro', projectRoot);
246
+
247
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
248
+
249
+ assert.deepStrictEqual(result.installed, ['autopilot']);
250
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'README.md')));
251
+ });
252
+
253
+ test('skip message includes version drift when source > installed', async () => {
254
+ const projectRoot = path.join(tmpRoot, 'project');
255
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
256
+ // Installed v1.0.0 at target
257
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
258
+ await writeFile(
259
+ path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
260
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n# autopilot\n',
261
+ );
262
+ // Seed Pro tier (creates manifest.json) then source skill with v1.5.0
263
+ await seedTier(projectRoot, 'pro');
264
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
265
+ await writeFile(
266
+ path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
267
+ '---\nname: autopilot\nmetadata:\n version: "1.5.0"\n---\n# autopilot\n',
268
+ );
269
+ const manifest = await resolveTier('pro', projectRoot);
270
+
271
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
272
+
273
+ assert.deepStrictEqual(result.installed, []);
274
+ assert.strictEqual(result.skipped.length, 1);
275
+ assert.match(result.skipped[0].reason, /stale: installed v1\.0\.0, source has v1\.5\.0/);
276
+ });
277
+
278
+ test('rejects symlink entries inside skills/ (no sandbox escape)', async (t) => {
279
+ // Symlinks on Windows require elevated privileges or Developer Mode — skip cleanly.
280
+ if (osPlatform() === 'win32') {
281
+ t.skip('symlink creation needs admin/dev-mode on Windows');
282
+ return;
283
+ }
284
+ const projectRoot = path.join(tmpRoot, 'project');
285
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
286
+ await seedTier(projectRoot, 'pro', { skills: ['real-skill'] });
287
+ // Create a malicious symlink targeting /etc inside the Pro skills/ dir
288
+ const escapeTarget = path.join(tmpRoot, 'attacker-target');
289
+ await mkdir(escapeTarget, { recursive: true });
290
+ await writeFile(path.join(escapeTarget, 'SECRET'), 'should not be reachable');
291
+ await symlink(escapeTarget, path.join(tmpRoot, 'Pro', 'skills', 'escape-link'), 'dir');
292
+ const manifest = await resolveTier('pro', projectRoot);
293
+
294
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
295
+
296
+ assert.deepStrictEqual(result.installed, ['real-skill']);
297
+ const rejected = result.skipped.find((s) => s.skill === 'escape-link');
298
+ assert.ok(rejected, 'symlink entry should appear in skipped[]');
299
+ assert.match(rejected.reason, /rejected: symlink/);
300
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'escape-link')), 'symlink must not be recreated at target');
301
+ });
302
+
303
+ test('version regex ignores `version:` text inside multiline description', async () => {
304
+ const projectRoot = path.join(tmpRoot, 'project');
305
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
306
+ // Installed v1.0.0
307
+ await mkdir(path.join(runeRoot, 'skills', 'foo'), { recursive: true });
308
+ await writeFile(
309
+ path.join(runeRoot, 'skills', 'foo', 'SKILL.md'),
310
+ '---\nname: foo\nmetadata:\n version: "1.0.0"\n---\n',
311
+ );
312
+ // Source has DECEPTIVE description with `version: 9.9.9` text inside YAML block scalar;
313
+ // real metadata.version is also 1.0.0 → drift detector must report "same version", NOT "stale v9.9.9 → v1.0.0"
314
+ await seedTier(projectRoot, 'pro');
315
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'foo'), { recursive: true });
316
+ await writeFile(
317
+ path.join(tmpRoot, 'Pro', 'skills', 'foo', 'SKILL.md'),
318
+ '---\nname: foo\ndescription: |\n version: 9.9.9 is the legacy format we used to use\nmetadata:\n version: "1.0.0"\n---\n',
319
+ );
320
+ const manifest = await resolveTier('pro', projectRoot);
321
+
322
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
323
+
324
+ // Same version — reason should be "already present (v1.0.0)", NOT a stale-drift message
325
+ assert.strictEqual(result.skipped.length, 1);
326
+ assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
327
+ assert.doesNotMatch(result.skipped[0].reason, /9\.9\.9/);
328
+ assert.doesNotMatch(result.skipped[0].reason, /stale/);
329
+ });
330
+
331
+ test('skip message reports matching version when source == installed', async () => {
332
+ const projectRoot = path.join(tmpRoot, 'project');
333
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
334
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
335
+ await writeFile(
336
+ path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
337
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
338
+ );
339
+ await seedTier(projectRoot, 'pro');
340
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
341
+ await writeFile(
342
+ path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
343
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
344
+ );
345
+ const manifest = await resolveTier('pro', projectRoot);
346
+
347
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
348
+
349
+ assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
350
+ });
351
+ });
352
+
353
+ describe('runSetup — Pro skill installation (regression: rune:autopilot Unknown skill)', () => {
354
+ test('installs Pro skills into runeRoot/skills as part of setup', async () => {
355
+ const projectRoot = path.join(tmpRoot, 'project');
356
+ await seedClaude(projectRoot);
357
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
358
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
359
+
360
+ const result = await runSetup({
361
+ projectRoot,
362
+ runeRoot,
363
+ args: { here: true, preset: 'gentle', tier: 'pro' },
364
+ });
365
+
366
+ assert.deepStrictEqual(result.tiers, ['pro']);
367
+ assert.strictEqual(result.skillResults.length, 1);
368
+ assert.strictEqual(result.skillResults[0].tier, 'pro');
369
+ assert.deepStrictEqual(result.skillResults[0].installed, ['autopilot']);
370
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
371
+ });
372
+
373
+ test('skillResults empty array when no tier selected', async () => {
374
+ const projectRoot = path.join(tmpRoot, 'project');
375
+ await seedClaude(projectRoot);
376
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
377
+
378
+ const result = await runSetup({
379
+ projectRoot,
380
+ runeRoot,
381
+ args: { here: true, preset: 'gentle', 'no-tier': true },
382
+ });
383
+
384
+ assert.deepStrictEqual(result.skillResults, []);
385
+ });
386
+ });
387
+
123
388
  describe('formatSetupResult', () => {
124
389
  test('renders summary with scope and tiers', () => {
125
390
  const out = formatSetupResult({
@@ -149,4 +414,65 @@ describe('formatSetupResult', () => {
149
414
  });
150
415
  assert.match(out, /GLOBAL/);
151
416
  });
417
+
418
+ test('renders skill install summary when skillResults present', () => {
419
+ const out = formatSetupResult({
420
+ scope: 'current',
421
+ targetRoot: '/path/to/project',
422
+ tiers: ['pro'],
423
+ preset: 'gentle',
424
+ platforms: ['claude'],
425
+ written: true,
426
+ notes: [],
427
+ skillResults: [{ tier: 'pro', installed: ['autopilot'], skipped: [], reason: null }],
428
+ });
429
+ assert.match(out, /Skills:.*pro: 1 installed.*autopilot/);
430
+ });
431
+
432
+ test('renders skip + warn lines for tiers with collisions or missing skills/', () => {
433
+ const out = formatSetupResult({
434
+ scope: 'current',
435
+ targetRoot: '/path/to/project',
436
+ tiers: ['pro', 'business'],
437
+ preset: 'gentle',
438
+ platforms: ['claude'],
439
+ written: true,
440
+ notes: [],
441
+ skillResults: [
442
+ { tier: 'pro', installed: [], skipped: [{ skill: 'autopilot', reason: 'already present' }], reason: null },
443
+ { tier: 'business', installed: [], skipped: [], reason: 'no skills/ dir at /tmp/Business/skills' },
444
+ ],
445
+ });
446
+ assert.match(out, /Skipped:.*pro: 1 already present/);
447
+ assert.match(out, /Skill warn:business: no skills\//);
448
+ });
449
+
450
+ test('partitions skipped into benign vs rejected and surfaces rejection details', () => {
451
+ const out = formatSetupResult({
452
+ scope: 'current',
453
+ targetRoot: '/path/to/project',
454
+ tiers: ['pro'],
455
+ preset: 'gentle',
456
+ platforms: ['claude'],
457
+ written: true,
458
+ notes: [],
459
+ skillResults: [
460
+ {
461
+ tier: 'pro',
462
+ installed: ['safe-skill'],
463
+ skipped: [
464
+ { skill: 'autopilot', reason: 'already present (v1.0.0)' },
465
+ { skill: 'evil-link', reason: 'rejected: symlink (would escape sandbox)' },
466
+ { skill: '../escape', reason: 'rejected: unsafe directory name' },
467
+ ],
468
+ reason: null,
469
+ },
470
+ ],
471
+ });
472
+ // Counts partitioned and reported together
473
+ assert.match(out, /Skipped:.*pro: 1 already present, 2 rejected/);
474
+ // Each rejected skill surfaced as a warning line (security visibility)
475
+ assert.match(out, /⚠ evil-link: rejected: symlink/);
476
+ assert.match(out, /⚠ \.\.\/escape: rejected: unsafe directory name/);
477
+ });
152
478
  });