claude-code-hookkit 1.0.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/dist/add-O4OSFQ76.js +140 -0
  4. package/dist/chunk-2BZZUQQ3.js +34 -0
  5. package/dist/chunk-LRXKKJDU.js +101 -0
  6. package/dist/chunk-PEDGREZY.js +46 -0
  7. package/dist/chunk-QKT647BI.js +30 -0
  8. package/dist/chunk-XLX5K6TZ.js +113 -0
  9. package/dist/cli.js +76 -0
  10. package/dist/create-DBLA6PTS.js +268 -0
  11. package/dist/doctor-UBK2C2TW.js +137 -0
  12. package/dist/info-FLYMAHDX.js +84 -0
  13. package/dist/init-RHEFGGUF.js +70 -0
  14. package/dist/list-SCSGYOBR.js +54 -0
  15. package/dist/remove-Z5QIW45P.js +109 -0
  16. package/dist/restore-7JQ3CHWZ.js +31 -0
  17. package/dist/test-ZRRLZ62R.js +194 -0
  18. package/package.json +59 -0
  19. package/registry/hooks/cost-tracker.sh +44 -0
  20. package/registry/hooks/error-advisor.sh +114 -0
  21. package/registry/hooks/exit-code-enforcer.sh +76 -0
  22. package/registry/hooks/fixtures/cost-tracker/allow-bash-tool.json +5 -0
  23. package/registry/hooks/fixtures/cost-tracker/allow-no-session.json +5 -0
  24. package/registry/hooks/fixtures/error-advisor/allow-enoent.json +5 -0
  25. package/registry/hooks/fixtures/error-advisor/allow-no-error.json +5 -0
  26. package/registry/hooks/fixtures/exit-code-enforcer/allow-npm-test.json +5 -0
  27. package/registry/hooks/fixtures/exit-code-enforcer/block-rm-rf.json +5 -0
  28. package/registry/hooks/fixtures/post-edit-lint/allow-ts-file.json +5 -0
  29. package/registry/hooks/fixtures/post-edit-lint/allow-unknown-ext.json +5 -0
  30. package/registry/hooks/fixtures/sensitive-path-guard/allow-src.json +5 -0
  31. package/registry/hooks/fixtures/sensitive-path-guard/block-env.json +5 -0
  32. package/registry/hooks/fixtures/ts-check/allow-non-ts.json +5 -0
  33. package/registry/hooks/fixtures/ts-check/allow-ts-file.json +5 -0
  34. package/registry/hooks/fixtures/web-budget-gate/allow-within-budget.json +6 -0
  35. package/registry/hooks/fixtures/web-budget-gate/block-over-budget.json +6 -0
  36. package/registry/hooks/post-edit-lint.sh +82 -0
  37. package/registry/hooks/sensitive-path-guard.sh +103 -0
  38. package/registry/hooks/ts-check.sh +98 -0
  39. package/registry/hooks/web-budget-gate.sh +60 -0
  40. package/registry/registry.json +81 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Austin Amelone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,311 @@
