feat: ClawTap v0.1.0 — initial release

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
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
// server/adapters/gemini/hook-config.ts
//
// Pure filesystem operations for Gemini hook management.
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Individual hook action (command or url based) */
interface HookAction {
type?: string;
command?: string;
url?: string;
timeout?: number;
}
/** A hook entry within a hook event */
interface HookEntry {
matcher?: string;
hooks: HookAction[];
}
/** The structure of Gemini's settings.json (partial) */
interface GeminiSettings {
hooks?: Record<string, HookEntry[]>;
[key: string]: unknown;
}
export class GeminiHookConfig {
port: number | string;
useHttps: boolean;
constructor(port?: number | string, useHttps?: boolean) {
this.port = port || process.env.PORT || 3456;
if (useHttps !== undefined) {
this.useHttps = useHttps;
} else {
// Auto-detect from cert files
const clawtapDir = join(homedir(), '.clawtap');
this.useHttps = existsSync(join(clawtapDir, 'cert.pem')) && existsSync(join(clawtapDir, 'key.pem'));
}
}
/** Install ClawTap hooks into ~/.gemini/settings.json */
install(): void {
const port = this.port;
const settingsDir = join(homedir(), '.gemini');
const settingsPath = join(settingsDir, 'settings.json');
const protocol = this.useHttps ? 'https' : 'http';
const desiredHooks = this._buildDesiredHooks(protocol);
try {
mkdirSync(settingsDir, { recursive: true });
let existing: GeminiSettings = {};
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {}
// Replace our hooks on every startup.
// Preserves other tools' hooks by filtering only ClawTap entries.
if (!existing.hooks) existing.hooks = {};
for (const [event, configs] of Object.entries(desiredHooks)) {
const existingEntries = existing.hooks[event] || [];
const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry));
existing.hooks[event] = [...filtered, ...configs];
}
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`);
} catch (err) {
console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`);
}
}
/**
* Remove ClawTap hooks from ~/.gemini/settings.json.
* Leaves other user settings intact. Only removes hooks owned by this port.
*/
uninstall(): void {
const settingsPath = join(homedir(), '.gemini', 'settings.json');
try {
const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings;
if (existing.hooks) {
const hookKeys = Object.keys(this._buildDesiredHooks('http'));
for (const key of hookKeys) {
const entries = existing.hooks[key];
if (!Array.isArray(entries)) continue;
const filtered = entries.filter(entry => !this._isOurHookEntry(entry));
if (filtered.length === 0) {
delete existing.hooks[key];
} else {
existing.hooks[key] = filtered;
}
}
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
}
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
console.log(`[hooks:gemini] Removed ClawTap hooks from ${settingsPath}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`);
}
}
// --- Internal helpers ---
private _isOurHookEntry(entry: HookEntry): boolean {
const hooks = entry.hooks || [];
return hooks.some(h =>
h.command != null && h.command.includes('bridge.sh') && h.command.includes(String(this.port))
);
}
private _buildDesiredHooks(protocol: string): Record<string, HookEntry[]> {
const port = this.port;
const bridgePath = resolve(__dirname, 'bridge.sh');
// Pass port and protocol as positional args (not env vars).
// Gemini CLI may use execFile instead of shell, so inline VAR=val doesn't work.
const mkCmd = (endpoint: string): string =>
`${bridgePath} ${endpoint} ${port} ${protocol}`;
// IMPORTANT: Gemini CLI timeout is in MILLISECONDS (not seconds like Claude Code).
// 5000ms = 5 seconds — enough for bridge.sh to read stdin, printf '{}', and background curl.
const timeout = 5000;
return {
SessionStart: [{ hooks: [{ type: 'command', command: mkCmd('session-start'), timeout }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: mkCmd('session-end'), timeout }] }],
BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('before-tool'), timeout }] }],
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: mkCmd('after-tool'), timeout }] }],
BeforeAgent: [{ hooks: [{ type: 'command', command: mkCmd('before-agent'), timeout }] }],
AfterAgent: [{ hooks: [{ type: 'command', command: mkCmd('after-agent'), timeout }] }],
};
}
}