@claude-code-hooks/cli 0.1.8 → 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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/cli.js +203 -100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-code-hooks/cli",
3
- "version": "0.1.8",
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",
@@ -30,7 +30,7 @@
30
30
  "@claude-code-hooks/core": "0.1.3",
31
31
  "@claude-code-hooks/security": "0.1.7",
32
32
  "@claude-code-hooks/secrets": "0.1.8",
33
- "@claude-code-hooks/sound": "0.2.10",
33
+ "@claude-code-hooks/sound": "0.2.11",
34
34
  "@claude-code-hooks/notification": "0.1.5"
35
35
  },
36
36
  "keywords": [
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,
@@ -42,6 +48,30 @@ function dieCancelled(msg = 'Cancelled') {
42
48
  process.exit(0);
43
49
  }
44
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
+
45
75
  function usage(exitCode = 0) {
46
76
  process.stdout.write(`\
47
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`);
@@ -85,116 +115,189 @@ async function main() {
85
115
  const projectDir = process.cwd();
86
116
 
87
117
  intro('claude-code-hooks');
118
+ note(`${pc.dim('ESC')} to exit • ${pc.dim('Backspace')} to go back`, 'Navigation');
88
119
 
89
- // ── Step 1–3: action, target, packages (with simple back navigation) ──
120
+ // ── Step 1–3: action, target, packages (Backspace = go to previous step) ──
90
121
  let action;
91
122
  let target;
92
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 };
93
127
 
94
- while (true) {
95
- action = await select({
96
- message: `${pc.dim('Step 1/5')} Choose an action`,
97
- options: [
98
- { value: 'setup', label: 'Install / update packages' },
99
- { value: 'uninstall', label: 'Uninstall (remove managed hooks)' },
100
- { value: 'exit', label: 'Exit' }
101
- ]
102
- });
103
- if (isCancel(action) || action === 'exit') dieCancelled('Bye');
104
-
105
- target = await select({
106
- message: `${pc.dim('Step 2/5')} Choose a target`,
107
- options: [
108
- { value: 'global', label: `Global (default): ${pc.dim('~/.claude/settings.json')}` },
109
- { value: 'projectOnly', label: `Project-only: write ${pc.bold(CONFIG_FILENAME)} + print a snippet` },
110
- { value: '__back__', label: 'Back' }
111
- ]
112
- });
113
- if (isCancel(target)) dieCancelled();
114
- if (target === '__back__') continue;
115
-
116
- selected = await multiselect({
117
- message: `${pc.dim('Step 3/5')} Select packages`,
118
- options: [
119
- { value: 'security', label: '@claude-code-hooks/security', hint: 'Warn/block risky commands' },
120
- { value: 'secrets', label: '@claude-code-hooks/secrets', hint: 'Detect secret-like tokens' },
121
- { value: 'sound', label: '@claude-code-hooks/sound', hint: 'Play sounds for key events' },
122
- { value: 'notification', label: '@claude-code-hooks/notification', hint: 'OS notifications for key events' }
123
- ],
124
- required: true
125
- });
126
- 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
+ ];
127
134
 
128
- const proceed = await confirm({ message: 'Configure these packages now?', initialValue: true });
129
- if (isCancel(proceed)) dieCancelled();
130
- 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');
131
297
 
132
298
  break;
133
299
  }
134
300
 
135
- // Build per-package plan/config
136
- const perPackage = { security: null, secrets: null, sound: null, notification: null };
137
-
138
- // ── Step 4/5: configure ──
139
- note(
140
- selected
141
- .map(
142
- (k) =>
143
- `${pc.bold(k)}: ${pc.dim(
144
- {
145
- security: 'Warn/block risky commands',
146
- secrets: 'Detect secret-like tokens',
147
- sound: 'Play sounds for key events',
148
- notification: 'OS notifications for key events'
149
- }[k] || ''
150
- )}`
151
- )
152
- .join('\n'),
153
- `${pc.dim('Step 4/5')} Configure packages`
154
- );
155
-
156
- if (selected.includes('security')) perPackage.security = await planSecuritySetup({ action, projectDir, ui: 'umbrella' });
157
- if (selected.includes('secrets')) perPackage.secrets = await planSecretsSetup({ action, projectDir, ui: 'umbrella' });
158
- if (selected.includes('sound')) perPackage.sound = await planSoundSetup({ action, projectDir, ui: 'umbrella' });
159
- if (selected.includes('notification')) perPackage.notification = await planNotificationSetup({ action, projectDir, ui: 'umbrella' });
160
-
161
- // ── Step 5/5: review ──
162
- const files = [];
163
- if (target === 'global') files.push(configPathForScope('global', projectDir));
164
- if (target === 'projectOnly') {
165
- files.push(path.join(projectDir, CONFIG_FILENAME));
166
- files.push(path.join(projectDir, 'claude-code-hooks.snippet.json (optional)'));
167
- }
168
-
169
- function summarizePlan(key, plan) {
170
- if (!plan) return `${key}: (skipped)`;
171
- if (action === 'uninstall') return `${key}: remove managed hooks`;
172
-
173
- const events = plan.snippetHooks ? Object.keys(plan.snippetHooks) : [];
174
- const list = events.slice(0, 5);
175
- const tail = events.length > 5 ? ` +${events.length - 5} more` : '';
176
- return `${key}: ${events.length} event(s)${events.length ? ` (${list.join(', ')}${tail})` : ''}`;
177
- }
178
-
179
- note(
180
- [
181
- `${pc.dim('Step 5/5')} Review`,
182
- '',
183
- `Action: ${pc.bold(action)}`,
184
- `Target: ${pc.bold(target === 'global' ? 'global settings' : 'project-only')}`,
185
- '',
186
- `${pc.bold('Packages')}`,
187
- ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
188
- '',
189
- `${pc.bold('Files')}`,
190
- ...files.map((f) => ` - ${f}`)
191
- ].join('\n'),
192
- 'Review'
193
- );
194
-
195
- const ok = await confirm({ message: 'Apply changes?', initialValue: true });
196
- if (isCancel(ok) || !ok) dieCancelled('No changes made');
197
-
198
301
  if (target === 'projectOnly') {
199
302
  // Write project config
200
303
  const s = spinner();