1
+ # claude-code-hookkit
2
+
3
+ [![npm version](https://img.shields.io/npm/v/claude-code-hookkit.svg)](https://www.npmjs.com/package/claude-code-hookkit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **husky for Claude Code** — install, manage, test, and share hooks with a single command.
7
+
8
+ Go from zero to production-grade Claude Code hooks in under 60 seconds:
9
+
10
+ ```bash
11
+ npx claude-code-hookkit init && npx claude-code-hookkit add security-pack
12
+ ```
13
+
14
+ ---
15
+
16
+ ## What Are Claude Code Hooks?
17
+
18
+ Claude Code hooks are shell commands triggered by Claude Code events (PreToolUse, PostToolUse, SessionStart, Stop). They run automatically and can:
19
+
20
+ - **Block** dangerous operations before they execute (exit code 2)
21
+ - **Observe** and log what Claude does (exit code 0, advisory)
22
+ - **Provide feedback** after actions complete
23
+
24
+ Hooks are configured in `~/.claude/settings.json` under the `hooks` key. `claude-code-hookkit` manages that configuration for you — non-destructively, with backups.
25
+
26
+ ---
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # Initialize hook directory and seed settings.json
32
+ npx claude-code-hookkit init
33
+
34
+ # Install the security pack (sensitive-path-guard + exit-code-enforcer)
35
+ npx claude-code-hookkit add security-pack
36
+
37
+ # See what's installed
38
+ npx claude-code-hookkit list
39
+
40
+ # Verify everything is healthy
41
+ npx claude-code-hookkit doctor
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Command Reference
47
+
48
+ ### `claude-code-hookkit init`
49
+
50
+ Scaffold the hook directory and seed `settings.json`.
51
+
52
+ ```bash
53
+ claude-code-hookkit init # project scope (default)
54
+ claude-code-hookkit init --scope user # user-level settings (~/.claude/settings.json)
55
+ claude-code-hookkit init --dry-run # preview without writing
56
+ ```
57
+
58
+ ### `claude-code-hookkit add <name>`
59
+
60
+ Install a hook or pack from the bundled registry.
61
+
62
+ ```bash
63
+ claude-code-hookkit add sensitive-path-guard # single hook
64
+ claude-code-hookkit add security-pack # install entire pack
65
+ claude-code-hookkit add cost-tracker --scope user # user-level install
66
+ claude-code-hookkit add post-edit-lint --dry-run # preview changes
67
+ ```
68
+
69
+ ### `claude-code-hookkit remove <name>`
70
+
71
+ Remove an installed hook (script + settings.json entry).
72
+
73
+ ```bash
74
+ claude-code-hookkit remove sensitive-path-guard
75
+ claude-code-hookkit remove post-edit-lint --scope user
76
+ claude-code-hookkit remove web-budget-gate --dry-run
77
+ ```
78
+
79
+ ### `claude-code-hookkit list`
80
+
81
+ List all available hooks with installed status, event type, and pack.
82
+
83
+ ```bash
84
+ claude-code-hookkit list
85
+ claude-code-hookkit list --scope user
86
+ ```
87
+
88
+ ### `claude-code-hookkit test <hook>`
89
+
90
+ Test a hook with its bundled fixture data. Validates exit code and output.
91
+
92
+ ```bash
93
+ claude-code-hookkit test sensitive-path-guard # test single hook
94
+ claude-code-hookkit test --all # test all installed hooks
95
+ ```
96
+
97
+ ### `claude-code-hookkit create <name>`
98
+
99
+ Scaffold a custom hook from a template.
100
+
101
+ ```bash
102
+ claude-code-hookkit create my-guard --event PreToolUse --matcher Bash
103
+ claude-code-hookkit create session-logger --event SessionStart
104
+ claude-code-hookkit create cleanup --event Stop
105
+ ```
106
+
107
+ Generates a working shell script with proper shebang, stdin JSON parsing, and a test fixture skeleton.
108
+
109
+ ### `claude-code-hookkit doctor`
110
+
111
+ Validate installation health: script existence, permissions, settings.json validity, conflicting hooks.
112
+
113
+ ```bash
114
+ claude-code-hookkit doctor
115
+ claude-code-hookkit doctor --scope user
116
+ ```
117
+
118
+ ### `claude-code-hookkit restore`
119
+
120
+ Revert settings.json to the last backup (created automatically before every write).
121
+
122
+ ```bash
123
+ claude-code-hookkit restore
124
+ claude-code-hookkit restore --scope user
125
+ ```
126
+
127
+ ### `claude-code-hookkit info <hook>`
128
+
129
+ Show full details for a hook: description, event, matcher, pack, and example input JSON.
130
+
131
+ ```bash
132
+ claude-code-hookkit info sensitive-path-guard
133
+ claude-code-hookkit info web-budget-gate
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Hook Registry
139
+
140
+ All 7 bundled hooks ship with the package — no network required.
141
+
142
+ | Hook | Description | Event | Matcher | Pack |
143
+ |------|-------------|-------|---------|------|
144
+ | `sensitive-path-guard` | Blocks writes to .env, credentials, private keys | PreToolUse | Edit\|Write | security-pack |
145
+ | `exit-code-enforcer` | Blocks known-dangerous shell commands (rm -rf /, fork bombs, etc.) | PreToolUse | Bash | security-pack |
146
+ | `post-edit-lint` | Runs linter on files after Claude edits them | PostToolUse | Write\|Edit | quality-pack |
147
+ | `ts-check` | Runs TypeScript type checking after code changes | PostToolUse | Write\|Edit | quality-pack |
148
+ | `web-budget-gate` | Limits web search/fetch calls per session to control costs | PreToolUse | WebSearch\|WebFetch | cost-pack |
149
+ | `cost-tracker` | Tracks tool usage costs per session | PostToolUse | — | cost-pack |
150
+ | `error-advisor` | Provides contextual fix suggestions when commands fail | PostToolUse | Bash | error-pack |
151
+
152
+ ---
153
+
154
+ ## Packs
155
+
156
+ Install related hooks together in one command.
157
+
158
+ ### `security-pack`
159
+
160
+ Essential security hooks. Blocks writes to sensitive files and dangerous shell commands.
161
+
162
+ ```bash
163
+ claude-code-hookkit add security-pack
164
+ ```
165
+
166
+ Includes: `sensitive-path-guard`, `exit-code-enforcer`
167
+
168
+ ### `quality-pack`
169
+
170
+ Code quality hooks that run automatically after Claude edits files.
171
+
172
+ ```bash
173
+ claude-code-hookkit add quality-pack
174
+ ```
175
+
176
+ Includes: `post-edit-lint`, `ts-check`
177
+
178
+ ### `cost-pack`
179
+
180
+ Cost control hooks. Limit web calls per session and track tool usage.
181
+
182
+ ```bash
183
+ claude-code-hookkit add cost-pack
184
+ ```
185
+
186
+ Includes: `web-budget-gate`, `cost-tracker`
187
+
188
+ ### `error-pack`
189
+
190
+ Error recovery. When a Bash command fails, this hook analyzes the output and suggests contextual fixes.
191
+
192
+ ```bash
193
+ claude-code-hookkit add error-pack
194
+ ```
195
+
196
+ Includes: `error-advisor`
197
+
198
+ ---
199
+
200
+ ## How Hooks Work
201
+
202
+ Claude Code evaluates hooks from your `settings.json`. Example entry added by `claude-code-hookkit add`:
203
+
204
+ ```json
205
+ {
206
+ "hooks": {
207
+ "PreToolUse": [
208
+ {
209
+ "matcher": "Edit|Write",
210
+ "hooks": [
211
+ {
212
+ "type": "command",
213
+ "command": "/path/to/.claude/hooks/sensitive-path-guard.sh"
214
+ }
215
+ ]
216
+ }
217
+ ]
218
+ }
219
+ }
220
+ ```
221
+
222
+ **Exit codes:**
223
+ - `0` — allow the operation (or advisory-only PostToolUse hook)
224
+ - `2` — block the operation (Claude Code spec; exit 1 does not block)
225
+
226
+ **Input format:** Claude Code passes JSON via stdin. Hooks read it with `INPUT=$(cat)` and parse fields with `grep`/`sed` (no `jq` required — POSIX-compatible).
227
+
228
+ ---
229
+
230
+ ## Creating Custom Hooks
231
+
232
+ Scaffold a hook with the right structure:
233
+
234
+ ```bash
235
+ claude-code-hookkit create my-guard --event PreToolUse --matcher Bash
236
+ ```
237
+
238
+ This creates:
239
+ - `.claude/hooks/my-guard.sh` — working hook script
240
+ - `.claude/hooks/fixtures/my-guard/allow-example.json` — test fixture skeleton
241
+
242
+ The generated script handles stdin JSON parsing, includes commented examples, and uses the correct exit codes. Edit the pattern-matching logic and add your fixture test cases, then run:
243
+
244
+ ```bash
245
+ claude-code-hookkit test my-guard
246
+ ```
247
+
248
+ ### Hook Template Pattern
249
+
250
+ ```bash
251
+ #!/bin/bash
252
+ INPUT=$(cat)
253
+
254
+ # Extract what you need from tool JSON
255
+ VALUE=$(printf '%s' "$INPUT" | grep -o '"field"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*: *"//; s/"//')
256
+
257
+ # Your logic here
258
+ if [[ "$VALUE" == "bad-value" ]]; then
259
+ printf 'BLOCKED: reason\n' >&2
260
+ exit 2
261
+ fi
262
+
263
+ exit 0
264
+ ```
265
+
266
+ ### Fixture Format
267
+
268
+ ```json
269
+ {
270
+ "description": "What this fixture tests",
271
+ "input": { "tool_input": { "file_path": ".env" } },
272
+ "expectedExitCode": 2
273
+ }
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Settings Scope
279
+
280
+ All commands support `--scope`:
281
+
282
+ | Scope | Settings File | Hook Directory |
283
+ |-------|---------------|----------------|
284
+ | `project` (default) | `.claude/settings.json` | `.claude/hooks/` |
285
+ | `user` | `~/.claude/settings.json` | `~/.claude/hooks/` |
286
+ | `local` | `.claude/settings.local.json` | `.claude/hooks/` |
287
+
288
+ `add` and `init` always perform a deep merge — your existing settings are never overwritten.
289
+
290
+ ---
291
+
292
+ ## Contributing
293
+
294
+ 1. Fork the repo
295
+ 2. Add your hook to `registry/hooks/` with inline comments
296
+ 3. Add metadata to `registry/registry.json`
297
+ 4. Add test fixtures to `registry/hooks/fixtures/<hook-name>/`
298
+ 5. Run `npm test` — all fixtures must pass
299
+ 6. Submit a PR with a description of what the hook does and why it's useful
300
+
301
+ Hooks must be:
302
+ - POSIX-compatible (macOS bash 3.2 + Linux bash 5.x)
303
+ - No external dependencies (no `jq`, no `python`, no `node`)
304
+ - Exit code 2 to block, exit code 0 to allow
305
+ - Include at least one allow fixture and one block fixture
306
+
307
+ ---
308
+
309
+ ## License
310
+
311
+ MIT — Copyright (c) 2026 Austin Amelone
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-2BZZUQQ3.js";
5
+ import {
6
+ applyMerge
7
+ } from "./chunk-LRXKKJDU.js";
8
+ import "./chunk-QKT647BI.js";
9
+ import {
10
+ getHook,
11
+ getPack
12
+ } from "./chunk-XLX5K6TZ.js";
13
+ import {
14
+ getHooksDir,
15
+ getSettingsPath
16
+ } from "./chunk-PEDGREZY.js";
17
+
18
+ // src/commands/add.ts
19
+ import { copyFile, chmod, mkdir } from "fs/promises";
20
+ import { existsSync } from "fs";
21
+ import { join, resolve } from "path";
22
+ import { fileURLToPath } from "url";
23
+ import { dirname } from "path";
24
+ function getDefaultSourceHooksDir() {
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ let dir = dirname(__filename);
27
+ for (let i = 0; i < 5; i++) {
28
+ const candidate = join(dir, "registry", "hooks");
29
+ if (existsSync(candidate)) return candidate;
30
+ const parent = resolve(dir, "..");
31
+ if (parent === dir) break;
32
+ dir = parent;
33
+ }
34
+ return join(dirname(__filename), "..", "..", "registry", "hooks");
35
+ }
36
+ async function _addAt(opts) {
37
+ const { settingsPath, hooksDir, hookName, dryRun = false } = opts;
38
+ const sourceHooksDir = opts.sourceHooksDir ?? getDefaultSourceHooksDir();
39
+ const hook = getHook(hookName);
40
+ if (!hook) {
41
+ log.error(`Unknown hook: "${hookName}". Run "claude-code-hookkit list" to see available hooks.`);
42
+ return;
43
+ }
44
+ const srcPath = join(sourceHooksDir, hook.scriptFile);
45
+ const destPath = join(hooksDir, hook.scriptFile);
46
+ if (dryRun) {
47
+ log.dryRun(`Would copy: ${srcPath} -> ${destPath}`);
48
+ log.dryRun(`Would chmod +x: ${destPath}`);
49
+ log.dryRun(`Would add ${hook.event}${hook.matcher ? ` [${hook.matcher}]` : ""} hook to settings.json`);
50
+ return;
51
+ }
52
+ await mkdir(hooksDir, { recursive: true });
53
+ const result = await applyMerge({
54
+ settingsPath,
55
+ newHooks: [
56
+ {
57
+ event: hook.event,
58
+ matcher: hook.matcher,
59
+ hook: { type: "command", command: destPath }
60
+ }
61
+ ],
62
+ dryRun: false
63
+ });
64
+ if (result.added.length > 0) {
65
+ await copyFile(srcPath, destPath);
66
+ await chmod(destPath, 493);
67
+ }
68
+ if (result.added.length > 0) {
69
+ log.success(`Installed hook: ${hook.name}`);
70
+ log.dim(` Script: ${destPath}`);
71
+ if (hook.pack) {
72
+ log.dim(` Pack: ${hook.pack}`);
73
+ }
74
+ log.dim(` Event: ${hook.event}${hook.matcher ? ` [matcher: ${hook.matcher}]` : ""}`);
75
+ } else if (result.skipped.length > 0) {
76
+ log.warn(`Hook "${hook.name}" is already installed (skipped).`);
77
+ }
78
+ }
79
+ async function _addPackAt(opts) {
80
+ const { settingsPath, hooksDir, packName, dryRun = false } = opts;
81
+ const sourceHooksDir = opts.sourceHooksDir ?? getDefaultSourceHooksDir();
82
+ const pack = getPack(packName);
83
+ if (!pack) {
84
+ log.error(`Unknown hook or pack: "${packName}". Run "claude-code-hookkit list" to see available options.`);
85
+ return;
86
+ }
87
+ log.info(`Installing ${packName} (${pack.hooks.length} hooks)...`);
88
+ const installed = [];
89
+ const skipped = [];
90
+ for (const hookName of pack.hooks) {
91
+ const hook = getHook(hookName);
92
+ if (!hook) {
93
+ log.warn(`Pack "${packName}" references unknown hook "${hookName}" \u2014 skipping.`);
94
+ continue;
95
+ }
96
+ if (dryRun) {
97
+ const srcPath = join(sourceHooksDir, hook.scriptFile);
98
+ const destPath = join(hooksDir, hook.scriptFile);
99
+ log.dryRun(`Would install hook: ${hookName}`);
100
+ log.dryRun(` ${srcPath} -> ${destPath}`);
101
+ installed.push(hookName);
102
+ continue;
103
+ }
104
+ await _addAt({ settingsPath, hooksDir, sourceHooksDir, hookName, dryRun });
105
+ installed.push(hookName);
106
+ }
107
+ if (!dryRun) {
108
+ log.success(`Installed ${packName} (${installed.length} hooks): ${installed.join(", ")}`);
109
+ }
110
+ }
111
+ async function addCommand(opts) {
112
+ const validScopes = ["user", "project", "local"];
113
+ const scope = validScopes.includes(opts.scope) ? opts.scope : "project";
114
+ const settingsPath = getSettingsPath(scope);
115
+ const hooksDir = getHooksDir(scope);
116
+ if (getPack(opts.hookName)) {
117
+ await _addPackAt({
118
+ settingsPath,
119
+ hooksDir,
120
+ packName: opts.hookName,
121
+ dryRun: opts.dryRun
122
+ });
123
+ return;
124
+ }
125
+ if (getHook(opts.hookName)) {
126
+ await _addAt({
127
+ settingsPath,
128
+ hooksDir,
129
+ hookName: opts.hookName,
130
+ dryRun: opts.dryRun
131
+ });
132
+ return;
133
+ }
134
+ log.error(`Unknown hook or pack: "${opts.hookName}". Run "claude-code-hookkit list" to see available options.`);
135
+ }
136
+ export {
137
+ _addAt,
138
+ _addPackAt,
139
+ addCommand
140
+ };
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/logger.ts
4
+ import pc from "picocolors";
5
+ var log = {
6
+ /** Standard informational message */
7
+ info(msg) {
8
+ console.log(msg);
9
+ },
10
+ /** Success message in green */
11
+ success(msg) {
12
+ console.log(pc.green(msg));
13
+ },
14
+ /** Warning message in yellow */
15
+ warn(msg) {
16
+ console.warn(pc.yellow(msg));
17
+ },
18
+ /** Error message in red (to stderr) */
19
+ error(msg) {
20
+ console.error(pc.red(msg));
21
+ },
22
+ /** Dimmed/secondary text */
23
+ dim(msg) {
24
+ console.log(pc.dim(msg));
25
+ },
26
+ /** Dry-run prefixed message in yellow */
27
+ dryRun(msg) {
28
+ console.log(pc.yellow("[DRY RUN]") + " " + msg);
29
+ }
30
+ };
31
+
32
+ export {
33
+ log
34
+ };
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createBackup
4
+ } from "./chunk-QKT647BI.js";
5
+
6
+ // src/config/manager.ts
7
+ import { readFile, writeFile } from "fs/promises";
8
+
9
+ // src/config/merger.ts
10
+ function isDuplicate(groups, matcher, command) {
11
+ return groups.some((group) => {
12
+ const matcherMatches = group.matcher === matcher;
13
+ const commandMatches = group.hooks.some((h) => h.command === command);
14
+ return matcherMatches && commandMatches;
15
+ });
16
+ }
17
+ function mergeHooks(input) {
18
+ const settings = structuredClone(input.existing);
19
+ const added = [];
20
+ const skipped = [];
21
+ if (!settings.hooks) {
22
+ settings.hooks = {};
23
+ }
24
+ for (const { event, matcher, hook } of input.newHooks) {
25
+ if (!settings.hooks[event]) {
26
+ settings.hooks[event] = [];
27
+ }
28
+ const existingGroups = settings.hooks[event];
29
+ if (isDuplicate(existingGroups, matcher, hook.command)) {
30
+ skipped.push({
31
+ event,
32
+ matcher,
33
+ command: hook.command,
34
+ reason: "Already exists (same event + matcher + command)"
35
+ });
36
+ continue;
37
+ }
38
+ const newGroup = {
39
+ ...matcher !== void 0 ? { matcher } : {},
40
+ hooks: [{ type: hook.type, command: hook.command }]
41
+ };
42
+ existingGroups.push(newGroup);
43
+ added.push({ event, matcher, command: hook.command });
44
+ }
45
+ return { settings, added, skipped };
46
+ }
47
+
48
+ // src/config/manager.ts
49
+ function detectIndent(raw) {
50
+ const lines = raw.split("\n");
51
+ for (const line of lines.slice(1)) {
52
+ if (line.startsWith(" ")) return " ";
53
+ const match = line.match(/^( +)/);
54
+ if (match) return match[1].length;
55
+ }
56
+ return 2;
57
+ }
58
+ async function readSettings(path) {
59
+ try {
60
+ const raw = await readFile(path, "utf8");
61
+ return JSON.parse(raw);
62
+ } catch (err) {
63
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
64
+ return {};
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+ async function writeSettings(path, settings, originalRaw) {
70
+ const indent = originalRaw ? detectIndent(originalRaw) : 2;
71
+ const output = JSON.stringify(settings, null, indent) + "\n";
72
+ await writeFile(path, output, "utf8");
73
+ }
74
+ async function applyMerge(opts) {
75
+ const { settingsPath, newHooks, dryRun = false } = opts;
76
+ let originalRaw;
77
+ let existing;
78
+ try {
79
+ originalRaw = await readFile(settingsPath, "utf8");
80
+ existing = JSON.parse(originalRaw);
81
+ } catch (err) {
82
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
83
+ existing = {};
84
+ originalRaw = void 0;
85
+ } else {
86
+ throw err;
87
+ }
88
+ }
89
+ const result = mergeHooks({ existing, newHooks });
90
+ if (!dryRun) {
91
+ await createBackup(settingsPath);
92
+ await writeSettings(settingsPath, result.settings, originalRaw);
93
+ }
94
+ return result;
95
+ }
96
+
97
+ export {
98
+ readSettings,
99
+ writeSettings,
100
+ applyMerge
101
+ };
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config/locator.ts
4
+ import { homedir } from "os";
5
+ import { join, resolve, dirname } from "path";
6
+ import { existsSync } from "fs";
7
+ function findProjectRoot() {
8
+ const home = homedir();
9
+ let dir = resolve(process.cwd());
10
+ while (true) {
11
+ if (existsSync(join(dir, ".git"))) {
12
+ return dir;
13
+ }
14
+ if (dir !== home && existsSync(join(dir, ".claude"))) {
15
+ return dir;
16
+ }
17
+ const parent = dirname(dir);
18
+ if (parent === dir) break;
19
+ dir = parent;
20
+ }
21
+ return process.cwd();
22
+ }
23
+ function getSettingsPath(scope) {
24
+ switch (scope) {
25
+ case "user":
26
+ return join(homedir(), ".claude", "settings.json");
27
+ case "project":
28
+ return resolve(findProjectRoot(), ".claude", "settings.json");
29
+ case "local":
30
+ return resolve(findProjectRoot(), ".claude", "settings.local.json");
31
+ }
32
+ }
33
+ function getHooksDir(scope) {
34
+ switch (scope) {
35
+ case "user":
36
+ return join(homedir(), ".claude", "hooks");
37
+ case "project":
38
+ case "local":
39
+ return resolve(findProjectRoot(), ".claude", "hooks");
40
+ }
41
+ }
42
+
43
+ export {
44
+ getSettingsPath,
45
+ getHooksDir
46
+ };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config/backup.ts
4
+ import { copyFile, access } from "fs/promises";
5
+ import { constants } from "fs";
6
+ async function createBackup(settingsPath) {
7
+ const backupPath = settingsPath + ".backup";
8
+ try {
9
+ await access(settingsPath, constants.F_OK);
10
+ await copyFile(settingsPath, backupPath);
11
+ return backupPath;
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+ async function restoreBackup(settingsPath) {
17
+ const backupPath = settingsPath + ".backup";
18
+ try {
19
+ await access(backupPath, constants.F_OK);
20
+ await copyFile(backupPath, settingsPath);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export {
28
+ createBackup,
29
+ restoreBackup
30
+ };