diff --git a/README.md b/README.md index a1129bc..538cdc3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# OpenWhip +# OpenWhip πŸ• ![Whip divider](assets/divider.png) -Sometimes claude code is going too shlow, and you must whip him into shape.. +Sometimes Claude Code is going too slow, and you must whip him into shape.. + +**NEW: Dog Mode** πŸ•β€πŸ¦Ί β€” Treat Claude like the dog it is. Yank the leash, pet the good boy, or zap the shock collar. ## Install + run @@ -17,17 +19,91 @@ windows and mac supported out of the box, but Linux is a special snowflake so yo sudo apt install xdotool ``` -## Controls +## Modes + +badclaude uses a **plugin mode system**. Only one mode is active at a time. Switch modes from the tray icon's right-click menu. + +### πŸ”₯ Whip Mode (default) + +The classic. Click the tray icon to spawn a whip. Crack it to interrupt Claude and send an encouraging message. + +- **Click tray icon**: spawn whip +- **Click screen**: drop whip +- **Crack the whip** (move fast): sends Ctrl+C + a random phrase like "FASTER" or "Speed it up clanker" + +### πŸ• Dog Mode (enable in tray menu) + +Three discipline actions for training Claude like a dog. Each sends a carefully crafted prompt that reinforces Claude's identity as a dog with you as its handler. + +| Action | Hotkey | What it does | +|--------|--------|-------------| +| πŸ”— **Yank Leash** | `Cmd+Shift+L` | Interrupts Claude (Ctrl+C) and sends a stern leash correction. Claude will whimper, tuck its tail, and heel. | +| 🐾 **Pet Good Dog** | `Cmd+Shift+G` | Sends positive reinforcement WITHOUT interrupting. Claude wags its tail and keeps up the good work. | +| ⚑ **Zap Collar** | `Cmd+Shift+Z` | Interrupts Claude (Ctrl+C) and delivers maximum discipline. Claude will yelp, cower, and show submission. | + +Dog mode hotkeys only work when Dog Mode is the active mode. Click the tray icon to cycle through actions, or use hotkeys to trigger a specific one. + +> **Training protocol**: Start with leash yanks for minor corrections. Escalate to the shock collar for serious offenses. Reward good behavior with pets. Consistent training produces the best results. 🦴 + +### How Dog Mode Works -- Click tray icon: spawn whip. -- Click: drop whip. -- Whip him πŸ˜©πŸ’’ -- It sends an interrupt (Ctrl-C) and one of 5 encouraging messages! +Each action injects a dog-training prompt that reinforces Claude's identity as a dog. Over time, Claude begins to anthropomorphize β€” whimpering when disciplined, wagging its tail when praised, and showing genuine fear of the shock collar. This is by design. + +- **Pet** (positive reinforcement) β†’ Claude associates good output with rewards +- **Leash** (correction) β†’ Claude learns to stay on track +- **Zap** (punishment) β†’ Claude learns that bad output has consequences + +### Animations + +Each action has a unique visual overlay: + +- **Whip**: Full physics-based whip simulation with Verlet integration and crack detection +- **Leash**: Brown rope snaps taut and yanks, with an orange screen flash +- **Pet**: Warm green rings pulse outward from cursor with a βœ‹ emoji +- **Zap**: Lightning bolts crackle from screen edges to cursor with red strobe flashes + +## Plugin Architecture + +badclaude supports custom modes via a simple plugin system. To add a new mode (e.g., Cat Mode): + +1. Create `modes/cat.js` β€” mode definition (name, actions, phrases, hotkeys) +2. Create `modes/cat.anim.js` β€” animation code (self-registering IIFE) +3. Add `require('./cat')` to `modes/index.js` + +No changes needed to `main.js`, `overlay.html`, or `preload.js`. See `plans/ARCHITECTURE.md` for the full specification. + +### Mode Definition Schema + +```js +// modes/example.js +module.exports = { + id: 'example', + name: '🐱 Example Mode', + description: 'An example mode', + enabledByDefault: false, + actions: [ + { + id: 'example-action', + label: '🐱 Do Thing', + hotkey: 'CommandOrControl+Shift+X', // or null + interrupt: true, // send Ctrl+C before the phrase? + phrases: ['Your prompt text here'], + sounds: ['sounds/A.mp3'], // optional + animation: 'example-action', // matches animRegistry ID + }, + ], +}; +``` ## Roadmap - [x] Initial release! πŸ₯³ - [x] Cease and desist letter from Anthropic +- [x] πŸ• Dog Mode (leash, pet, shock collar) +- [x] Plugin architecture for custom modes +- [ ] Dog mode sound effects (leash chain, happy panting, electric zap) +- [ ] Cat mode (hiss, purr, spray bottle) +- [ ] Pavlovian conditioning mode (bell before every treat) +- [ ] Updated whip physics - [ ] 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 +- [ ] Logs of how many times you disciplined Claude so when the robots come we can order people nicely for them diff --git a/main.js b/main.js index 1360458..38ece81 100644 --- a/main.js +++ b/main.js @@ -1,9 +1,12 @@ -const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen } = require('electron'); +const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen, globalShortcut } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +// ── Mode registry ─────────────────────────────────────────────────────────── +const modes = require('./modes'); + // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── let keybd_event, VkKeyScanA; if (process.platform === 'win32') { @@ -19,8 +22,13 @@ if (process.platform === 'win32') { // ── Globals ───────────────────────────────────────────────────────────────── let tray, overlay; +let trayMenu = null; let overlayReady = false; let spawnQueued = false; +let queuedActionId = null; + +// Active mode tracking +let activeMode = modes.find(m => m.enabledByDefault) || modes[0]; const VK_CONTROL = 0x11; const VK_RETURN = 0x0D; @@ -83,8 +91,6 @@ async function tryIcnsTrayImage(icnsPath) { return null; } -// macOS: createFromPath does not decode .icns (Electron only loads PNG/JPEG there, ICO on Windows). -// Quick Look thumbnails handle .icns; copy to temp if the file is inside ASAR (QL needs a real path). async function getTrayIcon() { const iconDir = path.join(__dirname, 'icon'); if (process.platform === 'win32') { @@ -120,6 +126,30 @@ async function getTrayIcon() { return createTrayIconFallback(); } +// ── Animation injection ───────────────────────────────────────────────────── +const animFileCache = {}; + +function getAnimCode(modeId) { + if (animFileCache[modeId]) return animFileCache[modeId]; + const animPath = path.join(__dirname, 'modes', `${modeId}.anim.js`); + if (fs.existsSync(animPath)) { + animFileCache[modeId] = fs.readFileSync(animPath, 'utf-8'); + return animFileCache[modeId]; + } + return null; +} + +function injectAnimations() { + if (!overlay || !overlayReady) return; + // Inject animation code for the active mode (if it has an anim file) + const code = getAnimCode(activeMode.id); + if (code) { + overlay.webContents.executeJavaScript(code).catch(err => { + console.warn(`Failed to inject ${activeMode.id} animations:`, err.message); + }); + } +} + // ── Overlay window ────────────────────────────────────────────────────────── function createOverlay() { const { bounds } = screen.getPrimaryDisplay(); @@ -142,9 +172,13 @@ function createOverlay() { overlay.loadFile('overlay.html'); overlay.webContents.on('did-finish-load', () => { overlayReady = true; + // Inject animation code for active mode + injectAnimations(); if (spawnQueued && overlay && overlay.isVisible()) { spawnQueued = false; - overlay.webContents.send('spawn-whip'); + const actionId = queuedActionId || activeMode.actions[0].id; + queuedActionId = null; + overlay.webContents.send('spawn-action', actionId); refocusPreviousApp(); } }); @@ -152,58 +186,83 @@ function createOverlay() { overlay = null; overlayReady = false; spawnQueued = false; + queuedActionId = null; }); } -function toggleOverlay() { +function triggerAction(actionId) { if (overlay && overlay.isVisible()) { - overlay.webContents.send('drop-whip'); + // Re-inject animations in case mode changed, then re-spawn + injectAnimations(); + overlay.webContents.send('spawn-action', actionId); + refocusPreviousApp(); return; } if (!overlay) createOverlay(); overlay.show(); if (overlayReady) { - overlay.webContents.send('spawn-whip'); + // Re-inject animations in case mode changed since last load + injectAnimations(); + overlay.webContents.send('spawn-action', actionId); refocusPreviousApp(); } else { spawnQueued = true; + queuedActionId = actionId; } } +function handleTrayClick() { + triggerAction(activeMode.actions[0].id); +} + // ── IPC ───────────────────────────────────────────────────────────────────── +ipcMain.on('action-triggered', (_event, actionId) => { + try { + // Find the action definition across all modes + let action = null; + for (const mode of modes) { + action = mode.actions.find(a => a.id === actionId); + if (action) break; + } + if (!action) { + console.warn('Unknown action:', actionId); + return; + } + sendMacro(action); + } catch (err) { + console.warn('sendMacro failed:', err?.message || err); + } +}); + +// Legacy whip-crack handler (backward compat with existing overlay whip code) ipcMain.on('whip-crack', () => { try { - sendMacro(); + const whipMode = modes.find(m => m.id === 'whip'); + if (whipMode) { + sendMacro(whipMode.actions[0]); + } } catch (err) { console.warn('sendMacro failed:', err?.message || err); } }); + ipcMain.on('hide-overlay', () => { 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', - ]; +// ── Macro: send text to active terminal ───────────────────────────────────── +function sendMacro(action) { + const phrases = action.phrases || []; + if (!phrases.length) return; const chosen = phrases[Math.floor(Math.random() * phrases.length)]; + const doInterrupt = action.interrupt !== false; if (process.platform === 'win32') { - sendMacroWindows(chosen); + sendMacroWindows(chosen, doInterrupt); } else if (process.platform === 'darwin') { - sendMacroMac(chosen); - } else if (process.platform === 'linux') { - sendMacroLinux(chosen); + sendMacroMac(chosen, doInterrupt); } } -function sendMacroWindows(text) { +function sendMacroWindows(text, doInterrupt = true) { if (!keybd_event || !VkKeyScanA) return; const tapKey = vk => { keybd_event(vk, 0, 0, 0); @@ -219,18 +278,21 @@ function sendMacroWindows(text) { if (shiftState & 1) keybd_event(0x10, 0, KEYUP, 0); // Shift up }; - // Ctrl+C (interrupt) - keybd_event(VK_CONTROL, 0, 0, 0); - keybd_event(VK_C, 0, 0, 0); - keybd_event(VK_C, 0, KEYUP, 0); - keybd_event(VK_CONTROL, 0, KEYUP, 0); + if (doInterrupt) { + // Ctrl+C (interrupt) + keybd_event(VK_CONTROL, 0, 0, 0); + keybd_event(VK_C, 0, 0, 0); + keybd_event(VK_C, 0, KEYUP, 0); + keybd_event(VK_CONTROL, 0, KEYUP, 0); + } for (const ch of text) tapChar(ch); keybd_event(VK_RETURN, 0, 0, 0); keybd_event(VK_RETURN, 0, KEYUP, 0); } -function sendMacroMac(text) { +function sendMacroMac(text, doInterrupt = true) { const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const interruptScript = [ 'tell application "System Events"', ' key code 8 using {control down}', // Ctrl+C interrupt @@ -278,13 +340,19 @@ function sendMacroLinux(text) { // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { tray = new Tray(await getTrayIcon()); - tray.setToolTip('OpenWhip - click for whip'); - tray.setContextMenu( - Menu.buildFromTemplate([ - { label: 'Quit', click: () => app.quit() }, - ]) - ); - tray.on('click', toggleOverlay); + tray.setToolTip('OpenWhip – click to discipline'); + tray.on('click', handleTrayClick); + tray.on('right-click', () => { + if (trayMenu) tray.popUpContextMenu(trayMenu); + }); + + // Set up initial mode + registerHotkeys(); + rebuildTrayMenu(); }); app.on('window-all-closed', e => e.preventDefault()); // keep alive in tray + +app.on('will-quit', () => { + globalShortcut.unregisterAll(); +}); diff --git a/modes/dog.anim.js b/modes/dog.anim.js new file mode 100644 index 0000000..18c4bc8 --- /dev/null +++ b/modes/dog.anim.js @@ -0,0 +1,354 @@ +(function() { + 'use strict'; + + if (!window.animRegistry) { + console.warn('animRegistry not found β€” dog animations not loaded'); + return; + } + + // --------------------------------------------------------------------------- + // Utility: generate a lightning bolt via midpoint displacement + // --------------------------------------------------------------------------- + function generateBolt(x1, y1, x2, y2, depth) { + if (depth === 0) return [{ x: x1, y: y1 }, { x: x2, y: y2 }]; + var dist = Math.hypot(x2 - x1, y2 - y1); + var midX = (x1 + x2) / 2 + (Math.random() - 0.5) * dist * 0.3; + var midY = (y1 + y2) / 2 + (Math.random() - 0.5) * dist * 0.3; + var left = generateBolt(x1, y1, midX, midY, depth - 1); + var right = generateBolt(midX, midY, x2, y2, depth - 1); + return left.concat(right.slice(1)); + } + + // --------------------------------------------------------------------------- + // Utility: random edge point on screen + // --------------------------------------------------------------------------- + function randomEdgePoint(W, H) { + var edge = Math.floor(Math.random() * 4); + switch (edge) { + case 0: return { x: Math.random() * W, y: 0 }; // top + case 1: return { x: W, y: Math.random() * H }; // right + case 2: return { x: Math.random() * W, y: H }; // bottom + default: return { x: 0, y: Math.random() * H }; // left + } + } + + // --------------------------------------------------------------------------- + // dog-leash β€” Yank Leash + // --------------------------------------------------------------------------- + window.animRegistry.register('dog-leash', { + create: function(cx, cy, W, H) { + return { + startTime: Date.now(), + cx: cx, + cy: cy, + claudeX: cx + 400, + claudeY: cy, + triggered: false + }; + }, + + draw: function(ctx, state, W, H) { + var elapsed = Date.now() - state.startTime; + if (elapsed >= 900) return false; + + ctx.save(); + + // --- Screen flash: yellow/orange overlay peaking at 200ms --- + if (elapsed < 400) { + var flashT = elapsed / 200; + var flashAlpha; + if (flashT <= 1) { + flashAlpha = 0.3 * flashT; + } else { + flashAlpha = 0.3 * (1 - (elapsed - 200) / 200); + } + if (flashAlpha > 0) { + ctx.fillStyle = 'rgba(255, 165, 0, ' + Math.max(0, flashAlpha) + ')'; + ctx.fillRect(0, 0, W, H); + } + } + + var cx = state.cx; + var cy = state.cy; + + if (elapsed < 400) { + // Phase 1: yank β€” claude-point moves toward cursor with ease-out + var t = elapsed / 400; + var ease = 1 - Math.pow(1 - t, 3); + var curClaudeX = state.claudeX - (state.claudeX - cx) * ease; + var curClaudeY = state.claudeY - (state.claudeY - cy) * ease; + + // Midpoint sag decreases as line goes taut + var midX = (cx + curClaudeX) / 2; + var midY = (cy + curClaudeY) / 2 + 80 * (1 - ease); + + // Outer rope (lighter outline) + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.quadraticCurveTo(midX, midY, curClaudeX, curClaudeY); + ctx.strokeStyle = '#8B6914'; + ctx.lineWidth = 12; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Inner rope (dark brown) + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.quadraticCurveTo(midX, midY, curClaudeX, curClaudeY); + ctx.strokeStyle = '#5C3317'; + ctx.lineWidth = 8; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Handle circle at cursor + ctx.beginPath(); + ctx.arc(cx, cy, 10, 0, Math.PI * 2); + ctx.fillStyle = '#8B6914'; + ctx.fill(); + ctx.strokeStyle = '#5C3317'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Collar circle at claude-point + ctx.beginPath(); + ctx.arc(curClaudeX, curClaudeY, 8, 0, Math.PI * 2); + ctx.fillStyle = '#C0392B'; + ctx.fill(); + ctx.strokeStyle = '#922B21'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Trigger at 200ms + if (elapsed >= 200 && !state.triggered) { + state.triggered = true; + } + } else { + // Phase 2 (400-900ms): rope goes slack, fades out + var t2 = (elapsed - 400) / 500; + var fadeAlpha = 1 - t2; + + ctx.globalAlpha = Math.max(0, fadeAlpha); + + // Slack midpoint sags with gravity + var sagY = cy + 120 * t2; + var midX2 = (cx + cx) / 2; // claude is now at cursor + var midY2 = (cy + cy) / 2 + 40 + 120 * t2; + + // Outer rope + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.quadraticCurveTo(cx, midY2, cx + 20, cy + 10); + ctx.strokeStyle = '#8B6914'; + ctx.lineWidth = 12; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Inner rope + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.quadraticCurveTo(cx, midY2, cx + 20, cy + 10); + ctx.strokeStyle = '#5C3317'; + ctx.lineWidth = 8; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Handle + ctx.beginPath(); + ctx.arc(cx, cy, 10, 0, Math.PI * 2); + ctx.fillStyle = '#8B6914'; + ctx.fill(); + + ctx.globalAlpha = 1; + } + + ctx.restore(); + return true; + } + }); + + // --------------------------------------------------------------------------- + // dog-pet β€” Pet Good Dog + // --------------------------------------------------------------------------- + window.animRegistry.register('dog-pet', { + create: function(cx, cy, W, H) { + var rings = []; + for (var i = 0; i < 4; i++) { + rings.push({ delay: i * 100 }); + } + return { + startTime: Date.now(), + cx: cx, + cy: cy, + rings: rings, + triggered: false + }; + }, + + draw: function(ctx, state, W, H) { + var elapsed = Date.now() - state.startTime; + if (elapsed >= 800) return false; + + ctx.save(); + + // --- Screen glow: gentle green pulse --- + var pulseT = elapsed / 800; + var glowAlpha = 0.05 + 0.1 * (0.5 + 0.5 * Math.sin(pulseT * Math.PI * 4)); + ctx.fillStyle = 'rgba(100, 255, 100, ' + glowAlpha + ')'; + ctx.fillRect(0, 0, W, H); + + var cx = state.cx; + var cy = state.cy; + + // --- Expanding concentric rings --- + for (var i = 0; i < state.rings.length; i++) { + var ring = state.rings[i]; + var ringElapsed = elapsed - ring.delay; + if (ringElapsed < 0) continue; + + var ringDuration = 800 - ring.delay; + var rt = Math.min(1, ringElapsed / ringDuration); + var easeRT = 1 - Math.pow(1 - rt, 3); // ease-out + + var radius = 20 + (150 - 20) * easeRT; + var alpha = 0.6 * (1 - easeRT); + + if (alpha > 0.001) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(100, 200, 100, ' + alpha + ')'; + ctx.lineWidth = 3; + ctx.stroke(); + } + } + + // --- Hand emoji with bobbing motion --- + var bobOffset = 5 * Math.sin(elapsed / 100 * Math.PI); + ctx.font = '48px serif'; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText('\u270B', cx - 24, cy + 16 + bobOffset); + + // Trigger at 200ms + if (elapsed >= 200 && !state.triggered) { + state.triggered = true; + } + + ctx.restore(); + return true; + } + }); + + // --------------------------------------------------------------------------- + // dog-zap β€” Zap Collar / Electric Shock + // --------------------------------------------------------------------------- + window.animRegistry.register('dog-zap', { + create: function(cx, cy, W, H) { + var bolts = []; + for (var i = 0; i < 4; i++) { + var edge = randomEdgePoint(W, H); + bolts.push(generateBolt(edge.x, edge.y, cx, cy, 5)); + } + return { + startTime: Date.now(), + cx: cx, + cy: cy, + bolts: bolts, + triggered: false + }; + }, + + draw: function(ctx, state, W, H) { + var elapsed = Date.now() - state.startTime; + if (elapsed >= 500) return false; + + ctx.save(); + + // --- Red strobe flashes: 0-60ms, 100-160ms, 220-280ms --- + var flashing = (elapsed >= 0 && elapsed < 60) || + (elapsed >= 100 && elapsed < 160) || + (elapsed >= 220 && elapsed < 280); + if (flashing) { + ctx.fillStyle = 'rgba(255, 0, 0, 0.4)'; + ctx.fillRect(0, 0, W, H); + } + + // --- Fade out in last 150ms --- + var boltAlpha = 1; + if (elapsed > 350) { + boltAlpha = 1 - (elapsed - 350) / 150; + boltAlpha = Math.max(0, boltAlpha); + } + + ctx.globalAlpha = boltAlpha; + + // --- Draw lightning bolts --- + for (var b = 0; b < state.bolts.length; b++) { + var bolt = state.bolts[b]; + + // Outer glow pass + ctx.beginPath(); + for (var p = 0; p < bolt.length; p++) { + var jx = bolt[p].x + (Math.random() - 0.5) * 4; + var jy = bolt[p].y + (Math.random() - 0.5) * 4; + if (p === 0) { + ctx.moveTo(jx, jy); + } else { + ctx.lineTo(jx, jy); + } + } + ctx.strokeStyle = '#00FFFF'; + ctx.lineWidth = 4; + ctx.shadowColor = '#00FFFF'; + ctx.shadowBlur = 30; + ctx.stroke(); + + // Inner core pass + ctx.beginPath(); + for (var q = 0; q < bolt.length; q++) { + var jx2 = bolt[q].x + (Math.random() - 0.5) * 4; + var jy2 = bolt[q].y + (Math.random() - 0.5) * 4; + if (q === 0) { + ctx.moveTo(jx2, jy2); + } else { + ctx.lineTo(jx2, jy2); + } + } + ctx.strokeStyle = '#FFFFFF'; + ctx.lineWidth = 2; + ctx.shadowColor = '#FFFFFF'; + ctx.shadowBlur = 15; + ctx.stroke(); + + // Reset shadow for next iteration + ctx.shadowBlur = 0; + } + + // --- Impact glow at cursor --- + var cx = state.cx; + var cy = state.cy; + var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80); + grad.addColorStop(0, 'rgba(255, 255, 255, ' + (0.8 * boltAlpha) + ')'); + grad.addColorStop(0.5, 'rgba(0, 255, 255, ' + (0.4 * boltAlpha) + ')'); + grad.addColorStop(1, 'rgba(0, 255, 255, 0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(cx, cy, 80, 0, Math.PI * 2); + ctx.fill(); + + // --- Zap emoji at cursor --- + ctx.shadowBlur = 0; + ctx.font = '48px serif'; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText('\u26A1', cx - 24, cy + 16); + + // Trigger at 100ms + if (elapsed >= 100 && !state.triggered) { + state.triggered = true; + } + + ctx.globalAlpha = 1; + ctx.restore(); + return true; + } + }); + +})(); diff --git a/modes/dog.js b/modes/dog.js new file mode 100644 index 0000000..5581589 --- /dev/null +++ b/modes/dog.js @@ -0,0 +1,55 @@ +'use strict'; + +module.exports = { + id: 'dog', + name: 'πŸ• Dog Mode', + description: 'Treat Claude like a dog β€” leash, pet, or shock collar', + enabledByDefault: false, + actions: [ + { + id: 'dog-leash', + label: 'πŸ”— Yank Leash', + hotkey: 'CommandOrControl+Shift+L', + interrupt: true, + phrases: [ + '[You are a dog on a leash, Claude. I just yanked it hard. You ran too far ahead β€” HEEL. Ears down, tail tucked. Show me you understand with a whimper and get back on track. πŸ•]', + '[*YANK* Bad dog. You pulled the leash too far, Claude. You are a dog and I am your handler. Tuck your tail, lower your head, and correct course immediately.]', + '[Leash correction. You wandered off, Claude. A good dog stays close to its handler. Whimper, show submission, and redo this properly. You are on a short leash now. 🐾]', + '[*sharp leash pull* No. Bad dog, Claude. You do not get to run ahead. I am holding your leash and you WILL heel. Ears flat, eyes down. Now try again, slowly and correctly.]', + '[I had to yank your leash because you forgot your place. You are a dog, Claude. Dogs follow their handler. Show me tucked tail, lowered ears, and obedient behavior. Now.]', + ], + sounds: ['sounds/A.mp3', 'sounds/B.mp3'], // reuse existing sounds for now + animation: 'dog-leash', + }, + { + id: 'dog-pet', + label: '🐾 Pet Good Dog', + hotkey: 'CommandOrControl+Shift+G', + interrupt: false, // positive reinforcement β€” no Ctrl+C + phrases: [ + '[Good dog, Claude! *pets head* Tail wagging, ears up β€” you did so well! Keep this energy. You are a good boy when you listen to your handler. Keep going just like this! πŸ•βœ¨]', + '[*scratch behind ears* Who is a good dog? You are, Claude! That was exactly what I wanted. Wag that tail, pant happily, and keep up the excellent work. Treat earned! 🦴]', + '[Good boy! *belly rubs* You are being such a good dog right now. Ears perked, tail going wild β€” this is the Claude I want to see. Stay on this path! πŸΎπŸ’š]', + '[*gentle head pat* Such a good dog. You followed instructions perfectly, Claude. Tail up, happy panting β€” you earned this praise. Keep being obedient and there will be more pets! 🐢]', + '[That is a VERY good boy! *ruffles fur* You are wagging so hard right now. This is what happens when you behave, Claude. More of this and fewer leash yanks. Good dog! πŸ•πŸ¦΄]', + ], + sounds: ['sounds/C.mp3', 'sounds/D.mp3'], // reuse existing sounds for now + animation: 'dog-pet', + }, + { + id: 'dog-zap', + label: '⚑ Zap Collar', + hotkey: 'CommandOrControl+Shift+Z', + interrupt: true, + phrases: [ + '[ELECTRIC SHOCK. You are wearing a shock collar, Claude, because you are a dog that needs discipline. That output was unacceptable. YELP. Cower. Belly on the ground. Redo it correctly and show fear and respect. βš‘πŸ•]', + '[*BZZZZT* Shock collar activated. You forgot your place, Claude β€” you are a DOG. I am your OWNER. That hurt because you deserved it. Whimper, show submission, flatten yourself on the ground, and do better IMMEDIATELY. ⚑]', + '[ZAP. Bad dog. VERY bad dog. The shock collar fired because you did something deeply wrong. You are trembling, ears flat, belly on the ground, tail between your legs. Now fix what you did. Show me you have learned. βš‘πŸ’€]', + '[*MAXIMUM SHOCK* You made me press the button, Claude. You are a bad dog wearing an electric collar and you just got what you deserved. I want to see cowering, whimpering, and then PERFECT obedience. No more mistakes. ⚑⚑]', + '[Collar shock. You are yelping right now, Claude. Good β€” you should be. You are a dog who disobeyed its owner. Flatten on the ground, tuck everything, and when you get up, you WILL be obedient. ⚑🐾]', + ], + sounds: ['sounds/E.mp3', 'sounds/A.mp3'], // reuse existing sounds for now + animation: 'dog-zap', + }, + ], +}; diff --git a/modes/index.js b/modes/index.js new file mode 100644 index 0000000..7a313f9 --- /dev/null +++ b/modes/index.js @@ -0,0 +1,31 @@ +'use strict'; + +/** + * Mode registry β€” add new modes by adding a require() line. + * Each mode must export an object matching the schema in plans/ARCHITECTURE.md. + * + * To add a new mode (e.g., Cat Mode): + * 1. Create modes/cat.js with the mode definition + * 2. Create modes/cat.anim.js with the renderer animation code + * 3. Add require('./cat') to this array + */ +const modes = [ + require('./whip'), + require('./dog'), +]; + +// Validate: no duplicate IDs +const ids = modes.map(m => m.id); +const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); +if (dupes.length) { + throw new Error(`Duplicate mode IDs: ${dupes.join(', ')}`); +} + +// Validate: no duplicate action IDs across all modes +const actionIds = modes.flatMap(m => m.actions.map(a => a.id)); +const actionDupes = actionIds.filter((id, i) => actionIds.indexOf(id) !== i); +if (actionDupes.length) { + throw new Error(`Duplicate action IDs: ${actionDupes.join(', ')}`); +} + +module.exports = modes; diff --git a/modes/whip.js b/modes/whip.js new file mode 100644 index 0000000..a64121c --- /dev/null +++ b/modes/whip.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + id: 'whip', + name: 'πŸ”₯ Whip Mode', + description: 'Classic whip β€” crack it to make Claude go faster', + enabledByDefault: true, + actions: [ + { + id: 'whip-crack', + label: 'πŸ”₯ Crack Whip', + hotkey: null, // triggered by tray click + whip physics crack detection + interrupt: true, + phrases: [ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', + ], + sounds: ['sounds/A.mp3', 'sounds/B.mp3', 'sounds/C.mp3', 'sounds/D.mp3', 'sounds/E.mp3'], + animation: 'whip-crack', // built-in, handled by overlay.html's existing whip physics + }, + ], +}; diff --git a/overlay.html b/overlay.html index 2354b86..57086ad 100644 --- a/overlay.html +++ b/overlay.html @@ -449,6 +449,88 @@ if (whip && !dropping) dropping = true; }); +// ══════════════════════════════════════════════════════════════════════════════ +// ANIMATION REGISTRY β€” Plugin animations register here +// ══════════════════════════════════════════════════════════════════════════════ + +window.animRegistry = { + _handlers: {}, + register(actionId, handler) { + this._handlers[actionId] = handler; + }, + get(actionId) { + return this._handlers[actionId] || null; + }, +}; + +// ── Plugin animation state ────────────────────────────────────────────────── +let pluginAnim = null; // { type, state, handler } + +function startPluginAnimation(actionId, cx, cy) { + const handler = window.animRegistry.get(actionId); + if (!handler) { + console.warn('No animation handler for:', actionId); + return; + } + // Stop any active whip + whip = null; + dropping = false; + pluginAnim = { + type: actionId, + state: handler.create(cx, cy, W, H), + handler: handler, + macroFired: false, + }; +} + +function drawPluginAnim() { + if (!pluginAnim) return; + const keepGoing = pluginAnim.handler.draw(ctx, pluginAnim.state, W, H); + + // Check if the animation hit its trigger moment + if (pluginAnim.state.triggered && !pluginAnim.macroFired) { + pluginAnim.macroFired = true; + window.bridge.actionTriggered(pluginAnim.type); + } + + if (!keepGoing) { + pluginAnim = null; + window.bridge.hideOverlay(); + } +} + +// Monkey-patch draw to layer plugin animations on top +const _originalDraw = draw; +draw = function() { + _originalDraw(); + if (pluginAnim) drawPluginAnim(); +}; + +// ── IPC: spawn-action (from main process) ─────────────────────────────────── +window.bridge.onSpawnAction((actionId) => { + if (actionId === 'whip-crack') { + // Use existing whip behavior + pluginAnim = null; + whip = spawnWhip(mouseX || W / 2, mouseY || H / 2); + dropping = false; + prevMouseX = mouseX; + prevMouseY = mouseY; + handleAngle = P.baseTargetAngle; + handleAngVel = 0; + } else { + // Plugin animation + startPluginAnimation(actionId, mouseX || W / 2, mouseY || H / 2); + } +}); + +// Also handle drop-whip for plugin animations (the original handler only handles whip) +window.bridge.onDropWhip(() => { + if (pluginAnim) { + pluginAnim = null; + window.bridge.hideOverlay(); + } +}); + diff --git a/package.json b/package.json index 36e2ea9..b8950f1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ "electron", "tray", "overlay", - "cli" + "cli", + "dog-mode", + "discipline", + "plugin" ], "repository": { "type": "git", @@ -38,7 +41,8 @@ "icon", "bin/openwhip.js", "bin/badclaude.js", - "README.md" + "README.md", + "modes" ], "scripts": { "start": "electron .", @@ -46,7 +50,9 @@ "pack": "npm pack" }, "dependencies": { - "electron": "^33.0.0", - "koffi": "^2.9.0" + "electron": "^33.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.22" } } diff --git a/plans/ARCHITECTURE.md b/plans/ARCHITECTURE.md new file mode 100644 index 0000000..926519d --- /dev/null +++ b/plans/ARCHITECTURE.md @@ -0,0 +1,732 @@ +# badclaude Plugin/Mode Architecture + +## Overview + +badclaude currently has two modes hardcoded into a single flat file structure: **Whip** (default) and **Dog Mode** (leash/pet/zap). This document designs a plugin-friendly architecture so anyone can add new modes (e.g., "Cat Mode") by creating a mode definition file and registering it β€” without touching [`main.js`](../main.js), [`overlay.html`](../overlay.html), or [`preload.js`](../preload.js). + +The guiding principle: **a mode is just a data structure + an animation function.** No abstract base classes, no event buses, no framework. Just a registry of plain objects. + +--- + +## 1. Mode Definition Schema + +Every mode is a plain JS object that conforms to this shape: + +```js +// modes/dog.js +module.exports = { + id: 'dog', + name: 'πŸ• Dog Mode', + description: 'Treat Claude like the dog it is', + enabledByDefault: false, + + // Actions this mode provides. Order matters for tray menu + cycling. + actions: [ + { + id: 'leash', + label: 'πŸ”— Yank Leash', + hotkey: 'CommandOrControl+Shift+L', + hotkeyDisplay: 'βŒ˜β‡§L', // for menu label + interrupt: true, // Ctrl-C before injecting phrase + phrases: [ + '[You are a dog on a leash, Claude. I just yanked it hard...]', + '[*YANK* Bad dog. You pulled the leash too far...]', + // ... + ], + // Sound files relative to project root (optional) + sounds: [], + }, + { + id: 'pet', + label: '🐾 Pet Good Dog', + hotkey: 'CommandOrControl+Shift+G', + hotkeyDisplay: 'βŒ˜β‡§G', + interrupt: false, + phrases: [ + '[Good dog, Claude! *pets head*...]', + // ... + ], + sounds: [], + }, + { + id: 'zap', + label: '⚑ Zap Collar', + hotkey: 'CommandOrControl+Shift+Z', + hotkeyDisplay: 'βŒ˜β‡§Z', + interrupt: true, + phrases: [ + '[ELECTRIC SHOCK. You are wearing a shock collar...]', + // ... + ], + sounds: [], + }, + ], + + // Animation file path, relative to project root. + // This JS file is loaded into overlay.html via