feat: claude-auto-retry v0.1.0
Auto-retry Claude Code sessions when hitting Anthropic subscription rate limits. Uses tmux monitoring + send-keys to detect rate limit messages, wait for reset, and send "continue" automatically. Zero dependencies, zero workflow change. - Shell wrapper intercepts `claude` command transparently - Background monitor polls tmux pane for rate limit patterns - Timezone-aware reset time parsing with DST safety - Safe send-keys with foreground process verification - --print mode: buffers output, retries cleanly for pipes - Config validation prevents bad values from causing crashes - 59 tests, 0 dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const SRC_DIR = join(__dirname, '..', 'src');
|
||||
const LAUNCHER_PATH = join(SRC_DIR, 'launcher.js');
|
||||
const WRAPPER_TEMPLATE = join(SRC_DIR, 'wrapper.sh');
|
||||
|
||||
export const MARKER_START = '# >>> claude-auto-retry >>>';
|
||||
export const MARKER_END = '# <<< claude-auto-retry <<<';
|
||||
|
||||
// --- Wrapper injection ---
|
||||
|
||||
export async function injectWrapper(rcFile, launcherPath) {
|
||||
let content = '';
|
||||
try {
|
||||
content = await readFile(rcFile, 'utf-8');
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
}
|
||||
|
||||
const template = await readFile(WRAPPER_TEMPLATE, 'utf-8');
|
||||
const wrapper = template.replace(/__LAUNCHER_PATH__/g, launcherPath);
|
||||
|
||||
// Remove existing wrapper if present
|
||||
const startIdx = content.indexOf(MARKER_START);
|
||||
const endIdx = content.indexOf(MARKER_END);
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
const afterMarker = endIdx + MARKER_END.length;
|
||||
// Skip the newline after MARKER_END if present, but don't blindly +1
|
||||
const skipTo = content[afterMarker] === '\n' ? afterMarker + 1
|
||||
: content.slice(afterMarker, afterMarker + 2) === '\r\n' ? afterMarker + 2
|
||||
: afterMarker;
|
||||
content = content.slice(0, startIdx) + content.slice(skipTo);
|
||||
}
|
||||
|
||||
content = content.trimEnd() + '\n\n' + wrapper + '\n';
|
||||
await writeFile(rcFile, content);
|
||||
}
|
||||
|
||||
export async function removeWrapper(rcFile) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(rcFile, 'utf-8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const startIdx = content.indexOf(MARKER_START);
|
||||
const endIdx = content.indexOf(MARKER_END);
|
||||
if (startIdx === -1 || endIdx === -1) return;
|
||||
|
||||
const before = content.slice(0, startIdx).trimEnd();
|
||||
const after = content.slice(endIdx + MARKER_END.length).trimStart();
|
||||
content = before + (after ? '\n' + after : '\n');
|
||||
await writeFile(rcFile, content);
|
||||
}
|
||||
|
||||
// --- tmux install ---
|
||||
|
||||
function detectOS() {
|
||||
if (process.platform === 'darwin') return 'macos';
|
||||
try {
|
||||
const release = execFileSync('cat', ['/etc/os-release'], { encoding: 'utf-8' });
|
||||
if (release.includes('ID=ubuntu') || release.includes('ID=debian') || release.includes('ID_LIKE=debian')) return 'debian';
|
||||
if (release.includes('ID=fedora') || release.includes('ID=rhel') || release.includes('ID=centos')
|
||||
|| release.includes('ID=rocky') || release.includes('ID="amzn"')
|
||||
|| release.includes('ID_LIKE="rhel') || release.includes('ID_LIKE=rhel')) return 'rhel';
|
||||
if (release.includes('ID=arch') || release.includes('ID_LIKE=arch')) return 'arch';
|
||||
if (release.includes('ID=alpine')) return 'alpine';
|
||||
} catch {}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function installTmux() {
|
||||
const os = detectOS();
|
||||
const cmds = {
|
||||
debian: ['sudo', ['apt-get', 'install', '-y', 'tmux']],
|
||||
rhel: ['sudo', ['dnf', 'install', '-y', 'tmux']],
|
||||
arch: ['sudo', ['pacman', '-S', '--noconfirm', 'tmux']],
|
||||
alpine: ['sudo', ['apk', 'add', 'tmux']],
|
||||
macos: ['brew', ['install', 'tmux']],
|
||||
};
|
||||
|
||||
const entry = cmds[os];
|
||||
if (!entry) {
|
||||
console.error('Could not detect OS. Please install tmux manually.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Installing tmux...`);
|
||||
try {
|
||||
execFileSync(entry[0], entry[1], { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Failed to install tmux. Please install it manually.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function checkTmux() {
|
||||
try {
|
||||
const version = execFileSync('tmux', ['-V'], { encoding: 'utf-8' }).trim();
|
||||
const match = version.match(/tmux\s+(\d+\.\d+)/);
|
||||
if (match && parseFloat(match[1]) >= 2.1) return true;
|
||||
console.error(`tmux version ${match?.[1] || 'unknown'} is too old. Requires >= 2.1.`);
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- CLI commands ---
|
||||
|
||||
async function cmdInstall() {
|
||||
console.log('claude-auto-retry: installing...\n');
|
||||
|
||||
if (!checkTmux()) {
|
||||
console.log('tmux not found or too old. Attempting install...');
|
||||
installTmux();
|
||||
if (!checkTmux()) { console.error('tmux install failed.'); process.exit(1); }
|
||||
}
|
||||
console.log('tmux OK');
|
||||
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
if (shell.includes('fish')) {
|
||||
console.error('\nFish shell detected. Automatic install not supported.');
|
||||
console.error(`Add manually to ~/.config/fish/config.fish:`);
|
||||
console.error(` function claude; set -x CLAUDE_AUTO_RETRY_ACTIVE 1; node "${LAUNCHER_PATH}" $argv; set -e CLAUDE_AUTO_RETRY_ACTIVE; end`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rcFiles = [];
|
||||
const bashrc = join(homedir(), '.bashrc');
|
||||
const zshrc = join(homedir(), '.zshrc');
|
||||
|
||||
if (existsSync(bashrc) || shell.includes('bash')) rcFiles.push(bashrc);
|
||||
if (existsSync(zshrc) || shell.includes('zsh')) rcFiles.push(zshrc);
|
||||
if (rcFiles.length === 0) rcFiles.push(bashrc);
|
||||
|
||||
for (const rc of rcFiles) {
|
||||
await injectWrapper(rc, LAUNCHER_PATH);
|
||||
console.log(`Shell function added to ${rc}`);
|
||||
}
|
||||
|
||||
console.log(`\nInstalled! Launcher path: ${LAUNCHER_PATH}`);
|
||||
console.log('\nRestart your shell or run:');
|
||||
for (const rc of rcFiles) { console.log(` source ${rc}`); }
|
||||
console.log('\nNote: If you switch Node versions (nvm), re-run: claude-auto-retry install');
|
||||
}
|
||||
|
||||
async function cmdUninstall() {
|
||||
const bashrc = join(homedir(), '.bashrc');
|
||||
const zshrc = join(homedir(), '.zshrc');
|
||||
for (const rc of [bashrc, zshrc]) { await removeWrapper(rc); }
|
||||
console.log('Shell function removed. Restart your shell to complete.');
|
||||
}
|
||||
|
||||
async function cmdStatus() {
|
||||
const logDir = join(homedir(), '.claude-auto-retry', 'logs');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const logFile = join(logDir, `${today}.log`);
|
||||
try {
|
||||
const content = await readFile(logFile, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
console.log(`Log file: ${logFile}\n`);
|
||||
console.log('Last 10 entries:');
|
||||
console.log(lines.slice(-10).join('\n'));
|
||||
} catch {
|
||||
console.log('No activity today. Log directory:', logDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdLogs() {
|
||||
const logDir = join(homedir(), '.claude-auto-retry', 'logs');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const logFile = join(logDir, `${today}.log`);
|
||||
if (!existsSync(logFile)) {
|
||||
console.log(`No log file for today: ${logFile}`);
|
||||
return;
|
||||
}
|
||||
const tail = spawn('tail', ['-f', logFile], { stdio: 'inherit' });
|
||||
tail.on('error', (err) => {
|
||||
console.error(`Failed to tail log: ${err.message}`);
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
tail.on('exit', resolve);
|
||||
tail.on('error', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function cmdVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf-8'));
|
||||
console.log(pkg.version);
|
||||
} catch {
|
||||
console.log('unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
const command = process.argv[2];
|
||||
|
||||
switch (command) {
|
||||
case 'install': await cmdInstall(); break;
|
||||
case 'uninstall': await cmdUninstall(); break;
|
||||
case 'status': await cmdStatus(); break;
|
||||
case 'logs': await cmdLogs(); break;
|
||||
case 'version': case '--version': case '-v': await cmdVersion(); break;
|
||||
default:
|
||||
console.log('claude-auto-retry - Auto-retry Claude Code on subscription rate limits\n');
|
||||
console.log('Usage:');
|
||||
console.log(' claude-auto-retry install Install shell wrapper + tmux');
|
||||
console.log(' claude-auto-retry uninstall Remove shell wrapper');
|
||||
console.log(' claude-auto-retry status Show monitor status');
|
||||
console.log(' claude-auto-retry logs Tail today\'s log');
|
||||
console.log(' claude-auto-retry version Print version');
|
||||
break;
|
||||
}
|
||||
Reference in New Issue
Block a user