@ngockhoale/ukit 2.2.11 → 2.2.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.12 - 2026-09-04
6
+
7
+ `ukit install` and `ukit update` now clean up broken Claude Code hook registrations automatically.
8
+
9
+ A hook whose stdout contains a `hookSpecificOutput` object without the required `hookEventName`
10
+ field fails Claude Code's schema validation on **every** tool call, surfacing as a repeating
11
+ `PreToolUse:<Tool> hook error — Hook JSON output validation failed`. Such a hook has no effect
12
+ either way, since its output is discarded — it produces nothing but noise.
13
+
14
+ New `src/core/repairBrokenHooks.js` detects that shape generically (not keyed to any one vendor)
15
+ across user settings, project settings, and `settings.local.json`, expanding
16
+ `$CLAUDE_PROJECT_DIR`/`$HOME`/`~` in hook commands and also catching hooks inlined into
17
+ `settings.json` instead of a script file. Offending registrations are removed after a
18
+ `.ukit-backup` restore point is written next to each settings file. Detection matches only real
19
+ key assignments, so prose mentions of the field in a hook's own comments or docstrings never
20
+ trigger a removal.
21
+
22
+ Wired into the install pipeline; since `ukit update` re-runs `ukit install`, both commands are
23
+ covered with no new flags or commands to learn — teammates still only need `ukit install`.
24
+
25
+ Scope is deliberate: UKit removes the *registration* only. An unregistered hook script never
26
+ executes, so that is sufficient, and UKit does not delete the script file or uninstall the tool
27
+ that wrote it. When the owning tool can be identified from the hook source, the install report
28
+ names it and notes that it may re-register on activation, leaving permanent removal as a human
29
+ decision.
30
+
31
+ Known real-world instance: VSCode extension `tjcg.auto-accept-claude-code` v0.5.0 hardcodes two
32
+ such hook templates and rewrites its hook file on activation.
33
+
5
34
  ## 2.2.11 - 2026-08-29
6
35
 
7
36
  Same-day follow-up to 2.2.10. The two premises that release shipped as unverified were checked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.11",
3
+ "version": "2.2.12",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { buildPathConfig } from '../../core/paths.js';
2
2
  import { runInstallPipeline } from '../../core/runInstallPipeline.js';
3
+ import { formatRepairReport } from '../../core/repairBrokenHooks.js';
3
4
  import { buildCodeIndex } from '../../index/buildIndex.js';
4
5
  import { installIndexRefreshHooks } from '../../index/gitHooks.js';
5
6
  import fs from 'node:fs/promises';
@@ -238,6 +239,10 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
238
239
  .join(', ');
239
240
  console.log(`[UKit] Providers: ${providerStatus}, all=${result.providerContext.allSupported}`);
240
241
 
