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.
84 lines (77 loc) • 5.57 kB
text/typescript
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import print from '../print';
import { findLatestTranscript } from './transcript';
import { findOnPath, resolveHandoffPaths, shortPath } from './paths';
import { hasInstalledHook, hasUserOnlyHandoffSkill } from './settings';
import { isOwnedLauncher } from './launcher';
import { hasInstalledCodexHook, isCodexHooksFeatureDisabled } from './codex-settings';
import { findLatestCodexTranscript } from './codex-transcript';
import { readCodexLatestPointer } from './codex-pointer';
export interface DoctorCheck {
name: string;
ok: boolean;
detail: string;
fatal: boolean;
}
interface DoctorOptions {
json?: boolean;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}
export function inspectHandoffInstallation(options: DoctorOptions = {}): DoctorCheck[] {
const env = options.env || process.env;
const platform = options.platform || process.platform;
const paths = resolveHandoffPaths(env);
const rig = env.RIG_HANDOFF_RIG_BIN || paths.handoffExecutable;
const claude = findOnPath('claude', env);
const codex = findOnPath('codex', env);
const claudeVersion = claude ? spawnSync(claude, ['--version'], { encoding: 'utf8', timeout: 3000 }) : null;
const launcherVersion = isOwnedLauncher(rig)
? spawnSync(rig, ['--version'], { encoding: 'utf8', env: { ...env, PATH: '/usr/bin:/bin' }, timeout: 3000 })
: null;
const latest = findLatestTranscript(paths.claudeProjects);
const latestCodex = findLatestCodexTranscript(paths.codexSessions, undefined, paths.codexLatestPointer);
const codexPointer = readCodexLatestPointer(paths.codexLatestPointer);
const hooksFeatureDisabled = isCodexHooksFeatureDisabled(paths.codexConfig);
return [
{ name: 'platform', ok: platform === 'darwin', detail: platform, fatal: true },
{ name: 'handoff launcher', ok: !!launcherVersion && launcherVersion.status === 0, detail: launcherVersion && launcherVersion.status === 0 ? `${shortPath(rig, env)} — ${launcherVersion.stdout.trim()}` : shortPath(rig, env), fatal: true },
{ name: 'pbcopy', ok: fs.existsSync('/usr/bin/pbcopy'), detail: '/usr/bin/pbcopy', fatal: true },
{ name: 'Claude Code', ok: !!claude, detail: claude ? `${claude} — ${(claudeVersion && claudeVersion.stdout || '').trim()}` : 'not found on PATH', fatal: true },
{ name: 'Codex', ok: !!codex || fs.existsSync(paths.codexHome), detail: codex || paths.codexHome, fatal: false },
{ name: 'Codex hooks feature', ok: !hooksFeatureDisabled, detail: hooksFeatureDisabled ? `${shortPath(paths.codexConfig, env)} sets [features].hooks = false` : 'enabled or default', fatal: true },
{ name: 'Claude handoff skill', ok: isLinkTo(paths.claudeSkill, paths.handoffSkillSource), detail: shortPath(paths.claudeSkill, env), fatal: true },
{ name: 'Codex rig-from-claude adapter', ok: isLinkTo(paths.codexSkill, paths.rigFromClaudeSkillSource), detail: shortPath(paths.codexSkill, env), fatal: true },
{ name: 'Codex handoff skill', ok: isLinkTo(paths.codexHandoffSkill, paths.handoffSkillSource), detail: shortPath(paths.codexHandoffSkill, env), fatal: true },
{ name: 'Claude rig-from-codex adapter', ok: isLinkTo(paths.claudeFromCodexSkill, paths.rigFromCodexSkillSource), detail: shortPath(paths.claudeFromCodexSkill, env), fatal: true },
{ name: 'UserPromptExpansion hook', ok: hasInstalledHook(paths.claudeSettings, 'UserPromptExpansion'), detail: shortPath(paths.claudeSettings, env), fatal: true },
{ name: 'StopFailure hook', ok: hasInstalledHook(paths.claudeSettings, 'StopFailure'), detail: shortPath(paths.claudeSettings, env), fatal: false },
{ name: 'Claude handoff invocation', ok: hasUserOnlyHandoffSkill(paths.claudeSettings), detail: 'skillOverrides.handoff = user-invocable-only', fatal: true },
{ name: 'Codex UserPromptSubmit hook', ok: hasInstalledCodexHook(paths.codexHooks), detail: `${shortPath(paths.codexHooks, env)} — configured`, fatal: true },
{ name: 'Codex hook trust', ok: false, detail: 'trust state is not exposed to Rig; confirm once with /hooks', fatal: false },
{ name: 'Claude transcript', ok: !!latest, detail: latest ? shortPath(latest, env) : 'no JSONL transcript yet', fatal: false },
{ name: 'Codex transcript', ok: !!latestCodex && fs.existsSync(latestCodex), detail: latestCodex ? `${shortPath(latestCodex, env)}${fs.existsSync(latestCodex) ? '' : ' — waiting for first JSONL flush'}` : 'no root Codex rollout found', fatal: false },
{ name: 'Codex latest pointer', ok: !!codexPointer, detail: codexPointer ? shortPath(codexPointer.transcriptPath, env) : 'created by the next $handoff trigger', fatal: false },
];
}
export default function doctorHandoffCli(options: DoctorOptions = {}): void {
const checks = inspectHandoffInstallation(options);
if (options.json) {
process.stdout.write(JSON.stringify({ ok: checks.every(c => c.ok || !c.fatal), checks }, null, 2) + '\n');
} else {
for (const check of checks) {
if (check.ok) print.succeed(`${check.name}: ${check.detail}`);
else if (check.fatal) print.error(`${check.name}: ${check.detail}`);
else print.warn(`${check.name}: ${check.detail}`);
}
}
if (checks.some(check => check.fatal && !check.ok)) process.exitCode = 1;
}
function isLinkTo(target: string, source: string): boolean {
try {
if (!fs.lstatSync(target).isSymbolicLink()) return false;
return path.resolve(path.dirname(target), fs.readlinkSync(target)) === path.resolve(source);
} catch { return false; }
}