mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
65207a5200
Adds a browser game (Dino Runner, Snake, Flappy Bird) that launches automatically when Claude enters Plan Mode. Users can play while Claude thinks, with a live activity panel showing tool calls in real time. - EnterPlanMode / /plan → starts Node.js server on port 3456, opens browser - Read/Bash/Edit/etc → streams tool activity to the activity panel - Write to ~/.claude/plans/ → also triggers the game (for /plan command) - AskUserQuestion → pauses the game until user responds - ExitPlanMode → shows "Claude Termino!" overlay with confetti Features: 3 games with high score persistence, signed score sharing URLs, pause overlay when Claude needs input, activity feed with tool icons. Requires Node.js and curl. macOS only (uses 'open' command). Install globally in ~/.claude/settings.json to work across all projects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
8.5 KiB
JavaScript
226 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const PORT = 3456;
|
|
const PID_FILE = path.join(__dirname, 'server.pid');
|
|
const SECRET_FILE = path.join(__dirname, '.secret');
|
|
const sseClients = new Set();
|
|
|
|
function getSecret() {
|
|
try {
|
|
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
|
} catch {
|
|
const secret = crypto.randomBytes(32).toString('hex');
|
|
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
|
return secret;
|
|
}
|
|
}
|
|
|
|
const hmacSecret = getSecret();
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
return res.end();
|
|
}
|
|
|
|
// Serve the game
|
|
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
const htmlPath = path.join(__dirname, 'index.html');
|
|
fs.readFile(htmlPath, (err, data) => {
|
|
if (err) { res.writeHead(500); return res.end('Error loading game'); }
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(data);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// SSE endpoint
|
|
if (req.method === 'GET' && req.url === '/events') {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
});
|
|
res.write('data: connected\n\n');
|
|
sseClients.add(res);
|
|
req.on('close', () => sseClients.delete(res));
|
|
return;
|
|
}
|
|
|
|
// Trigger announcement
|
|
if (req.method === 'POST' && req.url === '/done') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
|
req.on('end', () => {
|
|
const message = body || 'Claude Termino';
|
|
for (const client of sseClients) {
|
|
client.write(`event: done\ndata: ${message}\n\n`);
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, clients: sseClients.size }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Activity feed from hooks
|
|
if (req.method === 'POST' && req.url === '/activity') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
|
req.on('end', () => {
|
|
for (const client of sseClients) {
|
|
client.write(`event: activity\ndata: ${body}\n\n`);
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Pause - Claude needs user input
|
|
if (req.method === 'POST' && req.url === '/pause') {
|
|
for (const client of sseClients) {
|
|
client.write(`event: pause\ndata: Claude needs you\n\n`);
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
return res.end(JSON.stringify({ ok: true }));
|
|
}
|
|
|
|
// Status - check if plan mode is active
|
|
if (req.method === 'GET' && req.url === '/status') {
|
|
const planActive = fs.existsSync(path.join(__dirname, '.plan-active'));
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
return res.end(JSON.stringify({ planActive }));
|
|
}
|
|
|
|
// Health check
|
|
if (req.method === 'GET' && req.url === '/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
return res.end(JSON.stringify({ status: 'ok', clients: sseClients.size }));
|
|
}
|
|
|
|
// Share scores - generate signed URL
|
|
if (req.method === 'POST' && req.url === '/share') {
|
|
let body = '';
|
|
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
|
req.on('end', () => {
|
|
try {
|
|
const { dino, snake, flappy } = JSON.parse(body);
|
|
const payload = { d: dino || 0, s: snake || 0, f: flappy || 0, t: Date.now() };
|
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const sig = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
|
|
const token = `${payloadB64}.${sig}`;
|
|
const url = `http://localhost:${PORT}/v/${token}`;
|
|
const text = [
|
|
'Plan Mode Game Scores:',
|
|
`\u{1F995} Dino Runner: ${payload.d} pts`,
|
|
`\u{1F40D} Snake: ${payload.s} pts`,
|
|
`\u{1F426} Flappy Bird: ${payload.f} pts`,
|
|
'',
|
|
`Verify → ${url}`
|
|
].join('\n');
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ url, text }));
|
|
} catch {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid request' }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Verify scores - render verification page
|
|
if (req.method === 'GET' && req.url.startsWith('/v/')) {
|
|
const token = req.url.slice(3);
|
|
const dotIdx = token.lastIndexOf('.');
|
|
let valid = false, payload = null;
|
|
|
|
if (dotIdx > 0) {
|
|
const payloadB64 = token.slice(0, dotIdx);
|
|
const sig = token.slice(dotIdx + 1);
|
|
const expected = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
|
|
valid = sig === expected;
|
|
try { payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { valid = false; }
|
|
}
|
|
|
|
const ts = payload && payload.t ? new Date(payload.t).toLocaleString() : 'Unknown';
|
|
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>${valid ? 'Verified' : 'Invalid'} - Plan Mode Game</title><style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:#1a1a2e;color:#e5e7eb;font-family:'Courier New',monospace;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
|
.card{text-align:center;max-width:420px;padding:40px;background:#12121f;border:1px solid #2d2d4e;border-radius:16px}
|
|
.icon{width:100px;height:100px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 24px;font-size:48px}
|
|
.icon.valid{background:linear-gradient(135deg,#059669,#10b981);box-shadow:0 0 40px rgba(16,185,129,0.3)}
|
|
.icon.invalid{background:linear-gradient(135deg,#dc2626,#ef4444);box-shadow:0 0 40px rgba(239,68,68,0.3)}
|
|
h1{font-size:28px;margin-bottom:8px;letter-spacing:1px}
|
|
h1.valid{color:#10b981}
|
|
h1.invalid{color:#ef4444}
|
|
.subtitle{color:#6b7280;font-size:13px;margin-bottom:28px}
|
|
table{width:100%;border-collapse:collapse;margin-bottom:20px}
|
|
td{padding:10px 16px;border-bottom:1px solid #1e1e35;font-size:14px}
|
|
tr:last-child td{border-bottom:none}
|
|
.game{color:#d1d5db;text-align:left}
|
|
.pts{color:#a78bfa;font-weight:700;text-align:right;font-variant-numeric:tabular-nums}
|
|
.meta{color:#4b5563;font-size:11px;letter-spacing:0.5px}
|
|
.play-link{display:inline-block;margin-top:20px;color:#a78bfa;text-decoration:none;font-size:13px;letter-spacing:1px;padding:10px 24px;border:1px solid #7c3aed;border-radius:8px;transition:all 0.2s}
|
|
.play-link:hover{background:#7c3aed;color:#fff}
|
|
</style></head><body><div class="card">
|
|
<div class="icon ${valid ? 'valid' : 'invalid'}">${valid ? '✓' : '✗'}</div>
|
|
<h1 class="${valid ? 'valid' : 'invalid'}">${valid ? 'VERIFIED' : 'INVALID'}</h1>
|
|
<p class="subtitle">${valid ? 'These scores are authentic' : 'This score link has been tampered with'}</p>
|
|
${valid && payload ? `<table>
|
|
<tr><td class="game">\u{1F995} Dino Runner</td><td class="pts">${payload.d} pts</td></tr>
|
|
<tr><td class="game">\u{1F40D} Snake</td><td class="pts">${payload.s} pts</td></tr>
|
|
<tr><td class="game">\u{1F426} Flappy Bird</td><td class="pts">${payload.f} pts</td></tr>
|
|
</table>
|
|
<p class="meta">Shared on ${ts}</p>` : ''}
|
|
<a class="play-link" href="/">Play Plan Mode Game</a>
|
|
</div></body></html>`;
|
|
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
res.end(html);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end('Not found');
|
|
});
|
|
|
|
// Handle port conflicts
|
|
server.on('error', (err) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
console.error(`Port ${PORT} already in use. Exiting.`);
|
|
process.exit(1);
|
|
}
|
|
throw err;
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
console.log(`Plan Mode Game server running on http://localhost:${PORT}`);
|
|
});
|
|
|
|
// SSE heartbeat - keeps connections alive and cleans dead clients
|
|
setInterval(() => {
|
|
for (const client of sseClients) {
|
|
try { client.write(': heartbeat\n\n'); }
|
|
catch { sseClients.delete(client); }
|
|
}
|
|
}, 30000);
|
|
|
|
// Cleanup on exit
|
|
process.on('SIGTERM', cleanup);
|
|
process.on('SIGINT', cleanup);
|
|
|
|
function cleanup() {
|
|
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
server.close();
|
|
process.exit(0);
|
|
}
|