@rune-kit/rune 2.18.0 → 2.20.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.
- package/README.md +26 -7
- package/compiler/__tests__/comprehension.test.js +916 -0
- package/compiler/__tests__/governance-collector.test.js +376 -0
- package/compiler/__tests__/setup.test.js +338 -7
- package/compiler/analytics.js +5 -0
- package/compiler/bin/rune.js +165 -1
- package/compiler/commands/setup.js +190 -3
- package/compiler/comprehension-client.js +2348 -0
- package/compiler/comprehension.js +1254 -0
- package/compiler/governance-collector.js +382 -0
- package/compiler/schemas/comprehension.schema.json +87 -0
- package/compiler/schemas/governance.schema.json +78 -0
- package/compiler/transforms/branding.js +1 -1
- package/hooks/context-watch/index.cjs +24 -13
- package/hooks/hooks.json +2 -2
- package/hooks/lib/context-key.cjs +37 -0
- package/hooks/metrics-collector/index.cjs +37 -8
- package/hooks/pre-tool-guard/index.cjs +44 -0
- package/hooks/session-start/index.cjs +4 -10
- package/package.json +1 -1
- package/skills/adversary/SKILL.md +36 -3
- package/skills/adversary/references/cross-model-escalation.md +85 -0
- package/skills/adversary/references/reasoning-modes.md +95 -0
- package/skills/autopsy/SKILL.md +41 -0
- package/skills/ba/SKILL.md +44 -2
- package/skills/ba/references/ears-format.md +91 -0
- package/skills/brainstorm/SKILL.md +32 -7
- package/skills/cook/SKILL.md +8 -1
- package/skills/debug/SKILL.md +4 -2
- package/skills/deploy/SKILL.md +20 -1
- package/skills/deploy/references/observability.md +146 -0
- package/skills/graft/SKILL.md +24 -8
- package/skills/onboard/SKILL.md +45 -0
- package/skills/perf/SKILL.md +4 -1
- package/skills/review/SKILL.md +4 -2
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { cp, readdir, readFile, rm } from 'node:fs/promises';
|
|
24
25
|
import os from 'node:os';
|
|
25
26
|
import path from 'node:path';
|
|
26
27
|
import { createInterface } from 'node:readline';
|
|
27
28
|
import { installHooks } from './hooks/install.js';
|
|
28
|
-
import { TIER_ENV_VARS } from './hooks/tiers.js';
|
|
29
|
+
import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
|
|
29
30
|
|
|
30
31
|
export const WELL_KNOWN_TIER_PATHS = {
|
|
31
32
|
pro: ['D:/Project/Rune/Pro', path.join(os.homedir(), 'rune-pro'), path.join(os.homedir(), 'Project', 'Rune', 'Pro')],
|
|
@@ -38,9 +39,9 @@ export const WELL_KNOWN_TIER_PATHS = {
|
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* @param {{ projectRoot: string, runeRoot: string, args: object }} opts
|
|
41
|
-
* @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[] }>}
|
|
42
|
+
* @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[], skillResults: Array<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}> }>}
|
|
42
43
|
*/
|
|
43
|
-
export async function runSetup({ projectRoot, runeRoot
|
|
44
|
+
export async function runSetup({ projectRoot, runeRoot, args = {} }) {
|
|
44
45
|
const detected = detectTiers(projectRoot);
|
|
45
46
|
|
|
46
47
|
// Scope resolution
|
|
@@ -88,16 +89,180 @@ export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} })
|
|
|
88
89
|
dry: args.dry,
|
|
89
90
|
});
|
|
90
91
|
|
|
92
|
+
// Install tier skill files into <runeRoot>/skills/ so Claude Code (and any
|
|
93
|
+
// plugin-aware platform) discovers them alongside Free skills. Without this
|
|
94
|
+
// step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
|
|
95
|
+
// because Pro/skills/autopilot/ is invisible to the plugin runtime.
|
|
96
|
+
const skillResults = [];
|
|
97
|
+
for (const tier of tiers) {
|
|
98
|
+
try {
|
|
99
|
+
const tierManifest = await resolveTier(tier, projectRoot);
|
|
100
|
+
const skillResult = await installTierSkills({
|
|
101
|
+
tier,
|
|
102
|
+
tierManifest,
|
|
103
|
+
runeRoot,
|
|
104
|
+
dry: args.dry,
|
|
105
|
+
});
|
|
106
|
+
skillResults.push(skillResult);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
skillResults.push({ tier, installed: [], skipped: [], reason: err.message });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
return {
|
|
92
113
|
scope,
|
|
93
114
|
targetRoot,
|
|
94
115
|
tiers,
|
|
95
116
|
preset,
|
|
96
117
|
detected,
|
|
118
|
+
skillResults,
|
|
97
119
|
...result,
|
|
98
120
|
};
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Copy a tier's skill directories into the Free plugin's skills/ folder so
|
|
125
|
+
* the Claude Code plugin runtime discovers them with the `rune:` prefix.
|
|
126
|
+
*
|
|
127
|
+
* Idempotent: existing skill directories are SKIPPED (not overwritten). This
|
|
128
|
+
* protects Free skills from being clobbered by a same-named Pro skill, and
|
|
129
|
+
* protects an in-place edit of a previously-installed Pro skill from being
|
|
130
|
+
* stomped on a re-run.
|
|
131
|
+
*
|
|
132
|
+
* Pre-condition: tierManifest.source points at the absolute path of the tier's
|
|
133
|
+
* manifest.json (set by validateManifest). Tier root = grandparent of that path.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} opts
|
|
136
|
+
* @param {string} opts.tier
|
|
137
|
+
* @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
|
|
138
|
+
* @param {string} opts.runeRoot — target is `<runeRoot>/skills/`
|
|
139
|
+
* @param {boolean} [opts.dry]
|
|
140
|
+
* @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
|
|
141
|
+
*/
|
|
142
|
+
export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
143
|
+
if (!tierManifest?.source) {
|
|
144
|
+
return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
|
|
145
|
+
}
|
|
146
|
+
// Defensive: validateManifest accepts any string; only locateTierManifest guarantees absolute.
|
|
147
|
+
// Reject relative to keep tierRoot derivation honest.
|
|
148
|
+
if (!path.isAbsolute(tierManifest.source)) {
|
|
149
|
+
return {
|
|
150
|
+
tier,
|
|
151
|
+
installed: [],
|
|
152
|
+
skipped: [],
|
|
153
|
+
reason: `tier manifest source must be absolute (got ${tierManifest.source})`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const tierRoot = path.dirname(path.dirname(tierManifest.source));
|
|
157
|
+
const sourceDir = path.join(tierRoot, 'skills');
|
|
158
|
+
if (!existsSync(sourceDir)) {
|
|
159
|
+
return { tier, installed: [], skipped: [], reason: `no skills/ dir at ${sourceDir}` };
|
|
160
|
+
}
|
|
161
|
+
if (!runeRoot) {
|
|
162
|
+
return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
|
|
163
|
+
}
|
|
164
|
+
const targetDir = path.join(runeRoot, 'skills');
|
|
165
|
+
if (!existsSync(targetDir)) {
|
|
166
|
+
return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
|
|
167
|
+
}
|
|
168
|
+
const installed = [];
|
|
169
|
+
const skipped = [];
|
|
170
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isDirectory()) {
|
|
173
|
+
// Reject symlinks even when their target is a directory — `cp` with default
|
|
174
|
+
// dereference=false would recreate the symlink and let the Claude Code runtime
|
|
175
|
+
// follow it outside runeRoot/skills/ at read time (POSIX symlink-escape).
|
|
176
|
+
if (entry.isSymbolicLink()) {
|
|
177
|
+
skipped.push({ skill: entry.name, reason: 'rejected: symlink (would escape sandbox)' });
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const skillName = entry.name;
|
|
182
|
+
// Guard against path traversal via adversarial directory names (../, /, \).
|
|
183
|
+
// A compromised tier repo with a skill dir like '../../../etc' would otherwise
|
|
184
|
+
// escape runeRoot/skills/ and overwrite arbitrary files. assertSafeTierName
|
|
185
|
+
// in tiers.js covers the tier name but NOT per-skill directory names.
|
|
186
|
+
if (path.basename(skillName) !== skillName || skillName === '.' || skillName === '..') {
|
|
187
|
+
skipped.push({ skill: skillName, reason: 'rejected: unsafe directory name' });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const src = path.join(sourceDir, skillName);
|
|
191
|
+
const dst = path.join(targetDir, skillName);
|
|
192
|
+
if (existsSync(dst)) {
|
|
193
|
+
const drift = await detectVersionDrift(src, dst);
|
|
194
|
+
skipped.push({ skill: skillName, reason: drift || 'already present' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!dry) {
|
|
198
|
+
try {
|
|
199
|
+
// dereference: true → copy symlink targets as content instead of recreating
|
|
200
|
+
// symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
|
|
201
|
+
// (nested symlinks inside a skill dir would otherwise still recreate).
|
|
202
|
+
await cp(src, dst, { recursive: true, dereference: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Clean partial-copy residue so next run isn't silently locked-out by
|
|
205
|
+
// existsSync(dst) on a corrupt half-written directory.
|
|
206
|
+
await rm(dst, { recursive: true, force: true }).catch(() => {});
|
|
207
|
+
skipped.push({ skill: skillName, reason: `copy failed: ${err.message}` });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
installed.push(skillName);
|
|
212
|
+
}
|
|
213
|
+
return { tier, installed, skipped, reason: null };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compare source SKILL.md version against installed version. Returns a string
|
|
218
|
+
* describing the drift if source is newer (so user knows an upgrade exists),
|
|
219
|
+
* or null when versions match / cannot be parsed. Never throws — best-effort.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} srcSkillDir
|
|
222
|
+
* @param {string} dstSkillDir
|
|
223
|
+
* @returns {Promise<string|null>}
|
|
224
|
+
*/
|
|
225
|
+
async function detectVersionDrift(srcSkillDir, dstSkillDir) {
|
|
226
|
+
try {
|
|
227
|
+
const [srcVer, dstVer] = await Promise.all([
|
|
228
|
+
readSkillVersion(path.join(srcSkillDir, 'SKILL.md')),
|
|
229
|
+
readSkillVersion(path.join(dstSkillDir, 'SKILL.md')),
|
|
230
|
+
]);
|
|
231
|
+
if (!srcVer || !dstVer) return null;
|
|
232
|
+
if (srcVer === dstVer) return `already present (v${dstVer})`;
|
|
233
|
+
return `stale: installed v${dstVer}, source has v${srcVer} — delete target dir to upgrade`;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Extract version string from a SKILL.md `metadata.version` field. Minimal
|
|
241
|
+
* YAML scan scoped to the `metadata:` block — avoids false positives from
|
|
242
|
+
* multiline description continuation lines that contain `version:` text
|
|
243
|
+
* (e.g. `description: |\n version: 1.0.0 is legacy`). Avoids pulling in a
|
|
244
|
+
* full YAML dependency for one field.
|
|
245
|
+
*
|
|
246
|
+
* Returns null when SKILL.md cannot be read, frontmatter missing, metadata
|
|
247
|
+
* block missing, or version field absent.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} skillMdPath
|
|
250
|
+
* @returns {Promise<string|null>}
|
|
251
|
+
*/
|
|
252
|
+
async function readSkillVersion(skillMdPath) {
|
|
253
|
+
try {
|
|
254
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
255
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
256
|
+
if (!fmMatch) return null;
|
|
257
|
+
// Require metadata: header followed by an indented version: line.
|
|
258
|
+
// Indent is preserved so a top-level `version:` (outside metadata) doesn't match.
|
|
259
|
+
const verMatch = fmMatch[1].match(/^metadata:\s*\n(?:\s+[^\n]*\n)*?\s+version:\s*["']?([^"'\s#]+)/m);
|
|
260
|
+
return verMatch ? verMatch[1] : null;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
101
266
|
/**
|
|
102
267
|
* Auto-detect Pro/Business tiers across env vars, sibling paths, and well-known
|
|
103
268
|
* locations. Returns { pro: { path, source } | null, business: { ... } | null }.
|
|
@@ -228,6 +393,28 @@ export function formatSetupResult(result) {
|
|
|
228
393
|
lines.push(` Tiers: Free${result.tiers.length > 0 ? ` + ${result.tiers.join(' + ')}` : ''}`);
|
|
229
394
|
lines.push(` Preset: ${result.preset}`);
|
|
230
395
|
lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
|
|
396
|
+
for (const sr of result.skillResults || []) {
|
|
397
|
+
if (sr.installed.length > 0) {
|
|
398
|
+
const preview = sr.installed.slice(0, 3).join(', ');
|
|
399
|
+
const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
|
|
400
|
+
lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
|
|
401
|
+
}
|
|
402
|
+
if (sr.skipped.length > 0) {
|
|
403
|
+
const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
|
|
404
|
+
const benign = sr.skipped.length - rejected.length;
|
|
405
|
+
const parts = [];
|
|
406
|
+
if (benign > 0) parts.push(`${benign} already present`);
|
|
407
|
+
if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
|
|
408
|
+
lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
|
|
409
|
+
// Surface rejection details so the operator can investigate a compromised tier repo.
|
|
410
|
+
for (const r of rejected) {
|
|
411
|
+
lines.push(` ⚠ ${r.skill}: ${r.reason}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (sr.reason) {
|
|
415
|
+
lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
231
418
|
if (result.notes?.length) {
|
|
232
419
|
lines.push('');
|
|
233
420
|
lines.push(' Notes:');
|