@mcptoolshop/claude-hook-debug 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcp-tool-shop
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,80 @@
1
+ # claude-hook-debug
2
+
3
+ Diagnostic CLI for Claude Code hook issues. Detects ghost hooks from disabled plugins, scope conflicts, misconfigured settings, and known Claude Code bugs.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @mcptoolshop/claude-hook-debug
9
+ ```
10
+
11
+ Or run directly:
12
+
13
+ ```bash
14
+ npx @mcptoolshop/claude-hook-debug
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ # Scan current workspace
21
+ claude-hook-debug
22
+
23
+ # Scan a specific project
24
+ claude-hook-debug /path/to/project
25
+
26
+ # JSON output (for piping/scripting)
27
+ claude-hook-debug --json
28
+ ```
29
+
30
+ Exit code 1 if any errors are found, 0 otherwise.
31
+
32
+ ## What It Detects
33
+
34
+ | ID | Severity | Description |
35
+ |----|----------|-------------|
36
+ | `GHOST_HOOK_PREVIEW` | error | claude-preview plugin disabled but Stop hook still fires ([#19893](https://github.com/anthropics/claude-code/issues/19893)) |
37
+ | `GHOST_HOOK_GENERIC` | warning | Any disabled plugin that may still have active hooks |
38
+ | `LOCAL_ONLY_PLUGINS` | error | `enabledPlugins` in local settings only — overrides silently dropped ([#25086](https://github.com/anthropics/claude-code/issues/25086)) |
39
+ | `SCOPE_CONFLICT` | warning | Plugin enabled in one scope, disabled in another |
40
+ | `STOP_CONTINUE_LOOP` | error | Stop hook outputs `continue:true` causing infinite loop ([#1288](https://github.com/anthropics/claude-code/issues/1288)) |
41
+ | `DISABLE_ALL_HOOKS_ACTIVE` | warning/error | `disableAllHooks: true` suppresses all hooks (escalates to error if managed settings exist) |
42
+ | `BROKEN_SETTINGS_JSON` | error | Invalid JSON silently disables all settings from that file |
43
+ | `LARGE_SETTINGS_FILE` | warning | Settings file >100KB (may cause slow startup) |
44
+ | `PLUGIN_HOOKS_INVISIBLE` | info | No user hooks but plugins are enabled — plugin hooks are invisible to inspection |
45
+
46
+ ## Settings Scopes
47
+
48
+ The tool reads all four settings scopes in Claude Code's load order:
49
+
50
+ | Scope | Path | Precedence |
51
+ |-------|------|------------|
52
+ | managed | `~/.claude/managed-settings.json` | Highest |
53
+ | user | `~/.claude/settings.json` | |
54
+ | project | `.claude/settings.json` | |
55
+ | local | `.claude/settings.local.json` | Lowest (last write wins) |
56
+
57
+ ## Library Usage
58
+
59
+ ```typescript
60
+ import { debug } from '@mcptoolshop/claude-hook-debug';
61
+
62
+ const report = debug({ projectRoot: '/path/to/project' });
63
+
64
+ console.log(report.diagnostics);
65
+ // [{ id: 'GHOST_HOOK_PREVIEW', severity: 'error', title: '...', ... }]
66
+
67
+ console.log(report.plugins);
68
+ // [{ pluginId: 'claude-preview@...', mergedEnabled: false, scopes: [...] }]
69
+ ```
70
+
71
+ ## Security
72
+
73
+ - **Read-only.** Reads settings files, never modifies them.
74
+ - **No network.** No API calls, no telemetry, no phone-home.
75
+ - **No secrets.** Does not read or log env var values, API keys, or tokens.
76
+ - **Zero dependencies.** Only Node.js built-ins.
77
+
78
+ ---
79
+
80
+ Built by [MCP Tool Shop](https://mcp-tool-shop.github.io/)
package/dist/cli.js ADDED
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { platform } from "os";
5
+
6
+ // src/scanner.ts
7
+ import { readFileSync, existsSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ function userSettingsDir() {
11
+ return join(homedir(), ".claude");
12
+ }
13
+ function resolveSettingsPaths(opts) {
14
+ const root = opts?.projectRoot ?? process.cwd();
15
+ const userDir = userSettingsDir();
16
+ return {
17
+ managed: opts?.overridePaths?.managed ?? join(userDir, "managed-settings.json"),
18
+ user: opts?.overridePaths?.user ?? join(userDir, "settings.json"),
19
+ project: opts?.overridePaths?.project ?? join(root, ".claude", "settings.json"),
20
+ local: opts?.overridePaths?.local ?? join(root, ".claude", "settings.local.json")
21
+ };
22
+ }
23
+ function readSettingsFile(scope, path) {
24
+ if (!existsSync(path)) {
25
+ return { scope, path, exists: false };
26
+ }
27
+ try {
28
+ const content = readFileSync(path, "utf-8");
29
+ const raw = JSON.parse(content);
30
+ return { scope, path, exists: true, raw };
31
+ } catch (err) {
32
+ return {
33
+ scope,
34
+ path,
35
+ exists: true,
36
+ error: err instanceof Error ? err.message : String(err)
37
+ };
38
+ }
39
+ }
40
+ function scanAllSettings(opts) {
41
+ const paths = resolveSettingsPaths(opts);
42
+ const scopes = ["managed", "user", "project", "local"];
43
+ return scopes.map((scope) => readSettingsFile(scope, paths[scope]));
44
+ }
45
+ function getPlugins(file) {
46
+ if (!file.exists || !file.raw) return {};
47
+ const ep = file.raw.enabledPlugins;
48
+ if (!ep || typeof ep !== "object") return {};
49
+ const result = {};
50
+ for (const [key, val] of Object.entries(ep)) {
51
+ result[key] = val === true || Array.isArray(val) && val.length > 0;
52
+ }
53
+ return result;
54
+ }
55
+ function extractPlugins(files) {
56
+ const allIds = /* @__PURE__ */ new Set();
57
+ for (const f of files) {
58
+ for (const id of Object.keys(getPlugins(f))) {
59
+ allIds.add(id);
60
+ }
61
+ }
62
+ const states = [];
63
+ for (const pluginId of allIds) {
64
+ const scopes = [];
65
+ let mergedEnabled = false;
66
+ for (const f of files) {
67
+ const plugins = getPlugins(f);
68
+ if (pluginId in plugins) {
69
+ scopes.push({ scope: f.scope, enabled: plugins[pluginId] });
70
+ mergedEnabled = plugins[pluginId];
71
+ }
72
+ }
73
+ states.push({ pluginId, scopes, mergedEnabled });
74
+ }
75
+ return states;
76
+ }
77
+ function getHooks(file) {
78
+ if (!file.exists || !file.raw) return {};
79
+ const h = file.raw.hooks;
80
+ if (!h || typeof h !== "object") return {};
81
+ return h;
82
+ }
83
+ function extractHooks(files) {
84
+ const resolved = [];
85
+ for (const f of files) {
86
+ const hooks = getHooks(f);
87
+ for (const [event, groups] of Object.entries(hooks)) {
88
+ if (!Array.isArray(groups)) continue;
89
+ for (const group of groups) {
90
+ if (!Array.isArray(group.hooks)) continue;
91
+ for (const hook of group.hooks) {
92
+ resolved.push({
93
+ event,
94
+ matcher: group.matcher,
95
+ hook,
96
+ source: f.scope
97
+ });
98
+ }
99
+ }
100
+ }
101
+ }
102
+ return resolved;
103
+ }
104
+ function getDisableAllHooks(files) {
105
+ for (const f of files) {
106
+ if (f.exists && f.raw && f.raw.disableAllHooks === true) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ // src/diagnostics.ts
114
+ function ghostHooks(ctx) {
115
+ const diagnostics = [];
116
+ const disabledPlugins = ctx.plugins.filter((p) => !p.mergedEnabled);
117
+ for (const plugin of disabledPlugins) {
118
+ const id = plugin.pluginId;
119
+ if (id.includes("preview")) {
120
+ diagnostics.push({
121
+ id: "GHOST_HOOK_PREVIEW",
122
+ severity: "error",
123
+ title: `Disabled plugin "${id}" may still fire hooks`,
124
+ detail: `Plugin is disabled (merged: false) but Claude Code has a known bug where disabled plugins still register hooks. The plugin's Stop hook fires "[Preview Required]" even when explicitly disabled at all scopes. Scopes: ${plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ")}.`,
125
+ fix: `1. Ensure "enabledPlugins" key exists in ~/.claude/settings.json (even as {})
126
+ 2. Set "${id}": false in ~/.claude/settings.json (not just local)
127
+ 3. If still firing, add "disableAllHooks": true in project .claude/settings.local.json
128
+ 4. File a bug at https://github.com/anthropics/claude-code/issues`,
129
+ references: [
130
+ "https://github.com/anthropics/claude-code/issues/19893",
131
+ "https://github.com/anthropics/claude-code/issues/25086"
132
+ ]
133
+ });
134
+ } else {
135
+ diagnostics.push({
136
+ id: "GHOST_HOOK_GENERIC",
137
+ severity: "warning",
138
+ title: `Disabled plugin "${id}" may still have active hooks`,
139
+ detail: `Plugin is disabled (merged: false) across scopes: ${plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ")}. Due to known bugs, disabled plugins can still register and fire hooks.`,
140
+ fix: `Disable in ~/.claude/settings.json (user scope) rather than local/project scope. Ensure the "enabledPlugins" key exists in settings.json for local overrides to take effect.`,
141
+ references: ["https://github.com/anthropics/claude-code/issues/19893"]
142
+ });
143
+ }
144
+ }
145
+ return diagnostics;
146
+ }
147
+ function localOnlyPlugins(ctx) {
148
+ const diagnostics = [];
149
+ const hasPluginsInUser = ctx.files.some(
150
+ (f) => f.scope === "user" && f.exists && f.raw && "enabledPlugins" in f.raw
151
+ );
152
+ const hasPluginsInProject = ctx.files.some(
153
+ (f) => f.scope === "project" && f.exists && f.raw && "enabledPlugins" in f.raw
154
+ );
155
+ const hasPluginsInLocal = ctx.files.some(
156
+ (f) => f.scope === "local" && f.exists && f.raw && "enabledPlugins" in f.raw
157
+ );
158
+ if (hasPluginsInLocal && !hasPluginsInUser && !hasPluginsInProject) {
159
+ diagnostics.push({
160
+ id: "LOCAL_ONLY_PLUGINS",
161
+ severity: "error",
162
+ title: "enabledPlugins in local settings only \u2014 overrides silently dropped",
163
+ detail: `"enabledPlugins" exists in settings.local.json but not in any settings.json. Claude Code merges local overrides into existing keys. If the key doesn't exist in a broader scope (user or project settings.json), the local value is silently ignored.`,
164
+ fix: `Add an "enabledPlugins": {} entry to ~/.claude/settings.json or .claude/settings.json, then the local override will merge correctly.`,
165
+ references: ["https://github.com/anthropics/claude-code/issues/25086"]
166
+ });
167
+ }
168
+ return diagnostics;
169
+ }
170
+ function disableAllHooksWarning(ctx) {
171
+ if (!ctx.disableAllHooks) return [];
172
+ const managedFile = ctx.files.find((f) => f.scope === "managed");
173
+ const hasManaged = managedFile?.exists && managedFile.raw;
174
+ return [
175
+ {
176
+ id: "DISABLE_ALL_HOOKS_ACTIVE",
177
+ severity: hasManaged ? "error" : "warning",
178
+ title: "disableAllHooks is active \u2014 all hooks suppressed",
179
+ detail: `"disableAllHooks": true is set. This disables ALL hooks including managed/organization hooks. ` + (hasManaged ? `A managed settings file exists \u2014 this overrides organization-enforced hooks, which is a known security bug.` : `No managed settings file detected, so this is likely safe.`),
180
+ fix: hasManaged ? `Remove "disableAllHooks": true \u2014 it bypasses managed hooks. Instead, disable specific plugins via "enabledPlugins".` : `This is a broad hammer. Consider disabling specific plugins instead.`,
181
+ references: []
182
+ }
183
+ ];
184
+ }
185
+ function stopContinueLoop(ctx) {
186
+ const diagnostics = [];
187
+ for (const h of ctx.hooks) {
188
+ if (h.event !== "Stop") continue;
189
+ if (h.hook.type !== "command") continue;
190
+ const cmd = h.hook.command ?? "";
191
+ if (cmd.includes('"continue"') && cmd.includes("true") && !cmd.includes("false")) {
192
+ diagnostics.push({
193
+ id: "STOP_CONTINUE_LOOP",
194
+ severity: "error",
195
+ title: `Stop hook outputs continue:true \u2014 causes infinite loop`,
196
+ detail: `A Stop hook in ${h.source} scope outputs {"continue": true}. In Claude Code, continue:true on a Stop hook means "don't stop yet", which re-invokes stop hooks in an infinite loop. Command: ${cmd}`,
197
+ fix: `Remove the hook. To allow stopping, either output nothing, output {"continue": false}, or omit the "decision" field entirely.`,
198
+ references: ["https://github.com/anthropics/claude-code/issues/1288"]
199
+ });
200
+ }
201
+ }
202
+ return diagnostics;
203
+ }
204
+ function brokenSettings(ctx) {
205
+ const diagnostics = [];
206
+ for (const f of ctx.files) {
207
+ if (f.exists && f.error) {
208
+ diagnostics.push({
209
+ id: "BROKEN_SETTINGS_JSON",
210
+ severity: "error",
211
+ title: `Invalid JSON in ${f.scope} settings`,
212
+ detail: `File ${f.path} exists but failed to parse: ${f.error}. A broken settings.json silently disables ALL settings from that file.`,
213
+ fix: `Fix the JSON syntax in ${f.path}. Common causes: trailing commas, missing quotes.`,
214
+ references: []
215
+ });
216
+ }
217
+ }
218
+ return diagnostics;
219
+ }
220
+ function emptyHooksButPluginActive(ctx) {
221
+ const diagnostics = [];
222
+ const userDefinedHookCount = ctx.hooks.filter((h) => h.source !== "plugin").length;
223
+ const hasEnabledPlugins = ctx.plugins.some((p) => p.mergedEnabled);
224
+ if (userDefinedHookCount === 0 && hasEnabledPlugins) {
225
+ diagnostics.push({
226
+ id: "PLUGIN_HOOKS_INVISIBLE",
227
+ severity: "info",
228
+ title: "No user-defined hooks, but plugins may inject hooks at runtime",
229
+ detail: `The hooks config across all settings files is empty, but ${ctx.plugins.filter((p) => p.mergedEnabled).length} plugin(s) are enabled. Plugins register hooks from their manifests at load time \u2014 these don't appear in your settings.json hooks object.`,
230
+ fix: `Plugin hooks are invisible to settings inspection. To debug, run "claude --debug" and look for hook events in the log.`,
231
+ references: []
232
+ });
233
+ }
234
+ return diagnostics;
235
+ }
236
+ function largeSettingsFile(ctx) {
237
+ const diagnostics = [];
238
+ for (const f of ctx.files) {
239
+ if (f.exists && f.raw) {
240
+ const size = JSON.stringify(f.raw).length;
241
+ if (size > 1e5) {
242
+ diagnostics.push({
243
+ id: "LARGE_SETTINGS_FILE",
244
+ severity: "warning",
245
+ title: `${f.scope} settings file is unusually large (${(size / 1024).toFixed(0)}KB)`,
246
+ detail: `File ${f.path} is ${(size / 1024).toFixed(0)}KB. Large settings files can cause slow startup and may indicate accumulated cruft (e.g. large permission arrays).`,
247
+ fix: `Review ${f.path} for unnecessary entries. Permissions arrays tend to grow over time.`,
248
+ references: []
249
+ });
250
+ }
251
+ }
252
+ }
253
+ return diagnostics;
254
+ }
255
+ function scopeConflicts(ctx) {
256
+ const diagnostics = [];
257
+ for (const plugin of ctx.plugins) {
258
+ if (plugin.scopes.length < 2) continue;
259
+ const values = plugin.scopes.map((s) => s.enabled);
260
+ const hasConflict = values.some((v) => v !== values[0]);
261
+ if (hasConflict) {
262
+ const scopeStr = plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ");
263
+ diagnostics.push({
264
+ id: "SCOPE_CONFLICT",
265
+ severity: "warning",
266
+ title: `Plugin "${plugin.pluginId}" has conflicting enable/disable across scopes`,
267
+ detail: `Plugin state differs across scopes: ${scopeStr}. The last scope in load order wins (local > project > user > managed). Final merged state: ${plugin.mergedEnabled ? "enabled" : "disabled"}.`,
268
+ fix: `Align the plugin state across scopes. If you want it disabled, set false in ~/.claude/settings.json (user scope) for reliable behavior.`,
269
+ references: ["https://github.com/anthropics/claude-code/issues/25086"]
270
+ });
271
+ }
272
+ }
273
+ return diagnostics;
274
+ }
275
+ var ALL_RULES = [
276
+ brokenSettings,
277
+ localOnlyPlugins,
278
+ ghostHooks,
279
+ scopeConflicts,
280
+ emptyHooksButPluginActive,
281
+ stopContinueLoop,
282
+ disableAllHooksWarning,
283
+ largeSettingsFile
284
+ ];
285
+ function runDiagnostics(ctx) {
286
+ const results = [];
287
+ for (const rule of ALL_RULES) {
288
+ results.push(...rule(ctx));
289
+ }
290
+ const order = { error: 0, warning: 1, info: 2 };
291
+ results.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
292
+ return results;
293
+ }
294
+
295
+ // src/report.ts
296
+ var BOLD = "\x1B[1m";
297
+ var GREEN = "\x1B[32m";
298
+ var YELLOW = "\x1B[33m";
299
+ var RED = "\x1B[31m";
300
+ var CYAN = "\x1B[36m";
301
+ var DIM = "\x1B[2m";
302
+ var RESET = "\x1B[0m";
303
+ var UNDERLINE = "\x1B[4m";
304
+ var severityColor = {
305
+ error: RED,
306
+ warning: YELLOW,
307
+ info: CYAN
308
+ };
309
+ var severityIcon = {
310
+ error: "\u2717",
311
+ warning: "\u26A0",
312
+ info: "\u2139"
313
+ };
314
+ function formatSettingsSection(files) {
315
+ const lines = [];
316
+ lines.push(`${BOLD}${UNDERLINE}Settings Files${RESET}`);
317
+ lines.push("");
318
+ for (const f of files) {
319
+ const status = f.exists ? f.error ? `${RED}\u2717 BROKEN${RESET}` : `${GREEN}\u2713 loaded${RESET}` : `${DIM}\u2014 not found${RESET}`;
320
+ const size = f.exists && f.raw ? `${DIM}(${(JSON.stringify(f.raw).length / 1024).toFixed(1)}KB)${RESET}` : "";
321
+ lines.push(` ${BOLD}${f.scope.padEnd(8)}${RESET} ${status} ${size}`);
322
+ lines.push(` ${DIM}${f.path}${RESET}`);
323
+ if (f.error) {
324
+ lines.push(` ${RED}${f.error}${RESET}`);
325
+ }
326
+ lines.push("");
327
+ }
328
+ return lines.join("\n");
329
+ }
330
+ function formatPluginsSection(plugins) {
331
+ const lines = [];
332
+ lines.push(`${BOLD}${UNDERLINE}Plugins${RESET}`);
333
+ lines.push("");
334
+ if (plugins.length === 0) {
335
+ lines.push(` ${DIM}No plugins configured.${RESET}`);
336
+ lines.push("");
337
+ return lines.join("\n");
338
+ }
339
+ for (const p of plugins) {
340
+ const enabled = p.mergedEnabled;
341
+ const icon = enabled ? `${GREEN}\u25CF${RESET}` : `${RED}\u25CB${RESET}`;
342
+ const state = enabled ? `${GREEN}enabled${RESET}` : `${RED}disabled${RESET}`;
343
+ lines.push(` ${icon} ${BOLD}${p.pluginId}${RESET} \u2192 ${state}`);
344
+ for (const s of p.scopes) {
345
+ const scopeState = s.enabled ? `${GREEN}true${RESET}` : `${RED}false${RESET}`;
346
+ lines.push(` ${DIM}${s.scope}:${RESET} ${scopeState}`);
347
+ }
348
+ lines.push("");
349
+ }
350
+ return lines.join("\n");
351
+ }
352
+ function formatHooksSection(hooks, disableAllHooks) {
353
+ const lines = [];
354
+ lines.push(`${BOLD}${UNDERLINE}Hooks${RESET}`);
355
+ if (disableAllHooks) {
356
+ lines.push(` ${RED}${BOLD}disableAllHooks: true${RESET} \u2014 all hooks suppressed`);
357
+ }
358
+ lines.push("");
359
+ if (hooks.length === 0) {
360
+ lines.push(` ${DIM}No user-defined hooks in settings files.${RESET}`);
361
+ lines.push(` ${DIM}(Plugin hooks are injected at runtime and invisible here.)${RESET}`);
362
+ lines.push("");
363
+ return lines.join("\n");
364
+ }
365
+ const byEvent = /* @__PURE__ */ new Map();
366
+ for (const h of hooks) {
367
+ const list = byEvent.get(h.event) ?? [];
368
+ list.push(h);
369
+ byEvent.set(h.event, list);
370
+ }
371
+ for (const [event, eventHooks] of byEvent) {
372
+ lines.push(` ${CYAN}${event}${RESET} (${eventHooks.length})`);
373
+ for (const h of eventHooks) {
374
+ const matcher = h.matcher ? ` ${DIM}matcher=${h.matcher}${RESET}` : "";
375
+ const source = `${DIM}[${h.source}]${RESET}`;
376
+ const desc = h.hook.type === "command" ? h.hook.command ?? "(empty)" : h.hook.type === "prompt" ? `prompt: ${(h.hook.prompt ?? "").slice(0, 60)}...` : h.hook.type === "http" ? `http: ${h.hook.url ?? "(no url)"}` : `${h.hook.type}: agent`;
377
+ lines.push(` ${source}${matcher} ${h.hook.type}: ${DIM}${desc}${RESET}`);
378
+ }
379
+ lines.push("");
380
+ }
381
+ return lines.join("\n");
382
+ }
383
+ function formatDiagnosticsSection(diagnostics) {
384
+ const lines = [];
385
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
386
+ const warnings = diagnostics.filter((d) => d.severity === "warning").length;
387
+ const infos = diagnostics.filter((d) => d.severity === "info").length;
388
+ lines.push(`${BOLD}${UNDERLINE}Diagnostics${RESET} (${errors} errors, ${warnings} warnings, ${infos} info)`);
389
+ lines.push("");
390
+ if (diagnostics.length === 0) {
391
+ lines.push(` ${GREEN}\u2713 No issues detected.${RESET}`);
392
+ lines.push("");
393
+ return lines.join("\n");
394
+ }
395
+ for (const d of diagnostics) {
396
+ const color = severityColor[d.severity] ?? DIM;
397
+ const icon = severityIcon[d.severity] ?? "?";
398
+ lines.push(` ${color}${icon} [${d.id}]${RESET} ${BOLD}${d.title}${RESET}`);
399
+ lines.push(` ${d.detail}`);
400
+ if (d.fix) {
401
+ lines.push(` ${GREEN}Fix:${RESET} ${d.fix}`);
402
+ }
403
+ if (d.references && d.references.length > 0) {
404
+ lines.push(` ${DIM}Refs: ${d.references.join(", ")}${RESET}`);
405
+ }
406
+ lines.push("");
407
+ }
408
+ return lines.join("\n");
409
+ }
410
+ function formatReport(report2) {
411
+ const lines = [];
412
+ lines.push("");
413
+ lines.push(`${BOLD}Claude Hook Debug${RESET} ${DIM}${report2.timestamp} ${report2.platform}${RESET}`);
414
+ lines.push(`${"\u2550".repeat(60)}`);
415
+ lines.push("");
416
+ lines.push(formatSettingsSection(report2.settingsFiles));
417
+ lines.push(formatPluginsSection(report2.plugins));
418
+ lines.push(formatHooksSection(report2.hooks, report2.disableAllHooks));
419
+ lines.push(formatDiagnosticsSection(report2.diagnostics));
420
+ return lines.join("\n");
421
+ }
422
+ function formatReportJson(report2) {
423
+ return JSON.stringify(report2, null, 2);
424
+ }
425
+
426
+ // src/index.ts
427
+ function debug(opts) {
428
+ const files = scanAllSettings(opts);
429
+ const plugins = extractPlugins(files);
430
+ const hooks = extractHooks(files);
431
+ const disableAllHooks = getDisableAllHooks(files);
432
+ const diagnostics = runDiagnostics({
433
+ files,
434
+ plugins,
435
+ hooks,
436
+ disableAllHooks
437
+ });
438
+ return {
439
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
440
+ platform: `${platform()} ${process.arch}`,
441
+ settingsFiles: files,
442
+ plugins,
443
+ hooks,
444
+ diagnostics,
445
+ disableAllHooks
446
+ };
447
+ }
448
+
449
+ // src/cli.ts
450
+ var args = process.argv.slice(2);
451
+ var useJson = args.includes("--json");
452
+ var projectRoot = args.find((a) => !a.startsWith("-"));
453
+ var report = debug({ projectRoot: projectRoot ?? process.cwd() });
454
+ if (useJson) {
455
+ console.log(formatReportJson(report));
456
+ } else {
457
+ console.log(formatReport(report));
458
+ const hasErrors = report.diagnostics.some((d) => d.severity === "error");
459
+ process.exit(hasErrors ? 1 : 0);
460
+ }
@@ -0,0 +1,87 @@
1
+ type SettingsScope = 'managed' | 'user' | 'project' | 'local';
2
+ interface SettingsFile {
3
+ scope: SettingsScope;
4
+ path: string;
5
+ exists: boolean;
6
+ raw?: Record<string, unknown>;
7
+ error?: string;
8
+ }
9
+ type HookEvent = 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'Notification' | 'UserPromptSubmit' | 'SessionStart' | 'SessionEnd' | 'Stop' | 'StopFailure' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PostCompact' | 'PermissionRequest' | 'Setup' | 'TeammateIdle' | 'TaskCompleted' | 'Elicitation' | 'ElicitationResult' | 'ConfigChange' | 'WorktreeCreate' | 'WorktreeRemove' | 'InstructionsLoaded';
10
+ interface HookCommand {
11
+ type: 'command' | 'prompt' | 'agent' | 'http';
12
+ command?: string;
13
+ prompt?: string;
14
+ url?: string;
15
+ timeout?: number;
16
+ statusMessage?: string;
17
+ once?: boolean;
18
+ async?: boolean;
19
+ }
20
+ interface HookGroup {
21
+ matcher?: string;
22
+ hooks: HookCommand[];
23
+ }
24
+ type HooksConfig = Partial<Record<HookEvent, HookGroup[]>>;
25
+ interface PluginState {
26
+ pluginId: string;
27
+ scopes: {
28
+ scope: SettingsScope;
29
+ enabled: boolean;
30
+ }[];
31
+ mergedEnabled: boolean;
32
+ }
33
+ interface ResolvedHook {
34
+ event: HookEvent;
35
+ matcher?: string;
36
+ hook: HookCommand;
37
+ source: SettingsScope | 'plugin';
38
+ pluginId?: string;
39
+ }
40
+ type Severity = 'error' | 'warning' | 'info';
41
+ interface Diagnostic {
42
+ id: string;
43
+ severity: Severity;
44
+ title: string;
45
+ detail: string;
46
+ fix?: string;
47
+ references?: string[];
48
+ }
49
+ interface DebugReport {
50
+ timestamp: string;
51
+ platform: string;
52
+ settingsFiles: SettingsFile[];
53
+ plugins: PluginState[];
54
+ hooks: ResolvedHook[];
55
+ diagnostics: Diagnostic[];
56
+ disableAllHooks: boolean;
57
+ }
58
+
59
+ interface ScanOptions {
60
+ /** Project root to scan (defaults to cwd) */
61
+ projectRoot?: string;
62
+ /** Override paths for testing */
63
+ overridePaths?: Partial<Record<SettingsScope, string>>;
64
+ }
65
+ declare function resolveSettingsPaths(opts?: ScanOptions): Record<SettingsScope, string>;
66
+ declare function scanAllSettings(opts?: ScanOptions): SettingsFile[];
67
+ declare function extractPlugins(files: SettingsFile[]): PluginState[];
68
+ declare function extractHooks(files: SettingsFile[]): ResolvedHook[];
69
+ declare function getDisableAllHooks(files: SettingsFile[]): boolean;
70
+
71
+ interface DiagnosticContext {
72
+ files: SettingsFile[];
73
+ plugins: PluginState[];
74
+ hooks: ResolvedHook[];
75
+ disableAllHooks: boolean;
76
+ }
77
+ declare function runDiagnostics(ctx: DiagnosticContext): Diagnostic[];
78
+
79
+ declare function formatReport(report: DebugReport): string;
80
+ declare function formatReportJson(report: DebugReport): string;
81
+
82
+ /**
83
+ * Run a full diagnostic scan and return a structured report.
84
+ */
85
+ declare function debug(opts?: ScanOptions): DebugReport;
86
+
87
+ export { type DebugReport, type Diagnostic, type HookCommand, type HookEvent, type HookGroup, type HooksConfig, type PluginState, type ResolvedHook, type ScanOptions, type SettingsFile, type SettingsScope, type Severity, debug, extractHooks, extractPlugins, formatReport, formatReportJson, getDisableAllHooks, resolveSettingsPaths, runDiagnostics, scanAllSettings };
package/dist/index.js ADDED
@@ -0,0 +1,456 @@
1
+ // src/index.ts
2
+ import { platform } from "os";
3
+
4
+ // src/scanner.ts
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { homedir } from "os";
8
+ function userSettingsDir() {
9
+ return join(homedir(), ".claude");
10
+ }
11
+ function resolveSettingsPaths(opts) {
12
+ const root = opts?.projectRoot ?? process.cwd();
13
+ const userDir = userSettingsDir();
14
+ return {
15
+ managed: opts?.overridePaths?.managed ?? join(userDir, "managed-settings.json"),
16
+ user: opts?.overridePaths?.user ?? join(userDir, "settings.json"),
17
+ project: opts?.overridePaths?.project ?? join(root, ".claude", "settings.json"),
18
+ local: opts?.overridePaths?.local ?? join(root, ".claude", "settings.local.json")
19
+ };
20
+ }
21
+ function readSettingsFile(scope, path) {
22
+ if (!existsSync(path)) {
23
+ return { scope, path, exists: false };
24
+ }
25
+ try {
26
+ const content = readFileSync(path, "utf-8");
27
+ const raw = JSON.parse(content);
28
+ return { scope, path, exists: true, raw };
29
+ } catch (err) {
30
+ return {
31
+ scope,
32
+ path,
33
+ exists: true,
34
+ error: err instanceof Error ? err.message : String(err)
35
+ };
36
+ }
37
+ }
38
+ function scanAllSettings(opts) {
39
+ const paths = resolveSettingsPaths(opts);
40
+ const scopes = ["managed", "user", "project", "local"];
41
+ return scopes.map((scope) => readSettingsFile(scope, paths[scope]));
42
+ }
43
+ function getPlugins(file) {
44
+ if (!file.exists || !file.raw) return {};
45
+ const ep = file.raw.enabledPlugins;
46
+ if (!ep || typeof ep !== "object") return {};
47
+ const result = {};
48
+ for (const [key, val] of Object.entries(ep)) {
49
+ result[key] = val === true || Array.isArray(val) && val.length > 0;
50
+ }
51
+ return result;
52
+ }
53
+ function extractPlugins(files) {
54
+ const allIds = /* @__PURE__ */ new Set();
55
+ for (const f of files) {
56
+ for (const id of Object.keys(getPlugins(f))) {
57
+ allIds.add(id);
58
+ }
59
+ }
60
+ const states = [];
61
+ for (const pluginId of allIds) {
62
+ const scopes = [];
63
+ let mergedEnabled = false;
64
+ for (const f of files) {
65
+ const plugins = getPlugins(f);
66
+ if (pluginId in plugins) {
67
+ scopes.push({ scope: f.scope, enabled: plugins[pluginId] });
68
+ mergedEnabled = plugins[pluginId];
69
+ }
70
+ }
71
+ states.push({ pluginId, scopes, mergedEnabled });
72
+ }
73
+ return states;
74
+ }
75
+ function getHooks(file) {
76
+ if (!file.exists || !file.raw) return {};
77
+ const h = file.raw.hooks;
78
+ if (!h || typeof h !== "object") return {};
79
+ return h;
80
+ }
81
+ function extractHooks(files) {
82
+ const resolved = [];
83
+ for (const f of files) {
84
+ const hooks = getHooks(f);
85
+ for (const [event, groups] of Object.entries(hooks)) {
86
+ if (!Array.isArray(groups)) continue;
87
+ for (const group of groups) {
88
+ if (!Array.isArray(group.hooks)) continue;
89
+ for (const hook of group.hooks) {
90
+ resolved.push({
91
+ event,
92
+ matcher: group.matcher,
93
+ hook,
94
+ source: f.scope
95
+ });
96
+ }
97
+ }
98
+ }
99
+ }
100
+ return resolved;
101
+ }
102
+ function getDisableAllHooks(files) {
103
+ for (const f of files) {
104
+ if (f.exists && f.raw && f.raw.disableAllHooks === true) {
105
+ return true;
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // src/diagnostics.ts
112
+ function ghostHooks(ctx) {
113
+ const diagnostics = [];
114
+ const disabledPlugins = ctx.plugins.filter((p) => !p.mergedEnabled);
115
+ for (const plugin of disabledPlugins) {
116
+ const id = plugin.pluginId;
117
+ if (id.includes("preview")) {
118
+ diagnostics.push({
119
+ id: "GHOST_HOOK_PREVIEW",
120
+ severity: "error",
121
+ title: `Disabled plugin "${id}" may still fire hooks`,
122
+ detail: `Plugin is disabled (merged: false) but Claude Code has a known bug where disabled plugins still register hooks. The plugin's Stop hook fires "[Preview Required]" even when explicitly disabled at all scopes. Scopes: ${plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ")}.`,
123
+ fix: `1. Ensure "enabledPlugins" key exists in ~/.claude/settings.json (even as {})
124
+ 2. Set "${id}": false in ~/.claude/settings.json (not just local)
125
+ 3. If still firing, add "disableAllHooks": true in project .claude/settings.local.json
126
+ 4. File a bug at https://github.com/anthropics/claude-code/issues`,
127
+ references: [
128
+ "https://github.com/anthropics/claude-code/issues/19893",
129
+ "https://github.com/anthropics/claude-code/issues/25086"
130
+ ]
131
+ });
132
+ } else {
133
+ diagnostics.push({
134
+ id: "GHOST_HOOK_GENERIC",
135
+ severity: "warning",
136
+ title: `Disabled plugin "${id}" may still have active hooks`,
137
+ detail: `Plugin is disabled (merged: false) across scopes: ${plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ")}. Due to known bugs, disabled plugins can still register and fire hooks.`,
138
+ fix: `Disable in ~/.claude/settings.json (user scope) rather than local/project scope. Ensure the "enabledPlugins" key exists in settings.json for local overrides to take effect.`,
139
+ references: ["https://github.com/anthropics/claude-code/issues/19893"]
140
+ });
141
+ }
142
+ }
143
+ return diagnostics;
144
+ }
145
+ function localOnlyPlugins(ctx) {
146
+ const diagnostics = [];
147
+ const hasPluginsInUser = ctx.files.some(
148
+ (f) => f.scope === "user" && f.exists && f.raw && "enabledPlugins" in f.raw
149
+ );
150
+ const hasPluginsInProject = ctx.files.some(
151
+ (f) => f.scope === "project" && f.exists && f.raw && "enabledPlugins" in f.raw
152
+ );
153
+ const hasPluginsInLocal = ctx.files.some(
154
+ (f) => f.scope === "local" && f.exists && f.raw && "enabledPlugins" in f.raw
155
+ );
156
+ if (hasPluginsInLocal && !hasPluginsInUser && !hasPluginsInProject) {
157
+ diagnostics.push({
158
+ id: "LOCAL_ONLY_PLUGINS",
159
+ severity: "error",
160
+ title: "enabledPlugins in local settings only \u2014 overrides silently dropped",
161
+ detail: `"enabledPlugins" exists in settings.local.json but not in any settings.json. Claude Code merges local overrides into existing keys. If the key doesn't exist in a broader scope (user or project settings.json), the local value is silently ignored.`,
162
+ fix: `Add an "enabledPlugins": {} entry to ~/.claude/settings.json or .claude/settings.json, then the local override will merge correctly.`,
163
+ references: ["https://github.com/anthropics/claude-code/issues/25086"]
164
+ });
165
+ }
166
+ return diagnostics;
167
+ }
168
+ function disableAllHooksWarning(ctx) {
169
+ if (!ctx.disableAllHooks) return [];
170
+ const managedFile = ctx.files.find((f) => f.scope === "managed");
171
+ const hasManaged = managedFile?.exists && managedFile.raw;
172
+ return [
173
+ {
174
+ id: "DISABLE_ALL_HOOKS_ACTIVE",
175
+ severity: hasManaged ? "error" : "warning",
176
+ title: "disableAllHooks is active \u2014 all hooks suppressed",
177
+ detail: `"disableAllHooks": true is set. This disables ALL hooks including managed/organization hooks. ` + (hasManaged ? `A managed settings file exists \u2014 this overrides organization-enforced hooks, which is a known security bug.` : `No managed settings file detected, so this is likely safe.`),
178
+ fix: hasManaged ? `Remove "disableAllHooks": true \u2014 it bypasses managed hooks. Instead, disable specific plugins via "enabledPlugins".` : `This is a broad hammer. Consider disabling specific plugins instead.`,
179
+ references: []
180
+ }
181
+ ];
182
+ }
183
+ function stopContinueLoop(ctx) {
184
+ const diagnostics = [];
185
+ for (const h of ctx.hooks) {
186
+ if (h.event !== "Stop") continue;
187
+ if (h.hook.type !== "command") continue;
188
+ const cmd = h.hook.command ?? "";
189
+ if (cmd.includes('"continue"') && cmd.includes("true") && !cmd.includes("false")) {
190
+ diagnostics.push({
191
+ id: "STOP_CONTINUE_LOOP",
192
+ severity: "error",
193
+ title: `Stop hook outputs continue:true \u2014 causes infinite loop`,
194
+ detail: `A Stop hook in ${h.source} scope outputs {"continue": true}. In Claude Code, continue:true on a Stop hook means "don't stop yet", which re-invokes stop hooks in an infinite loop. Command: ${cmd}`,
195
+ fix: `Remove the hook. To allow stopping, either output nothing, output {"continue": false}, or omit the "decision" field entirely.`,
196
+ references: ["https://github.com/anthropics/claude-code/issues/1288"]
197
+ });
198
+ }
199
+ }
200
+ return diagnostics;
201
+ }
202
+ function brokenSettings(ctx) {
203
+ const diagnostics = [];
204
+ for (const f of ctx.files) {
205
+ if (f.exists && f.error) {
206
+ diagnostics.push({
207
+ id: "BROKEN_SETTINGS_JSON",
208
+ severity: "error",
209
+ title: `Invalid JSON in ${f.scope} settings`,
210
+ detail: `File ${f.path} exists but failed to parse: ${f.error}. A broken settings.json silently disables ALL settings from that file.`,
211
+ fix: `Fix the JSON syntax in ${f.path}. Common causes: trailing commas, missing quotes.`,
212
+ references: []
213
+ });
214
+ }
215
+ }
216
+ return diagnostics;
217
+ }
218
+ function emptyHooksButPluginActive(ctx) {
219
+ const diagnostics = [];
220
+ const userDefinedHookCount = ctx.hooks.filter((h) => h.source !== "plugin").length;
221
+ const hasEnabledPlugins = ctx.plugins.some((p) => p.mergedEnabled);
222
+ if (userDefinedHookCount === 0 && hasEnabledPlugins) {
223
+ diagnostics.push({
224
+ id: "PLUGIN_HOOKS_INVISIBLE",
225
+ severity: "info",
226
+ title: "No user-defined hooks, but plugins may inject hooks at runtime",
227
+ detail: `The hooks config across all settings files is empty, but ${ctx.plugins.filter((p) => p.mergedEnabled).length} plugin(s) are enabled. Plugins register hooks from their manifests at load time \u2014 these don't appear in your settings.json hooks object.`,
228
+ fix: `Plugin hooks are invisible to settings inspection. To debug, run "claude --debug" and look for hook events in the log.`,
229
+ references: []
230
+ });
231
+ }
232
+ return diagnostics;
233
+ }
234
+ function largeSettingsFile(ctx) {
235
+ const diagnostics = [];
236
+ for (const f of ctx.files) {
237
+ if (f.exists && f.raw) {
238
+ const size = JSON.stringify(f.raw).length;
239
+ if (size > 1e5) {
240
+ diagnostics.push({
241
+ id: "LARGE_SETTINGS_FILE",
242
+ severity: "warning",
243
+ title: `${f.scope} settings file is unusually large (${(size / 1024).toFixed(0)}KB)`,
244
+ detail: `File ${f.path} is ${(size / 1024).toFixed(0)}KB. Large settings files can cause slow startup and may indicate accumulated cruft (e.g. large permission arrays).`,
245
+ fix: `Review ${f.path} for unnecessary entries. Permissions arrays tend to grow over time.`,
246
+ references: []
247
+ });
248
+ }
249
+ }
250
+ }
251
+ return diagnostics;
252
+ }
253
+ function scopeConflicts(ctx) {
254
+ const diagnostics = [];
255
+ for (const plugin of ctx.plugins) {
256
+ if (plugin.scopes.length < 2) continue;
257
+ const values = plugin.scopes.map((s) => s.enabled);
258
+ const hasConflict = values.some((v) => v !== values[0]);
259
+ if (hasConflict) {
260
+ const scopeStr = plugin.scopes.map((s) => `${s.scope}=${s.enabled}`).join(", ");
261
+ diagnostics.push({
262
+ id: "SCOPE_CONFLICT",
263
+ severity: "warning",
264
+ title: `Plugin "${plugin.pluginId}" has conflicting enable/disable across scopes`,
265
+ detail: `Plugin state differs across scopes: ${scopeStr}. The last scope in load order wins (local > project > user > managed). Final merged state: ${plugin.mergedEnabled ? "enabled" : "disabled"}.`,
266
+ fix: `Align the plugin state across scopes. If you want it disabled, set false in ~/.claude/settings.json (user scope) for reliable behavior.`,
267
+ references: ["https://github.com/anthropics/claude-code/issues/25086"]
268
+ });
269
+ }
270
+ }
271
+ return diagnostics;
272
+ }
273
+ var ALL_RULES = [
274
+ brokenSettings,
275
+ localOnlyPlugins,
276
+ ghostHooks,
277
+ scopeConflicts,
278
+ emptyHooksButPluginActive,
279
+ stopContinueLoop,
280
+ disableAllHooksWarning,
281
+ largeSettingsFile
282
+ ];
283
+ function runDiagnostics(ctx) {
284
+ const results = [];
285
+ for (const rule of ALL_RULES) {
286
+ results.push(...rule(ctx));
287
+ }
288
+ const order = { error: 0, warning: 1, info: 2 };
289
+ results.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
290
+ return results;
291
+ }
292
+
293
+ // src/report.ts
294
+ var BOLD = "\x1B[1m";
295
+ var GREEN = "\x1B[32m";
296
+ var YELLOW = "\x1B[33m";
297
+ var RED = "\x1B[31m";
298
+ var CYAN = "\x1B[36m";
299
+ var DIM = "\x1B[2m";
300
+ var RESET = "\x1B[0m";
301
+ var UNDERLINE = "\x1B[4m";
302
+ var severityColor = {
303
+ error: RED,
304
+ warning: YELLOW,
305
+ info: CYAN
306
+ };
307
+ var severityIcon = {
308
+ error: "\u2717",
309
+ warning: "\u26A0",
310
+ info: "\u2139"
311
+ };
312
+ function formatSettingsSection(files) {
313
+ const lines = [];
314
+ lines.push(`${BOLD}${UNDERLINE}Settings Files${RESET}`);
315
+ lines.push("");
316
+ for (const f of files) {
317
+ const status = f.exists ? f.error ? `${RED}\u2717 BROKEN${RESET}` : `${GREEN}\u2713 loaded${RESET}` : `${DIM}\u2014 not found${RESET}`;
318
+ const size = f.exists && f.raw ? `${DIM}(${(JSON.stringify(f.raw).length / 1024).toFixed(1)}KB)${RESET}` : "";
319
+ lines.push(` ${BOLD}${f.scope.padEnd(8)}${RESET} ${status} ${size}`);
320
+ lines.push(` ${DIM}${f.path}${RESET}`);
321
+ if (f.error) {
322
+ lines.push(` ${RED}${f.error}${RESET}`);
323
+ }
324
+ lines.push("");
325
+ }
326
+ return lines.join("\n");
327
+ }
328
+ function formatPluginsSection(plugins) {
329
+ const lines = [];
330
+ lines.push(`${BOLD}${UNDERLINE}Plugins${RESET}`);
331
+ lines.push("");
332
+ if (plugins.length === 0) {
333
+ lines.push(` ${DIM}No plugins configured.${RESET}`);
334
+ lines.push("");
335
+ return lines.join("\n");
336
+ }
337
+ for (const p of plugins) {
338
+ const enabled = p.mergedEnabled;
339
+ const icon = enabled ? `${GREEN}\u25CF${RESET}` : `${RED}\u25CB${RESET}`;
340
+ const state = enabled ? `${GREEN}enabled${RESET}` : `${RED}disabled${RESET}`;
341
+ lines.push(` ${icon} ${BOLD}${p.pluginId}${RESET} \u2192 ${state}`);
342
+ for (const s of p.scopes) {
343
+ const scopeState = s.enabled ? `${GREEN}true${RESET}` : `${RED}false${RESET}`;
344
+ lines.push(` ${DIM}${s.scope}:${RESET} ${scopeState}`);
345
+ }
346
+ lines.push("");
347
+ }
348
+ return lines.join("\n");
349
+ }
350
+ function formatHooksSection(hooks, disableAllHooks) {
351
+ const lines = [];
352
+ lines.push(`${BOLD}${UNDERLINE}Hooks${RESET}`);
353
+ if (disableAllHooks) {
354
+ lines.push(` ${RED}${BOLD}disableAllHooks: true${RESET} \u2014 all hooks suppressed`);
355
+ }
356
+ lines.push("");
357
+ if (hooks.length === 0) {
358
+ lines.push(` ${DIM}No user-defined hooks in settings files.${RESET}`);
359
+ lines.push(` ${DIM}(Plugin hooks are injected at runtime and invisible here.)${RESET}`);
360
+ lines.push("");
361
+ return lines.join("\n");
362
+ }
363
+ const byEvent = /* @__PURE__ */ new Map();
364
+ for (const h of hooks) {
365
+ const list = byEvent.get(h.event) ?? [];
366
+ list.push(h);
367
+ byEvent.set(h.event, list);
368
+ }
369
+ for (const [event, eventHooks] of byEvent) {
370
+ lines.push(` ${CYAN}${event}${RESET} (${eventHooks.length})`);
371
+ for (const h of eventHooks) {
372
+ const matcher = h.matcher ? ` ${DIM}matcher=${h.matcher}${RESET}` : "";
373
+ const source = `${DIM}[${h.source}]${RESET}`;
374
+ const desc = h.hook.type === "command" ? h.hook.command ?? "(empty)" : h.hook.type === "prompt" ? `prompt: ${(h.hook.prompt ?? "").slice(0, 60)}...` : h.hook.type === "http" ? `http: ${h.hook.url ?? "(no url)"}` : `${h.hook.type}: agent`;
375
+ lines.push(` ${source}${matcher} ${h.hook.type}: ${DIM}${desc}${RESET}`);
376
+ }
377
+ lines.push("");
378
+ }
379
+ return lines.join("\n");
380
+ }
381
+ function formatDiagnosticsSection(diagnostics) {
382
+ const lines = [];
383
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
384
+ const warnings = diagnostics.filter((d) => d.severity === "warning").length;
385
+ const infos = diagnostics.filter((d) => d.severity === "info").length;
386
+ lines.push(`${BOLD}${UNDERLINE}Diagnostics${RESET} (${errors} errors, ${warnings} warnings, ${infos} info)`);
387
+ lines.push("");
388
+ if (diagnostics.length === 0) {
389
+ lines.push(` ${GREEN}\u2713 No issues detected.${RESET}`);
390
+ lines.push("");
391
+ return lines.join("\n");
392
+ }
393
+ for (const d of diagnostics) {
394
+ const color = severityColor[d.severity] ?? DIM;
395
+ const icon = severityIcon[d.severity] ?? "?";
396
+ lines.push(` ${color}${icon} [${d.id}]${RESET} ${BOLD}${d.title}${RESET}`);
397
+ lines.push(` ${d.detail}`);
398
+ if (d.fix) {
399
+ lines.push(` ${GREEN}Fix:${RESET} ${d.fix}`);
400
+ }
401
+ if (d.references && d.references.length > 0) {
402
+ lines.push(` ${DIM}Refs: ${d.references.join(", ")}${RESET}`);
403
+ }
404
+ lines.push("");
405
+ }
406
+ return lines.join("\n");
407
+ }
408
+ function formatReport(report) {
409
+ const lines = [];
410
+ lines.push("");
411
+ lines.push(`${BOLD}Claude Hook Debug${RESET} ${DIM}${report.timestamp} ${report.platform}${RESET}`);
412
+ lines.push(`${"\u2550".repeat(60)}`);
413
+ lines.push("");
414
+ lines.push(formatSettingsSection(report.settingsFiles));
415
+ lines.push(formatPluginsSection(report.plugins));
416
+ lines.push(formatHooksSection(report.hooks, report.disableAllHooks));
417
+ lines.push(formatDiagnosticsSection(report.diagnostics));
418
+ return lines.join("\n");
419
+ }
420
+ function formatReportJson(report) {
421
+ return JSON.stringify(report, null, 2);
422
+ }
423
+
424
+ // src/index.ts
425
+ function debug(opts) {
426
+ const files = scanAllSettings(opts);
427
+ const plugins = extractPlugins(files);
428
+ const hooks = extractHooks(files);
429
+ const disableAllHooks = getDisableAllHooks(files);
430
+ const diagnostics = runDiagnostics({
431
+ files,
432
+ plugins,
433
+ hooks,
434
+ disableAllHooks
435
+ });
436
+ return {
437
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
438
+ platform: `${platform()} ${process.arch}`,
439
+ settingsFiles: files,
440
+ plugins,
441
+ hooks,
442
+ diagnostics,
443
+ disableAllHooks
444
+ };
445
+ }
446
+ export {
447
+ debug,
448
+ extractHooks,
449
+ extractPlugins,
450
+ formatReport,
451
+ formatReportJson,
452
+ getDisableAllHooks,
453
+ resolveSettingsPaths,
454
+ runDiagnostics,
455
+ scanAllSettings
456
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@mcptoolshop/claude-hook-debug",
3
+ "version": "1.0.0",
4
+ "description": "Diagnostic CLI for Claude Code hook issues — detects ghost hooks, scope conflicts, and plugin lifecycle bugs",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ }
15
+ },
16
+ "bin": {
17
+ "claude-hook-debug": "./dist/cli.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "verify": "npm run build && node dist/cli.js",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "claude-code",
33
+ "hooks",
34
+ "debug",
35
+ "diagnostic",
36
+ "plugin",
37
+ "settings"
38
+ ],
39
+ "author": "mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com>",
40
+ "license": "MIT",
41
+ "homepage": "https://github.com/mcp-tool-shop-org/claude-hook-debug",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/mcp-tool-shop-org/claude-hook-debug.git"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.0.0",
51
+ "tsup": "^8.0.0",
52
+ "typescript": "^5.4.0",
53
+ "vitest": "^3.0.0"
54
+ }
55
+ }