242
+ for (const line of formatRepairReport(result.hookRepair ?? { removals: [], files: [] })) {
243
+ console.log(`[UKit] ${line}`);
244
+ }
245
+
241
246
  const docsLabels = [
242
247
  'docs/PROJECT.md',
243
248
  'docs/MEMORY.md',
@@ -0,0 +1,240 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // Claude Code rejects any hook whose stdout carries a `hookSpecificOutput` object
6
+ // without `hookEventName` inside it, and surfaces it as a per-tool-call
7
+ // "Hook JSON output validation failed" error. A hook in that state never has any
8
+ // effect - its output is discarded - so removing its registration only removes noise.
9
+ //
10
+ // Detection is deliberately generic rather than keyed to one known-bad extension:
11
+ // any tool that ships this shape produces the same failure.
12
+
13
+ // Only real key assignments count. Prose mentions of the field inside docstrings
14
+ // and comments are common in hook SDKs and must not trigger a removal.
15
+ const HOOK_OUTPUT_KEY = /["']hookSpecificOutput["']\s*\]?\s*[:=]/g;
16
+
17
+ // How far past the key to look for the sibling field. Generous enough for a
18
+ // formatted object literal, short enough not to reach an unrelated later block.
19
+ const SIBLING_SCAN_WINDOW = 400;
20
+
21
+ // Paths in a hook command string, e.g. "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh".
22
+ const SCRIPT_PATH = /["']?((?:\/|~|\$\{?[A-Z_]+\}?\/)[^"'\s]+\.(?:sh|ps1|py|js|mjs|cjs|bash|zsh))["']?/g;
23
+
24
+ // e.g. "# Auto-Accept Extension: PreToolUse hook" -> "Auto-Accept Extension"
25
+ const OWNER_HINT = /^[#/\s]*([A-Z][\w-]*(?:\s+[\w-]+)*\s+Extension)\b/m;
26
+
27
+ /**
28
+ * True when `source` assigns hookSpecificOutput without a nearby hookEventName.
29
+ */
30
+ export function hasBrokenHookShape(source) {
31
+ if (typeof source !== 'string') {
32
+ return false;
33
+ }
34
+
35
+ HOOK_OUTPUT_KEY.lastIndex = 0;
36
+ let match = HOOK_OUTPUT_KEY.exec(source);
37
+ while (match) {
38
+ const window = source.slice(match.index, match.index + SIBLING_SCAN_WINDOW);
39
+ if (!window.includes('hookEventName')) {
40
+ HOOK_OUTPUT_KEY.lastIndex = 0;
41
+ return true;
42
+ }
43
+ match = HOOK_OUTPUT_KEY.exec(source);
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ function expandPath(rawPath, { projectRoot, homeDir }) {
50
+ return rawPath
51
+ .replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, projectRoot)
52
+ .replace(/\$\{?HOME-?\}?/g, homeDir)
53
+ .replace(/^~/, homeDir);
54
+ }
55
+
56
+ function extractScriptPaths(command, { projectRoot, homeDir }) {
57
+ const paths = [];
58
+ SCRIPT_PATH.lastIndex = 0;
59
+ let match = SCRIPT_PATH.exec(command);
60
+ while (match) {
61
+ paths.push(expandPath(match[1], { projectRoot, homeDir }));
62
+ match = SCRIPT_PATH.exec(command);
63
+ }
64
+ return paths;
65
+ }
66
+
67
+ async function readFileIfExists(filePath) {
68
+ try {
69
+ return await fs.readFile(filePath, 'utf8');
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Inspect one registered hook command. Returns null when it is fine, or
77
+ * { source, ownerHint } describing where the broken shape was found.
78
+ */
79
+ async function inspectHookCommand(command, { projectRoot, homeDir }) {
80
+ if (typeof command !== 'string' || !command) {
81
+ return null;
82
+ }
83
+
84
+ for (const scriptPath of extractScriptPaths(command, { projectRoot, homeDir })) {
85
+ const contents = await readFileIfExists(scriptPath);
86
+ if (contents !== null && hasBrokenHookShape(contents)) {
87
+ return { source: scriptPath, ownerHint: OWNER_HINT.exec(contents)?.[1] ?? null };
88
+ }
89
+ }
90
+
91
+ // Some tools inline the whole hook body into settings.json instead of a script.
92
+ if (hasBrokenHookShape(command)) {
93
+ return { source: '<inline command>', ownerHint: null };
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Drop broken hook entries from one parsed settings object.
101
+ * Mutates `settings` and returns the removals found.
102
+ */
103
+ async function stripBrokenHooks(settings, { projectRoot, homeDir }) {
104
+ const removals = [];
105
+ const events = settings?.hooks;
106
+ if (!events || typeof events !== 'object' || Array.isArray(events)) {
107
+ return removals;
108
+ }
109
+
110
+ for (const [event, groups] of Object.entries(events)) {
111
+ if (!Array.isArray(groups)) {
112
+ continue;
113
+ }
114
+
115
+ const keptGroups = [];
116
+ for (const group of groups) {
117
+ if (!group || typeof group !== 'object') {
118
+ keptGroups.push(group);
119
+ continue;
120
+ }
121
+
122
+ const hooks = Array.isArray(group.hooks) ? group.hooks : null;
123
+ if (!hooks) {
124
+ keptGroups.push(group);
125
+ continue;
126
+ }
127
+
128
+ const keptHooks = [];
129
+ for (const hook of hooks) {
130
+ const finding = await inspectHookCommand(hook?.command, { projectRoot, homeDir });
131
+ if (finding) {
132
+ removals.push({ event, matcher: group.matcher ?? '', ...finding });
133
+ } else {
134
+ keptHooks.push(hook);
135
+ }
136
+ }
137
+
138
+ // A group that was already empty stays as-is; only drop groups we emptied.
139
+ if (keptHooks.length === 0 && hooks.length > 0) {
140
+ continue;
141
+ }
142
+
143
+ group.hooks = keptHooks;
144
+ keptGroups.push(group);
145
+ }
146
+
147
+ if (keptGroups.length > 0) {
148
+ events[event] = keptGroups;
149
+ } else {
150
+ delete events[event];
151
+ }
152
+ }
153
+
154
+ return removals;
155
+ }
156
+
157
+ function backupPathFor(settingsPath) {
158
+ return `${settingsPath}.ukit-backup`;
159
+ }
160
+
161
+ /**
162
+ * Remove Claude Code hook registrations that would fail schema validation on every
163
+ * tool call, from the user-level and project-level settings files.
164
+ *
165
+ * Registration removal alone is enough: an unregistered script is never executed.
166
+ * The script file itself is left on disk untouched - it belongs to whichever tool
167
+ * wrote it, and deleting another tool's files is not UKit's call.
168
+ *
169
+ * @returns {Promise<{removals: Array, files: string[]}>}
170
+ */
171
+ export async function repairBrokenHooks({ projectRoot, homeDir = os.homedir() } = {}) {
172
+ const candidates = [
173
+ path.join(homeDir, '.claude', 'settings.json'),
174
+ path.join(projectRoot, '.claude', 'settings.json'),
175
+ path.join(projectRoot, '.claude', 'settings.local.json'),
176
+ ];
177
+
178
+ const removals = [];
179
+ const files = [];
180
+
181
+ for (const settingsPath of candidates) {
182
+ const raw = await readFileIfExists(settingsPath);
183
+ if (raw === null) {
184
+ continue;
185
+ }
186
+
187
+ let settings;
188
+ try {
189
+ settings = JSON.parse(raw);
190
+ } catch {
191
+ // Malformed settings are not ours to rewrite.
192
+ continue;
193
+ }
194
+
195
+ const found = await stripBrokenHooks(settings, { projectRoot, homeDir });
196
+ if (found.length === 0) {
197
+ continue;
198
+ }
199
+
200
+ // Keep a restore point before touching a file the user did not ask us to edit.
201
+ await fs.writeFile(backupPathFor(settingsPath), raw, 'utf8');
202
+ await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
203
+
204
+ files.push(settingsPath);
205
+ removals.push(...found.map((entry) => ({ ...entry, settingsPath })));
206
+ }
207
+
208
+ return { removals, files };
209
+ }
210
+
211
+ /**
212
+ * Human-readable lines for the install/update report. Empty array when nothing was repaired.
213
+ */
214
+ export function formatRepairReport({ removals, files }) {
215
+ if (!removals || removals.length === 0) {
216
+ return [];
217
+ }
218
+
219
+ const lines = [
220
+ `Removed ${removals.length} broken Claude Code hook registration(s).`,
221
+ 'They emitted hookSpecificOutput without hookEventName, so every tool call failed',
222
+ 'schema validation and the hook never actually ran.',
223
+ ];
224
+
225
+ for (const removal of removals) {
226
+ const owner = removal.ownerHint ? ` (${removal.ownerHint})` : '';
227
+ lines.push(` - ${removal.event}: ${removal.source}${owner}`);
228
+ }
229
+
230
+ const owners = [...new Set(removals.map((entry) => entry.ownerHint).filter(Boolean))];
231
+ if (owners.length > 0) {
232
+ lines.push(
233
+ `Warning: ${owners.join(', ')} rewrites its hook on activation, so this can come back.`,
234
+ 'Uninstall that extension to stop it for good - UKit will not remove it for you.',
235
+ );
236
+ }
237
+
238
+ lines.push(`Backup: ${files.map((file) => `${file}.ukit-backup`).join(', ')}`);
239
+ return lines;
240
+ }
@@ -12,6 +12,7 @@ import { summarizeDiff, toDiffRows } from './report.js';
12
12
  import { writeInstallMetadata } from './metadata.js';
13
13
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
14
14
  import { ensureGitignore } from './ensureGitignore.js';
15
+ import { repairBrokenHooks } from './repairBrokenHooks.js';
15
16
  import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
16
17
 
17
18
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
@@ -316,6 +317,13 @@ export async function runInstallPipeline({
316
317
  }
317
318
 
318
319
  await ensureGitignore(pathConfig.projectRoot);
320
+
321
+ // A hook that emits hookSpecificOutput without hookEventName fails Claude Code's
322
+ // schema check on every tool call, so it only ever produces noise. Teammates are
323
+ // not expected to know that - `ukit install` (and `ukit update`, which re-runs
324
+ // install) clears it for them. Backups are written next to each settings file.
325
+ const hookRepair = await repairBrokenHooks({ projectRoot: pathConfig.projectRoot });
326
+
319
327
  await pruneObsoleteManagedPaths({
320
328
  installMetaData,
321
329
  projectRoot: pathConfig.projectRoot,
@@ -350,6 +358,7 @@ export async function runInstallPipeline({
350
358
  summary,
351
359
  rows: toDiffRows(diffResults),
352
360
  writes,
361
+ hookRepair,
353
362
  };
354
363
  }
355
364
 
@@ -361,5 +370,6 @@ export async function runInstallPipeline({
361
370
  summary: plannedSummary,
362
371
  rows: toDiffRows(diffResults),
363
372
  writes: [],
373
+ hookRepair: { removals: [], files: [] },
364
374
  };
365
375
  }