Created
March 31, 2026 02:04
-
-
Save balloonlimb/aceb4e4af20c8e43fa7e134fdfca691f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env tsx | |
| import { | |
| existsSync, | |
| mkdirSync, | |
| rmSync, | |
| cpSync, | |
| unlinkSync, | |
| readFileSync, | |
| writeFileSync, | |
| } from "fs"; | |
| import { tmpdir } from "os"; | |
| import { join, dirname } from "path"; | |
| import { fileURLToPath } from "url"; | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const repoDir = join(__dirname, ".."); | |
| const claudeCurrDir = join(repoDir, ".claude"); | |
| const claudeAltsDir = join(repoDir, ".claude.alts"); | |
| const currClaudeMD = join(repoDir, "CLAUDE.md"); | |
| const archivedDir = join(claudeAltsDir, ".archived"); | |
| type ParsedArgs = { | |
| name: string; | |
| targetClaudeAltsDir: string; | |
| targetClaudeMD: string; | |
| }; | |
| function printUsage(exitCode = 0): never { | |
| console.error(` | |
| Usage: | |
| npx tsx .claude.alts/load.ts <name> | |
| Purpose: | |
| Load a Claude config from .claude.alts/<name> into the active repo state, | |
| with archive + rollback protection. | |
| Expected repo structure: | |
| <repo>/ | |
| .claude/ | |
| .claude.alts/ | |
| <name>/ | |
| CLAUDE.md | |
| ... | |
| .archived/ | |
| CLAUDE.md | |
| Behavior: | |
| 1. Validate the requested alternative config. | |
| 2. Archive the current active config into: | |
| .claude.alts/.archived/<timestamp>/ | |
| 3. Stage the requested alternative config in a temp directory. | |
| 4. Replace the active config. | |
| 5. If anything fails after replacement begins, automatically restore the | |
| previously active config from the archive. | |
| Examples: | |
| npx tsx .claude.alts/load.ts planning | |
| npx tsx .claude.alts/load.ts research-mode | |
| `.trim()); | |
| process.exit(exitCode); | |
| } | |
| function timestampForPath(): string { | |
| return new Date().toISOString().replace(/[:.]/g, "-"); | |
| } | |
| function ensureDir(path: string): void { | |
| mkdirSync(path, { recursive: true }); | |
| } | |
| function pathExists(path: string): boolean { | |
| return existsSync(path); | |
| } | |
| function assertExists(path: string, label: string): void { | |
| if (!pathExists(path)) { | |
| throw new Error(`${label} does not exist: ${path}`); | |
| } | |
| } | |
| function warn(message: string): void { | |
| console.error(`WARN: ${message}`); | |
| } | |
| function info(message: string): void { | |
| console.error(`INFO: ${message}`); | |
| } | |
| function parseArgs(): ParsedArgs { | |
| const args = process.argv.slice(2); | |
| const name = args[0]?.trim() ?? ""; | |
| if (!name || ["help", "--help", "-h", "h"].includes(name)) { | |
| printUsage(name ? 0 : 1); | |
| } | |
| const targetClaudeAltsRoot = join(claudeAltsDir, name); | |
| const targetClaudeAltsDir = join(targetClaudeAltsRoot, '.claude'); | |
| const targetClaudeMD = join(targetClaudeAltsRoot, "CLAUDE.md"); | |
| assertExists(claudeAltsDir, "Alternatives directory"); | |
| assertExists(targetClaudeAltsDir, "Selected alternative config"); | |
| assertExists(targetClaudeMD, "Selected alternative CLAUDE.md"); | |
| return { name, targetClaudeAltsDir, targetClaudeMD }; | |
| } | |
| function removeIfExists(path: string): void { | |
| if (pathExists(path)) { | |
| rmSync(path, { recursive: true, force: true }); | |
| } | |
| } | |
| function archiveCurrent(archiveDir: string): void { | |
| ensureDir(archiveDir); | |
| if (pathExists(claudeCurrDir)) { | |
| cpSync(claudeCurrDir, join(archiveDir, ".claude"), { | |
| recursive: true, | |
| force: true, | |
| }); | |
| } else { | |
| warn(`No active .claude directory found at ${claudeCurrDir}; nothing to archive there.`); | |
| } | |
| if (pathExists(currClaudeMD)) { | |
| cpSync(currClaudeMD, join(archiveDir, "CLAUDE.md"), { | |
| force: true, | |
| }); | |
| } else { | |
| warn(`No active root CLAUDE.md found at ${currClaudeMD}; nothing to archive there.`); | |
| } | |
| } | |
| function stageTarget(targetClaudeAltsDir: string, targetClaudeMD: string, stageDir: string): void { | |
| ensureDir(stageDir); | |
| const stagedClaudeDir = join(stageDir, ".claude"); | |
| const stagedRootClaudeMD = join(stageDir, "CLAUDE.md"); | |
| cpSync(targetClaudeAltsDir, stagedClaudeDir, { | |
| recursive: true, | |
| force: true, | |
| }); | |
| const targetClaudeMDContents = readFileSync(targetClaudeMD, "utf8"); | |
| writeFileSync(stagedRootClaudeMD, targetClaudeMDContents, "utf8"); | |
| const stagedInnerClaudeMD = join(stagedClaudeDir, "CLAUDE.md"); | |
| if (pathExists(stagedInnerClaudeMD)) { | |
| unlinkSync(stagedInnerClaudeMD); | |
| } | |
| } | |
| function clearActiveState(): void { | |
| if (pathExists(claudeCurrDir)) { | |
| rmSync(claudeCurrDir, { recursive: true, force: true }); | |
| } | |
| if (pathExists(currClaudeMD)) { | |
| rmSync(currClaudeMD, { force: true }); | |
| } | |
| } | |
| function applyStaged(stageDir: string): void { | |
| const stagedClaudeDir = join(stageDir, ".claude"); | |
| const stagedRootClaudeMD = join(stageDir, "CLAUDE.md"); | |
| assertExists(stagedClaudeDir, "Staged .claude directory"); | |
| assertExists(stagedRootClaudeMD, "Staged root CLAUDE.md"); | |
| clearActiveState(); | |
| cpSync(stagedClaudeDir, claudeCurrDir, { | |
| recursive: true, | |
| force: true, | |
| }); | |
| cpSync(stagedRootClaudeMD, currClaudeMD, { | |
| force: true, | |
| }); | |
| } | |
| function restoreFromArchive(archiveDir: string): void { | |
| const archivedClaudeDir = join(archiveDir, ".claude"); | |
| const archivedClaudeMD = join(archiveDir, "CLAUDE.md"); | |
| info(`Attempting rollback from archive: ${archiveDir}`); | |
| clearActiveState(); | |
| if (pathExists(archivedClaudeDir)) { | |
| cpSync(archivedClaudeDir, claudeCurrDir, { | |
| recursive: true, | |
| force: true, | |
| }); | |
| } else { | |
| warn(`Archive does not contain .claude at ${archivedClaudeDir}; active .claude will remain absent.`); | |
| } | |
| if (pathExists(archivedClaudeMD)) { | |
| cpSync(archivedClaudeMD, currClaudeMD, { | |
| force: true, | |
| }); | |
| } else { | |
| warn(`Archive does not contain root CLAUDE.md at ${archivedClaudeMD}; active root CLAUDE.md will remain absent.`); | |
| } | |
| } | |
| function cleanupStage(stageDir: string): void { | |
| removeIfExists(stageDir); | |
| } | |
| function main(): void { | |
| const { name, targetClaudeAltsDir, targetClaudeMD } = parseArgs(); | |
| ensureDir(archivedDir); | |
| const runId = timestampForPath(); | |
| // create a unique temp directory like: | |
| // /tmp/claude-switch-<pid>-<timestamp> | |
| const stageDir = join( | |
| tmpdir(), | |
| `claude-switch-${process.pid}-${runId}` | |
| ); | |
| const archiveDir = join(archivedDir, runId); | |
| let switchStarted = false; | |
| try { | |
| info(`Validating alternative config "${name}"`); | |
| assertExists(targetClaudeAltsDir, "Selected alternative config"); | |
| assertExists(targetClaudeMD, "Selected alternative CLAUDE.md"); | |
| info(`Archiving current active config to ${archiveDir}`); | |
| archiveCurrent(archiveDir); | |
| info(`Staging new config in ${stageDir}`); | |
| stageTarget(targetClaudeAltsDir, targetClaudeMD, stageDir); | |
| info(`Applying staged config`); | |
| switchStarted = true; | |
| applyStaged(stageDir); | |
| cleanupStage(stageDir); | |
| console.log(`SUCCESS: loaded alternative config "${name}"`); | |
| console.log(`SOURCE: ${targetClaudeAltsDir}`); | |
| console.log(`ARCHIVE: previous active config saved to ${archiveDir}`); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| console.error(""); | |
| console.error("ERROR: Claude config switch failed."); | |
| console.error(`ERROR: ${message}`); | |
| if (switchStarted) { | |
| console.error(""); | |
| console.error("WARN: The active config may be partially updated."); | |
| console.error("WARN: Automatic rollback will now be attempted."); | |
| try { | |
| restoreFromArchive(archiveDir); | |
| cleanupStage(stageDir); | |
| console.error("ROLLBACK: success."); | |
| console.error(`ROLLBACK: restored previous active config from ${archiveDir}`); | |
| } catch (rollbackError) { | |
| const rollbackMessage = | |
| rollbackError instanceof Error ? rollbackError.message : String(rollbackError); | |
| console.error("ROLLBACK: failed."); | |
| console.error(`ROLLBACK ERROR: ${rollbackMessage}`); | |
| console.error(""); | |
| console.error("MANUAL ACTION REQUIRED:"); | |
| console.error(`1. Inspect archive: ${archiveDir}`); | |
| console.error(`2. Restore ${join(archiveDir, ".claude")} -> ${claudeCurrDir} if present`); | |
| console.error(`3. Restore ${join(archiveDir, "CLAUDE.md")} -> ${currClaudeMD} if present`); | |
| process.exit(1); | |
| } | |
| } else { | |
| console.error(""); | |
| console.error("WARN: No active config changes were applied."); | |
| cleanupStage(stageDir); | |
| } | |
| process.exit(1); | |
| } | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment