@mcptoolshop/claude-sfx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Global configuration — persisted to ~/.claude-sfx/config.json.
3
+ * Controls profile, volume, quiet hours, mute state, per-verb toggles,
4
+ * and per-repo profile overrides.
5
+ */
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ // --- Config directory ---
10
+ const CONFIG_DIR = join(homedir(), ".claude-sfx");
11
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
12
+ function ensureConfigDir() {
13
+ if (!existsSync(CONFIG_DIR)) {
14
+ mkdirSync(CONFIG_DIR, { recursive: true });
15
+ }
16
+ }
17
+ const DEFAULT_CONFIG = {
18
+ profile: "minimal",
19
+ volume: 80,
20
+ muted: false,
21
+ quietHours: null,
22
+ disabledVerbs: [],
23
+ repoProfiles: {},
24
+ };
25
+ // --- Read/Write ---
26
+ export function loadConfig() {
27
+ if (!existsSync(CONFIG_FILE)) {
28
+ return { ...DEFAULT_CONFIG };
29
+ }
30
+ try {
31
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
32
+ const parsed = JSON.parse(raw);
33
+ // Merge with defaults so new fields are always present
34
+ return { ...DEFAULT_CONFIG, ...parsed };
35
+ }
36
+ catch {
37
+ return { ...DEFAULT_CONFIG };
38
+ }
39
+ }
40
+ export function saveConfig(config) {
41
+ ensureConfigDir();
42
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
43
+ }
44
+ // --- Convenience setters ---
45
+ export function setMuted(muted) {
46
+ const cfg = loadConfig();
47
+ cfg.muted = muted;
48
+ saveConfig(cfg);
49
+ return cfg;
50
+ }
51
+ export function setVolume(volume) {
52
+ const cfg = loadConfig();
53
+ cfg.volume = Math.max(0, Math.min(100, Math.round(volume)));
54
+ saveConfig(cfg);
55
+ return cfg;
56
+ }
57
+ export function setProfile(profile) {
58
+ const cfg = loadConfig();
59
+ cfg.profile = profile;
60
+ saveConfig(cfg);
61
+ return cfg;
62
+ }
63
+ export function setQuietHours(start, end) {
64
+ const cfg = loadConfig();
65
+ cfg.quietHours = { start, end };
66
+ saveConfig(cfg);
67
+ return cfg;
68
+ }
69
+ export function clearQuietHours() {
70
+ const cfg = loadConfig();
71
+ cfg.quietHours = null;
72
+ saveConfig(cfg);
73
+ return cfg;
74
+ }
75
+ // --- Quiet hours check ---
76
+ function parseTime(hhmm) {
77
+ const [h, m] = hhmm.split(":").map(Number);
78
+ return h * 60 + m;
79
+ }
80
+ export function isQuietTime(config) {
81
+ if (!config.quietHours)
82
+ return false;
83
+ const now = new Date();
84
+ const currentMinutes = now.getHours() * 60 + now.getMinutes();
85
+ const start = parseTime(config.quietHours.start);
86
+ const end = parseTime(config.quietHours.end);
87
+ // Handle overnight ranges (e.g., 22:00 → 07:00)
88
+ if (start > end) {
89
+ return currentMinutes >= start || currentMinutes < end;
90
+ }
91
+ return currentMinutes >= start && currentMinutes < end;
92
+ }
93
+ /** Resolve the effective profile name for the current working directory. */
94
+ export function resolveProfileName(config, cwd) {
95
+ if (cwd && config.repoProfiles[cwd]) {
96
+ return config.repoProfiles[cwd];
97
+ }
98
+ return config.profile;
99
+ }
100
+ /** Convert volume (0–100) to gain (0.0–1.0). */
101
+ export function volumeToGain(volume) {
102
+ return Math.max(0, Math.min(1, volume / 100));
103
+ }
104
+ export { CONFIG_DIR, CONFIG_FILE };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sound guard — debounce, rate limiting, mute, quiet hours.
3
+ * All "should this sound play?" logic lives here.
4
+ *
5
+ * State is tracked via a lightweight temp file (no daemon needed).
6
+ * Each play call reads/writes a small JSON ledger.
7
+ */
8
+ import { type SfxConfig } from "./config.js";
9
+ export interface GuardResult {
10
+ allowed: boolean;
11
+ reason?: string;
12
+ }
13
+ /**
14
+ * Check whether a sound should play, and record it in the ledger if allowed.
15
+ * This is the single entry point for all anti-annoyance logic.
16
+ */
17
+ export declare function guardPlay(verb: string, config: SfxConfig): GuardResult;
18
+ /** Reset the ledger (for testing or after mute toggle). */
19
+ export declare function resetLedger(): void;
package/dist/guard.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Sound guard — debounce, rate limiting, mute, quiet hours.
3
+ * All "should this sound play?" logic lives here.
4
+ *
5
+ * State is tracked via a lightweight temp file (no daemon needed).
6
+ * Each play call reads/writes a small JSON ledger.
7
+ */
8
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { isQuietTime } from "./config.js";
12
+ // --- Constants ---
13
+ /** Debounce window: same verb within this window → skip. */
14
+ const DEBOUNCE_MS = 200;
15
+ /** Rate limit: max sounds in the sliding window. */
16
+ const RATE_LIMIT_MAX = 8;
17
+ /** Rate limit sliding window duration. */
18
+ const RATE_LIMIT_WINDOW_MS = 10_000;
19
+ /** Ledger file — tiny JSON tracking recent plays. */
20
+ const LEDGER_FILE = join(tmpdir(), "claude-sfx-ledger.json");
21
+ function readLedger() {
22
+ if (!existsSync(LEDGER_FILE)) {
23
+ return { entries: [] };
24
+ }
25
+ try {
26
+ const raw = readFileSync(LEDGER_FILE, "utf-8");
27
+ return JSON.parse(raw);
28
+ }
29
+ catch {
30
+ return { entries: [] };
31
+ }
32
+ }
33
+ function writeLedger(ledger) {
34
+ writeFileSync(LEDGER_FILE, JSON.stringify(ledger));
35
+ }
36
+ /** Prune entries older than the rate limit window. */
37
+ function pruneEntries(entries, now) {
38
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
39
+ return entries.filter((e) => e.timestamp > cutoff);
40
+ }
41
+ /**
42
+ * Check whether a sound should play, and record it in the ledger if allowed.
43
+ * This is the single entry point for all anti-annoyance logic.
44
+ */
45
+ export function guardPlay(verb, config) {
46
+ // 1. Muted?
47
+ if (config.muted) {
48
+ return { allowed: false, reason: "muted" };
49
+ }
50
+ // 2. Quiet hours?
51
+ if (isQuietTime(config)) {
52
+ return { allowed: false, reason: "quiet hours" };
53
+ }
54
+ // 3. Verb disabled?
55
+ if (config.disabledVerbs.includes(verb)) {
56
+ return { allowed: false, reason: `verb "${verb}" disabled` };
57
+ }
58
+ // 4. Volume zero?
59
+ if (config.volume <= 0) {
60
+ return { allowed: false, reason: "volume is 0" };
61
+ }
62
+ const now = Date.now();
63
+ const ledger = readLedger();
64
+ const entries = pruneEntries(ledger.entries, now);
65
+ // 5. Debounce — same verb within the window?
66
+ const lastSameVerb = entries.filter((e) => e.verb === verb);
67
+ if (lastSameVerb.length > 0) {
68
+ const mostRecent = lastSameVerb[lastSameVerb.length - 1];
69
+ if (now - mostRecent.timestamp < DEBOUNCE_MS) {
70
+ writeLedger({ entries }); // still prune stale entries
71
+ return { allowed: false, reason: "debounced" };
72
+ }
73
+ }
74
+ // 6. Rate limit — too many sounds in the window?
75
+ if (entries.length >= RATE_LIMIT_MAX) {
76
+ writeLedger({ entries });
77
+ return { allowed: false, reason: "rate limited" };
78
+ }
79
+ // All checks passed — record and allow
80
+ entries.push({ verb, timestamp: now });
81
+ writeLedger({ entries });
82
+ return { allowed: true };
83
+ }
84
+ /** Reset the ledger (for testing or after mute toggle). */
85
+ export function resetLedger() {
86
+ writeLedger({ entries: [] });
87
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Hook handler — the bridge between Claude Code hooks and claude-sfx.
3
+ *
4
+ * All hooks route here. Reads JSON from stdin, determines the verb + modifiers,
5
+ * and dispatches to `generateVerb → playSync`. Runs in-process (no child spawn)
6
+ * for minimal latency.
7
+ *
8
+ * Usage (called by Claude Code hooks, not directly by users):
9
+ * echo '{"tool_name":"Read",...}' | claude-sfx hook-handler PostToolUse
10
+ * echo '{"session_id":"..."}' | claude-sfx hook-handler SessionStart
11
+ */
12
+ import { type Verb, type PlayOptions } from "./verbs.js";
13
+ export interface VerbMapping {
14
+ verb: Verb;
15
+ options?: PlayOptions;
16
+ }
17
+ /** Map a PostToolUse tool_name to a verb + options. */
18
+ export declare function mapToolToVerb(toolName: string, input: StdinPayload): VerbMapping | null;
19
+ /** Map Bash tool usage to a verb, detecting git commands and exit codes. */
20
+ export declare function mapBashVerb(input: StdinPayload): VerbMapping;
21
+ export interface StdinPayload {
22
+ session_id?: string;
23
+ cwd?: string;
24
+ hook_event_name?: string;
25
+ tool_name?: string;
26
+ tool_input?: unknown;
27
+ tool_output?: unknown;
28
+ exit_code?: number;
29
+ error_message?: string;
30
+ source?: string;
31
+ reason?: string;
32
+ [key: string]: unknown;
33
+ }
34
+ export declare function handleHook(eventName: string): Promise<void>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Hook handler — the bridge between Claude Code hooks and claude-sfx.
3
+ *
4
+ * All hooks route here. Reads JSON from stdin, determines the verb + modifiers,
5
+ * and dispatches to `generateVerb → playSync`. Runs in-process (no child spawn)
6
+ * for minimal latency.
7
+ *
8
+ * Usage (called by Claude Code hooks, not directly by users):
9
+ * echo '{"tool_name":"Read",...}' | claude-sfx hook-handler PostToolUse
10
+ * echo '{"session_id":"..."}' | claude-sfx hook-handler SessionStart
11
+ */
12
+ import { generateVerb, generateSessionStart, generateSessionEnd, } from "./verbs.js";
13
+ import { applyVolume } from "./synth.js";
14
+ import { playSync } from "./player.js";
15
+ import { resolveProfile } from "./profiles.js";
16
+ import { loadConfig, resolveProfileName, volumeToGain } from "./config.js";
17
+ import { guardPlay } from "./guard.js";
18
+ import { resolveAmbient, isAmbientRunning } from "./ambient.js";
19
+ /** Map a PostToolUse tool_name to a verb + options. */
20
+ export function mapToolToVerb(toolName, input) {
21
+ // Exact matches first
22
+ switch (toolName) {
23
+ case "Read":
24
+ return { verb: "intake" };
25
+ case "Edit":
26
+ return { verb: "transform" };
27
+ case "Write":
28
+ case "NotebookEdit":
29
+ return { verb: "commit" };
30
+ case "Grep":
31
+ case "Glob":
32
+ return { verb: "navigate" };
33
+ case "WebFetch":
34
+ case "WebSearch":
35
+ return { verb: "intake", options: { scope: "remote" } };
36
+ case "TodoWrite":
37
+ return { verb: "commit" };
38
+ }
39
+ // Bash — detect git commands and exit code
40
+ if (toolName === "Bash") {
41
+ return mapBashVerb(input);
42
+ }
43
+ // Task (subagent return) — treat as remote commit
44
+ if (toolName === "Task") {
45
+ return { verb: "commit", options: { scope: "remote" } };
46
+ }
47
+ // MCP tools — treat as remote intake
48
+ if (toolName.startsWith("mcp__")) {
49
+ return { verb: "intake", options: { scope: "remote" } };
50
+ }
51
+ // Unknown tool — skip
52
+ return null;
53
+ }
54
+ /** Map Bash tool usage to a verb, detecting git commands and exit codes. */
55
+ export function mapBashVerb(input) {
56
+ const toolInput = typeof input.tool_input === "object"
57
+ ? JSON.stringify(input.tool_input)
58
+ : String(input.tool_input ?? "");
59
+ const exitCode = input.exit_code ?? 0;
60
+ const status = exitCode === 0 ? "ok" : "err";
61
+ // Git push/pull → sync with direction
62
+ if (/git\s+push/i.test(toolInput)) {
63
+ return { verb: "sync", options: { direction: "up", status } };
64
+ }
65
+ if (/git\s+(pull|fetch)/i.test(toolInput)) {
66
+ return { verb: "sync", options: { direction: "down", status } };
67
+ }
68
+ // Git commit → commit verb
69
+ if (/git\s+commit/i.test(toolInput)) {
70
+ return { verb: "commit", options: { status } };
71
+ }
72
+ // npm/yarn/pnpm install → intake (bringing deps in)
73
+ if (/(?:npm|yarn|pnpm|bun)\s+install/i.test(toolInput)) {
74
+ return { verb: "intake", options: { scope: "remote", status } };
75
+ }
76
+ // Test commands → execute with status
77
+ if (/(?:npm\s+test|pytest|jest|vitest|cargo\s+test|dotnet\s+test)/i.test(toolInput)) {
78
+ return { verb: "execute", options: { status } };
79
+ }
80
+ // Build commands → execute with status
81
+ if (/(?:npm\s+run\s+build|tsc|cargo\s+build|dotnet\s+build|make\b)/i.test(toolInput)) {
82
+ return { verb: "execute", options: { status } };
83
+ }
84
+ // mv/cp/rename → move
85
+ if (/\b(?:mv|cp|rename|move)\b/i.test(toolInput)) {
86
+ return { verb: "move" };
87
+ }
88
+ // rm/del → move with err-ish tone (destructive)
89
+ if (/\b(?:rm|del|rmdir)\b/i.test(toolInput)) {
90
+ return { verb: "move", options: { status: "warn" } };
91
+ }
92
+ // Default bash → execute
93
+ return { verb: "execute", options: { status } };
94
+ }
95
+ function readStdin() {
96
+ return new Promise((resolve) => {
97
+ let data = "";
98
+ process.stdin.setEncoding("utf-8");
99
+ process.stdin.on("data", (chunk) => { data += chunk; });
100
+ process.stdin.on("end", () => resolve(data));
101
+ // If stdin is a TTY (no pipe), resolve immediately with empty
102
+ if (process.stdin.isTTY) {
103
+ resolve("");
104
+ }
105
+ });
106
+ }
107
+ // --- Play helper ---
108
+ function playVerb(verb, options = {}) {
109
+ const config = loadConfig();
110
+ // Guard check
111
+ const guard = guardPlay(verb, config);
112
+ if (!guard.allowed)
113
+ return;
114
+ const profileName = resolveProfileName(config);
115
+ const profile = resolveProfile(profileName);
116
+ let buffer = generateVerb(profile, verb, options);
117
+ const gain = volumeToGain(config.volume);
118
+ if (gain < 1) {
119
+ buffer = applyVolume(buffer, gain);
120
+ }
121
+ playSync(buffer);
122
+ }
123
+ function playSession(type) {
124
+ const config = loadConfig();
125
+ if (config.muted)
126
+ return;
127
+ const profileName = resolveProfileName(config);
128
+ const profile = resolveProfile(profileName);
129
+ const gen = type === "start" ? generateSessionStart : generateSessionEnd;
130
+ let buffer = gen(profile);
131
+ const gain = volumeToGain(config.volume);
132
+ if (gain < 1) {
133
+ buffer = applyVolume(buffer, gain);
134
+ }
135
+ playSync(buffer);
136
+ }
137
+ // --- Main handler ---
138
+ export async function handleHook(eventName) {
139
+ const raw = await readStdin();
140
+ let payload = {};
141
+ try {
142
+ if (raw.trim()) {
143
+ payload = JSON.parse(raw);
144
+ }
145
+ }
146
+ catch {
147
+ // Malformed stdin — not an error, just skip
148
+ return;
149
+ }
150
+ switch (eventName) {
151
+ case "SessionStart":
152
+ playSession("start");
153
+ break;
154
+ case "Stop":
155
+ // Stop any ambient drone on session end
156
+ if (isAmbientRunning()) {
157
+ const config = loadConfig();
158
+ const profile = resolveProfile(resolveProfileName(config));
159
+ resolveAmbient(profile);
160
+ }
161
+ playSession("end");
162
+ break;
163
+ case "PostToolUse": {
164
+ const toolName = payload.tool_name;
165
+ if (!toolName)
166
+ return;
167
+ const mapping = mapToolToVerb(toolName, payload);
168
+ if (!mapping)
169
+ return;
170
+ playVerb(mapping.verb, mapping.options ?? {});
171
+ break;
172
+ }
173
+ case "PostToolUseFailure": {
174
+ // Tool failed — play the verb with error status
175
+ const toolName = payload.tool_name;
176
+ if (!toolName)
177
+ return;
178
+ const mapping = mapToolToVerb(toolName, payload);
179
+ if (!mapping)
180
+ return;
181
+ const opts = { ...mapping.options, status: "err" };
182
+ playVerb(mapping.verb, opts);
183
+ break;
184
+ }
185
+ case "SubagentStart":
186
+ playVerb("move", { scope: "remote" });
187
+ break;
188
+ case "SubagentStop": {
189
+ const exitCode = payload.exit_code ?? 0;
190
+ playVerb("commit", {
191
+ scope: "remote",
192
+ status: exitCode === 0 ? "ok" : "err",
193
+ });
194
+ break;
195
+ }
196
+ default:
197
+ // Unknown event — ignore
198
+ break;
199
+ }
200
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Hook config generator — creates Claude Code hooks entries for .claude/settings.json.
3
+ * Handles merging with existing settings non-destructively.
4
+ */
5
+ interface HookEntry {
6
+ type: "command";
7
+ command: string;
8
+ timeout: number;
9
+ }
10
+ interface HookMatcher {
11
+ matcher?: string;
12
+ hooks: HookEntry[];
13
+ }
14
+ interface HooksConfig {
15
+ [eventName: string]: HookMatcher[];
16
+ }
17
+ /** Generate the full hooks config for claude-sfx. */
18
+ export declare function generateHooksConfig(): HooksConfig;
19
+ /**
20
+ * Install claude-sfx hooks into .claude/settings.json.
21
+ * Merges non-destructively — preserves existing hooks from other tools.
22
+ */
23
+ export declare function installHooks(cwd: string): {
24
+ settingsPath: string;
25
+ eventsAdded: string[];
26
+ };
27
+ /**
28
+ * Remove all claude-sfx hooks from .claude/settings.json.
29
+ * Preserves hooks from other tools.
30
+ */
31
+ export declare function uninstallHooks(cwd: string): {
32
+ settingsPath: string;
33
+ removed: boolean;
34
+ };
35
+ export {};
package/dist/hooks.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Hook config generator — creates Claude Code hooks entries for .claude/settings.json.
3
+ * Handles merging with existing settings non-destructively.
4
+ */
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { execSync } from "node:child_process";
9
+ // --- Marker to identify our hooks ---
10
+ const SFX_MARKER = "claude-sfx";
11
+ /** Resolve the path to the claude-sfx CLI (the installed binary or dist/cli.js). */
12
+ function resolveSfxBin() {
13
+ // Try to find the globally/locally installed binary
14
+ try {
15
+ const which = process.platform === "win32"
16
+ ? execSync("where claude-sfx", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim().split("\n")[0]
17
+ : execSync("which claude-sfx", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
18
+ if (which)
19
+ return which;
20
+ }
21
+ catch {
22
+ // Not installed globally — use node + dist path
23
+ }
24
+ // Fallback: use node with the dist path relative to this package
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const cliPath = join(dirname(__filename), "cli.js");
27
+ return `node "${cliPath}"`;
28
+ }
29
+ function makeHookCommand(sfxBin, eventName) {
30
+ return `${sfxBin} hook-handler ${eventName}`;
31
+ }
32
+ /** Generate the full hooks config for claude-sfx. */
33
+ export function generateHooksConfig() {
34
+ const sfxBin = resolveSfxBin();
35
+ return {
36
+ SessionStart: [
37
+ {
38
+ hooks: [
39
+ {
40
+ type: "command",
41
+ command: makeHookCommand(sfxBin, "SessionStart"),
42
+ timeout: 5,
43
+ },
44
+ ],
45
+ },
46
+ ],
47
+ PostToolUse: [
48
+ {
49
+ // Match all tools — the handler does the mapping internally
50
+ matcher: ".*",
51
+ hooks: [
52
+ {
53
+ type: "command",
54
+ command: makeHookCommand(sfxBin, "PostToolUse"),
55
+ timeout: 5,
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ PostToolUseFailure: [
61
+ {
62
+ matcher: ".*",
63
+ hooks: [
64
+ {
65
+ type: "command",
66
+ command: makeHookCommand(sfxBin, "PostToolUseFailure"),
67
+ timeout: 5,
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ SubagentStart: [
73
+ {
74
+ hooks: [
75
+ {
76
+ type: "command",
77
+ command: makeHookCommand(sfxBin, "SubagentStart"),
78
+ timeout: 5,
79
+ },
80
+ ],
81
+ },
82
+ ],
83
+ SubagentStop: [
84
+ {
85
+ hooks: [
86
+ {
87
+ type: "command",
88
+ command: makeHookCommand(sfxBin, "SubagentStop"),
89
+ timeout: 5,
90
+ },
91
+ ],
92
+ },
93
+ ],
94
+ Stop: [
95
+ {
96
+ hooks: [
97
+ {
98
+ type: "command",
99
+ command: makeHookCommand(sfxBin, "Stop"),
100
+ timeout: 5,
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ };
106
+ }
107
+ /** Check if a hook entry is one of ours. */
108
+ function isSfxHook(entry) {
109
+ return entry.command.includes(SFX_MARKER);
110
+ }
111
+ /** Check if a matcher block contains any of our hooks. */
112
+ function containsSfxHook(matcherBlock) {
113
+ return matcherBlock.hooks.some(isSfxHook);
114
+ }
115
+ // --- Init / Uninstall ---
116
+ function getSettingsPath(cwd) {
117
+ return join(cwd, ".claude", "settings.json");
118
+ }
119
+ function readSettings(settingsPath) {
120
+ if (!existsSync(settingsPath))
121
+ return {};
122
+ try {
123
+ return JSON.parse(readFileSync(settingsPath, "utf-8"));
124
+ }
125
+ catch {
126
+ return {};
127
+ }
128
+ }
129
+ function writeSettings(settingsPath, settings) {
130
+ const dir = dirname(settingsPath);
131
+ if (!existsSync(dir)) {
132
+ mkdirSync(dir, { recursive: true });
133
+ }
134
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
135
+ }
136
+ /**
137
+ * Install claude-sfx hooks into .claude/settings.json.
138
+ * Merges non-destructively — preserves existing hooks from other tools.
139
+ */
140
+ export function installHooks(cwd) {
141
+ const settingsPath = getSettingsPath(cwd);
142
+ const settings = readSettings(settingsPath);
143
+ const sfxHooks = generateHooksConfig();
144
+ if (!settings.hooks) {
145
+ settings.hooks = {};
146
+ }
147
+ const eventsAdded = [];
148
+ for (const [eventName, sfxMatchers] of Object.entries(sfxHooks)) {
149
+ if (!settings.hooks[eventName]) {
150
+ settings.hooks[eventName] = [];
151
+ }
152
+ // Remove any existing SFX hooks (in case of re-init)
153
+ settings.hooks[eventName] = settings.hooks[eventName].filter((m) => !containsSfxHook(m));
154
+ // Add our hooks
155
+ settings.hooks[eventName].push(...sfxMatchers);
156
+ eventsAdded.push(eventName);
157
+ }
158
+ writeSettings(settingsPath, settings);
159
+ return { settingsPath, eventsAdded };
160
+ }
161
+ /**
162
+ * Remove all claude-sfx hooks from .claude/settings.json.
163
+ * Preserves hooks from other tools.
164
+ */
165
+ export function uninstallHooks(cwd) {
166
+ const settingsPath = getSettingsPath(cwd);
167
+ if (!existsSync(settingsPath)) {
168
+ return { settingsPath, removed: false };
169
+ }
170
+ const settings = readSettings(settingsPath);
171
+ if (!settings.hooks) {
172
+ return { settingsPath, removed: false };
173
+ }
174
+ let removedAny = false;
175
+ for (const eventName of Object.keys(settings.hooks)) {
176
+ const before = settings.hooks[eventName].length;
177
+ settings.hooks[eventName] = settings.hooks[eventName].filter((m) => !containsSfxHook(m));
178
+ if (settings.hooks[eventName].length < before) {
179
+ removedAny = true;
180
+ }
181
+ // Clean up empty event arrays
182
+ if (settings.hooks[eventName].length === 0) {
183
+ delete settings.hooks[eventName];
184
+ }
185
+ }
186
+ // Clean up empty hooks object
187
+ if (Object.keys(settings.hooks).length === 0) {
188
+ delete settings.hooks;
189
+ }
190
+ writeSettings(settingsPath, settings);
191
+ return { settingsPath, removed: removedAny };
192
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Cross-platform audio playback.
3
+ * Writes a temp .wav file, plays it with the OS native player, cleans up.
4
+ * Zero dependencies.
5
+ */
6
+ export interface PlayResult {
7
+ played: boolean;
8
+ method: string;
9
+ durationMs: number;
10
+ }
11
+ /** Play a PCM audio buffer through the system speakers. Blocks until done. */
12
+ export declare function playSync(buffer: Float64Array): PlayResult;
13
+ /** Play a PCM audio buffer asynchronously (fire and forget). */
14
+ export declare function playAsync(buffer: Float64Array): void;
15
+ /** Write a WAV file to disk (for export / debugging). */
16
+ export declare function saveWav(buffer: Float64Array, outputPath: string): void;