rigjs
Version:
A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.
178 lines (160 loc) • 5.97 kB
text/typescript
import fs from 'fs';
import path from 'path';
import os from 'os';
import print from '../print';
import { paths } from './paths';
const BUNDLED_SKILLS = [
{ name: 'rig-wiki', file: 'RIG_WIKI_SKILL.md' },
{ name: 'rig-crew', file: 'RIG_CREW_SKILL.md' },
];
/** Find rig's package root by walking up from built/ or lib/. */
function findRigRoot(): string | undefined {
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const pkg = path.join(dir, 'package.json');
if (fs.existsSync(pkg)) {
try {
const p = JSON.parse(fs.readFileSync(pkg, 'utf8'));
if (p.name === 'rigjs') return dir;
} catch { /* keep walking */ }
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return undefined;
}
interface InstallOpts { force?: boolean; project?: boolean; }
/**
* Install bundled skills (rig-wiki, rig-crew).
*
* Default (global): symlink into `~/.claude/skills/<name>/SKILL.md`. Affects
* every project that uses Claude Code on this machine. Symlink because
* `~/.claude/skills/` is not committed; the link should auto-track
* whichever rigjs version is installed.
*
* `--project`: **copy actual file contents** into BOTH
* `<cwd>/.claude/skills/<name>/SKILL.md` (Claude Code) AND
* `<cwd>/.agents/skills/<name>/SKILL.md` (Codex). Copy because:
* 1. these paths get committed to the project's repo and need to work
* after a fresh clone on any machine, regardless of where rigjs lives
* on that machine's disk;
* 2. a symlink targeting `/usr/local/lib/node_modules/rigjs/...` would
* be machine-specific and meaningless on CI / colleague's laptop /
* a different install prefix.
* To refresh project-local copies after a rigjs upgrade, re-run
* `rig wiki install-skill --project --force`.
*/
export default function wikiInstallSkill(opts: InstallOpts): void {
const root = findRigRoot();
if (!root) {
print.error('could not locate the rigjs install root. Reinstall rigjs?');
process.exit(1);
}
const targetRoots = resolveTargetRoots(opts);
for (const tr of targetRoots) {
if (!fs.existsSync(tr.parent)) {
// Project-level install creates the dirs on demand (parent is <cwd>).
// Global install requires the dir to already exist — that's how we
// detect "Claude Code is installed on this machine".
if (tr.kind === 'global') {
print.error(`${tr.label} dir not found: ${tr.parent}`);
print.info(`install ${tr.label} first, then re-run \`rig wiki install-skill\`.`);
process.exit(1);
}
}
for (const skill of BUNDLED_SKILLS) {
const src = path.join(root, skill.file);
if (!fs.existsSync(src)) {
print.warn(`skipping ${skill.name}: ${skill.file} not found inside rigjs install`);
continue;
}
linkSkill(skill.name, src, tr.parent, opts);
}
}
if (opts.project) {
print.info('project-local skills will override global ones when this project is the cwd.');
} else {
print.info('restart Claude Code to pick up new or updated skills.');
}
}
interface TargetRoot {
/** `<parent>/<skill-name>/SKILL.md` is the resulting symlink. */
parent: string;
kind: 'global' | 'project';
label: string;
}
function resolveTargetRoots(opts: InstallOpts): TargetRoot[] {
if (opts.project) {
const cwd = process.cwd();
return [
{ parent: path.join(cwd, '.claude', 'skills'), kind: 'project', label: 'Claude Code project skills' },
{ parent: path.join(cwd, '.agents', 'skills'), kind: 'project', label: 'Codex project skills' },
];
}
return [
{ parent: paths.claudeSkillsDir, kind: 'global', label: 'Claude Code skills' },
];
}
function linkSkill(name: string, src: string, parentDir: string, opts: InstallOpts): void {
const targetDir = path.join(parentDir, name);
const target = path.join(targetDir, 'SKILL.md');
fs.mkdirSync(targetDir, { recursive: true });
const useCopy = !!opts.project;
if (fs.existsSync(target) || isBrokenSymlink(target)) {
const existingLink = safeReadlink(target);
if (useCopy) {
// Copy mode: same-content existing copy is a no-op; otherwise overwrite
// when --force, error when not.
if (!existingLink) {
try {
if (fs.readFileSync(target, 'utf8') === fs.readFileSync(src, 'utf8')) {
print.info(`already up to date: ${shortPath(target)}`);
return;
}
} catch { /* fall through to the force-check below */ }
}
if (!opts.force) {
const what = existingLink ? `symlink -> ${existingLink}` : 'a different file';
print.error(`${shortPath(target)} exists as ${what}. Pass --force to replace.`);
process.exit(1);
}
fs.rmSync(target, { force: true });
} else {
// Symlink mode (global): same-target existing symlink is a no-op.
if (existingLink === src) {
print.info(`already linked: ${shortPath(target)}`);
return;
}
if (!opts.force) {
const what = existingLink ? `symlink -> ${existingLink}` : 'a regular file';
print.error(`${shortPath(target)} exists as ${what}. Pass --force to replace.`);
process.exit(1);
}
fs.rmSync(target, { force: true });
}
}
if (useCopy) {
fs.copyFileSync(src, target);
print.succeed(`wrote ${shortPath(target)}`);
} else {
fs.symlinkSync(src, target);
print.succeed(`linked ${shortPath(target)} -> ${shortPath(src)}`);
}
}
function safeReadlink(p: string): string | null {
try { return fs.readlinkSync(p); } catch { return null; }
}
function isBrokenSymlink(p: string): boolean {
try {
fs.statSync(p);
return false;
} catch {
try { return Boolean(fs.readlinkSync(p)); } catch { return false; }
}
}
function shortPath(p: string): string {
const home = os.homedir();
if (p.startsWith(home + path.sep)) return '~' + p.slice(home.length);
return p;
}