@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/LICENSE +21 -0
- package/README.es.md +188 -0
- package/README.fr.md +188 -0
- package/README.hi.md +188 -0
- package/README.it.md +188 -0
- package/README.ja.md +188 -0
- package/README.md +188 -0
- package/README.pt-BR.md +188 -0
- package/README.zh.md +188 -0
- package/assets/logo.jpg +0 -0
- package/dist/ambient.d.ts +28 -0
- package/dist/ambient.js +179 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +594 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +104 -0
- package/dist/guard.d.ts +19 -0
- package/dist/guard.js +87 -0
- package/dist/hook-handler.d.ts +34 -0
- package/dist/hook-handler.js +200 -0
- package/dist/hooks.d.ts +35 -0
- package/dist/hooks.js +192 -0
- package/dist/player.d.ts +16 -0
- package/dist/player.js +109 -0
- package/dist/profiles.d.ts +103 -0
- package/dist/profiles.js +297 -0
- package/dist/synth.d.ts +72 -0
- package/dist/synth.js +254 -0
- package/dist/verbs.d.ts +25 -0
- package/dist/verbs.js +251 -0
- package/package.json +54 -0
- package/profiles/minimal.json +202 -0
- package/profiles/retro.json +200 -0
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 };
|
package/dist/guard.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/player.d.ts
ADDED
|
@@ -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;
|