@claude-code-hooks/cli 0.1.7 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +238 -0
  2. package/package.json +6 -6
  3. package/src/cli.js +208 -101
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # @claude-code-hooks/cli
2
+
3
+ > Part of the [claude-code-hooks](../../README.md) monorepo.
4
+
5
+ Umbrella wizard CLI to set up and manage `@claude-code-hooks` packages for Claude Code (sound, notification, security, secrets).
6
+
7
+ ![CLI install and usage demo](https://github.com/beefiker/claude-code-hooks/raw/main/images/claude-code-hooks-cli.gif)
8
+
9
+ ## Install / run
10
+
11
+ From anywhere:
12
+
13
+ ```bash
14
+ npx @claude-code-hooks/cli@latest
15
+ ```
16
+
17
+ You'll be prompted to choose which packages to configure and where to write settings:
18
+
19
+ - Project (shared): `.claude/settings.json`
20
+ - Project (local): `.claude/settings.local.json`
21
+ - Global: `~/.claude/settings.json`
22
+
23
+ Then you can enable/disable each package (sound, notification, security, secrets) and customize their options.
24
+
25
+ ---
26
+
27
+ ## @claude-code-hooks/sound
28
+
29
+ Cross-platform CLI (macOS, Windows, Linux) that configures **Claude Code Hooks** to play **bundled sounds**.
30
+
31
+ ![claude-sound CLI](https://github.com/beefiker/claude-code-hooks/raw/main/packages/sound/assets/images/how-to-use.gif)
32
+
33
+ - Setup UI: `npx @claude-code-hooks/sound@latest`
34
+ - Hook runner: `npx --yes @claude-code-hooks/sound@latest play --event <Event> --sound <SoundId> --managed-by @claude-code-hooks/sound`
35
+
36
+ ### Install / run
37
+
38
+ ```bash
39
+ npx @claude-code-hooks/sound@latest
40
+ ```
41
+
42
+ You'll be prompted to choose where to write settings, then enable/disable events and choose a sound per event. Selecting a sound plays a quick preview. Choose **Create my own** to generate custom text-to-speech sounds, or **Import from file** to add your own MP3/WAV files.
43
+
44
+ ### Commands
45
+
46
+ ```bash
47
+ claude-sound list-events
48
+ claude-sound list-sounds
49
+ claude-sound play --sound ring1
50
+ claude-sound import <path> # Import MP3/WAV into ~/.claude-sound/sounds/
51
+ ```
52
+
53
+ ### Uninstall / remove hooks
54
+
55
+ Run the setup again and choose **Remove all claude-sound hooks**, then **Apply**. Or manually delete any hook handlers whose command contains:
56
+
57
+ ```
58
+ --managed-by @claude-code-hooks/sound
59
+ ```
60
+
61
+ ### Create my own (text-to-speech)
62
+
63
+ When picking a sound, choose **Create my own** to generate custom sounds from text. Supports English (default) and Korean. See [packages/sound/docs/TTS.md](../sound/docs/TTS.md) for details.
64
+
65
+ ### Import from file
66
+
67
+ Choose **Import from file** and enter a path to an MP3 or WAV file. Or use the CLI:
68
+
69
+ ```bash
70
+ claude-sound import ./my-notification.mp3
71
+ ```
72
+
73
+ Supported formats: MP3, WAV. Max file size: 5MB.
74
+
75
+ ### Platform support
76
+
77
+ | Platform | Audio player | Notes |
78
+ |----------|--------------|-------|
79
+ | **macOS** | `afplay` | Built-in, no setup needed |
80
+ | **Windows** | `ffplay`, `mpv`, `mpg123`, or PowerShell | Install [ffmpeg](https://ffmpeg.org/) or [mpv](https://mpv.io/) for best support. PowerShell plays WAV only. |
81
+ | **Linux** | `ffplay`, `mpv`, `mpg123`, `aplay`, etc. | Install ffmpeg or mpv for MP3 support. |
82
+
83
+ ---
84
+
85
+ ## @claude-code-hooks/notification
86
+
87
+ **Zero-dependency** CLI that sends OS-level notifications from Claude Code hooks.
88
+
89
+ - **macOS**: `osascript` (`display notification`)
90
+ - **Linux**: `notify-send` (libnotify)
91
+ - **Windows**: PowerShell toast via Windows Runtime types (no external modules)
92
+
93
+ Falls back to stdout when no GUI environment is detected (SSH, headless, CI).
94
+
95
+ ### Install / run
96
+
97
+ ```bash
98
+ npx --yes @claude-code-hooks/notification@latest --event Stop
99
+ ```
100
+
101
+ ### CLI options
102
+
103
+ ```
104
+ --title <text> Notification title (default: derived from --event or "Claude Code")
105
+ --message <text> Notification body (default: derived from --event or stdin JSON)
106
+ --event <name> Hook event name (e.g. Stop, Notification, TaskCompleted)
107
+ --dry-run Build the command but don't execute it (prints JSON to stdout)
108
+ ```
109
+
110
+ ### Usage as a Claude Code hook
111
+
112
+ Add to your Claude Code `settings.json`:
113
+
114
+ ```json
115
+ {
116
+ "hooks": {
117
+ "Stop": [
118
+ {
119
+ "matcher": "*",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "npx --yes @claude-code-hooks/notification@latest --event Stop",
124
+ "timeout": 8
125
+ }
126
+ ]
127
+ }
128
+ ]
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### Hook event types
134
+
135
+ | Event | Description |
136
+ |-------|-------------|
137
+ | `SessionStart` | Session begins |
138
+ | `Stop` | Claude stops generating |
139
+ | `TaskCompleted` | Task is complete |
140
+ | `Notification` | Claude wants your attention |
141
+ | `PostToolUseFailure` | After a tool fails |
142
+ | ... and more |
143
+
144
+ ### Platform support
145
+
146
+ | Platform | Method | Requirements |
147
+ |----------|--------|--------------|
148
+ | **macOS** | `osascript` | Built-in |
149
+ | **Linux** | `notify-send` | Install `libnotify-bin` (apt) or `libnotify` (pacman/dnf) |
150
+ | **Windows** | PowerShell toast | Built-in PowerShell 5+ |
151
+ | **Other/Headless** | stdout | Prints `[notification] Title: Message` to stdout |
152
+
153
+ ---
154
+
155
+ ## @claude-code-hooks/security
156
+
157
+ Warns (or optionally **blocks**) risky commands/tool invocations. Heuristic and lightweight: scans for suspicious patterns like `rm -rf`, `curl | bash`, writes to `~/.ssh`, etc.
158
+
159
+ ### Install / run
160
+
161
+ ```bash
162
+ npx @claude-code-hooks/security@latest
163
+ ```
164
+
165
+ ### Project config: claude-code-hooks.config.json
166
+
167
+ ```json
168
+ {
169
+ "security": {
170
+ "mode": "warn",
171
+ "enabledEvents": ["PreToolUse", "PermissionRequest"],
172
+ "ignore": { "regex": [] },
173
+ "allow": { "regex": [] }
174
+ }
175
+ }
176
+ ```
177
+
178
+ - `allow.regex`: if any pattern matches, **all risks are suppressed**
179
+ - `ignore.regex`: if any pattern matches, risks are suppressed and a dim note is printed
180
+
181
+ ### Modes
182
+
183
+ - `warn` (default): prints warnings to stderr, exits 0
184
+ - `block`: exits 2 when a risk is detected (**PreToolUse only**; `PermissionRequest` stays advisory)
185
+
186
+ ### Commands
187
+
188
+ ```bash
189
+ claude-security list-events
190
+ claude-security run --event PreToolUse --mode warn
191
+ claude-security doctor
192
+ ```
193
+
194
+ ---
195
+
196
+ ## @claude-code-hooks/secrets
197
+
198
+ Warns when **secret-like tokens** appear in tool inputs — and optionally scans **staged git files** before commits.
199
+
200
+ ### Install / run
201
+
202
+ ```bash
203
+ npx @claude-code-hooks/secrets@latest
204
+ ```
205
+
206
+ ### Project config: claude-code-hooks.config.json
207
+
208
+ ```json
209
+ {
210
+ "secrets": {
211
+ "mode": "warn",
212
+ "enabledEvents": ["PreToolUse", "PermissionRequest"],
213
+ "scanGitCommit": false,
214
+ "ignore": { "regex": [] },
215
+ "allow": { "regex": [] }
216
+ }
217
+ }
218
+ ```
219
+
220
+ - `scanGitCommit`: when `true`, intercepts `git commit` and scans staged files for secret patterns
221
+
222
+ ### Modes
223
+
224
+ - `warn` (default): prints warnings to stderr, exits 0
225
+ - `block`: exits 2 **only for HIGH confidence** findings (private key material)
226
+
227
+ ### What it detects
228
+
229
+ - **HIGH**: `-----BEGIN (RSA|OPENSSH|EC|PGP|DSA|ENCRYPTED) PRIVATE KEY-----`
230
+ - **MED**: OpenAI `sk-...`, GitHub `ghp_...` / `github_pat_...`, AWS `AKIA...`, Slack `xox*`, Google `AIza...`, Stripe `sk_live_...`, npm `npm_...`, PyPI `pypi-...`, DB URLs with credentials, etc.
231
+
232
+ ### Commands
233
+
234
+ ```bash
235
+ claude-secrets list-events
236
+ claude-secrets run --event PreToolUse --mode warn
237
+ claude-secrets doctor
238
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-code-hooks/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Wizard CLI to set up and manage @claude-code-hooks packages for Claude Code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/beefiker/claude-code-hooks/tree/main/packages/cli",
@@ -27,11 +27,11 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^1.0.0",
30
- "@claude-code-hooks/core": "0.1.2",
31
- "@claude-code-hooks/security": "0.1.4",
32
- "@claude-code-hooks/secrets": "0.1.5",
33
- "@claude-code-hooks/sound": "0.2.8",
34
- "@claude-code-hooks/notification": "0.1.2"
30
+ "@claude-code-hooks/core": "0.1.3",
31
+ "@claude-code-hooks/security": "0.1.7",
32
+ "@claude-code-hooks/secrets": "0.1.8",
33
+ "@claude-code-hooks/sound": "0.2.11",
34
+ "@claude-code-hooks/notification": "0.1.5"
35
35
  },
36
36
  "keywords": [
37
37
  "claude",
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import process from 'node:process';
4
+ import readline from 'node:readline';
4
5
  import fs from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
 
@@ -16,6 +17,11 @@ import {
16
17
  spinner
17
18
  } from '@clack/prompts';
18
19
 
20
+ // Enable keypress events so we can intercept Backspace
21
+ if (process.stdin.isTTY) {
22
+ readline.emitKeypressEvents(process.stdin);
23
+ }
24
+
19
25
  import {
20
26
  ansi as pc,
21
27
  configPathForScope,
@@ -24,7 +30,8 @@ import {
24
30
  CONFIG_FILENAME,
25
31
  configFilePath,
26
32
  readProjectConfig,
27
- writeProjectConfig
33
+ writeProjectConfig,
34
+ removeLegacyClaudeSoundHooks
28
35
  } from '@claude-code-hooks/core';
29
36
 
30
37
  import { buildSettingsSnippet } from './snippet.js';
@@ -41,6 +48,30 @@ function dieCancelled(msg = 'Cancelled') {
41
48
  process.exit(0);
42
49
  }
43
50
 
51
+ /**
52
+ * Run a prompt with Backspace = go back. When Backspace is pressed, aborts and returns
53
+ * { wentBack: true }. ESC still exits. Caller must handle wentBack by continuing the loop.
54
+ * @param {AbortController} controller
55
+ * @param {() => Promise<T>} runPrompt - async function that runs the prompt (receives no args)
56
+ * @returns {Promise<{ result: T; wentBack: boolean }>}
57
+ */
58
+ async function withBackspaceBack(controller, runPrompt) {
59
+ let wentBack = false;
60
+ const handler = (_str, key) => {
61
+ if (key?.name === 'backspace') {
62
+ wentBack = true;
63
+ controller.abort();
64
+ }
65
+ };
66
+ process.stdin.on('keypress', handler);
67
+ try {
68
+ const result = await runPrompt();
69
+ return { result, wentBack };
70
+ } finally {
71
+ process.stdin.off('keypress', handler);
72
+ }
73
+ }
74
+
44
75
  function usage(exitCode = 0) {
45
76
  process.stdout.write(`\
46
77
  claude-code-hooks\n\nUsage:\n claude-code-hooks\n npx @claude-code-hooks/cli@latest\n\nWhat it does:\n - Update Claude Code settings (global), or generate a project-only config + pasteable snippet.\n`);
@@ -84,116 +115,189 @@ async function main() {
84
115
  const projectDir = process.cwd();
85
116
 
86
117
  intro('claude-code-hooks');
118
+ note(`${pc.dim('ESC')} to exit • ${pc.dim('Backspace')} to go back`, 'Navigation');
87
119
 
88
- // ── Step 1–3: action, target, packages (with simple back navigation) ──
120
+ // ── Step 1–3: action, target, packages (Backspace = go to previous step) ──
89
121
  let action;
90
122
  let target;
91
123
  let selected;
124
+ let step = 1;
125
+ /** @type {{ security: unknown; secrets: unknown; sound: unknown; notification: unknown }} */
126
+ let perPackage = { security: null, secrets: null, sound: null, notification: null };
92
127
 
93
- while (true) {
94
- action = await select({
95
- message: `${pc.dim('Step 1/5')} Choose an action`,
96
- options: [
97
- { value: 'setup', label: 'Install / update packages' },
98
- { value: 'uninstall', label: 'Uninstall (remove managed hooks)' },
99
- { value: 'exit', label: 'Exit' }
100
- ]
101
- });
102
- if (isCancel(action) || action === 'exit') dieCancelled('Bye');
103
-
104
- target = await select({
105
- message: `${pc.dim('Step 2/5')} Choose a target`,
106
- options: [
107
- { value: 'global', label: `Global (default): ${pc.dim('~/.claude/settings.json')}` },
108
- { value: 'projectOnly', label: `Project-only: write ${pc.bold(CONFIG_FILENAME)} + print a snippet` },
109
- { value: '__back__', label: 'Back' }
110
- ]
111
- });
112
- if (isCancel(target)) dieCancelled();
113
- if (target === '__back__') continue;
114
-
115
- selected = await multiselect({
116
- message: `${pc.dim('Step 3/5')} Select packages`,
117
- options: [
118
- { value: 'security', label: '@claude-code-hooks/security', hint: 'Warn/block risky commands' },
119
- { value: 'secrets', label: '@claude-code-hooks/secrets', hint: 'Detect secret-like tokens' },
120
- { value: 'sound', label: '@claude-code-hooks/sound', hint: 'Play sounds for key events' },
121
- { value: 'notification', label: '@claude-code-hooks/notification', hint: 'OS notifications for key events' }
122
- ],
123
- required: true
124
- });
125
- if (isCancel(selected)) dieCancelled();
128
+ const packageOptions = [
129
+ { value: 'security', label: '@claude-code-hooks/security', hint: 'Warn/block risky commands' },
130
+ { value: 'secrets', label: '@claude-code-hooks/secrets', hint: 'Detect secret-like tokens' },
131
+ { value: 'sound', label: '@claude-code-hooks/sound', hint: 'Play sounds for key events' },
132
+ { value: 'notification', label: '@claude-code-hooks/notification', hint: 'OS notifications for key events' }
133
+ ];
126
134
 
127
- const proceed = await confirm({ message: 'Configure these packages now?', initialValue: true });
128
- if (isCancel(proceed)) dieCancelled();
129
- if (!proceed) continue;
135
+ while (true) {
136
+ if (step === 1) {
137
+ action = await select({
138
+ message: `${pc.dim('Step 1/5')} Choose an action`,
139
+ options: [
140
+ { value: 'setup', label: 'Install / update packages' },
141
+ { value: 'uninstall', label: 'Uninstall (remove managed hooks)' },
142
+ { value: 'exit', label: 'Exit' }
143
+ ]
144
+ });
145
+ if (isCancel(action) || action === 'exit') dieCancelled('Bye');
146
+ step = 2;
147
+ }
148
+
149
+ if (step === 2) {
150
+ const targetCtrl = new AbortController();
151
+ const { result: targetResult, wentBack: targetBack } = await withBackspaceBack(targetCtrl, () =>
152
+ select({
153
+ message: `${pc.dim('Step 2/5')} Choose a target`,
154
+ options: [
155
+ { value: 'global', label: `Global (default): ${pc.dim('~/.claude/settings.json')}` },
156
+ { value: 'projectOnly', label: `Project-only: write ${pc.bold(CONFIG_FILENAME)} + print a snippet` }
157
+ ],
158
+ signal: targetCtrl.signal
159
+ })
160
+ );
161
+ if (targetBack) {
162
+ step = 1;
163
+ continue;
164
+ }
165
+ if (isCancel(targetResult)) dieCancelled();
166
+ target = targetResult;
167
+ step = 3;
168
+ }
169
+
170
+ if (step === 3) {
171
+ const pkgsCtrl = new AbortController();
172
+ const { result: pkgsResult, wentBack: pkgsBack } = await withBackspaceBack(pkgsCtrl, () =>
173
+ multiselect({
174
+ message: `${pc.dim('Step 3/5')} Select packages`,
175
+ options: packageOptions,
176
+ required: true,
177
+ signal: pkgsCtrl.signal
178
+ })
179
+ );
180
+ if (pkgsBack) {
181
+ step = 2;
182
+ continue;
183
+ }
184
+ if (isCancel(pkgsResult)) dieCancelled();
185
+ selected = pkgsResult;
186
+ step = 4;
187
+ }
188
+
189
+ if (step === 4) {
190
+ const proceedCtrl = new AbortController();
191
+ const { result: proceedResult, wentBack: proceedBack } = await withBackspaceBack(proceedCtrl, () =>
192
+ confirm({ message: 'Configure these packages now?', initialValue: true, signal: proceedCtrl.signal })
193
+ );
194
+ if (proceedBack) {
195
+ step = 3;
196
+ continue;
197
+ }
198
+ if (isCancel(proceedResult)) dieCancelled();
199
+ if (!proceedResult) {
200
+ step = 2;
201
+ continue;
202
+ }
203
+ step = 5;
204
+ }
205
+
206
+ if (step !== 5) continue;
207
+
208
+ // Build per-package plan/config
209
+ perPackage = { security: null, secrets: null, sound: null, notification: null };
210
+
211
+ const packageDescs = {
212
+ security: 'Warn/block risky commands',
213
+ secrets: 'Detect secret-like tokens',
214
+ sound: 'Play sounds for key events',
215
+ notification: 'OS notifications for key events'
216
+ };
217
+
218
+ const formatPackageList = (/** @type {string | null} */ highlightKey) =>
219
+ selected
220
+ .map(
221
+ (k) => {
222
+ const desc = pc.dim(packageDescs[k] || '');
223
+ const label = highlightKey === k ? pc.cyan(pc.bold(k)) : pc.bold(k);
224
+ return `${label}: ${desc}`;
225
+ }
226
+ )
227
+ .join('\n');
228
+
229
+ // ── Step 4/5: configure (highlight current package) ──
230
+ if (selected.includes('security')) {
231
+ note(formatPackageList('security'), `${pc.dim('Step 4/5')} Configure packages`);
232
+ perPackage.security = await planSecuritySetup({ action, projectDir, ui: 'umbrella' });
233
+ }
234
+ if (selected.includes('secrets')) {
235
+ note(formatPackageList('secrets'), `${pc.dim('Step 4/5')} Configure packages`);
236
+ perPackage.secrets = await planSecretsSetup({ action, projectDir, ui: 'umbrella' });
237
+ }
238
+ if (selected.includes('sound')) {
239
+ note(formatPackageList('sound'), `${pc.dim('Step 4/5')} Configure packages`);
240
+ perPackage.sound = await planSoundSetup({ action, projectDir, ui: 'umbrella' });
241
+ }
242
+ if (selected.includes('notification')) {
243
+ note(formatPackageList('notification'), `${pc.dim('Step 4/5')} Configure packages`);
244
+ perPackage.notification = await planNotificationSetup({ action, projectDir, ui: 'umbrella' });
245
+ }
246
+
247
+ // ── Step 5/5: review ──
248
+ const files = [];
249
+ if (target === 'global') files.push(configPathForScope('global', projectDir));
250
+ if (target === 'projectOnly') {
251
+ files.push(path.join(projectDir, CONFIG_FILENAME));
252
+ files.push(path.join(projectDir, 'claude-code-hooks.snippet.json (optional)'));
253
+ }
254
+
255
+ function summarizePlan(key, plan) {
256
+ if (!plan) return `${key}: (skipped)`;
257
+ if (action === 'uninstall') return `${key}: remove managed hooks`;
258
+
259
+ const events = plan.snippetHooks ? Object.keys(plan.snippetHooks) : [];
260
+ const list = events.slice(0, 5);
261
+ const tail = events.length > 5 ? ` +${events.length - 5} more` : '';
262
+ return `${key}: ${events.length} event(s)${events.length ? ` (${list.join(', ')}${tail})` : ''}`;
263
+ }
264
+
265
+ note(
266
+ [
267
+ `${pc.dim('Step 5/5')} Review`,
268
+ '',
269
+ `Action: ${pc.bold(action)}`,
270
+ `Target: ${pc.bold(target === 'global' ? 'global settings' : 'project-only')}`,
271
+ '',
272
+ `${pc.bold('Packages')}`,
273
+ ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
274
+ '',
275
+ `${pc.bold('Files')}`,
276
+ ...files.map((f) => ` - ${f}`)
277
+ ].join('\n'),
278
+ 'Review'
279
+ );
280
+
281
+ const applyCtrl = new AbortController();
282
+ const { result: applyResult, wentBack: applyBack } = await withBackspaceBack(applyCtrl, () =>
283
+ select({
284
+ message: 'Apply changes?',
285
+ options: [
286
+ { value: 'yes', label: 'Yes, apply' },
287
+ { value: 'cancel', label: 'Cancel (exit)' }
288
+ ],
289
+ signal: applyCtrl.signal
290
+ })
291
+ );
292
+ if (applyBack) {
293
+ step = 3;
294
+ continue;
295
+ }
296
+ if (isCancel(applyResult) || applyResult === 'cancel') dieCancelled('No changes made');
130
297
 
131
298
  break;
132
299
  }
133
300
 
134
- // Build per-package plan/config
135
- const perPackage = { security: null, secrets: null, sound: null, notification: null };
136
-
137
- // ── Step 4/5: configure ──
138
- note(
139
- selected
140
- .map(
141
- (k) =>
142
- `${pc.bold(k)}: ${pc.dim(
143
- {
144
- security: 'Warn/block risky commands',
145
- secrets: 'Detect secret-like tokens',
146
- sound: 'Play sounds for key events',
147
- notification: 'OS notifications for key events'
148
- }[k] || ''
149
- )}`
150
- )
151
- .join('\n'),
152
- `${pc.dim('Step 4/5')} Configure packages`
153
- );
154
-
155
- if (selected.includes('security')) perPackage.security = await planSecuritySetup({ action, projectDir, ui: 'umbrella' });
156
- if (selected.includes('secrets')) perPackage.secrets = await planSecretsSetup({ action, projectDir, ui: 'umbrella' });
157
- if (selected.includes('sound')) perPackage.sound = await planSoundSetup({ action, projectDir, ui: 'umbrella' });
158
- if (selected.includes('notification')) perPackage.notification = await planNotificationSetup({ action, projectDir, ui: 'umbrella' });
159
-
160
- // ── Step 5/5: review ──
161
- const files = [];
162
- if (target === 'global') files.push(configPathForScope('global', projectDir));
163
- if (target === 'projectOnly') {
164
- files.push(path.join(projectDir, CONFIG_FILENAME));
165
- files.push(path.join(projectDir, 'claude-code-hooks.snippet.json (optional)'));
166
- }
167
-
168
- function summarizePlan(key, plan) {
169
- if (!plan) return `${key}: (skipped)`;
170
- if (action === 'uninstall') return `${key}: remove managed hooks`;
171
-
172
- const events = plan.snippetHooks ? Object.keys(plan.snippetHooks) : [];
173
- const list = events.slice(0, 5);
174
- const tail = events.length > 5 ? ` +${events.length - 5} more` : '';
175
- return `${key}: ${events.length} event(s)${events.length ? ` (${list.join(', ')}${tail})` : ''}`;
176
- }
177
-
178
- note(
179
- [
180
- `${pc.dim('Step 5/5')} Review`,
181
- '',
182
- `Action: ${pc.bold(action)}`,
183
- `Target: ${pc.bold(target === 'global' ? 'global settings' : 'project-only')}`,
184
- '',
185
- `${pc.bold('Packages')}`,
186
- ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
187
- '',
188
- `${pc.bold('Files')}`,
189
- ...files.map((f) => ` - ${f}`)
190
- ].join('\n'),
191
- 'Review'
192
- );
193
-
194
- const ok = await confirm({ message: 'Apply changes?', initialValue: true });
195
- if (isCancel(ok) || !ok) dieCancelled('No changes made');
196
-
197
301
  if (target === 'projectOnly') {
198
302
  // Write project config
199
303
  const s = spinner();
@@ -247,6 +351,9 @@ async function main() {
247
351
  settings = await plan.applyToSettings(settings);
248
352
  }
249
353
 
354
+ // Remove legacy claude-sound hooks (from old standalone package) to avoid duplicates
355
+ settings = removeLegacyClaudeSoundHooks(settings);
356
+
250
357
  await writeJson(settingsPath, settings);
251
358
 
252
359
  // Update project config only on setup.