@claude-code-hooks/cli 0.1.14 → 0.1.16

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 +110 -64
  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.14",
3
+ "version": "0.1.16",
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('claude-code-hooks'))}${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,7 +205,7 @@ async function main() {
132
205
 
133
206
  const projectDir = process.cwd();
134
207
 
135
- intro('claude-code-hooks');
208
+ showWelcome();
136
209
  note(t('cli.navHint'), t('cli.navTitle'));
137
210
 
138
211
  // ── Step 1–3: action, target, packages (Backspace = go to previous step) ──
@@ -171,7 +244,8 @@ async function main() {
171
244
  message: `${pc.dim(t('cli.stepFormat', { n: 2 }))} ${t('cli.step2ChooseTarget')}`,
172
245
  options: [
173
246
  { value: 'global', label: t('cli.targetGlobal') },
174
- { value: 'projectOnly', label: t('cli.targetProjectOnly').replace(CONFIG_FILENAME, pc.bold(CONFIG_FILENAME)) }
247
+ { value: 'project', label: t('common.scopeProject') },
248
+ { value: 'projectLocal', label: t('common.scopeProjectLocal') }
175
249
  ],
176
250
  signal: targetCtrl.signal
177
251
  })
@@ -208,7 +282,7 @@ async function main() {
208
282
  if (step === 4) {
209
283
  const proceedCtrl = new AbortController();
210
284
  const { result: proceedResult, wentBack: proceedBack } = await withBackspaceBack(proceedCtrl, () =>
211
- confirm({ message: t('cli.configureNow'), initialValue: true, signal: proceedCtrl.signal })
285
+ confirm({ message: t('cli.configureNow'), initialValue: true, active: t('common.yes'), inactive: t('common.no'), signal: proceedCtrl.signal })
212
286
  );
213
287
  if (proceedBack) {
214
288
  step = 3;
@@ -239,7 +313,7 @@ async function main() {
239
313
  .map(
240
314
  (k) => {
241
315
  const desc = pc.dim(packageDescs[k] || '');
242
- const label = highlightKey === k ? pc.cyan(pc.bold(k)) : pc.bold(k);
316
+ const label = highlightKey === k ? pc.blue(pc.bold(k)) : pc.bold(k);
243
317
  return `${label}: ${desc}`;
244
318
  }
245
319
  )
@@ -266,10 +340,8 @@ async function main() {
266
340
  // ── Step 5/5: review ──
267
341
  const files = [];
268
342
  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
- }
343
+ if (target === 'project') files.push(configPathForScope('project', projectDir));
344
+ if (target === 'projectLocal') files.push(configPathForScope('projectLocal', projectDir));
273
345
 
274
346
  function summarizePlan(key, plan) {
275
347
  if (!plan) return `${key}: ${t('cli.summarySkipped')}`;
@@ -286,7 +358,11 @@ async function main() {
286
358
  `${pc.dim(t('cli.stepFormat', { n: 5 }))} ${t('cli.step5Review')}`,
287
359
  '',
288
360
  `${t('cli.reviewAction')}: ${pc.bold(action)}`,
289
- `${t('cli.reviewTarget')}: ${pc.bold(target === 'global' ? t('cli.reviewTargetGlobal') : t('cli.reviewTargetProjectOnly'))}`,
361
+ `${t('cli.reviewTarget')}: ${pc.bold(
362
+ target === 'global' ? t('cli.reviewTargetGlobal') :
363
+ target === 'project' ? t('cli.reviewTargetProject') :
364
+ t('cli.reviewTargetProjectLocal')
365
+ )}`,
290
366
  '',
291
367
  `${pc.bold(t('cli.reviewPackages'))}`,
292
368
  ...selected.map((k) => ` - ${summarizePlan(k, perPackage[k])}`),
@@ -317,42 +393,8 @@ async function main() {
317
393
  break;
318
394
  }
319
395
 
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
- }
353
-
354
- // Global apply: read settings.json, apply transforms, write once.
355
- const settingsPath = configPathForScope('global', projectDir);
396
+ // Global / project / projectLocal apply: read settings.json, apply transforms, write once.
397
+ const settingsPath = configPathForScope(target, projectDir);
356
398
  const res = await readJsonIfExists(settingsPath);
357
399
  if (!res.ok) {
358
400
  cancel(t('cli.couldNotReadJson', { path: settingsPath }));
@@ -375,6 +417,10 @@ async function main() {
375
417
 
376
418
  await writeJson(settingsPath, settings);
377
419
 
420
+ if (target === 'projectLocal') {
421
+ await ensureGitignoreLocalEntry(projectDir);
422
+ }
423
+
378
424
  // Update project config only on setup.
379
425
  if (action === 'setup') {
380
426
  await ensureProjectOnlyConfig(projectDir, selected, {
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
- }