diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 717f8aa4..796365b3 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -9,7 +9,7 @@ import { parseSpecialCommand } from '@/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; import { startHappyServer } from '@/claude/utils/startHappyServer'; import { startHookServer } from '@/claude/utils/startHookServer'; -import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings'; +import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; import { registerKillSessionHandler } from './registerKillSessionHandler'; import type { Session } from './session'; import { bootstrapSession } from '@/agent/sessionFactory'; @@ -101,7 +101,10 @@ export async function runClaude(options: StartOptions = {}): Promise { }); logger.debug(`[START] Hook server started on port ${hookServer.port}`); - const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token); + const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { + filenamePrefix: 'session-hook', + logLabel: 'generateHookSettings' + }); logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); // Print log file path @@ -116,7 +119,7 @@ export async function runClaude(options: StartOptions = {}): Promise { onAfterClose: () => { happyServer.stop(); hookServer.stop(); - cleanupHookSettingsFile(hookSettingsPath); + cleanupHookSettingsFile(hookSettingsPath, 'generateHookSettings'); } }); diff --git a/cli/src/claude/utils/generateHookSettings.ts b/cli/src/claude/utils/generateHookSettings.ts deleted file mode 100644 index f5be3fe6..00000000 --- a/cli/src/claude/utils/generateHookSettings.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Generate temporary settings file with Claude hooks for session tracking. - * - * Creates a settings.json file that configures Claude's SessionStart hook - * to notify our HTTP server when sessions change (new session, resume, compact, etc.). - */ - -import { join } from 'node:path'; -import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; -import { configuration } from '@/configuration'; -import { logger } from '@/ui/logger'; -import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; - -function shellQuote(value: string): string { - if (value.length === 0) { - return '""'; - } - - if (/^[A-Za-z0-9_\/:=-]+$/.test(value)) { - return value; - } - - return '"' + value.replace(/(["\\$`])/g, '\\$1') + '"'; -} - -function shellJoin(parts: string[]): string { - return parts.map(shellQuote).join(' '); -} - -/** - * Generate a temporary settings file with SessionStart hook configuration. - */ -export function generateHookSettingsFile(port: number, token: string): string { - const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); - mkdirSync(hooksDir, { recursive: true }); - - const filename = `session-hook-${process.pid}.json`; - const filepath = join(hooksDir, filename); - - const { command, args } = getHappyCliCommand(['hook-forwarder', '--port', String(port), '--token', token]); - const hookCommand = shellJoin([command, ...args]); - - const settings = { - hooks: { - SessionStart: [ - { - matcher: '*', - hooks: [ - { - type: 'command', - command: hookCommand - } - ] - } - ] - } - }; - - writeFileSync(filepath, JSON.stringify(settings, null, 4)); - logger.debug(`[generateHookSettings] Created hook settings file: ${filepath}`); - - return filepath; -} - -/** - * Clean up the temporary hook settings file. - */ -export function cleanupHookSettingsFile(filepath: string): void { - try { - if (existsSync(filepath)) { - unlinkSync(filepath); - logger.debug(`[generateHookSettings] Cleaned up hook settings file: ${filepath}`); - } - } catch (error) { - logger.debug(`[generateHookSettings] Failed to cleanup hook settings file: ${error}`); - } -} diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 12d31d50..5cef176b 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -9,7 +9,7 @@ import type { GeminiMode, PermissionMode } from './types'; import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { startHookServer } from '@/claude/utils/startHookServer'; -import { cleanupGeminiHookSettingsFile, generateGeminiHookSettingsFile } from './utils/generateHookSettings'; +import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; import { resolveGeminiRuntimeConfig } from './utils/config'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; @@ -72,7 +72,11 @@ export async function runGemini(opts: { } }); - const hookSettingsPath = generateGeminiHookSettingsFile(hookServer.port, hookServer.token); + const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { + filenamePrefix: 'gemini-session-hook', + logLabel: 'gemini-hook-settings', + hooksEnabled: true + }); const lifecycle = createRunnerLifecycle({ session, @@ -80,7 +84,7 @@ export async function runGemini(opts: { stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(), onAfterClose: () => { hookServer.stop(); - cleanupGeminiHookSettingsFile(hookSettingsPath); + cleanupHookSettingsFile(hookSettingsPath, 'gemini-hook-settings'); } }); diff --git a/cli/src/gemini/utils/generateHookSettings.ts b/cli/src/gemini/utils/generateHookSettings.ts deleted file mode 100644 index 0c77422d..00000000 --- a/cli/src/gemini/utils/generateHookSettings.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { join } from 'node:path'; -import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; -import { configuration } from '@/configuration'; -import { logger } from '@/ui/logger'; -import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; - -function shellQuote(value: string): string { - if (value.length === 0) { - return '""'; - } - - if (/^[A-Za-z0-9_\/:=-]+$/.test(value)) { - return value; - } - - return '"' + value.replace(/(["\\$`])/g, '\\$1') + '"'; -} - -function shellJoin(parts: string[]): string { - return parts.map(shellQuote).join(' '); -} - -export function generateGeminiHookSettingsFile(port: number, token: string): string { - const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); - mkdirSync(hooksDir, { recursive: true }); - - const filename = `gemini-session-hook-${process.pid}.json`; - const filepath = join(hooksDir, filename); - - const { command, args } = getHappyCliCommand(['hook-forwarder', '--port', String(port), '--token', token]); - const hookCommand = shellJoin([command, ...args]); - - const settings = { - hooks: { - enabled: true, - SessionStart: [ - { - matcher: '*', - hooks: [ - { - type: 'command', - command: hookCommand - } - ] - } - ] - } - }; - - writeFileSync(filepath, JSON.stringify(settings, null, 4)); - logger.debug(`[gemini-hook-settings] Created hook settings file: ${filepath}`); - - return filepath; -} - -export function cleanupGeminiHookSettingsFile(filepath: string): void { - try { - if (existsSync(filepath)) { - unlinkSync(filepath); - logger.debug(`[gemini-hook-settings] Cleaned up hook settings file: ${filepath}`); - } - } catch (error) { - logger.debug(`[gemini-hook-settings] Failed to cleanup hook settings file: ${error}`); - } -} diff --git a/cli/src/modules/common/hooks/generateHookSettings.ts b/cli/src/modules/common/hooks/generateHookSettings.ts new file mode 100644 index 00000000..2b45b60d --- /dev/null +++ b/cli/src/modules/common/hooks/generateHookSettings.ts @@ -0,0 +1,103 @@ +import { join } from 'node:path'; +import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; +import { configuration } from '@/configuration'; +import { logger } from '@/ui/logger'; +import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; + +type HookCommandConfig = { + matcher: string; + hooks: Array<{ + type: 'command'; + command: string; + }>; +}; + +type HookSettings = { + hooks: { + enabled?: boolean; + SessionStart: HookCommandConfig[]; + }; +}; + +export type HookSettingsOptions = { + filenamePrefix: string; + logLabel: string; + hooksEnabled?: boolean; +}; + +function shellQuote(value: string): string { + if (value.length === 0) { + return '""'; + } + + if (/^[A-Za-z0-9_\/:=-]+$/.test(value)) { + return value; + } + + return '"' + value.replace(/(["\\$`])/g, '\\$1') + '"'; +} + +function shellJoin(parts: string[]): string { + return parts.map(shellQuote).join(' '); +} + +function buildHookSettings(command: string, hooksEnabled?: boolean): HookSettings { + const hooks: HookSettings['hooks'] = { + SessionStart: [ + { + matcher: '*', + hooks: [ + { + type: 'command', + command + } + ] + } + ] + }; + + if (hooksEnabled !== undefined) { + hooks.enabled = hooksEnabled; + } + + return { hooks }; +} + +export function generateHookSettingsFile( + port: number, + token: string, + options: HookSettingsOptions +): string { + const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks'); + mkdirSync(hooksDir, { recursive: true }); + + const filename = `${options.filenamePrefix}-${process.pid}.json`; + const filepath = join(hooksDir, filename); + + const { command, args } = getHappyCliCommand([ + 'hook-forwarder', + '--port', + String(port), + '--token', + token + ]); + const hookCommand = shellJoin([command, ...args]); + + const settings = buildHookSettings(hookCommand, options.hooksEnabled); + + writeFileSync(filepath, JSON.stringify(settings, null, 4)); + logger.debug(`[${options.logLabel}] Created hook settings file: ${filepath}`); + + return filepath; +} + +export function cleanupHookSettingsFile(filepath: string, logLabel: string): void { + try { + if (existsSync(filepath)) { + unlinkSync(filepath); + logger.debug(`[${logLabel}] Cleaned up hook settings file: ${filepath}`); + } + } catch (error) { + logger.debug(`[${logLabel}] Failed to cleanup hook settings file: ${error}`); + } +}