42861ea7fa
Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
259 lines
8.1 KiB
JavaScript
259 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* MVP Test 1: Hook Delivery
|
|
*
|
|
* Validates that Claude Code HTTP hooks reach our server with correct data.
|
|
*
|
|
* Steps:
|
|
* 1. Start Express server on port 3457 (avoid conflict with main server)
|
|
* 2. Temporarily add hooks to ~/.claude/settings.local.json
|
|
* 3. Run: claude -p "read the file package.json" --verbose --output-format json
|
|
* 4. Collect all hook POSTs received
|
|
* 5. Assert: PreToolUse, PostToolUse, Stop received with correct data
|
|
* 6. Restore settings
|
|
*/
|
|
|
|
import express from 'express';
|
|
import { execSync, spawn } from 'child_process';
|
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
const PORT = 3457;
|
|
const SETTINGS_PATH = join(process.env.HOME, '.claude', 'settings.local.json');
|
|
const PROJECT_DIR = join(import.meta.dirname, '..', '..');
|
|
const TIMEOUT_MS = 60000;
|
|
|
|
// Event log
|
|
const events = [];
|
|
const startTime = Date.now();
|
|
|
|
function log(source, type, data = {}) {
|
|
const elapsed = Date.now() - startTime;
|
|
const entry = { elapsed, source, type, ...data };
|
|
events.push(entry);
|
|
console.log(` T+${String(elapsed).padStart(6)}ms ${source.padEnd(8)} ${type} ${JSON.stringify(data).substring(0, 120)}`);
|
|
}
|
|
|
|
async function main() {
|
|
console.log('\n=== MVP Test 1: Hook Delivery ===\n');
|
|
|
|
// Backup existing settings
|
|
let settingsBackup = null;
|
|
if (existsSync(SETTINGS_PATH)) {
|
|
settingsBackup = readFileSync(SETTINGS_PATH, 'utf-8');
|
|
console.log('Backed up existing settings.local.json');
|
|
}
|
|
|
|
// Write hook configuration
|
|
const hookConfig = {
|
|
hooks: {
|
|
PreToolUse: [{
|
|
matcher: '*',
|
|
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/pre-tool-use`, timeout: 10 }]
|
|
}],
|
|
PostToolUse: [{
|
|
matcher: '*',
|
|
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/post-tool-use`, timeout: 10 }]
|
|
}],
|
|
Stop: [{
|
|
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/stop`, timeout: 10 }]
|
|
}]
|
|
}
|
|
};
|
|
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify(hookConfig, null, 2));
|
|
console.log(`Wrote hook config to ${SETTINGS_PATH}`);
|
|
|
|
// Start Express server
|
|
const app = express();
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
app.post('/hooks/pre-tool-use', (req, res) => {
|
|
log('HOOK', 'PreToolUse', {
|
|
tool_name: req.body.tool_name,
|
|
session_id: req.body.session_id?.substring(0, 8),
|
|
has_tool_input: !!req.body.tool_input,
|
|
has_tool_use_id: !!req.body.tool_use_id
|
|
});
|
|
res.json({});
|
|
});
|
|
|
|
app.post('/hooks/post-tool-use', (req, res) => {
|
|
log('HOOK', 'PostToolUse', {
|
|
tool_name: req.body.tool_name,
|
|
session_id: req.body.session_id?.substring(0, 8),
|
|
has_tool_response: !!req.body.tool_response
|
|
});
|
|
res.json({});
|
|
});
|
|
|
|
app.post('/hooks/stop', (req, res) => {
|
|
log('HOOK', 'Stop', {
|
|
session_id: req.body.session_id?.substring(0, 8),
|
|
});
|
|
res.json({});
|
|
});
|
|
|
|
const server = await new Promise((resolve) => {
|
|
const s = app.listen(PORT, () => {
|
|
console.log(`Hook server listening on port ${PORT}`);
|
|
resolve(s);
|
|
});
|
|
});
|
|
|
|
try {
|
|
// Run Claude CLI with a prompt that triggers tool use
|
|
console.log('\nRunning: claude -p "read the file package.json and tell me the project name" ...\n');
|
|
console.log('--- Event Timeline ---');
|
|
|
|
const claudeProcess = spawn('claude', [
|
|
'-p', 'Read the file package.json in the current directory and tell me what the "name" field is. Be brief.',
|
|
'--output-format', 'json',
|
|
'--dangerously-skip-permissions',
|
|
'--no-session-persistence'
|
|
], {
|
|
cwd: PROJECT_DIR,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
shell: true
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
claudeProcess.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
claudeProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
|
|
// Wait for process to finish or timeout
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
claudeProcess.kill();
|
|
reject(new Error('Claude process timed out'));
|
|
}, TIMEOUT_MS);
|
|
|
|
claudeProcess.on('close', (code) => {
|
|
clearTimeout(timeout);
|
|
log('CLAUDE', 'exit', { code });
|
|
resolve();
|
|
});
|
|
|
|
claudeProcess.on('error', (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
// Parse Claude output
|
|
try {
|
|
const result = JSON.parse(stdout);
|
|
log('RESULT', 'response', {
|
|
session_id: result.session_id?.substring(0, 8),
|
|
result_length: result.result?.length,
|
|
});
|
|
} catch {
|
|
log('RESULT', 'raw', { length: stdout.length, preview: stdout.substring(0, 100) });
|
|
}
|
|
|
|
// Wait a moment for any trailing hooks
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
|
|
// Analyze results
|
|
console.log('\n--- Results ---\n');
|
|
|
|
const preToolUseEvents = events.filter(e => e.type === 'PreToolUse');
|
|
const postToolUseEvents = events.filter(e => e.type === 'PostToolUse');
|
|
const stopEvents = events.filter(e => e.type === 'Stop');
|
|
|
|
const checks = [
|
|
{
|
|
name: 'PreToolUse hook received',
|
|
pass: preToolUseEvents.length > 0,
|
|
detail: `${preToolUseEvents.length} event(s): ${preToolUseEvents.map(e => e.tool_name).join(', ')}`
|
|
},
|
|
{
|
|
name: 'PreToolUse has tool_name',
|
|
pass: preToolUseEvents.every(e => !!e.tool_name),
|
|
detail: preToolUseEvents.map(e => e.tool_name).join(', ')
|
|
},
|
|
{
|
|
name: 'PreToolUse has session_id',
|
|
pass: preToolUseEvents.every(e => !!e.session_id),
|
|
detail: preToolUseEvents[0]?.session_id || 'N/A'
|
|
},
|
|
{
|
|
name: 'PreToolUse has tool_input',
|
|
pass: preToolUseEvents.every(e => e.has_tool_input),
|
|
detail: ''
|
|
},
|
|
{
|
|
name: 'PostToolUse hook received',
|
|
pass: postToolUseEvents.length > 0,
|
|
detail: `${postToolUseEvents.length} event(s): ${postToolUseEvents.map(e => e.tool_name).join(', ')}`
|
|
},
|
|
{
|
|
name: 'PostToolUse has tool_response',
|
|
pass: postToolUseEvents.every(e => e.has_tool_response),
|
|
detail: ''
|
|
},
|
|
{
|
|
name: 'Stop hook received',
|
|
pass: stopEvents.length > 0,
|
|
detail: `${stopEvents.length} event(s)`
|
|
},
|
|
{
|
|
name: 'PreToolUse before PostToolUse',
|
|
pass: preToolUseEvents.length > 0 && postToolUseEvents.length > 0 &&
|
|
preToolUseEvents[0].elapsed < postToolUseEvents[0].elapsed,
|
|
detail: preToolUseEvents.length > 0 && postToolUseEvents.length > 0
|
|
? `Pre: T+${preToolUseEvents[0].elapsed}ms, Post: T+${postToolUseEvents[0].elapsed}ms`
|
|
: 'N/A'
|
|
},
|
|
{
|
|
name: 'Stop after PostToolUse',
|
|
pass: stopEvents.length > 0 && postToolUseEvents.length > 0 &&
|
|
stopEvents[stopEvents.length - 1].elapsed >= postToolUseEvents[postToolUseEvents.length - 1].elapsed,
|
|
detail: stopEvents.length > 0 && postToolUseEvents.length > 0
|
|
? `Post: T+${postToolUseEvents[postToolUseEvents.length - 1].elapsed}ms, Stop: T+${stopEvents[stopEvents.length - 1].elapsed}ms`
|
|
: 'N/A'
|
|
},
|
|
];
|
|
|
|
let allPass = true;
|
|
for (const check of checks) {
|
|
const icon = check.pass ? '✓' : '✗';
|
|
console.log(` ${icon} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`);
|
|
if (!check.pass) allPass = false;
|
|
}
|
|
|
|
console.log(`\n${allPass ? '✓ ALL CHECKS PASSED' : '✗ SOME CHECKS FAILED'}\n`);
|
|
|
|
if (stderr && !allPass) {
|
|
console.log('Claude stderr (first 500 chars):');
|
|
console.log(stderr.substring(0, 500));
|
|
}
|
|
|
|
return allPass;
|
|
|
|
} finally {
|
|
// Cleanup
|
|
server.close();
|
|
|
|
if (settingsBackup) {
|
|
writeFileSync(SETTINGS_PATH, settingsBackup);
|
|
console.log('Restored settings.local.json backup');
|
|
} else {
|
|
try { unlinkSync(SETTINGS_PATH); } catch {}
|
|
console.log('Removed temporary settings.local.json');
|
|
}
|
|
}
|
|
}
|
|
|
|
main()
|
|
.then(pass => process.exit(pass ? 0 : 1))
|
|
.catch(err => {
|
|
console.error('Test error:', err);
|
|
// Cleanup settings on error
|
|
try { unlinkSync(SETTINGS_PATH); } catch {}
|
|
process.exit(1);
|
|
});
|