// rnx skill - install and inspect agent skill files // // usage: // rnx skill install install all skills to supported agents // rnx skill install codex debug install specific skills to Codex only // rnx skill show [name] list skills or print one skill import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import { fileURLToPath } from 'url' import { rnxPublicBrand } from '../../src/public-brand' import { rnxExit } from '../run-rnx' function getSkillsDir(): string { try { const pkgPath = fileURLToPath( import.meta.resolve(`${rnxPublicBrand.packageName}/package.json`), ) return path.join(path.dirname(pkgPath), 'skills') } catch { const __dirname = path.dirname(fileURLToPath(import.meta.url)) return path.resolve(__dirname, '../../skills') } } const SKILLS_DIR = getSkillsDir() const AGENT_TARGET_IDS = new Set(['all', 'codex', 'claude']) interface SkillInfo { name: string description: string } interface InstallTarget { label: string dir: string } function printSkillHelp() { console.log(`rnx skill install and inspect bundled rnx agent skills. usage: rnx skill install [all|codex|claude] [skill-name...] rnx skill install --target [skill-name...] rnx skill show [skill-name] commands: install install skills; no target means all supported local agents show list bundled skills, or print one skill markdown targets: all Codex and Claude Code (default) codex $CODEX_HOME/skills, or ~/.codex/skills claude $CLAUDE_CONFIG_DIR/skills, or ~/.claude/skills `) } function printInstallHelp() { console.log(`rnx skill install install bundled rnx skills in the agent skill layout: /SKILL.md. each skill directory is symlinked back to its source, so edits stay live without a re-install. an existing install from an older CLI is upgraded in place; an unrelated skill directory is left alone unless --force is passed. usage: rnx skill install [all|codex|claude] [skill-name...] rnx skill install --target [skill-name...] options: --force, -f replace a conflicting existing skill with the symlink examples: rnx skill install rnx skill install codex rnx skill install codex rnx-debug rnx skill install --force rnx-debug `) } function targetFromHome(envName: string, fallbackDir: string): string { const home = process.env[envName] || path.join(os.homedir(), fallbackDir) return path.join(home, 'skills') } function defaultAgentTargets(): InstallTarget[] { return [ { label: 'codex', dir: targetFromHome('CODEX_HOME', '.codex') }, { label: 'claude', dir: targetFromHome('CLAUDE_CONFIG_DIR', '.claude') }, ] } function agentTarget(target: string): InstallTarget[] { if (target === 'all') return defaultAgentTargets() if (target === 'codex') { return [{ label: 'codex', dir: targetFromHome('CODEX_HOME', '.codex') }] } if (target === 'claude') { return [{ label: 'claude', dir: targetFromHome('CLAUDE_CONFIG_DIR', '.claude') }] } console.error(` unknown skill install target: ${target}`) console.error(' expected all, codex, or claude') rnxExit(1) } function parseSkillFrontmatter( content: string, ): { name: string; description: string } | null { const match = content.match(/^---\n([\s\S]*?)\n---/) if (!match) return null const frontmatter = match[1] const name = readFrontmatterField(frontmatter, 'name') if (!name) return null return { name, description: readFrontmatterField(frontmatter, 'description') || '', } } function readFrontmatterField(frontmatter: string, field: string): string | null { const lines = frontmatter.split('\n') const prefix = `${field}:` for (let index = 0; index < lines.length; index++) { const line = lines[index] if (!line.startsWith(prefix)) continue const rawValue = line.slice(prefix.length).trim() if (rawValue === '>-' || rawValue === '>' || rawValue === '|-' || rawValue === '|') { const blockLines: string[] = [] for (let blockIndex = index + 1; blockIndex < lines.length; blockIndex++) { const blockLine = lines[blockIndex] if (/^[A-Za-z0-9_-]+:\s*/.test(blockLine)) break if (blockLine.trim() === '') { blockLines.push('') continue } if (!/^\s+/.test(blockLine)) break blockLines.push(blockLine.trim()) } return blockLines.join(rawValue.startsWith('|') ? '\n' : ' ').trim() } if ( (rawValue.startsWith('"') && rawValue.endsWith('"')) || (rawValue.startsWith("'") && rawValue.endsWith("'")) ) { return rawValue.slice(1, -1) } return rawValue } return null } // bundled skills live in the standard agent layout: skills//SKILL.md. // the directory name is the skill name; frontmatter is the display metadata. function listSkills(): SkillInfo[] { if (!fs.existsSync(SKILLS_DIR)) return [] const skills: SkillInfo[] = [] for (const entry of fs.readdirSync(SKILLS_DIR)) { const skillMd = path.join(SKILLS_DIR, entry, 'SKILL.md') if (!fs.existsSync(skillMd)) continue const meta = parseSkillFrontmatter(fs.readFileSync(skillMd, 'utf8')) if (meta) { skills.push({ name: entry, description: meta.description }) } } return skills.sort((a, b) => a.name.localeCompare(b.name)) } function matchSkills(skills: SkillInfo[], filters: string[]): SkillInfo[] { if (filters.length === 0) return skills const matched = skills.filter((skill) => filters.some((filter) => { const needle = filter.replace(/^rnx-/, '') return ( skill.name.includes(filter) || skill.name.replace(/^rnx-/, '').includes(needle) ) }), ) if (matched.length === 0) { console.error(` no skills matched: ${filters.join(', ')}`) console.error(' available: ' + skills.map((skill) => skill.name).join(', ')) rnxExit(1) } return matched } // install is a SYMLINK of the whole skill DIRECTORY from the agent store back // to the source, not a copy, so editing the source skill is live everywhere // without re-installing. the directory (not the SKILL.md file) is linked // because Codex's skill discovery follows directory symlinks but silently // skips a SKILL.md that is itself a file symlink. conflict-safe: skip if // already linked to source, upgrade an install from an older CLI (or anything // under --force), otherwise refuse so we never clobber a skill the user owns. type LinkResult = 'up-to-date' | 'linked' | 'replaced' | 'conflict' function lstatOrNull(p: string): fs.Stats | null { try { return fs.lstatSync(p) } catch { return null } } function symlinkPointsTo(dest: string, src: string): boolean { const stat = lstatOrNull(dest) if (!stat || !stat.isSymbolicLink()) return false try { return fs.realpathSync(dest) === fs.realpathSync(src) } catch { return false } } // a store entry left by an older CLI is safe to upgrade: either a real dir // whose only content is a SKILL.md symlink into an rnx skills source (the // pre-dir-layout install — possibly broken after the source moved), or a dir // holding a byte-identical copy of the skill. only our installer creates // those shapes, so replacing them can't lose user work. function isOwnLegacyInstall(stat: fs.Stats, dest: string, srcDir: string): boolean { if (stat.isSymbolicLink() || !stat.isDirectory()) return false let entries: string[] try { entries = fs.readdirSync(dest) } catch { return false } if (entries.length !== 1 || entries[0] !== 'SKILL.md') return false const skillMd = path.join(dest, 'SKILL.md') const mdStat = lstatOrNull(skillMd) if (!mdStat) return false if (mdStat.isSymbolicLink()) { try { return fs.readlinkSync(skillMd).includes(`${path.sep}skills${path.sep}`) } catch { return false } } if (!mdStat.isFile()) return false try { return fs.readFileSync(skillMd).equals(fs.readFileSync(path.join(srcDir, 'SKILL.md'))) } catch { return false } } function linkSkill(skill: SkillInfo, targetDir: string, force: boolean): LinkResult { const src = path.join(SKILLS_DIR, skill.name) const dest = path.join(targetDir, skill.name) fs.mkdirSync(targetDir, { recursive: true }) const existing = lstatOrNull(dest) if (!existing) { fs.symlinkSync(src, dest) return 'linked' } if (symlinkPointsTo(dest, src)) { return 'up-to-date' } if (force || isOwnLegacyInstall(existing, dest, src)) { fs.rmSync(dest, { recursive: true, force: true }) fs.symlinkSync(src, dest) return 'replaced' } return 'conflict' } // quiet, conflict-safe (re)install of all bundled skills into the agent stores // that already exist on this machine. wired into the CLI build so building or // updating the CLI re-establishes the symlinks idempotently — never forces, so // it can't clobber a skill the user customized, and never creates a store for // an agent that isn't set up here. silent on success. export function refreshInstalledSkills() { const skills = listSkills() if (skills.length === 0) return for (const target of defaultAgentTargets()) { const skillsDir = path.resolve(target.dir) // only touch agents already present (~/.claude, ~/.codex) — don't seed a // store on a CI runner or a machine that doesn't use that agent. if (!fs.existsSync(path.dirname(skillsDir))) continue try { fs.mkdirSync(skillsDir, { recursive: true }) } catch { continue } try { removeLegacyBundledSkills(skillsDir) } catch {} for (const skill of skills) { try { linkSkill(skill, skillsDir, false) } catch { // best-effort: a single bad store must not fail the build } } } } const LEGACY_BUNDLED_SKILL_DIRS = [ 'sootsim-setup', 'sootsim-debug', 'sootsim-perf', 'sootsim-test', 'sootsim-visual', ] function removeLegacyBundledSkills(targetDir: string) { for (const name of LEGACY_BUNDLED_SKILL_DIRS) { const dest = path.join(targetDir, name) const stat = lstatOrNull(dest) if (!stat) continue if ( stat.isSymbolicLink() || isOwnLegacyInstall(stat, dest, path.join(SKILLS_DIR, name)) ) { fs.rmSync(dest, { recursive: true, force: true }) } } } function printSkillList(skills: SkillInfo[]) { if (skills.length === 0) { console.log(' no skills found') return } console.log('\n available skills:\n') for (const skill of skills) { console.log(` ${skill.name}`) console.log(` ${skill.description}\n`) } console.log(' usage: rnx skill install [all|codex|claude] [skill-name...]') console.log(' usage: rnx skill show [skill-name]\n') } function parseInstallArgs(args: string[]): { targets: InstallTarget[] filters: string[] force: boolean } { let targets: InstallTarget[] | null = null let force = false const filters: string[] = [] for (let index = 0; index < args.length; index++) { const arg = args[index] if (arg === '--help' || arg === '-h') { printInstallHelp() rnxExit(0) } if (arg === '--force' || arg === '-f') { force = true continue } if (arg === '--target') { const dir = args[index + 1] if (!dir || dir.startsWith('-')) { console.error(' --target requires a directory') rnxExit(1) } if (targets) { console.error(' pass only one install target') rnxExit(1) } targets = [{ label: 'target', dir }] index++ continue } if (arg.startsWith('-')) { console.error(` unknown option: ${arg}`) printInstallHelp() rnxExit(1) } if (!targets && AGENT_TARGET_IDS.has(arg)) { targets = agentTarget(arg) continue } filters.push(arg) } return { targets: targets ?? defaultAgentTargets(), filters, force } } async function runInstall(args: string[]) { const skills = listSkills() if (skills.length === 0) { console.error(' no skills found in rnx package') rnxExit(1) } const parsed = parseInstallArgs(args) const toInstall = matchSkills(skills, parsed.filters) const resolvedTargets = parsed.targets.map((target) => ({ ...target, dir: path.resolve(target.dir), })) for (const target of resolvedTargets) { fs.mkdirSync(target.dir, { recursive: true }) if (!fs.statSync(target.dir).isDirectory()) { console.error(` target is not a directory: ${target.dir}`) rnxExit(1) } removeLegacyBundledSkills(target.dir) } console.log( `\n installing ${toInstall.length} skill(s) to ${resolvedTargets.length} target(s)\n`, ) let conflicts = 0 for (const target of resolvedTargets) { console.log(` target ${target.label}: ${target.dir}`) for (const skill of toInstall) { const dest = path.join(target.dir, skill.name) const result = linkSkill(skill, target.dir, parsed.force) if (result === 'linked') console.log(` linked ${skill.name} -> ${dest}`) else if (result === 'replaced') console.log(` relinked ${skill.name} -> ${dest}`) else if (result === 'up-to-date') console.log(` up to date ${skill.name}`) else { conflicts++ console.log( ` conflict ${skill.name}: ${dest} exists and is not this skill — pass --force to replace`, ) } } console.log('') } if (conflicts > 0) { console.error( ` ${conflicts} conflict(s); rerun with --force only if replacing them is intended.\n`, ) rnxExit(1) } console.log(' done. restart the agent session to load newly installed skills.\n') } function runShow(args: string[]) { const skills = listSkills() const name = args.find((arg) => !arg.startsWith('-')) if (!name) { printSkillList(skills) return } const [skill] = matchSkills(skills, [name]) const content = fs.readFileSync(path.join(SKILLS_DIR, skill.name, 'SKILL.md'), 'utf8') console.log(content) } export async function runSkill(args: string[]) { const [command, ...rest] = args if (!command || command === '--help' || command === '-h') { printSkillHelp() return } if (command === 'install') { await runInstall(rest) return } if (command === 'show' || command === 'list') { runShow(rest) return } console.error(` unknown skill command: ${command}`) printSkillHelp() rnxExit(1) }