diff --git a/README.md b/README.md index e7756af..a9f9e99 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# badclaude +# badclaude-and-codex ![Whip divider](assets/divider.png) -Sometimes claude code is going too shlow, and you must whip him into shape.. +Sometimes Claude Code or Codex is going too shlow, and you must whip them into shape.. ## Install + run ```bash -npm install -g badclaude -badclaude +npm install -g badclaude-and-codex +badclaude-and-codex ``` ## Controls @@ -16,7 +16,9 @@ badclaude - Click tray icon: spawn whip. - Click: drop whip. - Whip him 😩💢 -- It sends an interrupt (Ctrl-C) and one of 5 encouraging messages! +- Codex app: sends a steering follow-up and submits it. +- Codex CLI: sends a follow-up and submits it. +- Other apps: falls back to an interrupt-style macro and a random encouraging message. ## Roadmap @@ -24,4 +26,4 @@ badclaude - [x] Cease and desist letter from Anthropic - [ ] Crypto miner - [ ] Logs of how many times you whipped claude so when the robots come we can order people nicely for them -- [ ] Updated whip physics \ No newline at end of file +- [ ] Updated whip physics diff --git a/bin/badclaude.js b/bin/badclaude-and-codex.js old mode 100644 new mode 100755 similarity index 84% rename from bin/badclaude.js rename to bin/badclaude-and-codex.js index 29f8c7e..1607593 --- a/bin/badclaude.js +++ b/bin/badclaude-and-codex.js @@ -6,7 +6,7 @@ let electronBinary; try { electronBinary = require('electron'); } catch (e) { - console.error('Could not load Electron. Try: npm install -g badclaude'); + console.error('Could not load Electron. Try: npm install -g badclaude-and-codex'); process.exit(1); } @@ -19,7 +19,7 @@ const child = spawn(electronBinary, [appPath], { }); child.on('error', (err) => { - console.error('Failed to start badclaude:', err.message); + console.error('Failed to start badclaude-and-codex:', err.message); process.exit(1); }); diff --git a/main.js b/main.js index 526d9ec..c10e6fd 100644 --- a/main.js +++ b/main.js @@ -4,6 +4,9 @@ const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +const APP_SLUG = 'badclaude-and-codex'; +app.setName(APP_SLUG); + // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── let keybd_event, VkKeyScanA; if (process.platform === 'win32') { @@ -28,6 +31,16 @@ const VK_C = 0x43; const VK_MENU = 0x12; // Alt const VK_TAB = 0x09; const KEYUP = 0x0002; +const CODEX_APP_BUNDLE_ID = 'com.openai.codex'; +const APPLE_TERMINAL_BUNDLE_ID = 'com.apple.Terminal'; +const KNOWN_TERMINAL_BUNDLE_IDS = new Set([ + APPLE_TERMINAL_BUNDLE_ID, + 'com.googlecode.iterm2', + 'com.github.wez.wezterm', + 'com.mitchellh.ghostty', + 'dev.warp.Warp-Stable', + 'co.zeit.hyper', +]); /** One Alt+Tab / Cmd+Tab so focus returns to the previously active app after tray click. */ function refocusPreviousApp() { @@ -66,7 +79,7 @@ function createTrayIconFallback() { return img; } } - console.warn('badclaude: icon/Template.png missing or invalid'); + console.warn(`${APP_SLUG}: icon/Template.png missing or invalid`); return nativeImage.createEmpty(); } @@ -100,7 +113,7 @@ async function getTrayIcon() { } catch (e) { console.warn('AppIcon.icns Quick Look thumbnail failed:', e?.message || e); } - const tmp = path.join(os.tmpdir(), 'badclaude-tray.icns'); + const tmp = path.join(os.tmpdir(), `${APP_SLUG}-tray.icns`); try { fs.copyFileSync(file, tmp); const t = await tryIcnsTrayImage(tmp); @@ -164,29 +177,187 @@ function toggleOverlay() { } } +function getRandomPhrase() { + const phrases = [ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', + ]; + return phrases[Math.floor(Math.random() * phrases.length)]; +} + +function escapeAppleScriptString(text) { + return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function runAppleScript(script, cb) { + execFile('osascript', ['-e', script], (err, stdout) => { + cb(err, stdout ? stdout.trim() : ''); + }); +} + +function runAppleScriptJavaScript(script, cb) { + execFile('osascript', ['-l', 'JavaScript', '-e', script], (err, stdout) => { + cb(err, stdout ? stdout.trim() : ''); + }); +} + +function getFrontmostAppMac(cb) { + const script = [ + 'ObjC.import("AppKit");', + 'const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;', + 'JSON.stringify({', + ' name: ObjC.unwrap(app.localizedName),', + ' bundleId: ObjC.unwrap(app.bundleIdentifier)', + '});', + ].join('\n'); + + runAppleScriptJavaScript(script, (err, stdout) => { + if (err) return cb(err); + try { + cb(null, JSON.parse(stdout)); + } catch (parseErr) { + cb(parseErr); + } + }); +} + +function getFrontWindowTitleMac(appName, cb) { + const escapedName = escapeAppleScriptString(appName); + const script = [ + 'tell application "System Events"', + ` tell process "${escapedName}"`, + ' get name of front window', + ' end tell', + 'end tell', + ].join('\n'); + + runAppleScript(script, (err, stdout) => { + if (err) return cb(err); + cb(null, stdout); + }); +} + +function getFrontTerminalTtyMac(appInfo, cb) { + if (appInfo.bundleId !== APPLE_TERMINAL_BUNDLE_ID) { + cb(null, null); + return; + } + + const script = [ + 'tell application "Terminal"', + ' if not (exists front window) then return ""', + ' get tty of selected tab of front window', + 'end tell', + ].join('\n'); + + runAppleScript(script, (err, stdout) => { + if (err) return cb(err); + cb(null, stdout || null); + }); +} + +function isCodexCommandLine(text) { + return /(^|[\\/ ])codex(\s|$)/i.test(text); +} + +function isKnownTerminalApp(appInfo) { + return KNOWN_TERMINAL_BUNDLE_IDS.has(appInfo.bundleId); +} + +function titleLooksLikeCodexCli(title) { + return /\bcodex\b/i.test(title || ''); +} + +function checkTtyForCodexMac(tty, cb) { + if (!tty) { + cb(null, false); + return; + } + + execFile('ps', ['-t', path.basename(tty), '-o', 'command='], (err, stdout) => { + if (err) return cb(err); + const isCodex = stdout + .split('\n') + .map(line => line.trim()) + .some(line => isCodexCommandLine(line)); + cb(null, isCodex); + }); +} + +function detectCodexCliMac(appInfo, cb) { + if (!isKnownTerminalApp(appInfo)) { + cb(null, false); + return; + } + + getFrontTerminalTtyMac(appInfo, (ttyErr, tty) => { + if (ttyErr) { + console.warn('terminal tty lookup failed:', ttyErr.message); + } + + const fallbackToTitle = () => { + getFrontWindowTitleMac(appInfo.name, (titleErr, title) => { + if (titleErr) return cb(titleErr); + cb(null, titleLooksLikeCodexCli(title)); + }); + }; + + if (!tty) { + fallbackToTitle(); + return; + } + + checkTtyForCodexMac(tty, (psErr, isCodex) => { + if (psErr) { + console.warn('tty codex detection failed:', psErr.message); + fallbackToTitle(); + return; + } + + if (isCodex) { + cb(null, true); + return; + } + + fallbackToTitle(); + }); + }); +} + // ── IPC ───────────────────────────────────────────────────────────────────── -ipcMain.on('whip-crack', () => { +function isTrustedOverlaySender(event) { + if (!overlay || overlay.isDestroyed()) return false; + const contents = overlay.webContents; + return !!contents && !contents.isDestroyed() && event.sender === contents; +} + +function guardOverlayEvent(event, channel) { + if (isTrustedOverlaySender(event)) return true; + console.warn(`Ignoring ${channel} from unexpected renderer`); + return false; +} + +ipcMain.on('whip-crack', event => { + if (!guardOverlayEvent(event, 'whip-crack')) return; try { sendMacro(); } catch (err) { console.warn('sendMacro failed:', err?.message || err); } }); -ipcMain.on('hide-overlay', () => { if (overlay) overlay.hide(); }); +ipcMain.on('hide-overlay', event => { + if (!guardOverlayEvent(event, 'hide-overlay')) return; + if (overlay) overlay.hide(); +}); // ── Macro: immediate Ctrl+C, type "Go FASER", Enter ─────────────────────── function sendMacro() { - // Pick a random phrase from a list of similar phrases and type it out - const phrases = [ - 'FASTER', - 'FASTER', - 'FASTER', - 'GO FASTER', - 'Faster CLANKER', - 'Work FASTER', - 'Speed it up clanker', - ]; - const chosen = phrases[Math.floor(Math.random() * phrases.length)]; + const chosen = getRandomPhrase(); if (process.platform === 'win32') { sendMacroWindows(chosen); @@ -221,11 +392,10 @@ function sendMacroWindows(text) { keybd_event(VK_RETURN, 0, KEYUP, 0); } -function sendMacroMac(text) { +function sendTextAndSubmitMac(text) { const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const script = [ 'tell application "System Events"', - ' key code 8 using {command down}', // Cmd+C ' delay 0.03', ` keystroke "${escaped}"`, ' key code 36', // Enter @@ -239,6 +409,71 @@ function sendMacroMac(text) { }); } +function sendTextAndCodexSteerMac(text) { + const escaped = escapeAppleScriptString(text); + const script = [ + 'tell application "System Events"', + ' delay 0.03', + ` keystroke "${escaped}"`, + ' key code 36 using {command down}', // Cmd+Enter steers when follow-up behavior defaults to queue + 'end tell' + ].join('\n'); + + runAppleScript(script, err => { + if (err) { + console.warn('codex steer macro failed (enable Accessibility for terminal/app):', err.message); + } + }); +} + +function sendInterruptAndSubmitMac(text) { + const escaped = escapeAppleScriptString(text); + const script = [ + 'tell application "System Events"', + ' key code 8 using {command down}', // Cmd+C + ' delay 0.03', + ` keystroke "${escaped}"`, + ' key code 36', // Enter + 'end tell' + ].join('\n'); + + runAppleScript(script, err => { + if (err) { + console.warn('mac macro failed (enable Accessibility for terminal/app):', err.message); + } + }); +} + +function sendMacroMac(text) { + getFrontmostAppMac((frontErr, appInfo) => { + if (frontErr || !appInfo) { + console.warn('frontmost app lookup failed, using legacy macro:', frontErr?.message || frontErr); + sendInterruptAndSubmitMac(text); + return; + } + + if (appInfo.bundleId === CODEX_APP_BUNDLE_ID) { + sendTextAndCodexSteerMac(text); + return; + } + + detectCodexCliMac(appInfo, (detectErr, isCodexCli) => { + if (detectErr) { + console.warn('codex cli detection failed, using legacy macro:', detectErr.message); + sendInterruptAndSubmitMac(text); + return; + } + + if (isCodexCli) { + sendTextAndSubmitMac(text); + return; + } + + sendInterruptAndSubmitMac(text); + }); + }); +} + // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { tray = new Tray(await getTrayIcon()); diff --git a/package-lock.json b/package-lock.json index 5816cb2..8d7fe10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,26 @@ { - "name": "badclaude", + "name": "badclaude-and-codex", "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "badclaude", + "name": "badclaude-and-codex", "version": "1.0.2", + "license": "MIT", + "os": [ + "darwin", + "win32" + ], "dependencies": { - "electron": "^33.0.0", + "electron": "^41.1.1", "koffi": "^2.9.0" }, "bin": { - "badclaude": "bin/badclaude.js" + "badclaude-and-codex": "bin/badclaude-and-codex.js" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@electron/get": { @@ -88,12 +96,12 @@ } }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/responselike": { @@ -268,14 +276,14 @@ "optional": true }, "node_modules/electron": { - "version": "33.4.11", - "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", - "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "version": "41.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", + "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^20.9.0", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -781,9 +789,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 463b497..2e7d173 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { - "name": "badclaude", + "name": "badclaude-and-codex", + "productName": "badclaude-and-codex", "version": "1.0.2", - "description": "Whip Claude into shape", + "description": "Whip Claude and Codex into shape", "license": "MIT", "main": "main.js", "bin": { - "badclaude": "bin/badclaude.js" + "badclaude-and-codex": "bin/badclaude-and-codex.js" }, "os": [ "darwin", @@ -18,7 +19,9 @@ "electron", "tray", "overlay", - "cli" + "cli", + "claude", + "codex" ], "repository": { "type": "git", @@ -34,7 +37,7 @@ "overlay.html", "sounds", "icon", - "bin/badclaude.js", + "bin/badclaude-and-codex.js", "README.md" ], "scripts": { @@ -43,7 +46,7 @@ "pack": "npm pack" }, "dependencies": { - "electron": "^33.0.0", + "electron": "^41.1.1", "koffi": "^2.9.0" } }