@claude-code-hooks/cli 0.1.0

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 +41 -0
  2. package/src/cli.js +218 -0
  3. package/src/snippet.js +29 -0
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@claude-code-hooks/cli",
3
+ "version": "0.1.0",
4
+ "description": "Wizard CLI to set up and manage @claude-code-hooks packages for Claude Code.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/beefiker/claude-code-hooks/tree/main/packages/cli",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/beefiker/claude-code-hooks.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/beefiker/claude-code-hooks/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "claude-code-hooks": "src/cli.js"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "dependencies": {
29
+ "@clack/prompts": "^1.0.0",
30
+ "@claude-code-hooks/core": "0.1.1",
31
+ "@claude-code-hooks/security": "0.1.2",
32
+ "@claude-code-hooks/secrets": "0.1.2",
33
+ "@claude-code-hooks/sound": "0.2.6"
34
+ },
35
+ "keywords": [
36
+ "claude",
37
+ "claude-code",
38
+ "hooks",
39
+ "wizard"
40
+ ]
41
+ }
package/src/cli.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ import {
8
+ intro,
9
+ outro,
10
+ select,
11
+ multiselect,
12
+ confirm,
13
+ isCancel,
14
+ cancel,
15
+ note,
16
+ spinner
17
+ } from '@clack/prompts';
18
+
19
+ import {
20
+ ansi as pc,
21
+ configPathForScope,
22
+ readJsonIfExists,
23
+ writeJson,
24
+ CONFIG_FILENAME,
25
+ configFilePath,
26
+ readProjectConfig,
27
+ writeProjectConfig
28
+ } from '@claude-code-hooks/core';
29
+
30
+ import { buildSettingsSnippet } from './snippet.js';
31
+
32
+ // In-workspace imports (when running from monorepo) and normal Node resolution
33
+ // (when installed from npm) both resolve these packages.
34
+ import { planInteractiveSetup as planSecuritySetup } from '@claude-code-hooks/security/src/plan.js';
35
+ import { planInteractiveSetup as planSecretsSetup } from '@claude-code-hooks/secrets/src/plan.js';
36
+ import { planInteractiveSetup as planSoundSetup } from '@claude-code-hooks/sound/src/plan.js';
37
+
38
+ function dieCancelled(msg = 'Cancelled') {
39
+ cancel(msg);
40
+ process.exit(0);
41
+ }
42
+
43
+ function usage(exitCode = 0) {
44
+ process.stdout.write(`\
45
+ claude-code-hooks\n\nUsage:\n npx @claude-code-hooks/cli@latest\n\nNotes:\n - This wizard can update your Claude Code settings (global) or generate project-only config + snippet.\n`);
46
+ process.exit(exitCode);
47
+ }
48
+
49
+ async function ensureProjectOnlyConfig(projectDir, selected, perPackageConfig) {
50
+ const cfgRes = await readProjectConfig(projectDir);
51
+ const rawCfg = cfgRes.ok ? { ...(cfgRes.value || {}) } : {};
52
+
53
+ // Our existing project config format is sectioned by package key.
54
+ // Keep only what we touch, preserve others.
55
+ const out = { ...rawCfg };
56
+
57
+ if (selected.includes('security') && perPackageConfig.security) out.security = perPackageConfig.security;
58
+ if (selected.includes('secrets') && perPackageConfig.secrets) out.secrets = perPackageConfig.secrets;
59
+ if (selected.includes('sound') && perPackageConfig.sound) out.sound = perPackageConfig.sound;
60
+
61
+ await writeProjectConfig(out, projectDir);
62
+ return out;
63
+ }
64
+
65
+ async function maybeWriteSnippet(projectDir, snippetObj) {
66
+ const ok = await confirm({
67
+ message: `Write snippet file to ${pc.bold(path.join(projectDir, 'claude-hooks.snippet.json'))}?`,
68
+ initialValue: false
69
+ });
70
+ if (isCancel(ok)) return;
71
+ if (!ok) return;
72
+
73
+ const filePath = path.join(projectDir, 'claude-hooks.snippet.json');
74
+ await fs.writeFile(filePath, JSON.stringify(snippetObj, null, 2) + '\n');
75
+ note(filePath, 'Wrote snippet');
76
+ }
77
+
78
+ async function main() {
79
+ const args = process.argv.slice(2);
80
+ if (args.includes('-h') || args.includes('--help')) usage(0);
81
+
82
+ const projectDir = process.cwd();
83
+
84
+ intro('claude-code-hooks');
85
+
86
+ const action = await select({
87
+ message: 'What do you want to do?',
88
+ options: [
89
+ { value: 'setup', label: 'Setup / enable packages' },
90
+ { value: 'uninstall', label: 'Uninstall / remove managed hooks' },
91
+ { value: 'exit', label: 'Exit' }
92
+ ]
93
+ });
94
+ if (isCancel(action) || action === 'exit') dieCancelled('Bye');
95
+
96
+ const target = await select({
97
+ message: 'Where do you want to apply changes?',
98
+ options: [
99
+ { value: 'global', label: `Global (default): ${pc.dim('~/.claude/settings.json')}` },
100
+ { value: 'projectOnly', label: `Project-only: write ${pc.bold(CONFIG_FILENAME)} + print snippet` }
101
+ ]
102
+ });
103
+ if (isCancel(target)) dieCancelled();
104
+
105
+ const selected = await multiselect({
106
+ message: action === 'setup' ? 'Which packages do you want to enable?' : 'Which packages do you want to uninstall?',
107
+ options: [
108
+ { value: 'security', label: '@claude-code-hooks/security' },
109
+ { value: 'secrets', label: '@claude-code-hooks/secrets' },
110
+ { value: 'sound', label: '@claude-code-hooks/sound' }
111
+ ],
112
+ required: true
113
+ });
114
+ if (isCancel(selected)) dieCancelled();
115
+
116
+ // Build per-package plan/config
117
+ const perPackage = {
118
+ security: null,
119
+ secrets: null,
120
+ sound: null
121
+ };
122
+
123
+ if (selected.includes('security')) perPackage.security = await planSecuritySetup({ action, projectDir });
124
+ if (selected.includes('secrets')) perPackage.secrets = await planSecretsSetup({ action, projectDir });
125
+ if (selected.includes('sound')) perPackage.sound = await planSoundSetup({ action, projectDir });
126
+
127
+ // Review summary
128
+ const files = [];
129
+ if (target === 'global') files.push(configPathForScope('global', projectDir));
130
+ if (target === 'projectOnly') {
131
+ files.push(path.join(projectDir, CONFIG_FILENAME));
132
+ files.push(path.join(projectDir, 'claude-hooks.snippet.json (optional)'));
133
+ }
134
+
135
+ note(
136
+ [
137
+ `Action: ${pc.bold(action)}`,
138
+ `Target: ${pc.bold(target === 'global' ? 'global settings' : 'project-only')}`,
139
+ `Packages: ${pc.bold(selected.join(', '))}`,
140
+ `Files:`,
141
+ ...files.map((f) => ` - ${f}`)
142
+ ].join('\n'),
143
+ 'Review'
144
+ );
145
+
146
+ const ok = await confirm({ message: 'Apply?', initialValue: true });
147
+ if (isCancel(ok) || !ok) dieCancelled('No changes written');
148
+
149
+ if (target === 'projectOnly') {
150
+ // Write project config
151
+ const s = spinner();
152
+ s.start('Writing project config...');
153
+
154
+ // perPackage.*.projectConfigSection is shaped for claude-hooks.config.json sections.
155
+ const projectCfg = await ensureProjectOnlyConfig(projectDir, selected, {
156
+ security: perPackage.security?.projectConfigSection,
157
+ secrets: perPackage.secrets?.projectConfigSection,
158
+ sound: perPackage.sound?.projectConfigSection
159
+ });
160
+
161
+ // Print snippet for user to paste into global settings.
162
+ const snippetObj = buildSettingsSnippet({
163
+ projectDir,
164
+ selected,
165
+ packagePlans: {
166
+ security: perPackage.security,
167
+ secrets: perPackage.secrets,
168
+ sound: perPackage.sound
169
+ }
170
+ });
171
+
172
+ s.stop('Done');
173
+
174
+ note(JSON.stringify(snippetObj, null, 2), 'Paste into ~/.claude/settings.json');
175
+ await maybeWriteSnippet(projectDir, snippetObj);
176
+
177
+ outro(`Project config written: ${pc.bold(configFilePath(projectDir))}`);
178
+ return;
179
+ }
180
+
181
+ // Global apply: read settings.json, apply transforms, write once.
182
+ const settingsPath = configPathForScope('global', projectDir);
183
+ const res = await readJsonIfExists(settingsPath);
184
+ if (!res.ok) {
185
+ cancel(`Could not read/parse JSON at ${settingsPath}`);
186
+ process.exit(1);
187
+ }
188
+
189
+ let settings = res.value;
190
+
191
+ const s = spinner();
192
+ s.start('Applying changes to global settings...');
193
+
194
+ for (const key of selected) {
195
+ const plan = perPackage[key];
196
+ if (!plan) continue;
197
+ settings = await plan.applyToSettings(settings);
198
+ }
199
+
200
+ await writeJson(settingsPath, settings);
201
+
202
+ // Update project config only on setup.
203
+ if (action === 'setup') {
204
+ await ensureProjectOnlyConfig(projectDir, selected, {
205
+ security: perPackage.security?.projectConfigSection,
206
+ secrets: perPackage.secrets?.projectConfigSection,
207
+ sound: perPackage.sound?.projectConfigSection
208
+ });
209
+ }
210
+
211
+ s.stop('Done');
212
+ outro(`Saved: ${pc.bold(settingsPath)}`);
213
+ }
214
+
215
+ main().catch((err) => {
216
+ process.stderr.write(String(err?.stack || err) + '\n');
217
+ process.exit(1);
218
+ });
package/src/snippet.js ADDED
@@ -0,0 +1,29 @@
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-hooks.config.json');
23
+
24
+ return {
25
+ "__generated_by": "@claude-code-hooks/cli",
26
+ "__project_config": cfgPath,
27
+ hooks
28
+ };
29
+ }