diff --git a/.claude/commands/new-hook.md b/.claude/commands/new-hook.md index 4b86ad9..453b447 100644 --- a/.claude/commands/new-hook.md +++ b/.claude/commands/new-hook.md @@ -255,6 +255,18 @@ export default defineHooks({ - Always check the hook structure before calling - Test with realistic hook inputs matching actual Claude Code events +### 10. Before Publishing + +**Git workflow before pushing:** +- If not on the master branch, rebase from master to ensure your changes are up to date: + ```bash + git checkout master + git pull origin master + git checkout feature/your-branch + git rebase master + ``` +- This ensures your hook is based on the latest code and prevents merge conflicts + ## Let me create the hook for you Based on your requirements, I'll create the appropriate hook structure and implementation. \ No newline at end of file diff --git a/README.md b/README.md index 217aeef..300c397 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ The library includes several predefined hook utilities for common logging scenar | **`logSubagentStopEvents`**
Logs subagent stop events | • `maxEventsStored` (default: 100)
• `logFileName` (default: 'hook-log.stop.json') | | **`logNotificationEvents`**
Logs notification messages | • `maxEventsStored` (default: 100)
• `logFileName` (default: 'hook-log.notification.json') | | **`blockEnvFiles`**
Blocks access to .env files | No options - blocks all .env file variants except example files | +| **`announceStop`**
Announces task completion via TTS | • `message` (default: 'Task completed')
• `voice` (system-specific voice name)
• `rate` (speech rate in WPM)
• `customCommand` (custom TTS command)
• `suppressOutput` (default: false) | +| **`announceSubagentStop`**
Announces subagent completion via TTS | Same options as `announceStop` | +| **`announcePreToolUse`**
Announces before tool execution | • First param: `matcher` (regex pattern, defaults to '.*')
• `message` (default: 'Using {toolName}')
• `voice`, `rate`, `customCommand`, `suppressOutput` | +| **`announcePostToolUse`**
Announces after tool execution | • First param: `matcher` (regex pattern, defaults to '.*')
• `message` (default: '{toolName} completed')
• `voice`, `rate`, `customCommand`, `suppressOutput` | +| **`announceNotification`**
Speaks notification messages | • `message` (default: '{message}')
• `voice`, `rate`, `customCommand`, `suppressOutput` | All predefined hooks: @@ -356,6 +361,82 @@ The `blockEnvFiles` hook: - Works with `Read`, `Write`, `Edit`, and `MultiEdit` tools - Provides clear error messages when access is blocked +### Text-to-Speech Announcements + +```typescript +import { + defineHooks, + announceStop, + announceSubagentStop, + announcePreToolUse, + announcePostToolUse, + announceNotification, +} from "@timoaus/define-claude-code-hooks"; + +// Basic usage - announce all events +export default defineHooks({ + Stop: [announceStop()], + SubagentStop: [announceSubagentStop()], + PreToolUse: [announcePreToolUse()], // Announces all tools + PostToolUse: [announcePostToolUse()], // Announces all tools + Notification: [announceNotification()], +}); + +// Announce specific tools only +export default defineHooks({ + PreToolUse: [ + announcePreToolUse('Bash|Write|Edit', { + message: "Running {toolName}" + }) + ], + PostToolUse: [ + announcePostToolUse('Bash|Write|Edit', { + message: "{toolName} finished" + }) + ], +}); + +// With custom voices and messages +export default defineHooks({ + Stop: [ + announceStop({ + message: "Claude has finished the task for session {sessionId}", + voice: "Samantha", // macOS voice + rate: 200, + }) + ], + Notification: [ + announceNotification({ + message: "Claude says: {message}", + voice: "Daniel" + }) + ], +}); + +// With custom TTS command (for Linux/Windows) +export default defineHooks({ + Stop: [ + announceStop({ + customCommand: "espeak -s 150 '{message}'", // Linux + // or for Windows PowerShell: + // customCommand: "powershell -Command \"(New-Object -ComObject SAPI.SpVoice).Speak('{message}')\"" + }) + ], +}); +``` + +The announcement hooks: +- Use text-to-speech to announce various Claude Code events +- Support macOS (say), Linux (espeak), and Windows (PowerShell SAPI) +- Allow custom messages with template variables: + - `{sessionId}` - The session ID + - `{timestamp}` - Current timestamp + - `{toolName}` - Tool name (for PreToolUse/PostToolUse) + - `{message}` - Notification message (for Notification hook) +- Support voice selection and speech rate customization +- Can use custom TTS commands for other systems +- Run asynchronously without blocking Claude Code + ### Combining Multiple Hooks ```typescript @@ -365,6 +446,7 @@ import { logPreToolUseEvents, logPostToolUseEvents, blockEnvFiles, + announceStop, } from "@timoaus/define-claude-code-hooks"; export default defineHooks({ @@ -382,7 +464,10 @@ export default defineHooks({ PostToolUse: logPostToolUseEvents({ logFileName: "hook-log.tool-use.json" }), - Stop: [logStopEvents("hook-log.stop.json")], + Stop: [ + logStopEvents("hook-log.stop.json"), + announceStop({ message: "Task completed successfully!" }), + ], }); ``` diff --git a/src/hooks/announceHooks.ts b/src/hooks/announceHooks.ts new file mode 100644 index 0000000..d2b9000 --- /dev/null +++ b/src/hooks/announceHooks.ts @@ -0,0 +1,288 @@ +import { defineHook } from '../index'; +import { StopInput, SubagentStopInput, PreToolUseInput, PostToolUseInput, NotificationInput, AnyHookDefinition } from '../types'; +import { spawn } from 'child_process'; + +export interface AnnouncementOptions { + /** + * The message to speak. + * Supports template variables: + * - {sessionId} - The session ID + * - {timestamp} - Current timestamp + * - {toolName} - Tool name (for tool hooks) + * - {message} - Notification message (for notification hook) + * @default Varies by hook type + */ + message?: string; + + /** + * The voice to use for TTS (system-specific). + * On macOS: Use 'say -v ?' to list available voices + * On Linux: Depends on the TTS engine installed + * @default undefined (uses system default) + */ + voice?: string; + + /** + * The speech rate (words per minute). + * @default undefined (uses system default) + */ + rate?: number; + + /** + * Whether to suppress the hook's console output + * @default false + */ + suppressOutput?: boolean; + + /** + * Custom TTS command to use instead of the default. + * Should include {message} placeholder for the text to speak. + * Example: "espeak '{message}'" or "say -v Daniel '{message}'" + * @default undefined (auto-detects based on platform) + */ + customCommand?: string; +} + +// Helper function to speak text +async function speak(text: string, options: AnnouncementOptions): Promise<{ suppressOutput?: boolean }> { + try { + const { voice, rate, suppressOutput = false, customCommand } = options; + + // Determine the TTS command based on platform + let command: string = ''; + let args: string[] = []; + + if (customCommand) { + // Use custom command + const fullCommand = customCommand.replace('{message}', text); + // Split command and args (simple split, doesn't handle quoted args perfectly) + const parts = fullCommand.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + command = parts[0] || ''; + args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, '')); + } else if (process.platform === 'darwin') { + // macOS: use 'say' command + command = 'say'; + args = []; + if (voice) args.push('-v', voice); + if (rate) args.push('-r', rate.toString()); + args.push(text); + } else if (process.platform === 'linux') { + // Linux: try espeak (most common) + command = 'espeak'; + args = []; + if (rate) args.push('-s', rate.toString()); + args.push(text); + } else if (process.platform === 'win32') { + // Windows: use PowerShell with SAPI + command = 'powershell'; + args = [ + '-Command', + `Add-Type -AssemblyName System.Speech; ` + + `$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer; ` + + (voice ? `$synth.SelectVoice("${voice}"); ` : '') + + (rate ? `$synth.Rate = ${Math.round((rate - 150) / 15)}; ` : '') + // Convert WPM to -10 to 10 scale + `$synth.Speak("${text.replace(/"/g, '`"')}")` + ]; + } else { + if (!suppressOutput) { + console.log('[TTS] Unsupported platform for text-to-speech'); + } + return { suppressOutput }; + } + + // Spawn the TTS process + const ttsProcess = spawn(command, args, { + detached: false, + stdio: 'ignore' + }); + + // Don't wait for the process to complete - let it run in background + ttsProcess.unref(); + + if (!suppressOutput) { + console.log(`[TTS] Speaking: "${text}"`); + } + + return { suppressOutput }; + } catch (error) { + if (!options.suppressOutput) { + console.error('[TTS] Error:', error instanceof Error ? error.message : String(error)); + } + return { suppressOutput: options.suppressOutput }; + } +} + +/** + * Creates a Stop hook that announces when tasks complete using text-to-speech. + * + * @example + * ```typescript + * import { defineHooks, announceStop } from 'define-claude-code-hooks'; + * + * export default defineHooks({ + * Stop: [announceStop()] + * }); + * ``` + * + * @example + * ```typescript + * // With custom message and voice + * export default defineHooks({ + * Stop: [announceStop({ + * message: "Claude has finished the task for session {sessionId}", + * voice: "Samantha", // macOS voice + * rate: 200 + * })] + * }); + * ``` + */ +export const announceStop = (options: AnnouncementOptions = {}): AnyHookDefinition<'Stop'> => { + const defaultMessage = "Task completed"; + + return defineHook('Stop', async (input: StopInput) => { + const message = (options.message || defaultMessage) + .replace('{sessionId}', input.session_id) + .replace('{timestamp}', new Date().toLocaleString()); + + return speak(message, options); + }); +}; + +/** + * Creates a SubagentStop hook that announces subagent task completion using text-to-speech. + * + * @example + * ```typescript + * import { defineHooks, announceSubagentStop } from 'define-claude-code-hooks'; + * + * export default defineHooks({ + * SubagentStop: [announceSubagentStop()] + * }); + * ``` + */ +export const announceSubagentStop = (options: AnnouncementOptions = {}): AnyHookDefinition<'SubagentStop'> => { + const defaultMessage = "Subagent task completed"; + + return defineHook('SubagentStop', async (input: SubagentStopInput) => { + const message = (options.message || defaultMessage) + .replace('{sessionId}', input.session_id) + .replace('{timestamp}', new Date().toLocaleString()); + + return speak(message, options); + }); +}; + +/** + * Creates a PreToolUse hook that announces tool usage before execution. + * + * @example + * ```typescript + * import { defineHooks, announcePreToolUse } from 'define-claude-code-hooks'; + * + * export default defineHooks({ + * PreToolUse: [ + * announcePreToolUse('.*') // Announce all tools + * ] + * }); + * ``` + * + * @example + * ```typescript + * // Only announce specific tools + * export default defineHooks({ + * PreToolUse: [ + * announcePreToolUse('Bash|Write|Edit', { + * message: "Running {toolName} tool" + * }) + * ] + * }); + * ``` + */ +export const announcePreToolUse = (matcher: string = '.*', options: AnnouncementOptions = {}): AnyHookDefinition<'PreToolUse'> => { + const defaultMessage = "Using {toolName}"; + + return defineHook('PreToolUse', { + matcher, + handler: async (input: PreToolUseInput) => { + const message = (options.message || defaultMessage) + .replace('{toolName}', input.tool_name) + .replace('{sessionId}', input.session_id) + .replace('{timestamp}', new Date().toLocaleString()); + + return speak(message, options); + } + }); +}; + +/** + * Creates a PostToolUse hook that announces tool usage after execution. + * + * @example + * ```typescript + * import { defineHooks, announcePostToolUse } from 'define-claude-code-hooks'; + * + * export default defineHooks({ + * PostToolUse: [ + * announcePostToolUse('.*', { + * message: "{toolName} completed" + * }) + * ] + * }); + * ``` + */ +export const announcePostToolUse = (matcher: string = '.*', options: AnnouncementOptions = {}): AnyHookDefinition<'PostToolUse'> => { + const defaultMessage = "{toolName} completed"; + + return defineHook('PostToolUse', { + matcher, + handler: async (input: PostToolUseInput) => { + const message = (options.message || defaultMessage) + .replace('{toolName}', input.tool_name) + .replace('{sessionId}', input.session_id) + .replace('{timestamp}', new Date().toLocaleString()); + + return speak(message, options); + } + }); +}; + +/** + * Creates a Notification hook that speaks notification messages aloud. + * + * @example + * ```typescript + * import { defineHooks, announceNotification } from 'define-claude-code-hooks'; + * + * export default defineHooks({ + * Notification: [announceNotification()] + * }); + * ``` + * + * @example + * ```typescript + * // With custom prefix + * export default defineHooks({ + * Notification: [ + * announceNotification({ + * message: "Claude says: {message}" + * }) + * ] + * }); + * ``` + */ +export const announceNotification = (options: AnnouncementOptions = {}): AnyHookDefinition<'Notification'> => { + const defaultMessage = "{message}"; + + return defineHook('Notification', async (input: NotificationInput) => { + const message = (options.message || defaultMessage) + .replace('{message}', input.message) + .replace('{sessionId}', input.session_id) + .replace('{timestamp}', new Date().toLocaleString()); + + return speak(message, options); + }); +}; + +// Legacy exports for backwards compatibility +export const announceTaskCompletion = announceStop; +export const announceSubagentTaskCompletion = announceSubagentStop; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 2230ab4..76e2f0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,16 @@ export { logPostToolUseEvents } from './hooks/logToolUseEvents'; export { blockEnvFiles } from './hooks/blockEnvFiles'; +export { + announceStop, + announceSubagentStop, + announcePreToolUse, + announcePostToolUse, + announceNotification, + // Legacy exports + announceTaskCompletion, + announceSubagentTaskCompletion +} from './hooks/announceHooks'; /** * Define a typed hook handler for Claude Code