// 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)
}