Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 85 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
- [ ] Logs of how many times you disciplined Claude so when the robots come we can order people nicely for them
144 changes: 106 additions & 38 deletions main.js
Original file line number Diff line number Diff line change
@@ -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') {
Expand All @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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();
Expand All @@ -142,68 +172,97 @@ 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();
}
});
overlay.on('closed', () => {
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);
Expand All @@ -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
Expand Down Expand Up @@ -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();
});
Loading