@axiomatic-labs/claudeflow 2.13.20 → 2.13.21

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.
@@ -0,0 +1,177 @@
1
+ // User hook overrides — toggle individual claudeflow hook handlers without
2
+ // editing the template-managed `.claude/settings.json` directly.
3
+ //
4
+ // File: `.claudeflow/config/user-hook-overrides.json`
5
+ // Shape:
6
+ // { "version": 1,
7
+ // "disabledHandlers": [
8
+ // { "event": "SessionStart", "matcher": "", "handler": "SessionStart/foo.js",
9
+ // "spec": { "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/SessionStart/foo.js\"" } } ] }
10
+ //
11
+ // The `spec` field captures the original hook entry verbatim so the panel
12
+ // can re-enable a handler later without consulting an external snapshot.
13
+ //
14
+ // Two operations:
15
+ // - `applyHookOverrides(settings, overrides)`: pure function, returns a
16
+ // new settings object with disabled handlers removed. Called by
17
+ // install.js right after writing `.claude/settings.json` so the
18
+ // effective config respects the overrides on every install/update.
19
+ // - `toggleHookOverride(...)`: stateful, used by the panel server.
20
+ // Reads / writes the overrides file and the resolved settings file.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const OVERRIDES_REL = path.join('.claudeflow', 'config', 'user-hook-overrides.json');
26
+ const SETTINGS_REL = path.join('.claude', 'settings.json');
27
+ const HOOK_PATH_REGEX = /\.claude\/hooks\/(.+?\.js)/;
28
+
29
+ function readJsonSafe(absPath) {
30
+ try { return JSON.parse(fs.readFileSync(absPath, 'utf8')); } catch { return null; }
31
+ }
32
+
33
+ function writeJson(absPath, data) {
34
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
35
+ fs.writeFileSync(absPath, JSON.stringify(data, null, 2) + '\n');
36
+ }
37
+
38
+ function normalizeHandlerPath(command) {
39
+ if (!command) return '';
40
+ const m = HOOK_PATH_REGEX.exec(command);
41
+ return m ? m[1] : command.replace(/^node\s+/, '').replace(/^["']|["']$/g, '').trim();
42
+ }
43
+
44
+ function defaultOverrides() {
45
+ return { version: 1, disabledHandlers: [] };
46
+ }
47
+
48
+ function readOverrides(projectRoot) {
49
+ const data = readJsonSafe(path.join(projectRoot, OVERRIDES_REL));
50
+ if (!data || typeof data !== 'object') return defaultOverrides();
51
+ return {
52
+ version: data.version || 1,
53
+ disabledHandlers: Array.isArray(data.disabledHandlers) ? data.disabledHandlers : [],
54
+ };
55
+ }
56
+
57
+ function writeOverrides(projectRoot, overrides) {
58
+ writeJson(path.join(projectRoot, OVERRIDES_REL), overrides);
59
+ }
60
+
61
+ function matchesOverride(override, event, matcher, handlerCommand) {
62
+ if (override.event !== event) return false;
63
+ if ((override.matcher || '') !== (matcher || '')) return false;
64
+ return normalizeHandlerPath(handlerCommand) === override.handler;
65
+ }
66
+
67
+ // Returns a fresh settings object with handlers disabled by `overrides` removed.
68
+ // Empty rules are pruned. The shape of `settings.hooks` is preserved otherwise.
69
+ function applyHookOverrides(settings, overrides) {
70
+ const disabled = (overrides && overrides.disabledHandlers) || [];
71
+ if (!disabled.length || !settings || !settings.hooks) return settings;
72
+ const next = JSON.parse(JSON.stringify(settings));
73
+ const hooks = next.hooks;
74
+ for (const event of Object.keys(hooks)) {
75
+ const rules = Array.isArray(hooks[event]) ? hooks[event] : [];
76
+ for (let i = rules.length - 1; i >= 0; i--) {
77
+ const rule = rules[i] || {};
78
+ const matcher = rule.matcher || '';
79
+ const remaining = (rule.hooks || []).filter(
80
+ (h) => !disabled.some((d) => matchesOverride(d, event, matcher, h.command || '')),
81
+ );
82
+ if (remaining.length === 0) rules.splice(i, 1);
83
+ else rule.hooks = remaining;
84
+ }
85
+ hooks[event] = rules;
86
+ }
87
+ return next;
88
+ }
89
+
90
+ // Apply on disk: read settings.json + overrides, write the pruned settings
91
+ // back. Idempotent — calling twice produces the same file.
92
+ function applyOverridesToFile(projectRoot) {
93
+ const settingsPath = path.join(projectRoot, SETTINGS_REL);
94
+ const settings = readJsonSafe(settingsPath);
95
+ if (!settings) return false;
96
+ const overrides = readOverrides(projectRoot);
97
+ if (!overrides.disabledHandlers.length) return false;
98
+ const next = applyHookOverrides(settings, overrides);
99
+ fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2) + '\n');
100
+ return true;
101
+ }
102
+
103
+ // Find a hook entry in current settings.json by event/matcher/handler.
104
+ // Used when disabling: we capture the spec before removing it.
105
+ function findHandlerInSettings(settings, event, matcher, handler) {
106
+ const rules = (settings.hooks && settings.hooks[event]) || [];
107
+ for (const rule of rules) {
108
+ if ((rule.matcher || '') !== (matcher || '')) continue;
109
+ for (const h of rule.hooks || []) {
110
+ if (normalizeHandlerPath(h.command || '') === handler) return h;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // Add an entry back to the live settings under the right event/matcher.
117
+ function restoreHandlerInSettings(settings, event, matcher, spec) {
118
+ if (!settings.hooks) settings.hooks = {};
119
+ if (!settings.hooks[event]) settings.hooks[event] = [];
120
+ const rules = settings.hooks[event];
121
+ let rule = rules.find((r) => (r.matcher || '') === (matcher || ''));
122
+ if (!rule) {
123
+ rule = { matcher, hooks: [] };
124
+ rules.push(rule);
125
+ }
126
+ // Avoid duplicate restoration if the spec is already present
127
+ const already = rule.hooks.some((h) => normalizeHandlerPath(h.command || '') === normalizeHandlerPath(spec.command || ''));
128
+ if (!already) rule.hooks.push(spec);
129
+ }
130
+
131
+ // Stateful toggle used by the panel.
132
+ // - disable=true: capture spec from current settings.json, append to
133
+ // overrides, remove from settings.json
134
+ // - disable=false: pop spec from overrides, restore to settings.json
135
+ function toggleHookOverride(projectRoot, { event, matcher = '', handler, disable }) {
136
+ const settingsPath = path.join(projectRoot, SETTINGS_REL);
137
+ const settings = readJsonSafe(settingsPath) || { hooks: {} };
138
+ const overrides = readOverrides(projectRoot);
139
+ const matcherKey = matcher || '';
140
+
141
+ if (disable) {
142
+ const already = overrides.disabledHandlers.find(
143
+ (d) => d.event === event && (d.matcher || '') === matcherKey && d.handler === handler,
144
+ );
145
+ if (already) return { state: 'noop', reason: 'already-disabled' };
146
+ const spec = findHandlerInSettings(settings, event, matcherKey, handler);
147
+ if (!spec) return { state: 'error', reason: 'handler-not-found-in-settings' };
148
+ overrides.disabledHandlers.push({ event, matcher: matcherKey, handler, spec });
149
+ writeOverrides(projectRoot, overrides);
150
+ const next = applyHookOverrides(settings, overrides);
151
+ fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2) + '\n');
152
+ return { state: 'disabled' };
153
+ }
154
+
155
+ const idx = overrides.disabledHandlers.findIndex(
156
+ (d) => d.event === event && (d.matcher || '') === matcherKey && d.handler === handler,
157
+ );
158
+ if (idx === -1) return { state: 'noop', reason: 'not-disabled' };
159
+ const [removed] = overrides.disabledHandlers.splice(idx, 1);
160
+ if (!removed.spec) return { state: 'error', reason: 'no-spec-recorded' };
161
+ restoreHandlerInSettings(settings, event, matcherKey, removed.spec);
162
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
163
+ writeOverrides(projectRoot, overrides);
164
+ return { state: 'enabled' };
165
+ }
166
+
167
+ module.exports = {
168
+ OVERRIDES_REL,
169
+ SETTINGS_REL,
170
+ applyHookOverrides,
171
+ applyOverridesToFile,
172
+ readOverrides,
173
+ writeOverrides,
174
+ toggleHookOverride,
175
+ normalizeHandlerPath,
176
+ matchesOverride,
177
+ };
package/lib/install.js CHANGED
@@ -4,6 +4,7 @@ const { execSync, execFileSync } = require('child_process');
4
4
  const { requireAuth } = require('./auth.js');
5
5
  const { getLatestRelease, downloadReleaseAsset } = require('./download.js');
6
6
  const { writeLocalVersion, readLocalVersion } = require('./version.js');
7
+ const { applyOverridesToFile: applyHookOverridesToFile } = require('./hook-overrides.js');
7
8
  const ui = require('./ui.js');
8
9
 
9
10
  // Template skills are shipped inside the ZIP. Copy every managed
@@ -85,6 +86,10 @@ async function run() {
85
86
  fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
86
87
  if (fs.existsSync(srcSettings)) {
87
88
  syncClaudeSettings(srcSettings, dstSettings);
89
+ // Re-apply user hook overrides (if any) so disabled handlers stay
90
+ // disabled across update — without this, every update would resurrect
91
+ // hooks the user explicitly turned off via `claudeflow panel`.
92
+ applyHookOverridesToFile(cwd);
88
93
  }
89
94
 
90
95
  // Upsert playwright entry in .mcp.json with project-specific CDP port.
package/lib/panel.js CHANGED
@@ -21,6 +21,11 @@ const {
21
21
  checkStaleLockfiles,
22
22
  readPlaywrightCdpEndpoint,
23
23
  } = require('./doctor.js');
24
+ const {
25
+ readOverrides,
26
+ toggleHookOverride,
27
+ normalizeHandlerPath,
28
+ } = require('./hook-overrides.js');
24
29
 
25
30
  const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min
26
31
 
@@ -85,38 +90,68 @@ function getHooksInfo(cwd) {
85
90
  try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); }
86
91
  catch { return { settingsFound: false, events: [] }; }
87
92
 
93
+ const overrides = readOverrides(cwd);
94
+ const disabledIndex = new Map();
95
+ for (const d of overrides.disabledHandlers || []) {
96
+ disabledIndex.set(`${d.event}|${d.matcher || ''}|${d.handler}`, d);
97
+ }
98
+
88
99
  const hooks = settings.hooks || {};
89
- const events = [];
100
+ const eventsMap = new Map();
90
101
  let totalHandlers = 0;
91
102
  let warnings = 0;
103
+ let disabledCount = 0;
92
104
 
93
105
  for (const [event, rules] of Object.entries(hooks)) {
94
- const handlers = [];
106
+ if (!eventsMap.has(event)) eventsMap.set(event, []);
95
107
  for (const rule of rules) {
96
108
  const matcher = rule.matcher || '';
97
109
  for (const h of rule.hooks || []) {
98
110
  const cmd = h.command || '';
99
- const handlerPath = cmd
100
- .replace(/^node\s+/, '')
101
- .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?\.claude\/hooks\//, '')
102
- .replace(/^.*\/\.claude\/hooks\//, '')
103
- .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?/, '')
104
- .replace(/"/g, '')
105
- .trim();
111
+ const handlerPath = normalizeHandlerPath(cmd) || (cmd ? cmd : '');
106
112
  const isEmpty = !handlerPath;
107
113
  if (isEmpty) warnings++;
108
- handlers.push({
114
+ eventsMap.get(event).push({
109
115
  handler: handlerPath || '(empty command)',
110
- matcher: matcher || '(any)',
116
+ matcher: matcher || '',
117
+ matcherDisplay: matcher || '(any)',
111
118
  empty: isEmpty,
119
+ disabled: false,
112
120
  rawCommand: cmd,
113
121
  });
114
122
  totalHandlers++;
115
123
  }
116
124
  }
117
- events.push({ event, count: handlers.length, handlers });
118
125
  }
119
- return { settingsFound: true, events, totalHandlers, warnings };
126
+
127
+ // Add disabled handlers (live in overrides, not in settings.json)
128
+ for (const d of overrides.disabledHandlers || []) {
129
+ if (!eventsMap.has(d.event)) eventsMap.set(d.event, []);
130
+ eventsMap.get(d.event).push({
131
+ handler: d.handler,
132
+ matcher: d.matcher || '',
133
+ matcherDisplay: d.matcher || '(any)',
134
+ empty: false,
135
+ disabled: true,
136
+ rawCommand: (d.spec && d.spec.command) || '',
137
+ });
138
+ disabledCount++;
139
+ }
140
+
141
+ const events = Array.from(eventsMap.entries()).map(([event, handlers]) => ({
142
+ event,
143
+ count: handlers.length,
144
+ handlers,
145
+ }));
146
+
147
+ return {
148
+ settingsFound: true,
149
+ events,
150
+ totalHandlers: totalHandlers + disabledCount,
151
+ activeHandlers: totalHandlers,
152
+ disabledHandlers: disabledCount,
153
+ warnings,
154
+ };
120
155
  }
121
156
 
122
157
  function getMcpInfo(cwd) {
@@ -297,6 +332,11 @@ details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--bord
297
332
  .warning { color: var(--warn); }
298
333
  .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
299
334
  .toggle input { accent-color: var(--accent); }
335
+ .disabled-row { opacity: 0.55; }
336
+ .disabled-row .matcher { font-style: italic; }
337
+ .toast { position: fixed; bottom: 18px; right: 18px; background: var(--panel); border: 1px solid var(--border); padding: 10px 14px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
338
+ .toast.err { border-color: var(--err); }
339
+ .toast.ok { border-color: var(--ok); }
300
340
  </style>
301
341
  </head>
302
342
  <body>
@@ -451,14 +491,23 @@ function renderHooks() {
451
491
  const h = state.hooks;
452
492
  if (!h.settingsFound) return '<h2>Hooks</h2><p class="error">.claude/settings.json not found</p>';
453
493
  const items = h.events.map(ev => {
454
- const handlers = ev.handlers.map(handler => {
455
- const mark = handler.empty ? '<span class="warning">⚠ empty command</span>' : '';
456
- return \`<div class="handler"><span>\${escapeHtml(handler.handler)} \${mark}</span><span class="matcher">\${escapeHtml(handler.matcher)}</span></div>\`;
494
+ const handlers = ev.handlers.map(hd => {
495
+ const mark = hd.empty ? '<span class="warning">⚠ empty command</span>' : '';
496
+ const cls = hd.disabled ? 'handler disabled-row' : 'handler';
497
+ const checked = hd.disabled ? '' : 'checked';
498
+ const dataset = \`data-event="\${escapeHtml(ev.event)}" data-matcher="\${escapeHtml(hd.matcher)}" data-handler="\${escapeHtml(hd.handler)}"\`;
499
+ const status = hd.disabled ? '<span class="badge info">disabled</span>' : '';
500
+ return \`<div class="\${cls}">
501
+ <label class="toggle"><input type="checkbox" class="hook-toggle" \${dataset} \${checked} \${hd.empty ? 'disabled' : ''} />
502
+ <span>\${escapeHtml(hd.handler)} \${status} \${mark}</span></label>
503
+ <span class="matcher">\${escapeHtml(hd.matcherDisplay)}</span>
504
+ </div>\`;
457
505
  }).join('');
458
506
  return \`<details><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
459
507
  }).join('');
460
508
  return \`<h2>Hooks</h2>
461
- <p class="sub">\${h.events.length} events · \${h.totalHandlers} handlers\${h.warnings ? ' · <span class="warning">' + h.warnings + ' warnings</span>' : ''}</p>
509
+ <p class="sub">\${h.events.length} events · \${h.activeHandlers} active\${h.disabledHandlers ? ' · <span class="muted">' + h.disabledHandlers + ' disabled</span>' : ''}\${h.warnings ? ' · <span class="warning">' + h.warnings + ' warnings</span>' : ''}</p>
510
+ <p class="sub muted">Uncheck a handler to disable it. Disabled handlers are recorded in <code>.claudeflow/config/user-hook-overrides.json</code> and survive <code>claudeflow update</code>. Toggling rewrites <code>.claude/settings.json</code> immediately; reload Claude Code to pick up the change.</p>
462
511
  \${items}\`;
463
512
  }
464
513
 
@@ -513,6 +562,46 @@ function renderDoctor() {
513
562
  </div>\`;
514
563
  }
515
564
 
565
+ function showToast(msg, kind) {
566
+ const el = document.createElement('div');
567
+ el.className = 'toast ' + (kind || 'ok');
568
+ el.textContent = msg;
569
+ document.body.appendChild(el);
570
+ setTimeout(() => el.remove(), 2400);
571
+ }
572
+
573
+ async function toggleHook(event, matcher, handler, disable) {
574
+ try {
575
+ const r = await fetch('/api/hooks/toggle', {
576
+ method: 'POST',
577
+ headers: { 'Content-Type': 'application/json' },
578
+ body: JSON.stringify({ event, matcher, handler, disable }),
579
+ });
580
+ const result = await r.json();
581
+ if (!r.ok || result.state === 'error') {
582
+ showToast('Toggle failed: ' + (result.reason || result.error || 'unknown'), 'err');
583
+ } else if (result.state === 'noop') {
584
+ showToast('Already in target state (' + result.reason + ')', 'ok');
585
+ } else {
586
+ showToast(disable ? 'Disabled. Reload Claude Code to apply.' : 'Enabled. Reload Claude Code to apply.', 'ok');
587
+ }
588
+ } catch (e) {
589
+ showToast('Network error: ' + e.message, 'err');
590
+ }
591
+ await refresh();
592
+ }
593
+
594
+ document.addEventListener('change', (e) => {
595
+ const t = e.target;
596
+ if (t && t.classList && t.classList.contains('hook-toggle')) {
597
+ const event = t.dataset.event;
598
+ const matcher = t.dataset.matcher;
599
+ const handler = t.dataset.handler;
600
+ const disable = !t.checked;
601
+ toggleHook(event, matcher, handler, disable);
602
+ }
603
+ });
604
+
516
605
  document.getElementById('refresh').onclick = refresh;
517
606
  document.getElementById('auto').onchange = (e) => {
518
607
  if (timer) clearInterval(timer);
@@ -532,8 +621,26 @@ function readClaudeMdSafe(cwd) {
532
621
  catch { return ''; }
533
622
  }
534
623
 
624
+ function readRequestBody(req, limit = 64 * 1024) {
625
+ return new Promise((resolve, reject) => {
626
+ const chunks = [];
627
+ let total = 0;
628
+ req.on('data', (c) => {
629
+ total += c.length;
630
+ if (total > limit) {
631
+ reject(new Error('Request body too large'));
632
+ req.destroy();
633
+ return;
634
+ }
635
+ chunks.push(c);
636
+ });
637
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
638
+ req.on('error', reject);
639
+ });
640
+ }
641
+
535
642
  function handler(cwd) {
536
- return (req, res) => {
643
+ return async (req, res) => {
537
644
  try {
538
645
  const url = new URL(req.url, 'http://localhost');
539
646
  const route = url.pathname;
@@ -543,10 +650,27 @@ function handler(cwd) {
543
650
  res.end(body);
544
651
  };
545
652
 
546
- if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
547
- if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
548
- if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
549
- if (route === '/healthz') return send(200, 'ok', 'text/plain');
653
+ if (req.method === 'GET') {
654
+ if (route === '/' || route === '/index.html') return send(200, renderHtml(), 'text/html; charset=utf-8');
655
+ if (route === '/api/status') return send(200, JSON.stringify(collectStatus(cwd)), 'application/json');
656
+ if (route === '/api/claude-md') return send(200, readClaudeMdSafe(cwd), 'text/plain; charset=utf-8');
657
+ if (route === '/healthz') return send(200, 'ok', 'text/plain');
658
+ }
659
+
660
+ if (req.method === 'POST' && route === '/api/hooks/toggle') {
661
+ const raw = await readRequestBody(req);
662
+ let payload;
663
+ try { payload = JSON.parse(raw); }
664
+ catch { return send(400, JSON.stringify({ error: 'invalid json' }), 'application/json'); }
665
+ const { event, matcher, handler: hdlr, disable } = payload;
666
+ if (typeof event !== 'string' || typeof hdlr !== 'string' || typeof disable !== 'boolean') {
667
+ return send(400, JSON.stringify({ error: 'event, handler, disable required' }), 'application/json');
668
+ }
669
+ const result = toggleHookOverride(cwd, { event, matcher: matcher || '', handler: hdlr, disable });
670
+ const status = result.state === 'error' ? 500 : 200;
671
+ return send(status, JSON.stringify(result), 'application/json');
672
+ }
673
+
550
674
  send(404, JSON.stringify({ error: 'not found' }), 'application/json');
551
675
  } catch (err) {
552
676
  res.writeHead(500, { 'Content-Type': 'application/json' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiomatic-labs/claudeflow",
3
- "version": "2.13.20",
3
+ "version": "2.13.21",
4
4
  "description": "Claudeflow — AI-powered development toolkit for Claude Code. Skills, agents, hooks, and quality gates that ship production apps.",
5
5
  "bin": {
6
6
  "claudeflow": "./bin/cli.js"