UNPKG

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.

223 lines (206 loc) 9.36 kB
import fs from 'fs'; import path from 'path'; import print from '../print'; import { resolveHandoffPaths, shortPath, HandoffPaths } from './paths'; import { requireMacOS } from './platform'; import { installHooks, HookInvocation, readSettings, uniqueBackupPath } from './settings'; import { installLauncher, preflightLauncher } from './launcher'; import { buildCodexHookCommand, installCodexHooks, isCodexHooksFeatureDisabled, readCodexHooks } from './codex-settings'; export interface InstallOptions { force?: boolean; stopFailure?: boolean; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; invocation?: HookInvocation; } export interface InstallResult { claudeSkillChanged: boolean; claudeFromCodexSkillChanged: boolean; codexSkillChanged: boolean; codexHandoffSkillChanged: boolean; legacyCodexSkillRemoved: boolean; launcherChanged: boolean; settingsChanged: boolean; settingsBackup?: string; codexHooksChanged: boolean; codexHooksBackup?: string; invocation: HookInvocation; paths: HandoffPaths; } export function installHandoff(options: InstallOptions = {}): InstallResult { requireMacOS(options.platform || process.platform); const env = options.env || process.env; const paths = resolveHandoffPaths(env); validateSources(paths); const rigBin = path.join(paths.rigRoot, 'bin', 'rig.js'); preflightInstall(paths, rigBin, options.force); const launcherChanged = installLauncher( rigBin, paths.handoffExecutable, paths.backups, env.RIG_HANDOFF_NODE_BIN || process.execPath, options.force, ); const invocation = options.invocation || resolveRigInvocation(paths, env); const claudeSkillChanged = linkSkillDirectory( paths.handoffSkillSource, paths.claudeSkill, paths.backups, options.force, ); const codexSkillChanged = linkSkillDirectory( paths.rigFromClaudeSkillSource, paths.codexSkill, paths.backups, options.force, ); const codexHandoffSkillChanged = linkSkillDirectory( paths.handoffSkillSource, paths.codexHandoffSkill, paths.backups, options.force, ); const claudeFromCodexSkillChanged = linkSkillDirectory( paths.rigFromCodexSkillSource, paths.claudeFromCodexSkill, paths.backups, options.force, ); const legacyCodexSkillRemoved = removeOwnedLegacySkillLink(paths); const settings = installHooks( paths.claudeSettings, paths.backups, invocation, options.stopFailure !== false, paths.claudeSkillOverrideState, ); const codexHooks = installCodexHooks( paths.codexHooks, paths.backups, buildCodexHookCommand(paths.handoffExecutable), ); return { claudeSkillChanged, claudeFromCodexSkillChanged, codexSkillChanged, codexHandoffSkillChanged, legacyCodexSkillRemoved, launcherChanged, settingsChanged: settings.changed, settingsBackup: settings.backupPath, codexHooksChanged: codexHooks.changed, codexHooksBackup: codexHooks.backupPath, invocation, paths, }; } export default function installHandoffCli(options: InstallOptions = {}): void { try { const result = installHandoff(options); reportSkill('Claude /handoff', result.paths.claudeSkill, result.claudeSkillChanged); reportSkill('Codex rig-from-claude adapter', result.paths.codexSkill, result.codexSkillChanged); reportSkill('Codex $handoff', result.paths.codexHandoffSkill, result.codexHandoffSkillChanged); reportSkill('Claude rig-from-codex adapter', result.paths.claudeFromCodexSkill, result.claudeFromCodexSkillChanged); if (result.legacyCodexSkillRemoved) print.succeed(`removed legacy skill link: ${shortPath(result.paths.legacyCodexSkill)}`); reportLauncher(result.paths.handoffExecutable, result.launcherChanged); if (result.settingsChanged) print.succeed(`updated ${shortPath(result.paths.claudeSettings)}`); else print.info(`hooks already installed in ${shortPath(result.paths.claudeSettings)}`); if (result.settingsBackup) print.info(`settings backup: ${shortPath(result.settingsBackup)}`); if (result.codexHooksChanged) print.succeed(`updated ${shortPath(result.paths.codexHooks)}`); else print.info(`hooks already installed in ${shortPath(result.paths.codexHooks)}`); if (result.codexHooksBackup) print.info(`Codex hooks backup: ${shortPath(result.codexHooksBackup)}`); if (isCodexHooksFeatureDisabled(result.paths.codexConfig)) { print.warn(`Codex hooks are disabled in ${shortPath(result.paths.codexConfig)}; enable [features].hooks before using $handoff.`); } print.warn('Codex trust cannot be verified by Rig: open /hooks once and trust the Rig UserPromptSubmit hook.'); print.succeed('installed: use /handoff in Claude, and $handoff in Codex after that trust step.'); } catch (error) { print.error(error instanceof Error ? error.message : String(error)); process.exitCode = (error as Error & { exitCode?: number }).exitCode || 1; } } function validateSources(paths: HandoffPaths): void { if (!paths.rigRoot) throw new Error('could not locate the rigjs package root.'); for (const source of [ paths.handoffSkillSource, paths.rigFromClaudeSkillSource, paths.rigFromCodexSkillSource, ]) { if (!fs.existsSync(path.join(source, 'SKILL.md'))) { throw new Error(`bundled skill is missing: ${path.join(source, 'SKILL.md')}`); } } } function preflightInstall(paths: HandoffPaths, rigBin: string, force = false): void { preflightLauncher(paths.handoffExecutable, rigBin, force); preflightSkillDirectory(paths.handoffSkillSource, paths.claudeSkill, force); preflightSkillDirectory(paths.rigFromClaudeSkillSource, paths.codexSkill, force); preflightSkillDirectory(paths.handoffSkillSource, paths.codexHandoffSkill, force); preflightSkillDirectory(paths.rigFromCodexSkillSource, paths.claudeFromCodexSkill, force); // Parse settings before creating any links, so a malformed dotfile cannot // leave a predictable partial installation behind. readSettings(paths.claudeSettings); readCodexHooks(paths.codexHooks); } export function resolveRigInvocation(paths: HandoffPaths, env: NodeJS.ProcessEnv = process.env): HookInvocation { const explicit = env.RIG_HANDOFF_RIG_BIN; const rig = explicit || paths.handoffExecutable; if (!fs.existsSync(rig)) throw new Error(`Rig handoff launcher is missing: ${rig}`); return { command: path.resolve(rig), args: ['handoff', 'hook'] }; } function linkSkillDirectory(source: string, target: string, backupDir: string, force = false): boolean { fs.mkdirSync(path.dirname(target), { recursive: true }); const existing = lstat(target); if (existing) { if (existing.isSymbolicLink()) { const current = fs.readlinkSync(target); const resolved = path.resolve(path.dirname(target), current); if (resolved === path.resolve(source)) return false; if (!force) throw new Error(`${target} already links to ${current}; pass --force to replace it.`); fs.rmSync(target, { force: true }); } else { if (!force) throw new Error(`${target} already exists; pass --force to back it up and replace it.`); fs.mkdirSync(backupDir, { recursive: true }); const backup = uniqueBackupPath(backupDir, skillBackupPrefix(target)); fs.renameSync(target, backup); } } fs.symlinkSync(source, target, 'dir'); return true; } function skillBackupPrefix(target: string): string { if (target.includes('rig-from-claude')) return 'codex-rig-from-claude-skill'; if (target.includes(`${path.sep}.codex${path.sep}skills${path.sep}handoff`)) return 'codex-handoff-skill'; if (target.includes('rig-from-codex')) return 'claude-rig-from-codex-skill'; return 'claude-handoff-skill'; } function removeOwnedLegacySkillLink(paths: HandoffPaths): boolean { const existing = lstat(paths.legacyCodexSkill); if (!existing || !existing.isSymbolicLink()) return false; const resolved = path.resolve(path.dirname(paths.legacyCodexSkill), fs.readlinkSync(paths.legacyCodexSkill)); const ownedSources = [paths.legacyFromClaudeSkillSource, paths.rigFromClaudeSkillSource].map(source => path.resolve(source)); if (!ownedSources.includes(resolved)) return false; fs.rmSync(paths.legacyCodexSkill, { force: true }); return true; } function preflightSkillDirectory(source: string, target: string, force = false): void { const existing = lstat(target); if (!existing || force) return; if (existing.isSymbolicLink()) { const resolved = path.resolve(path.dirname(target), fs.readlinkSync(target)); if (resolved === path.resolve(source)) return; throw new Error(`${target} already links elsewhere; pass --force to replace it.`); } throw new Error(`${target} already exists; pass --force to back it up and replace it.`); } function lstat(value: string): fs.Stats | null { try { return fs.lstatSync(value); } catch { return null; } } function reportSkill(label: string, target: string, changed: boolean): void { if (changed) print.succeed(`linked ${label}: ${shortPath(target)}`); else print.info(`${label} already linked: ${shortPath(target)}`); } function reportLauncher(target: string, changed: boolean): void { if (changed) print.succeed(`wrote stable handoff launcher: ${shortPath(target)}`); else print.info(`stable handoff launcher already current: ${shortPath(target)}`); }