@claude-code-hooks/cli 0.1.15 → 0.1.17

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/package.json +1 -1
  2. package/src/cli.js +159 -85
  3. package/src/snippet.js +0 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-code-hooks/cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",
package/src/cli.js CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  import process from 'node:process';
4
4
  import readline from 'node:readline';
5
- import fs from 'node:fs/promises';
6
5
  import path from 'node:path';
6
+ import fs from 'node:fs/promises';
7
+ import { createRequire } from 'node:module';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
12
 
8
13
  import {
9
- intro,
10
14
  outro,
11
15
  select,
12
16
  multiselect,
@@ -28,8 +32,6 @@ import {
28
32
  configPathForScope,
29
33
  readJsonIfExists,
30
34
  writeJson,
31
- CONFIG_FILENAME,
32
- configFilePath,
33
35
  readProjectConfig,
34
36
  writeProjectConfig,
35
37
  removeLegacyClaudeSoundHooks,
@@ -47,8 +49,6 @@ if (detectLanguage() !== 'en') {
47
49
  });
48
50
  }
49
51
 
50
- import { buildSettingsSnippet } from './snippet.js';
51
-
52
52
  // In-workspace imports (when running from monorepo) and normal Node resolution
53
53
  // (when installed from npm) both resolve these packages.
54
54
  import { planInteractiveSetup as planSecuritySetup } from '@claude-code-hooks/security/src/plan.js';
@@ -61,6 +61,51 @@ function dieCancelled(msg) {
61
61
  process.exit(0);
62
62
  }
63
63
 
