@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.
- package/package.json +41 -0
- package/src/cli.js +218 -0
- 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
|
+
}
|