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.
263 lines (239 loc) • 10.5 kB
text/typescript
import fs from 'fs';
import path from 'path';
type JsonObject = Record<string, any>;
export interface HookInvocation {
command: string;
args: string[];
}
export interface SettingsMutation {
changed: boolean;
backupPath?: string;
}
const MANUAL_MATCHER = '^handoff$';
const FAILURE_MATCHER = '^(rate_limit|billing_error|max_output_tokens|authentication_failed|oauth_org_not_allowed)$';
const OWNED_STATUS_MESSAGE = 'Preparing Codex handoff…';
const HANDOFF_SKILL_VISIBILITY = 'user-invocable-only';
interface SkillOverrideState {
schemaVersion: 1;
skill: 'handoff';
hadSkillOverrides: boolean;
hadHandoff: boolean;
previousValue?: unknown;
}
export function installHooks(
settingsPath: string,
backupDir: string,
invocation: HookInvocation,
includeStopFailure = true,
overrideStatePath?: string,
): SettingsMutation {
const settings = readSettings(settingsPath);
const before = stableJson(settings);
stripOwnedHooks(settings);
addHook(settings, 'UserPromptExpansion', MANUAL_MATCHER, invocation);
if (includeStopFailure) addHook(settings, 'StopFailure', FAILURE_MATCHER, invocation);
const newOverrideState = overrideStatePath
? applyHandoffSkillOverride(settings, readSkillOverrideState(overrideStatePath))
: null;
const after = stableJson(settings);
if (before === after) return { changed: false };
if (newOverrideState && overrideStatePath) writeSkillOverrideState(overrideStatePath, newOverrideState);
let backupPath: string | undefined;
try { backupPath = writeSettings(settingsPath, backupDir, settings); }
catch (error) {
if (newOverrideState && overrideStatePath) fs.rmSync(overrideStatePath, { force: true });
throw error;
}
return { changed: true, backupPath };
}
export function uninstallHooks(settingsPath: string, backupDir: string, overrideStatePath?: string): SettingsMutation {
if (!fs.existsSync(settingsPath)) {
if (overrideStatePath) fs.rmSync(overrideStatePath, { force: true });
return { changed: false };
}
const settings = readSettings(settingsPath);
const before = stableJson(settings);
stripOwnedHooks(settings);
const overrideState = overrideStatePath ? readSkillOverrideState(overrideStatePath) : null;
if (overrideState) restoreHandoffSkillOverride(settings, overrideState);
const after = stableJson(settings);
if (before === after) {
if (overrideStatePath) fs.rmSync(overrideStatePath, { force: true });
return { changed: false };
}
const backupPath = writeSettings(settingsPath, backupDir, settings);
if (overrideStatePath) fs.rmSync(overrideStatePath, { force: true });
return { changed: true, backupPath };
}
export function hasInstalledHook(settingsPath: string, event: 'UserPromptExpansion' | 'StopFailure'): boolean {
if (!fs.existsSync(settingsPath)) return false;
const settings = readSettings(settingsPath);
const groups = settings.hooks && settings.hooks[event];
if (!Array.isArray(groups)) return false;
return groups.some((group: any) => Array.isArray(group && group.hooks) && group.hooks.some(isOwnedHandler));
}
export function hasUserOnlyHandoffSkill(settingsPath: string): boolean {
if (!fs.existsSync(settingsPath)) return false;
const settings = readSettings(settingsPath);
return !!settings.skillOverrides
&& settings.skillOverrides.handoff === HANDOFF_SKILL_VISIBILITY;
}
export function readSettings(settingsPath: string): JsonObject {
if (!fs.existsSync(settingsPath)) {
let stat: fs.Stats;
try {
stat = fs.lstatSync(settingsPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw error;
}
if (stat.isSymbolicLink()) throw new Error(`Claude settings symlink is dangling: ${settingsPath}`);
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
throw new Error(`cannot parse Claude settings ${settingsPath}: ${error instanceof Error ? error.message : error}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Claude settings must contain a JSON object: ${settingsPath}`);
}
const settings = parsed as JsonObject;
validateOwnedSettingsShape(settings, settingsPath);
return settings;
}
function addHook(settings: JsonObject, event: string, matcher: string, invocation: HookInvocation): void {
if (!settings.hooks) settings.hooks = {};
if (settings.hooks[event] == null) settings.hooks[event] = [];
settings.hooks[event].push({
matcher,
hooks: [{
type: 'command',
command: invocation.command,
args: invocation.args,
timeout: 5,
statusMessage: OWNED_STATUS_MESSAGE,
}],
});
}
function validateOwnedSettingsShape(settings: JsonObject, settingsPath: string): void {
if (settings.hooks != null && (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) {
throw new Error(`Claude hooks must contain a JSON object: ${settingsPath}`);
}
for (const event of ['UserPromptExpansion', 'StopFailure']) {
if (settings.hooks && settings.hooks[event] != null && !Array.isArray(settings.hooks[event])) {
throw new Error(`Claude hooks.${event} must contain an array: ${settingsPath}`);
}
}
if (settings.skillOverrides != null
&& (typeof settings.skillOverrides !== 'object' || Array.isArray(settings.skillOverrides))) {
throw new Error(`Claude skillOverrides must contain a JSON object: ${settingsPath}`);
}
}
function applyHandoffSkillOverride(settings: JsonObject, existingState: SkillOverrideState | null): SkillOverrideState | null {
const hadSkillOverrides = !!settings.skillOverrides;
const overrides = settings.skillOverrides || {};
const hadHandoff = Object.prototype.hasOwnProperty.call(overrides, 'handoff');
const previousValue = overrides.handoff;
if (previousValue === HANDOFF_SKILL_VISIBILITY) return null;
if (!settings.skillOverrides) settings.skillOverrides = overrides;
settings.skillOverrides.handoff = HANDOFF_SKILL_VISIBILITY;
return existingState || {
schemaVersion: 1,
skill: 'handoff',
hadSkillOverrides,
hadHandoff,
previousValue,
};
}
function restoreHandoffSkillOverride(settings: JsonObject, state: SkillOverrideState): void {
if (!settings.skillOverrides || settings.skillOverrides.handoff !== HANDOFF_SKILL_VISIBILITY) return;
if (state.hadHandoff) settings.skillOverrides.handoff = state.previousValue;
else delete settings.skillOverrides.handoff;
if (!state.hadSkillOverrides && Object.keys(settings.skillOverrides).length === 0) delete settings.skillOverrides;
}
function readSkillOverrideState(statePath: string): SkillOverrideState | null {
let value: unknown;
try { value = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { return null; }
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const state = value as Record<string, unknown>;
if (state.schemaVersion !== 1 || state.skill !== 'handoff'
|| typeof state.hadSkillOverrides !== 'boolean' || typeof state.hadHandoff !== 'boolean') return null;
return state as unknown as SkillOverrideState;
}
function writeSkillOverrideState(statePath: string, state: SkillOverrideState): void {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const temp = `${statePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(temp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(temp, statePath);
fs.chmodSync(statePath, 0o600);
} finally {
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
}
}
function stripOwnedHooks(settings: JsonObject): void {
if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) return;
for (const event of ['UserPromptExpansion', 'StopFailure']) {
const groups = settings.hooks[event];
if (!Array.isArray(groups)) continue;
const nextGroups: any[] = [];
for (const group of groups) {
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) {
nextGroups.push(group);
continue;
}
const nextHandlers = group.hooks.filter((handler: any) => !isOwnedHandler(handler));
if (nextHandlers.length > 0) nextGroups.push({ ...group, hooks: nextHandlers });
}
if (nextGroups.length > 0) settings.hooks[event] = nextGroups;
else delete settings.hooks[event];
}
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
}
function isOwnedHandler(handler: any): boolean {
if (!handler || handler.type !== 'command') return false;
if (handler.statusMessage !== OWNED_STATUS_MESSAGE || !Array.isArray(handler.args)) return false;
const args = handler.args.map(String);
return args.length === 2 && args[0] === 'handoff' && args[1] === 'hook';
}
function writeSettings(settingsPath: string, backupDir: string, settings: JsonObject): string | undefined {
const writePath = resolveSettingsWritePath(settingsPath);
fs.mkdirSync(path.dirname(writePath), { recursive: true });
let backupPath: string | undefined;
let mode = 0o600;
if (fs.existsSync(writePath)) {
const stat = fs.statSync(writePath);
mode = stat.mode & 0o777;
fs.mkdirSync(backupDir, { recursive: true });
backupPath = uniqueBackupPath(backupDir, 'claude-settings', '.json');
fs.copyFileSync(writePath, backupPath);
fs.chmodSync(backupPath, 0o600);
}
const temp = `${writePath}.rig-handoff-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(temp, stableJson(settings), { mode });
fs.renameSync(temp, writePath);
} finally {
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
}
return backupPath;
}
function resolveSettingsWritePath(settingsPath: string): string {
let stat: fs.Stats;
try { stat = fs.lstatSync(settingsPath); } catch { return settingsPath; }
if (!stat.isSymbolicLink()) return settingsPath;
try { return fs.realpathSync(settingsPath); }
catch { throw new Error(`Claude settings symlink is dangling: ${settingsPath}`); }
}
export function uniqueBackupPath(dir: string, prefix: string, suffix = ''): string {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
let candidate = path.join(dir, `${prefix}-${stamp}${suffix}`);
let n = 1;
while (fs.existsSync(candidate)) candidate = path.join(dir, `${prefix}-${stamp}-${n++}${suffix}`);
return candidate;
}
function stableJson(value: JsonObject): string {
return JSON.stringify(value, null, 2) + '\n';
}