64
+ function showWelcome() {
65
+ let version = '';
66
+ try {
67
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
68
+ version = pkg.version ? ` v${pkg.version}` : '';
69
+ } catch {
70
+ // ignore
71
+ }
72
+
73
+ const width = 48;
74
+ const pad = '\n';
75
+ const particles = ['·', '•', '✦', '✧', '◦', '▪', 'º', '∗'];
76
+ const colors = [pc.blue, pc.cyan, pc.yellow, pc.magenta, pc.green];
77
+
78
+ const particleLine = (seed) => {
79
+ const len = width - 2;
80
+ let s = ' ';
81
+ for (let i = 0; i < len; i++) {
82
+ const show =
83
+ ((i * 17 + seed) % 5 === 0) ||
84
+ ((i * 13 + seed + 3) % 6 === 0) ||
85
+ ((i * 11 + seed + 1) % 4 === 0) ||
86
+ ((i * 19 + seed + 5) % 7 === 0);
87
+ s += show ? colors[(i + seed) % colors.length](particles[(i + seed) % particles.length]) : ' ';
88
+ }
89
+ return s + '\n';
90
+ };
91
+
92
+ const icon = pc.blue('◆');
93
+ const line = ` ${pc.blue('═')}${pc.cyan('═'.repeat(width - 4))}${pc.blue('═')}`;
94
+ const lineBottom = ` ${pc.yellow('═')}${pc.magenta('═'.repeat(width - 4))}${pc.yellow('═')}`;
95
+
96
+ process.stdout.write(pad);
97
+ process.stdout.write(particleLine(0));
98
+ process.stdout.write(line + pad);
99
+ process.stdout.write(pad);
100
+ process.stdout.write(` ${icon} ${pc.blue(pc.bold(t('cli.welcomeTitle')))}${version ? pc.gray(version) : ''}\n`);
101
+ process.stdout.write(pad);
102
+ process.stdout.write(` ${pc.brightCyan('Customize Claude Code with zero dependencies')}\n`);
103
+ process.stdout.write(pad);
104
+ process.stdout.write(lineBottom + pad);
105
+ process.stdout.write(particleLine(5));
106
+ process.stdout.write(pad);
107
+ }
108
+
64
109
  /**
65
110
  * Run a prompt with Backspace = go back. When Backspace is pressed, aborts and returns
66
111
  * { wentBack: true }. ESC still exits. Caller must handle wentBack by continuing the loop.
@@ -90,6 +135,47 @@ function usage(exitCode = 0) {
90
135
  process.exit(exitCode);
91
136
  }
92
137
 
138
+ const GITIGNORE_LOCAL_ENTRY = '.claude/settings.local.json';
139
+
140
+ /**
141
+ * Returns true if the trimmed gitignore line would cause settings.local.json to be ignored.
142
+ */
143
+ function wouldIgnoreLocalSettings(line) {
144
+ const t = line.replace(/#.*$/, '').trim();
145
+ if (!t) return false;
146
+ if (t === GITIGNORE_LOCAL_ENTRY || t === '**/' + GITIGNORE_LOCAL_ENTRY) return true;
147
+ if (t === '.claude/' || t === '.claude' || t === '**/.claude/' || t === '**/.claude') return true;
148
+ if (t === '.claude/*' || t === '.claude/**' || t.includes('**/.claude')) return true;
149
+ if (t.includes('settings.local.json')) return true;
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Ensures .gitignore in projectDir contains an entry to ignore settings.local.json.
155
+ * Called when user selects project (local) so their per-developer settings stay untracked.
156
+ * Never throws: catches all errors to avoid crashing the CLI.
157
+ */
158
+ async function ensureGitignoreLocalEntry(projectDir) {
159
+ try {
160
+ const gitignorePath = path.join(projectDir, '.gitignore');
161
+ let content = '';
162
+ try {
163
+ content = await fs.readFile(gitignorePath, 'utf-8');
164
+ } catch (err) {
165
+ if (err?.code !== 'ENOENT') return;
166
+ }
167
+
168
+ const lines = content.split(/\r?\n/);
169
+ if (lines.some(wouldIgnoreLocalSettings)) return;
170
+
171
+ const needsNewline = content.length > 0 && !content.endsWith('\n');
172
+ const block = '\n# claude-code-hooks: per-developer local settings\n' + GITIGNORE_LOCAL_ENTRY + '\n';
173
+ await fs.appendFile(gitignorePath, (needsNewline ? '\n' : '') + block);
174
+ } catch {
175
+ // EACCES, EPERM, ENOSPC, etc. — don't crash CLI; settings were already written
176
+ }
177
+ }
178
+
93
179
  async function ensureProjectOnlyConfig(projectDir, selected, perPackageConfig) {
94
180
  const cfgRes = await readProjectConfig(projectDir);
95
181
  const rawCfg = cfgRes.ok ? { ...(cfgRes.value || {}) } : {};
@@ -107,19 +193,6 @@ async function ensureProjectOnlyConfig(projectDir, selected, perPackageConfig) {
107
193
  return out;
108
194
  }
109
195
 
110
- async function maybeWriteSnippet(projectDir, snippetObj) {
111
- const ok = await confirm({
112
- message: t('cli.writeSnippet'),
113
- initialValue: false
114
- });
115
- if (isCancel(ok)) return;
116
- if (!ok) return;
117
-
118
- const filePath = path.join(projectDir, 'claude-code-hooks.snippet.json');
119
- await fs.writeFile(filePath, JSON.stringify(snippetObj, null, 2) + '\n');
120
- note(filePath, t('cli.wroteSnippet'));
121
- }
122
-
123
196
  async function main() {
124
197
  const args = process.argv.slice(2);
125
198
  if (args.includes('-h') || args.includes('--help')) usage(0);
@@ -132,10 +205,13 @@ async function main() {
132
205
 
133
206
  const projectDir = process.cwd();
134
207
 
135
- intro('claude-code-hooks');
136
- note(t('cli.navHint'), t('cli.navTitle'));
208
+ showWelcome();
209
+
210
+ /** Build step header: "Step N/5 — <title> (ESC exit · Backspace back)" */
211
+ const stepHeader = (n, title) =>
212
+ pc.dim(t('cli.stepHeader', { n, title, suffix: t('cli.stepHeaderSuffix') }));
137
213
 
138
- // ── Step 1–3: action, target, packages (Backspace = go to previous step) ──
214
+ // ── Step 1–5: action, target, packages, configure, review (Backspace = go to previous step) ──
139
215
  let action;
140
216
  let target;
141
217
  let selected;
@@ -153,7 +229,7 @@ async function main() {
153
229
  while (true) {
154
230
  if (step === 1) {
155
231
  action = await select({
156
- message: `${pc.dim(t('cli.stepFormat', { n: 1 }))} ${t('cli.step1ChooseAction')}`,
232
+ message: stepHeader(1, t('cli.step1ChooseAction')),
157
233
  options: [
158
234
  { value: 'setup', label: t('cli.actionSetup') },
159
235
  { value: 'uninstall', label: t('cli.actionUninstall') },
@@ -168,10 +244,11 @@ async function main() {
168
244
  const targetCtrl = new AbortController();
169
245
  const { result: targetResult, wentBack: targetBack } = await withBackspaceBack(targetCtrl, () =>
170
246
  select({
171
- message: `${pc.dim(t('cli.stepFormat', { n: 2 }))} ${t('cli.step2ChooseTarget')}`,
247
+ message: stepHeader(2, t('cli.step2ChooseTarget')),
172
248
  options: [
173
249
  { value: 'global', label: t('cli.targetGlobal') },
174
- { value: 'projectOnly', label: t('cli.targetProjectOnly').replace(CONFIG_FILENAME, pc.bold(CONFIG_FILENAME)) }
250
+ { value: 'project', label: t('common.scopeProject') },
251
+ { value: 'projectLocal', label: t('common.scopeProjectLocal') }
175
252
  ],
176
253
  signal: targetCtrl.signal
177
254
  })
@@ -189,7 +266,7 @@ async function main() {
189
266
  const pkgsCtrl = new AbortController();
190
267
  const { result: pkgsResult, wentBack: pkgsBack } = await withBackspaceBack(pkgsCtrl, () =>
191
268
  multiselect({
192
- message: `${pc.dim(t('cli.stepFormat', { n: 3 }))} ${t('cli.step3SelectPackages')}`,
269
+ message: stepHeader(3, t('cli.step3SelectPackages')),
193
270
  options: packageOptions,
194
271
  required: true,
195
272
  validate: (v) => (!v || v.length === 0) ? t('common.selectAtLeastOne') : true,
@@ -208,7 +285,7 @@ async function main() {
208
285
  if (step === 4) {
209
286
  const proceedCtrl = new AbortController();
210
287
  const { result: proceedResult, wentBack: proceedBack } = await withBackspaceBack(proceedCtrl, () =>
211
- confirm({ message: t('cli.configureNow'), initialValue: true, signal: proceedCtrl.signal })
288
+ confirm({ message: stepHeader(4, t('cli.configureNow')), initialValue: true, active: t('common.yes'), inactive: t('common.no'), signal: proceedCtrl.signal })
212
289
  );
213
290
  if (proceedBack) {
214
291
  step = 3;
@@ -239,7 +316,7 @@ async function main() {
239
316
  .map(
240
317
  (k) => {
241
318
  const desc = pc.dim(packageDescs[k] || '');
242
- const label = highlightKey === k ? pc.cyan(pc.bold(k)) : pc.bold(k);
319
+ const label = highlightKey === k ? pc.blue(pc.bold(k)) : pc.bold(k);
243
320
  return `${label}: ${desc}`;
244
321
  }
245
322
  )
@@ -247,29 +324,27 @@ async function main() {
247
324
 
248
325
  // ── Step 4/5: configure (highlight current package) ──
249
326
  if (selected.includes('security')) {
250
- note(formatPackageList('security'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
327
+ note(formatPackageList('security'), stepHeader(4, t('cli.step4Configure')));
251
328
  perPackage.security = await planSecuritySetup({ action, projectDir, ui: 'umbrella' });
252
329
  }
253
330
  if (selected.includes('secrets')) {
254
- note(formatPackageList('secrets'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
331
+ note(formatPackageList('secrets'), stepHeader(4, t('cli.step4Configure')));
255
332
  perPackage.secrets = await planSecretsSetup({ action, projectDir, ui: 'umbrella' });
256
333
  }
257
334
  if (selected.includes('sound')) {
258
- note(formatPackageList('sound'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
335
+ note(formatPackageList('sound'), stepHeader(4, t('cli.step4Configure')));
259
336
  perPackage.sound = await planSoundSetup({ action, projectDir, ui: 'umbrella' });
260
337
  }
261
338
  if (selected.includes('notification')) {
262
- note(formatPackageList('notification'), `${pc.dim(t('cli.stepFormat', { n: 4 }))} ${t('cli.step4Configure')}`);
339
+ note(formatPackageList('notification'), stepHeader(4, t('cli.step4Configure')));
263
340
  perPackage.notification = await planNotificationSetup({ action, projectDir, ui: 'umbrella' });
264
341
  }
265
342
 
266
343
  // ── Step 5/5: review ──
267
344
  const files = [];
268
345
  if (target === 'global') files.push(configPathForScope('global', projectDir));
269
- if (target === 'projectOnly') {
270
- files.push(path.join(projectDir, CONFIG_FILENAME));
271
- files.push(path.join(projectDir, 'claude-code-hooks.snippet.json (optional)'));
272
- }
346
+ if (target === 'project') files.push(configPathForScope('project', projectDir));
347
+ if (target === 'projectLocal') files.push(configPathForScope('projectLocal', projectDir));
273
348
 
274
349
  function summarizePlan(key, plan) {
275
350
  if (!plan) return `${key}: ${t('cli.summarySkipped')}`;
@@ -283,10 +358,15 @@ async function main() {
283
358
 
284
359
  note(
285
360
  [
286
- `${pc.dim(t('cli.stepFormat', { n: 5 }))} ${t('cli.step5Review')}`,
361
+ stepHeader(5, t('cli.step5Review')),
287
362
  '',
288
- `${t('cli.reviewAction')}: ${pc.bold(action)}`,
289
- `${t('cli.reviewTarget')}: ${pc.bold(target === 'global' ? t('cli.reviewTargetGlobal') : t('cli.reviewTargetProjectOnly'))}`,
363
+ `${pc.bold(t('cli.reviewSectionActionTarget'))}`,
364
+ ` ${t('cli.reviewAction')}: ${pc.bold(action)}`,
365
+ ` ${t('cli.reviewTarget')}: ${pc.bold(
366
+ target === 'global' ? t('cli.reviewTargetGlobal') :
367
+ target === 'project' ? t('cli.reviewTargetProject') :
368
+ t('cli.reviewTargetProjectLocal')
369
+ )}`,
290
370
  '',
291
371
  `${pc.bold(t('cli.reviewPackages'))}`,
292
372
  ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
@@ -297,10 +377,10 @@ async function main() {
297
377
  t('cli.step5Review')
298
378
  );
299
379
 
300
- const applyCtrl = new AbortController();
380
+ const applyCtrl = new AbortController();
301
381
  const { result: applyResult, wentBack: applyBack } = await withBackspaceBack(applyCtrl, () =>
302
382
  select({
303
- message: t('cli.applyChanges'),
383
+ message: stepHeader(5, t('cli.applyChanges')),
304
384
  options: [
305
385
  { value: 'yes', label: t('cli.applyYes') },
306
386
  { value: 'cancel', label: t('cli.applyCancel') }
@@ -317,66 +397,42 @@ async function main() {
317
397
  break;
318
398
  }
319
399
 
320
- if (target === 'projectOnly') {
321
- // Write project config
322
- const s = spinner();
323
- s.start(t('cli.writingProjectConfig'));
324
-
325
- // perPackage.*.projectConfigSection is shaped for claude-code-hooks.config.json sections.
326
- const projectCfg = await ensureProjectOnlyConfig(projectDir, selected, {
327
- security: perPackage.security?.projectConfigSection,
328
- secrets: perPackage.secrets?.projectConfigSection,
329
- sound: perPackage.sound?.projectConfigSection,
330
- notification: perPackage.notification?.projectConfigSection
331
- });
332
-
333
- // Print snippet for user to paste into global settings.
334
- const snippetObj = buildSettingsSnippet({
335
- projectDir,
336
- selected,
337
- packagePlans: {
338
- security: perPackage.security,
339
- secrets: perPackage.secrets,
340
- sound: perPackage.sound,
341
- notification: perPackage.notification
342
- }
343
- });
344
-
345
- s.stop(t('cli.done'));
346
-
347
- note(JSON.stringify(snippetObj, null, 2), t('cli.pasteSnippet'));
348
- await maybeWriteSnippet(projectDir, snippetObj);
349
-
350
- outro(`${t('cli.projectConfigWritten')}: ${pc.bold(configFilePath(projectDir))}`);
351
- return;
352
- }
400
+ // Global / project / projectLocal apply: read settings.json, apply transforms, write once.
401
+ const settingsPath = configPathForScope(target, projectDir);
402
+ const pkgLabels = {
403
+ security: t('cli.pkgSecurity'),
404
+ secrets: t('cli.pkgSecrets'),
405
+ sound: t('cli.pkgSound'),
406
+ notification: t('cli.pkgNotification')
407
+ };
408
+ const s = spinner();
409
+ s.start(t('cli.applyingChanges'));
353
410
 
354
- // Global apply: read settings.json, apply transforms, write once.
355
- const settingsPath = configPathForScope('global', projectDir);
356
411
  const res = await readJsonIfExists(settingsPath);
357
412
  if (!res.ok) {
413
+ s.stop(t('cli.done'));
358
414
  cancel(t('cli.couldNotReadJson', { path: settingsPath }));
359
415
  process.exit(1);
360
416
  }
361
-
362
417
  let settings = res.value;
363
418
 
364
- const s = spinner();
365
- s.start(t('cli.applyingChanges'));
366
-
367
419
  for (const key of selected) {
368
420
  const plan = perPackage[key];
369
421
  if (!plan) continue;
422
+ const pkgName = pkgLabels[key] ?? key;
423
+ s.message(t('cli.applyStepApplyPackage', { packageName: pkgName }));
370
424
  settings = await plan.applyToSettings(settings);
371
425
  }
372
426
 
373
- // Remove legacy claude-sound hooks (from old standalone package) to avoid duplicates
427
+ s.message(t('cli.applyStepWriteSettings'));
374
428
  settings = removeLegacyClaudeSoundHooks(settings);
375
-
376
429
  await writeJson(settingsPath, settings);
430
+ if (target === 'projectLocal') {
431
+ await ensureGitignoreLocalEntry(projectDir);
432
+ }
377
433
 
378
- // Update project config only on setup.
379
434
  if (action === 'setup') {
435
+ s.message(t('cli.applyStepWriteProjectConfig'));
380
436
  await ensureProjectOnlyConfig(projectDir, selected, {
381
437
  security: perPackage.security?.projectConfigSection,
382
438
  secrets: perPackage.secrets?.projectConfigSection,
@@ -384,9 +440,27 @@ async function main() {
384
440
  notification: perPackage.notification?.projectConfigSection
385
441
  });
386
442
  }
443
+ s.stop('');
444
+
445
+ // Build contextual completion note
446
+ const traits = [];
447
+ if (selected.includes('security') || selected.includes('secrets')) traits.push(t('cli.doneTraitSafer'));
448
+ if (selected.includes('sound')) traits.push(t('cli.doneTraitLouder'));
449
+ if (selected.includes('notification')) traits.push(t('cli.doneTraitAttentive'));
450
+
451
+ const lines = [];
452
+ lines.push(`${t('cli.saved')}: ${pc.cyan(settingsPath)}`);
453
+ lines.push(`${t('cli.reviewPackages')}: ${selected.map((k) => pc.green(pkgLabels[k] ?? k)).join(', ')}`);
454
+
455
+ if (traits.length > 0) {
456
+ const joined = traits.length === 1
457
+ ? traits[0]
458
+ : traits.slice(0, -1).join(', ') + ' & ' + traits[traits.length - 1];
459
+ lines.push('');
460
+ lines.push(pc.bold(t('cli.doneWithTraits', { traits: joined })));
461
+ }
387
462
 
388
- s.stop(t('cli.done'));
389
- outro(`${t('cli.saved')}: ${pc.bold(settingsPath)}`);
463
+ note(lines.join('\n'), t('cli.doneNoteTitle'));
390
464
  }
391
465
 
392
466
  main().catch((err) => {
package/src/snippet.js DELETED
@@ -1,29 +0,0 @@
1
- import path from 'node:path';
2
-
3
- // This file generates a snippet the user can paste into ~/.claude/settings.json.
4
- // We keep it simple: a "hooks" object with managed hook handlers.
5
-
6
- export function buildSettingsSnippet({ projectDir, selected, packagePlans }) {
7
- const hooks = {};
8
-
9
- for (const key of selected) {
10
- const plan = packagePlans[key];
11
- if (!plan) continue;
12
-
13
- // plan.snippetHooks is: { [eventName]: [ { matcher, hooks:[{type,command,async,timeout}]} ] }
14
- for (const [eventName, groups] of Object.entries(plan.snippetHooks || {})) {
15
- if (!Array.isArray(groups) || groups.length === 0) continue;
16
- const existing = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
17
- hooks[eventName] = [...existing, ...groups];
18
- }
19
- }
20
-
21
- // Include a comment-like pointer to project config path (JSON doesn't support comments, so we use a metadata key).
22
- const cfgPath = path.join(projectDir, 'claude-code-hooks.config.json');
23
-
24
- return {
25
- "__generated_by": "@claude-code-hooks/cli",
26
- "__project_config": cfgPath,
27
- hooks
28
- };
29
- }