@claude-code-hooks/cli 0.1.8 → 0.1.10

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 +225 -115
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.10",
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,
@@ -25,7 +31,9 @@ import {
25
31
  configFilePath,
26
32
  readProjectConfig,
27
33
  writeProjectConfig,
28
- removeLegacyClaudeSoundHooks
34
+ removeLegacyClaudeSoundHooks,
35
+ t,
36
+ detectLanguage
29
37
  } from '@claude-code-hooks/core';
30
38
 
31
39
  import { buildSettingsSnippet } from './snippet.js';
@@ -37,14 +45,37 @@ import { planInteractiveSetup as planSecretsSetup } from '@claude-code-hooks/sec
37
45
  import { planInteractiveSetup as planSoundSetup } from '@claude-code-hooks/sound/src/plan.js';
38
46
  import { planInteractiveSetup as planNotificationSetup } from '@claude-code-hooks/notification/src/plan.js';
39
47
 
40
- function dieCancelled(msg = 'Cancelled') {
41
- cancel(msg);
48
+ function dieCancelled(msg) {
49
+ cancel(msg ?? t('cli.cancelled'));
42
50
  process.exit(0);
43
51
  }
44
52
 
53
+ /**
54
+ * Run a prompt with Backspace = go back. When Backspace is pressed, aborts and returns
55
+ * { wentBack: true }. ESC still exits. Caller must handle wentBack by continuing the loop.
56
+ * @param {AbortController} controller
57
+ * @param {() => Promise<T>} runPrompt - async function that runs the prompt (receives no args)
58
+ * @returns {Promise<{ result: T; wentBack: boolean }>}
59
+ */
60
+ async function withBackspaceBack(controller, runPrompt) {
61
+ let wentBack = false;
62
+ const handler = (_str, key) => {
63
+ if (key?.name === 'backspace') {
64
+ wentBack = true;
65
+ controller.abort();
66
+ }
67
+ };
68
+ process.stdin.on('keypress', handler);
69
+ try {
70
+ const result = await runPrompt();
71
+ return { result, wentBack };
72
+ } finally {
73
+ process.stdin.off('keypress', handler);
74
+ }
75
+ }
76
+
45
77
  function usage(exitCode = 0) {
46
- process.stdout.write(`\
47
- 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`);
78
+ process.stdout.write(t('cli.usage') + '\n');
48
79
  process.exit(exitCode);
49
80
  }
50
81
 
@@ -67,7 +98,7 @@ async function ensureProjectOnlyConfig(projectDir, selected, perPackageConfig) {
67
98
 
68
99
  async function maybeWriteSnippet(projectDir, snippetObj) {
69
100
  const ok = await confirm({
70
- message: `Write snippet file (${pc.bold('claude-code-hooks.snippet.json')}) to this project?`,
101
+ message: t('cli.writeSnippet'),
71
102
  initialValue: false
72
103
  });
73
104
  if (isCancel(ok)) return;
@@ -75,130 +106,209 @@ async function maybeWriteSnippet(projectDir, snippetObj) {
75
106
 
76
107
  const filePath = path.join(projectDir, 'claude-code-hooks.snippet.json');
77
108
  await fs.writeFile(filePath, JSON.stringify(snippetObj, null, 2) + '\n');
78
- note(filePath, 'Wrote snippet');
109
+ note(filePath, t('cli.wroteSnippet'));
79
110
  }
80
111
 
81
112
  async function main() {
82
113
  const args = process.argv.slice(2);
83
114
  if (args.includes('-h') || args.includes('--help')) usage(0);
84
115
 
116
+ // i18n: --lang ko or env CLAUDE_CODE_HOOKS_LANG
117
+ const langIdx = args.indexOf('--lang');
118
+ if (langIdx !== -1 && args[langIdx + 1]) {
119
+ process.env.CLAUDE_CODE_HOOKS_LANG = args[langIdx + 1].toLowerCase().slice(0, 2);
120
+ }
121
+
85
122
  const projectDir = process.cwd();
86
123
 
87
124
  intro('claude-code-hooks');
125
+ note(t('cli.navHint'), t('cli.navTitle'));
88
126
 
89
- // ── Step 1–3: action, target, packages (with simple back navigation) ──
127
+ // ── Step 1–3: action, target, packages (Backspace = go to previous step) ──
90
128
  let action;
91
129
  let target;
92
130
  let selected;
131
+ let step = 1;
132
+ /** @type {{ security: unknown; secrets: unknown; sound: unknown; notification: unknown }} */
133
+ let perPackage = { security: null, secrets: null, sound: null, notification: null };
93
134
 
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();
135
+ const packageOptions = [
136
+ { value: 'security', label: t('cli.pkgSecurity'), hint: t('cli.pkgSecurityHint') },
137
+ { value: 'secrets', label: t('cli.pkgSecrets'), hint: t('cli.pkgSecretsHint') },
138
+ { value: 'sound', label: t('cli.pkgSound'), hint: t('cli.pkgSoundHint') },
139
+ { value: 'notification', label: t('cli.pkgNotification'), hint: t('cli.pkgNotificationHint') }
140
+ ];
127
141
 
128
- const proceed = await confirm({ message: 'Configure these packages now?', initialValue: true });
129
- if (isCancel(proceed)) dieCancelled();
130
- if (!proceed) continue;
142
+ while (true) {
143
+ if (step === 1) {
144
+ action = await select({
145
+ message: `${pc.dim(t('cli.stepFormat', { n: 1 }))} ${t('cli.step1ChooseAction')}`,
146
+ options: [
147
+ { value: 'setup', label: t('cli.actionSetup') },
148
+ { value: 'uninstall', label: t('cli.actionUninstall') },
149
+ { value: 'exit', label: t('cli.actionExit') }
150
+ ]
151
+ });
152
+ if (isCancel(action) || action === 'exit') dieCancelled(t('cli.bye'));
153
+ step = 2;
154
+ }
155
+
156
+ if (step === 2) {
157
+ const targetCtrl = new AbortController();
158
+ const { result: targetResult, wentBack: targetBack } = await withBackspaceBack(targetCtrl, () =>
159
+ select({
160
+ message: `${pc.dim(t('cli.stepFormat', { n: 2 }))} ${t('cli.step2ChooseTarget')}`,
161
+ options: [
162
+ { value: 'global', label: t('cli.targetGlobal') },
163
+ { value: 'projectOnly', label: t('cli.targetProjectOnly').replace(CONFIG_FILENAME, pc.bold(CONFIG_FILENAME)) }
164
+ ],
165
+ signal: targetCtrl.signal
166
+ })
167
+ );
168
+ if (targetBack) {
169
+ step = 1;
170
+ continue;
171
+ }
172
+ if (isCancel(targetResult)) dieCancelled();
173
+ target = targetResult;
174
+ step = 3;
175
+ }
176
+
177
+ if (step === 3) {
178
+ const pkgsCtrl = new AbortController();
179
+ const { result: pkgsResult, wentBack: pkgsBack } = await withBackspaceBack(pkgsCtrl, () =>
180
+ multiselect({
181
+ message: `${pc.dim(t('cli.stepFormat', { n: 3 }))} ${t('cli.step3SelectPackages')}`,
182
+ options: packageOptions,
183
+ required: true,
184
+ signal: pkgsCtrl.signal
185
+ })
186
+ );
187
+ if (pkgsBack) {
188
+ step = 2;
189
+ continue;
190
+ }
191
+ if (isCancel(pkgsResult)) dieCancelled();
192
+ selected = pkgsResult;
193
+ step = 4;
194
+ }
195
+
196
+ if (step === 4) {
197
+ const proceedCtrl = new AbortController();
198
+ const { result: proceedResult, wentBack: proceedBack } = await withBackspaceBack(proceedCtrl, () =>
199
+ confirm({ message: t('cli.configureNow'), initialValue: true, signal: proceedCtrl.signal })
200
+ );
201
+ if (proceedBack) {
202
+ step = 3;
203
+ continue;
204
+ }
205
+ if (isCancel(proceedResult)) dieCancelled();
206
+ if (!proceedResult) {
207
+ step = 2;
208
+ continue;
209
+ }
210
+ step = 5;
211
+ }
212
+
213
+ if (step !== 5) continue;
214
+
215
+ // Build per-package plan/config
216
+ perPackage = { security: null, secrets: null, sound: null, notification: null };
217
+
218
+ const packageDescs = {
219
+ security: t('cli.pkgSecurityHint'),
220
+ secrets: t('cli.pkgSecretsHint'),
221
+ sound: t('cli.pkgSoundHint'),
222
+ notification: t('cli.pkgNotificationHint')
223
+ };
224
+
225
+ const formatPackageList = (/** @type {string | null} */ highlightKey) =>
226
+ selected
227
+ .map(
228
+ (k) => {
229
+ const desc = pc.dim(packageDescs[k] || '');
230
+ const label = highlightKey === k ? pc.cyan(pc.bold(k)) : pc.bold(k);
231
+ return `${label}: ${desc}`;
232
+ }
233
+ )
234
+ .join('\n');
235
+
236
+ // ── Step 4/5: configure (highlight current package) ──
237
+ if (selected.includes('security')) {
238
+ note(formatPackageList('security'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
239
+ perPackage.security = await planSecuritySetup({ action, projectDir, ui: 'umbrella' });
240
+ }
241
+ if (selected.includes('secrets')) {
242
+ note(formatPackageList('secrets'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
243
+ perPackage.secrets = await planSecretsSetup({ action, projectDir, ui: 'umbrella' });
244
+ }
245
+ if (selected.includes('sound')) {
246
+ note(formatPackageList('sound'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
247
+ perPackage.sound = await planSoundSetup({ action, projectDir, ui: 'umbrella' });
248
+ }
249
+ if (selected.includes('notification')) {
250
+ note(formatPackageList('notification'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
251
+ perPackage.notification = await planNotificationSetup({ action, projectDir, ui: 'umbrella' });
252
+ }
253
+
254
+ // ── Step 5/5: review ──
255
+ const files = [];
256
+ if (target === 'global') files.push(configPathForScope('global', projectDir));
257
+ if (target === 'projectOnly') {
258
+ files.push(path.join(projectDir, CONFIG_FILENAME));
259
+ files.push(path.join(projectDir, 'claude-code-hooks.snippet.json (optional)'));
260
+ }
261
+
262
+ function summarizePlan(key, plan) {
263
+ if (!plan) return `${key}: ${t('cli.summarySkipped')}`;
264
+ if (action === 'uninstall') return `${key}: ${t('cli.summaryRemoveHooks')}`;
265
+
266
+ const events = plan.snippetHooks ? Object.keys(plan.snippetHooks) : [];
267
+ const list = events.slice(0, 5);
268
+ const tail = events.length > 5 ? ` +${events.length - 5} more` : '';
269
+ return `${key}: ${t('cli.summaryEvents', { count: events.length })}${events.length ? ` (${list.join(', ')}${tail})` : ''}`;
270
+ }
271
+
272
+ note(
273
+ [
274
+ `${pc.dim(t('cli.stepFormat', { n: 5 }))} ${t('cli.step5Review')}`,
275
+ '',
276
+ `${t('cli.reviewAction')}: ${pc.bold(action)}`,
277
+ `${t('cli.reviewTarget')}: ${pc.bold(target === 'global' ? t('cli.reviewTargetGlobal') : t('cli.reviewTargetProjectOnly'))}`,
278
+ '',
279
+ `${pc.bold(t('cli.reviewPackages'))}`,
280
+ ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
281
+ '',
282
+ `${pc.bold(t('cli.reviewFiles'))}`,
283
+ ...files.map((f) => ` - ${f}`)
284
+ ].join('\n'),
285
+ t('cli.step5Review')
286
+ );
287
+
288
+ const applyCtrl = new AbortController();
289
+ const { result: applyResult, wentBack: applyBack } = await withBackspaceBack(applyCtrl, () =>
290
+ select({
291
+ message: t('cli.applyChanges'),
292
+ options: [
293
+ { value: 'yes', label: t('cli.applyYes') },
294
+ { value: 'cancel', label: t('cli.applyCancel') }
295
+ ],
296
+ signal: applyCtrl.signal
297
+ })
298
+ );
299
+ if (applyBack) {
300
+ step = 3;
301
+ continue;
302
+ }
303
+ if (isCancel(applyResult) || applyResult === 'cancel') dieCancelled(t('cli.noChanges'));
131
304
 
132
305
  break;
133
306
  }
134
307
 
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
308
  if (target === 'projectOnly') {
199
309
  // Write project config
200
310
  const s = spinner();
201
- s.start('Writing project config...');
311
+ s.start(t('cli.writingProjectConfig'));
202
312
 
203
313
  // perPackage.*.projectConfigSection is shaped for claude-code-hooks.config.json sections.
204
314
  const projectCfg = await ensureProjectOnlyConfig(projectDir, selected, {
@@ -220,12 +330,12 @@ async function main() {
220
330
  }
221
331
  });
222
332
 
223
- s.stop('Done');
333
+ s.stop(t('cli.done'));
224
334
 
225
- note(JSON.stringify(snippetObj, null, 2), 'Paste into ~/.claude/settings.json (global)');
335
+ note(JSON.stringify(snippetObj, null, 2), t('cli.pasteSnippet'));
226
336
  await maybeWriteSnippet(projectDir, snippetObj);
227
337
 
228
- outro(`Project config written: ${pc.bold(configFilePath(projectDir))}`);
338
+ outro(`${t('cli.projectConfigWritten')}: ${pc.bold(configFilePath(projectDir))}`);
229
339
  return;
230
340
  }
231
341
 
@@ -233,14 +343,14 @@ async function main() {
233
343
  const settingsPath = configPathForScope('global', projectDir);
234
344
  const res = await readJsonIfExists(settingsPath);
235
345
  if (!res.ok) {
236
- cancel(`Could not read/parse JSON at ${settingsPath}`);
346
+ cancel(t('cli.couldNotReadJson', { path: settingsPath }));
237
347
  process.exit(1);
238
348
  }
239
349
 
240
350
  let settings = res.value;
241
351
 
242
352
  const s = spinner();
243
- s.start('Applying changes to global settings...');
353
+ s.start(t('cli.applyingChanges'));
244
354
 
245
355
  for (const key of selected) {
246
356
  const plan = perPackage[key];
@@ -263,8 +373,8 @@ async function main() {
263
373
  });
264
374
  }
265
375
 
266
- s.stop('Done');
267
- outro(`Saved: ${pc.bold(settingsPath)}`);
376
+ s.stop(t('cli.done'));
377
+ outro(`${t('cli.saved')}: ${pc.bold(settingsPath)}`);
268
378
  }
269
379
 
270
380
  main().catch((err) => {