@axiomatic-labs/claudeflow 2.13.19 → 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.
- package/bin/cli.js +7 -1
- package/lib/hook-overrides.js +177 -0
- package/lib/install.js +5 -0
- package/lib/panel.js +740 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
|
|
22
|
-
const SUBCOMMANDS = new Set(['install', 'version', '--version', '-v', 'help', '--help', '-h', 'doctor']);
|
|
22
|
+
const SUBCOMMANDS = new Set(['install', 'version', '--version', '-v', 'help', '--help', '-h', 'doctor', 'panel']);
|
|
23
23
|
|
|
24
24
|
function inClaudeflowProject(startDir) {
|
|
25
25
|
let dir = path.resolve(startDir);
|
|
@@ -52,6 +52,11 @@ async function runSubcommand(command) {
|
|
|
52
52
|
const code = await doctor(process.argv.slice(3));
|
|
53
53
|
process.exit(code || 0);
|
|
54
54
|
}
|
|
55
|
+
case 'panel': {
|
|
56
|
+
const panel = require('../lib/panel.js');
|
|
57
|
+
await panel(process.argv.slice(3));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
55
60
|
case 'help':
|
|
56
61
|
case '--help':
|
|
57
62
|
case '-h':
|
|
@@ -63,6 +68,7 @@ async function runSubcommand(command) {
|
|
|
63
68
|
console.log(' Claudeflow commands:');
|
|
64
69
|
console.log(` ${ui.CYAN}install${ui.RESET} Install or update Claudeflow in the current project`);
|
|
65
70
|
console.log(` ${ui.CYAN}doctor${ui.RESET} Diagnose local issues (CDP port, stale lockfiles); add --fix to repair`);
|
|
71
|
+
console.log(` ${ui.CYAN}panel${ui.RESET} Open the local web dashboard for hooks, CLAUDE.md, and run state`);
|
|
66
72
|
console.log(` ${ui.CYAN}version${ui.RESET} Show version info`);
|
|
67
73
|
console.log(` ${ui.CYAN}help${ui.RESET} Show this message`);
|
|
68
74
|
console.log('');
|
|
@@ -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
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
// `claudeflow panel` — local web dashboard for inspecting claudeflow state.
|
|
2
|
+
//
|
|
3
|
+
// Spawns a Node http server bound to 127.0.0.1 on a free port, opens the
|
|
4
|
+
// system browser, and serves a single-page UI that calls JSON endpoints
|
|
5
|
+
// to render: version, CLAUDE.md state, hooks, MCP/observer, setup-context,
|
|
6
|
+
// active build run, and doctor checks.
|
|
7
|
+
//
|
|
8
|
+
// Zero runtime dependencies — Node built-ins only. The HTML/CSS/JS frontend
|
|
9
|
+
// lives inline as a string template.
|
|
10
|
+
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const { URL } = require('url');
|
|
16
|
+
|
|
17
|
+
const ui = require('./ui.js');
|
|
18
|
+
const {
|
|
19
|
+
deriveCdpPort,
|
|
20
|
+
checkCdpPortMismatch,
|
|
21
|
+
checkStaleLockfiles,
|
|
22
|
+
readPlaywrightCdpEndpoint,
|
|
23
|
+
} = require('./doctor.js');
|
|
24
|
+
const {
|
|
25
|
+
readOverrides,
|
|
26
|
+
toggleHookOverride,
|
|
27
|
+
normalizeHandlerPath,
|
|
28
|
+
} = require('./hook-overrides.js');
|
|
29
|
+
|
|
30
|
+
const IDLE_SHUTDOWN_MS = 30 * 60 * 1000; // 30 min
|
|
31
|
+
|
|
32
|
+
// ─── data collectors ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function getVersionInfo(cwd) {
|
|
35
|
+
let installed = null;
|
|
36
|
+
for (const rel of [path.join('.claudeflow', 'version'), path.join('.claude', '.claudeflow-version')]) {
|
|
37
|
+
try {
|
|
38
|
+
installed = fs.readFileSync(path.join(cwd, rel), 'utf8').trim().replace(/^v/, '');
|
|
39
|
+
if (installed) break;
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
return { installed: installed || 'unknown' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getClaudeMdInfo(cwd) {
|
|
46
|
+
const p = path.join(cwd, 'CLAUDE.md');
|
|
47
|
+
let stat;
|
|
48
|
+
try { stat = fs.statSync(p); } catch { return { exists: false, path: p }; }
|
|
49
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
const begin = '<!-- claudeflow:default-rules:start';
|
|
52
|
+
const end = '<!-- claudeflow:default-rules:end -->';
|
|
53
|
+
const beginIdx = content.indexOf(begin);
|
|
54
|
+
const endIdx = content.indexOf(end);
|
|
55
|
+
let block = null;
|
|
56
|
+
let position = null;
|
|
57
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
58
|
+
const lineStart = content.slice(0, beginIdx).split('\n').length;
|
|
59
|
+
const lineEnd = content.slice(0, endIdx).split('\n').length;
|
|
60
|
+
block = { lineStart, lineEnd, bytes: endIdx + end.length - beginIdx };
|
|
61
|
+
const totalLines = lines.length;
|
|
62
|
+
if (lineStart <= 3) position = 'top';
|
|
63
|
+
else if (lineEnd >= totalLines - 3) position = 'bottom';
|
|
64
|
+
else position = 'middle';
|
|
65
|
+
}
|
|
66
|
+
const optOut = content.includes('claudeflow:default-rules:opt-out');
|
|
67
|
+
return {
|
|
68
|
+
exists: true,
|
|
69
|
+
path: p,
|
|
70
|
+
bytes: stat.size,
|
|
71
|
+
lines: lines.length,
|
|
72
|
+
block: block ? { present: true, position, ...block } : { present: false },
|
|
73
|
+
optOut,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getAppendPromptInfo(cwd) {
|
|
78
|
+
const p = path.join(cwd, 'append-system-prompt.md');
|
|
79
|
+
try {
|
|
80
|
+
const stat = fs.statSync(p);
|
|
81
|
+
return { exists: true, path: p, bytes: stat.size };
|
|
82
|
+
} catch {
|
|
83
|
+
return { exists: false, path: p };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getHooksInfo(cwd) {
|
|
88
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.json');
|
|
89
|
+
let settings;
|
|
90
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); }
|
|
91
|
+
catch { return { settingsFound: false, events: [] }; }
|
|
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
|
+
|
|
99
|
+
const hooks = settings.hooks || {};
|
|
100
|
+
const eventsMap = new Map();
|
|
101
|
+
let totalHandlers = 0;
|
|
102
|
+
let warnings = 0;
|
|
103
|
+
let disabledCount = 0;
|
|
104
|
+
|
|
105
|
+
for (const [event, rules] of Object.entries(hooks)) {
|
|
106
|
+
if (!eventsMap.has(event)) eventsMap.set(event, []);
|
|
107
|
+
for (const rule of rules) {
|
|
108
|
+
const matcher = rule.matcher || '';
|
|
109
|
+
for (const h of rule.hooks || []) {
|
|
110
|
+
const cmd = h.command || '';
|
|
111
|
+
const handlerPath = normalizeHandlerPath(cmd) || (cmd ? cmd : '');
|
|
112
|
+
const isEmpty = !handlerPath;
|
|
113
|
+
if (isEmpty) warnings++;
|
|
114
|
+
eventsMap.get(event).push({
|
|
115
|
+
handler: handlerPath || '(empty command)',
|
|
116
|
+
matcher: matcher || '',
|
|
117
|
+
matcherDisplay: matcher || '(any)',
|
|
118
|
+
empty: isEmpty,
|
|
119
|
+
disabled: false,
|
|
120
|
+
rawCommand: cmd,
|
|
121
|
+
});
|
|
122
|
+
totalHandlers++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getMcpInfo(cwd) {
|
|
158
|
+
const mcpPath = path.join(cwd, '.mcp.json');
|
|
159
|
+
let cfg;
|
|
160
|
+
try { cfg = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); }
|
|
161
|
+
catch { return { configFound: false }; }
|
|
162
|
+
|
|
163
|
+
const servers = Object.keys(cfg.mcpServers || {});
|
|
164
|
+
const cdp = checkCdpPortMismatch(cwd);
|
|
165
|
+
const reading = readPlaywrightCdpEndpoint(mcpPath);
|
|
166
|
+
const observer = readObserverState(cwd, deriveCdpPort(cwd));
|
|
167
|
+
const lockfiles = checkStaleLockfiles(cwd);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
configFound: true,
|
|
171
|
+
servers,
|
|
172
|
+
playwright: {
|
|
173
|
+
configured: reading.state === 'ok' ? reading.port : null,
|
|
174
|
+
computed: deriveCdpPort(cwd),
|
|
175
|
+
match: cdp.severity === 'ok',
|
|
176
|
+
state: reading.state,
|
|
177
|
+
message: cdp.message,
|
|
178
|
+
},
|
|
179
|
+
observer,
|
|
180
|
+
staleLockfiles: lockfiles.severity === 'mismatch' ? lockfiles.detail : [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readObserverState(cwd, cdpPort) {
|
|
185
|
+
const lockfile = path.join(cwd, '.claudeflow', 'tmp', `.browser-cdp-${cdpPort}.pid`);
|
|
186
|
+
let pid = null;
|
|
187
|
+
try {
|
|
188
|
+
pid = parseInt(fs.readFileSync(lockfile, 'utf8').trim(), 10);
|
|
189
|
+
} catch {
|
|
190
|
+
return { running: false, pid: null, lockfile };
|
|
191
|
+
}
|
|
192
|
+
if (!Number.isFinite(pid) || pid <= 0) return { running: false, pid, lockfile };
|
|
193
|
+
let alive = false;
|
|
194
|
+
try { process.kill(pid, 0); alive = true; } catch (e) { alive = e.code === 'EPERM'; }
|
|
195
|
+
return { running: alive, pid, lockfile };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getSetupContextInfo(cwd) {
|
|
199
|
+
const p = path.join(cwd, '.claudeflow', 'config', 'setup-context.json');
|
|
200
|
+
let ctx;
|
|
201
|
+
try { ctx = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
202
|
+
catch { return { exists: false, path: p }; }
|
|
203
|
+
|
|
204
|
+
const canonicalToolingTypes = ['unit_test', 'integration_test', 'api_contract_test', 'e2e_test', 'security_test'];
|
|
205
|
+
const tooling = ctx.test_tooling || {};
|
|
206
|
+
const toolingComplete = canonicalToolingTypes.every((t) => {
|
|
207
|
+
const e = tooling[t];
|
|
208
|
+
return e && typeof e === 'object' && typeof e.command_pattern === 'string' && e.command_pattern.trim();
|
|
209
|
+
});
|
|
210
|
+
const missingTooling = canonicalToolingTypes.filter((t) => {
|
|
211
|
+
const e = tooling[t];
|
|
212
|
+
return !(e && typeof e === 'object' && e.command_pattern && e.command_pattern.trim());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
exists: true,
|
|
217
|
+
path: p,
|
|
218
|
+
selections: ctx.selections || null,
|
|
219
|
+
testTooling: tooling,
|
|
220
|
+
toolingComplete,
|
|
221
|
+
missingTooling,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getActiveRunInfo(cwd) {
|
|
226
|
+
const p = path.join(cwd, '.claudeflow', 'tmp', 'workflow-state.json');
|
|
227
|
+
let state;
|
|
228
|
+
try { state = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
229
|
+
catch { return { active: false }; }
|
|
230
|
+
const activeRunId = state.active_run;
|
|
231
|
+
if (!activeRunId) return { active: false };
|
|
232
|
+
const run = (state.runs || {})[activeRunId] || null;
|
|
233
|
+
return {
|
|
234
|
+
active: true,
|
|
235
|
+
runId: activeRunId,
|
|
236
|
+
kind: run?.kind || null,
|
|
237
|
+
topLevel: run?.top_level || null,
|
|
238
|
+
taskCount: run?.tasks ? Object.keys(run.tasks).length : 0,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getDoctorInfo(cwd) {
|
|
243
|
+
const checks = [checkCdpPortMismatch(cwd), checkStaleLockfiles(cwd)];
|
|
244
|
+
const issues = checks.filter((c) => c.severity !== 'ok' && c.severity !== 'info');
|
|
245
|
+
return {
|
|
246
|
+
issueCount: issues.length,
|
|
247
|
+
checks: checks.map((c) => ({ id: c.id, severity: c.severity, message: c.message })),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function collectStatus(cwd) {
|
|
252
|
+
return {
|
|
253
|
+
cwd,
|
|
254
|
+
timestamp: new Date().toISOString(),
|
|
255
|
+
version: getVersionInfo(cwd),
|
|
256
|
+
claudeMd: getClaudeMdInfo(cwd),
|
|
257
|
+
appendPrompt: getAppendPromptInfo(cwd),
|
|
258
|
+
hooks: getHooksInfo(cwd),
|
|
259
|
+
mcp: getMcpInfo(cwd),
|
|
260
|
+
setupContext: getSetupContextInfo(cwd),
|
|
261
|
+
activeRun: getActiveRunInfo(cwd),
|
|
262
|
+
doctor: getDoctorInfo(cwd),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── frontend (HTML + CSS + JS embedded) ──────────────────────────
|
|
267
|
+
|
|
268
|
+
function renderHtml() {
|
|
269
|
+
return `<!DOCTYPE html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="utf-8" />
|
|
273
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
274
|
+
<title>claudeflow panel</title>
|
|
275
|
+
<style>
|
|
276
|
+
:root {
|
|
277
|
+
--bg: #0e1117;
|
|
278
|
+
--panel: #161b22;
|
|
279
|
+
--panel-2: #1c232c;
|
|
280
|
+
--border: #2a313c;
|
|
281
|
+
--fg: #e6edf3;
|
|
282
|
+
--muted: #8b949e;
|
|
283
|
+
--accent: #7c3aed;
|
|
284
|
+
--ok: #3fb950;
|
|
285
|
+
--warn: #d29922;
|
|
286
|
+
--err: #f85149;
|
|
287
|
+
--info: #58a6ff;
|
|
288
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
289
|
+
}
|
|
290
|
+
* { box-sizing: border-box; }
|
|
291
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, sans-serif; }
|
|
292
|
+
header { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--border); background: var(--panel); position: sticky; top: 0; z-index: 10; }
|
|
293
|
+
header .logo { color: var(--accent); font-size: 18px; }
|
|
294
|
+
header .title { font-weight: 600; letter-spacing: 0.5px; }
|
|
295
|
+
header .version { color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
|
296
|
+
header .cwd { color: var(--muted); font-family: var(--mono); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
297
|
+
header .actions { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 12px; }
|
|
298
|
+
header button { background: var(--panel-2); border: 1px solid var(--border); color: var(--fg); padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
|
|
299
|
+
header button:hover { border-color: var(--accent); }
|
|
300
|
+
main { display: grid; grid-template-columns: 220px 1fr; min-height: calc(100vh - 56px); }
|
|
301
|
+
nav { border-right: 1px solid var(--border); background: var(--panel); padding: 12px 0; }
|
|
302
|
+
nav a { display: flex; align-items: center; gap: 10px; padding: 10px 22px; color: var(--fg); text-decoration: none; cursor: pointer; border-left: 3px solid transparent; font-size: 13px; }
|
|
303
|
+
nav a:hover { background: var(--panel-2); }
|
|
304
|
+
nav a.active { background: var(--panel-2); border-left-color: var(--accent); }
|
|
305
|
+
nav .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
|
|
306
|
+
.dot.ok { background: var(--ok); }
|
|
307
|
+
.dot.warn { background: var(--warn); }
|
|
308
|
+
.dot.err { background: var(--err); }
|
|
309
|
+
.dot.info { background: var(--muted); }
|
|
310
|
+
section { padding: 22px 28px; max-width: 980px; }
|
|
311
|
+
section h2 { font-size: 16px; margin: 0 0 6px; letter-spacing: 0.3px; }
|
|
312
|
+
section .sub { color: var(--muted); font-size: 12px; margin-bottom: 18px; }
|
|
313
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; margin-bottom: 14px; }
|
|
314
|
+
.card-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: center; gap: 10px; }
|
|
315
|
+
.card-row:last-child { border-bottom: 0; }
|
|
316
|
+
.card-row .k { color: var(--muted); font-size: 13px; }
|
|
317
|
+
.card-row .v { font-family: var(--mono); font-size: 13px; }
|
|
318
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-family: var(--mono); }
|
|
319
|
+
.badge.ok { background: rgba(63, 185, 80, 0.12); color: var(--ok); }
|
|
320
|
+
.badge.warn { background: rgba(210, 153, 34, 0.12); color: var(--warn); }
|
|
321
|
+
.badge.err { background: rgba(248, 81, 73, 0.12); color: var(--err); }
|
|
322
|
+
.badge.info { background: rgba(139, 148, 158, 0.16); color: var(--muted); }
|
|
323
|
+
pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; overflow: auto; max-height: 480px; font: 12px/1.5 var(--mono); margin: 0; }
|
|
324
|
+
details { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; }
|
|
325
|
+
details summary { cursor: pointer; font-weight: 500; }
|
|
326
|
+
details[open] summary { padding-bottom: 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
|
|
327
|
+
.handler { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-family: var(--mono); font-size: 12px; padding: 4px 0; align-items: center; }
|
|
328
|
+
.matcher { color: var(--muted); font-size: 11px; }
|
|
329
|
+
.muted { color: var(--muted); }
|
|
330
|
+
.error { color: var(--err); }
|
|
331
|
+
.success { color: var(--ok); }
|
|
332
|
+
.warning { color: var(--warn); }
|
|
333
|
+
.toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
|
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); }
|
|
340
|
+
</style>
|
|
341
|
+
</head>
|
|
342
|
+
<body>
|
|
343
|
+
<header>
|
|
344
|
+
<span class="logo">◆</span>
|
|
345
|
+
<span class="title">claudeflow panel</span>
|
|
346
|
+
<span class="version" id="version">…</span>
|
|
347
|
+
<span class="cwd" id="cwd">…</span>
|
|
348
|
+
<span class="actions">
|
|
349
|
+
<label class="toggle"><input type="checkbox" id="auto" checked /> auto-refresh 5s</label>
|
|
350
|
+
<button id="refresh">Refresh</button>
|
|
351
|
+
</span>
|
|
352
|
+
</header>
|
|
353
|
+
<main>
|
|
354
|
+
<nav id="nav"></nav>
|
|
355
|
+
<section id="content"><p class="muted">Loading…</p></section>
|
|
356
|
+
</main>
|
|
357
|
+
<script>
|
|
358
|
+
const SECTIONS = [
|
|
359
|
+
{ id: 'overview', label: 'Overview' },
|
|
360
|
+
{ id: 'claudeMd', label: 'CLAUDE.md' },
|
|
361
|
+
{ id: 'hooks', label: 'Hooks' },
|
|
362
|
+
{ id: 'mcp', label: 'MCP & observer' },
|
|
363
|
+
{ id: 'setupContext', label: 'Setup context' },
|
|
364
|
+
{ id: 'activeRun', label: 'Active run' },
|
|
365
|
+
{ id: 'doctor', label: 'Issues' },
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
let state = null;
|
|
369
|
+
let active = 'overview';
|
|
370
|
+
let timer = null;
|
|
371
|
+
|
|
372
|
+
async function refresh() {
|
|
373
|
+
const r = await fetch('/api/status');
|
|
374
|
+
state = await r.json();
|
|
375
|
+
renderHeader();
|
|
376
|
+
renderNav();
|
|
377
|
+
renderContent();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderHeader() {
|
|
381
|
+
document.getElementById('version').textContent = 'v' + state.version.installed;
|
|
382
|
+
document.getElementById('cwd').textContent = state.cwd;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function severityFor(id) {
|
|
386
|
+
const s = state;
|
|
387
|
+
switch (id) {
|
|
388
|
+
case 'overview': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
|
|
389
|
+
case 'claudeMd': return !s.claudeMd.exists ? 'err' : (s.claudeMd.optOut ? 'info' : 'ok');
|
|
390
|
+
case 'hooks': return s.hooks.warnings > 0 ? 'warn' : 'ok';
|
|
391
|
+
case 'mcp': return !s.mcp.configFound ? 'info' : (s.mcp.playwright.match && s.mcp.staleLockfiles.length === 0 ? 'ok' : 'warn');
|
|
392
|
+
case 'setupContext': return !s.setupContext.exists ? 'err' : (s.setupContext.toolingComplete ? 'ok' : 'warn');
|
|
393
|
+
case 'activeRun': return s.activeRun.active ? 'info' : 'info';
|
|
394
|
+
case 'doctor': return s.doctor.issueCount > 0 ? 'warn' : 'ok';
|
|
395
|
+
}
|
|
396
|
+
return 'info';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function renderNav() {
|
|
400
|
+
const nav = document.getElementById('nav');
|
|
401
|
+
nav.innerHTML = SECTIONS.map(s => {
|
|
402
|
+
const sev = severityFor(s.id);
|
|
403
|
+
const cls = active === s.id ? 'active' : '';
|
|
404
|
+
return \`<a class="\${cls}" data-id="\${s.id}"><span class="dot \${sev}"></span>\${s.label}</a>\`;
|
|
405
|
+
}).join('');
|
|
406
|
+
nav.querySelectorAll('a').forEach(a => a.onclick = () => { active = a.dataset.id; renderNav(); renderContent(); });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function row(k, v, badge) {
|
|
410
|
+
const b = badge ? \`<span class="badge \${badge.kind}">\${escapeHtml(badge.text)}</span>\` : '';
|
|
411
|
+
return \`<div class="card-row"><span class="k">\${escapeHtml(k)}</span><span class="v">\${b}\${escapeHtml(v)}</span></div>\`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function escapeHtml(s) {
|
|
415
|
+
return String(s == null ? '' : s)
|
|
416
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
417
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function fmtBytes(n) {
|
|
421
|
+
if (n == null) return '—';
|
|
422
|
+
if (n < 1024) return n + ' B';
|
|
423
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
424
|
+
return (n / 1024 / 1024).toFixed(2) + ' MB';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function renderContent() {
|
|
428
|
+
const el = document.getElementById('content');
|
|
429
|
+
if (!state) { el.innerHTML = '<p class="muted">Loading…</p>'; return; }
|
|
430
|
+
const fn = {
|
|
431
|
+
overview: renderOverview,
|
|
432
|
+
claudeMd: renderClaudeMd,
|
|
433
|
+
hooks: renderHooks,
|
|
434
|
+
mcp: renderMcp,
|
|
435
|
+
setupContext: renderSetup,
|
|
436
|
+
activeRun: renderRun,
|
|
437
|
+
doctor: renderDoctor,
|
|
438
|
+
}[active];
|
|
439
|
+
el.innerHTML = fn ? fn() : '';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderOverview() {
|
|
443
|
+
const s = state;
|
|
444
|
+
const cm = s.claudeMd;
|
|
445
|
+
const cmText = cm.exists
|
|
446
|
+
? \`\${fmtBytes(cm.bytes)} / \${cm.lines} lines · block: \${cm.block.present ? cm.block.position : 'absent'}\${cm.optOut ? ' · OPT-OUT' : ''}\`
|
|
447
|
+
: 'missing';
|
|
448
|
+
const ap = s.appendPrompt.exists ? fmtBytes(s.appendPrompt.bytes) : 'missing';
|
|
449
|
+
const hk = \`\${s.hooks.events.length} events · \${s.hooks.totalHandlers} handlers\${s.hooks.warnings ? ' · ' + s.hooks.warnings + ' warnings' : ''}\`;
|
|
450
|
+
const mc = !s.mcp.configFound ? '(no .mcp.json)' :
|
|
451
|
+
\`Playwright \${s.mcp.playwright.match ? '✓' : '✗'} \${s.mcp.playwright.configured || '?'} \${s.mcp.playwright.match ? '' : '(expected ' + s.mcp.playwright.computed + ')'} · observer \${s.mcp.observer.running ? 'running pid '+s.mcp.observer.pid : 'stopped'}\`;
|
|
452
|
+
const sc = !s.setupContext.exists ? 'missing' : (s.setupContext.toolingComplete ? 'complete' : 'incomplete (' + s.setupContext.missingTooling.join(', ') + ')');
|
|
453
|
+
const ar = s.activeRun.active ? s.activeRun.runId : 'none';
|
|
454
|
+
const dc = s.doctor.issueCount === 0 ? '0 issues' : s.doctor.issueCount + ' issue(s) — run \`claudeflow doctor\`';
|
|
455
|
+
return \`<h2>Overview</h2>
|
|
456
|
+
<p class="sub">Snapshot at \${new Date(s.timestamp).toLocaleTimeString()}</p>
|
|
457
|
+
<div class="card">
|
|
458
|
+
\${row('CLAUDE.md', cmText, { kind: cm.exists ? (cm.optOut ? 'info' : 'ok') : 'err', text: cm.exists ? '✓' : '✗' })}
|
|
459
|
+
\${row('append-system-prompt.md', ap, { kind: s.appendPrompt.exists ? 'ok' : 'err', text: s.appendPrompt.exists ? '✓' : '✗' })}
|
|
460
|
+
\${row('Hooks', hk, { kind: s.hooks.warnings ? 'warn' : 'ok', text: s.hooks.warnings ? '!' : '✓' })}
|
|
461
|
+
\${row('MCP & observer', mc, { kind: !s.mcp.configFound ? 'info' : (s.mcp.playwright.match ? 'ok' : 'warn'), text: !s.mcp.configFound ? '·' : (s.mcp.playwright.match ? '✓' : '!') })}
|
|
462
|
+
\${row('Setup context', sc, { kind: s.setupContext.exists ? (s.setupContext.toolingComplete ? 'ok' : 'warn') : 'err', text: s.setupContext.exists ? (s.setupContext.toolingComplete ? '✓' : '!') : '✗' })}
|
|
463
|
+
\${row('Active run', ar, { kind: 'info', text: s.activeRun.active ? '·' : '·' })}
|
|
464
|
+
\${row('Doctor', dc, { kind: s.doctor.issueCount === 0 ? 'ok' : 'warn', text: s.doctor.issueCount === 0 ? '✓' : '!' })}
|
|
465
|
+
</div>\`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function renderClaudeMd() {
|
|
469
|
+
const c = state.claudeMd;
|
|
470
|
+
if (!c.exists) return \`<h2>CLAUDE.md</h2><p class="error">File not found at \${escapeHtml(c.path)}</p>\`;
|
|
471
|
+
const blk = c.block.present
|
|
472
|
+
? \`present at \${c.block.position} (lines \${c.block.lineStart}–\${c.block.lineEnd}, \${fmtBytes(c.block.bytes)})\`
|
|
473
|
+
: 'absent (no claudeflow:default-rules markers)';
|
|
474
|
+
return \`<h2>CLAUDE.md</h2>
|
|
475
|
+
<p class="sub">\${escapeHtml(c.path)}</p>
|
|
476
|
+
<div class="card">
|
|
477
|
+
\${row('Size', fmtBytes(c.bytes) + ' / ' + c.lines + ' lines')}
|
|
478
|
+
\${row('Default rules block', blk, { kind: c.block.present ? 'ok' : 'info', text: c.block.present ? '✓' : '·' })}
|
|
479
|
+
\${row('Opt-out marker', c.optOut ? 'present (block disabled)' : 'absent', { kind: c.optOut ? 'info' : 'ok', text: c.optOut ? '·' : '✓' })}
|
|
480
|
+
</div>
|
|
481
|
+
<details><summary>Preview content</summary><div id="claude-md-preview">Loading…</div></details>
|
|
482
|
+
<script>
|
|
483
|
+
fetch('/api/claude-md').then(r => r.text()).then(t => {
|
|
484
|
+
document.getElementById('claude-md-preview').innerHTML = '<pre>' + t.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])) + '</pre>';
|
|
485
|
+
});
|
|
486
|
+
</\${'script'}>
|
|
487
|
+
\`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderHooks() {
|
|
491
|
+
const h = state.hooks;
|
|
492
|
+
if (!h.settingsFound) return '<h2>Hooks</h2><p class="error">.claude/settings.json not found</p>';
|
|
493
|
+
const items = h.events.map(ev => {
|
|
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>\`;
|
|
505
|
+
}).join('');
|
|
506
|
+
return \`<details><summary>\${escapeHtml(ev.event)} <span class="muted">(\${ev.count})</span></summary>\${handlers}</details>\`;
|
|
507
|
+
}).join('');
|
|
508
|
+
return \`<h2>Hooks</h2>
|
|
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>
|
|
511
|
+
\${items}\`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderMcp() {
|
|
515
|
+
const m = state.mcp;
|
|
516
|
+
if (!m.configFound) return '<h2>MCP & observer</h2><p class="muted">.mcp.json not found</p>';
|
|
517
|
+
const lockfiles = m.staleLockfiles.length === 0
|
|
518
|
+
? '<p class="muted">No stale lockfiles.</p>'
|
|
519
|
+
: '<ul>' + m.staleLockfiles.map(l => \`<li>\${escapeHtml(l.file)} (\${escapeHtml(l.reason)}\${l.pid ? ', pid='+l.pid : ''})</li>\`).join('') + '</ul>';
|
|
520
|
+
return \`<h2>MCP & observer</h2>
|
|
521
|
+
<p class="sub">Servers: \${m.servers.map(escapeHtml).join(', ') || 'none'}</p>
|
|
522
|
+
<div class="card">
|
|
523
|
+
\${row('Playwright --cdp-endpoint port', String(m.playwright.configured || 'n/a'))}
|
|
524
|
+
\${row('Computed port (from path)', String(m.playwright.computed))}
|
|
525
|
+
\${row('Match', m.playwright.match ? 'YES' : 'NO', { kind: m.playwright.match ? 'ok' : 'err', text: m.playwright.match ? '✓' : '✗' })}
|
|
526
|
+
\${row('Observer', m.observer.running ? 'running (pid '+m.observer.pid+')' : 'stopped', { kind: m.observer.running ? 'ok' : 'info', text: m.observer.running ? '✓' : '·' })}
|
|
527
|
+
</div>
|
|
528
|
+
<h2 style="margin-top:18px">Stale lockfiles</h2>
|
|
529
|
+
\${lockfiles}\`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderSetup() {
|
|
533
|
+
const s = state.setupContext;
|
|
534
|
+
if (!s.exists) return '<h2>Setup context</h2><p class="error">setup-context.json not found</p>';
|
|
535
|
+
const sel = s.selections ? '<pre>' + escapeHtml(JSON.stringify(s.selections, null, 2)) + '</pre>' : '<p class="muted">No selections.</p>';
|
|
536
|
+
const tooling = '<pre>' + escapeHtml(JSON.stringify(s.testTooling, null, 2)) + '</pre>';
|
|
537
|
+
const missing = s.missingTooling.length ? '<p class="warning">Missing types: ' + s.missingTooling.map(escapeHtml).join(', ') + '</p>' : '<p class="success">All canonical tooling types defined.</p>';
|
|
538
|
+
return \`<h2>Setup context</h2>
|
|
539
|
+
<p class="sub">\${escapeHtml(s.path)}</p>
|
|
540
|
+
<h3>selections</h3>\${sel}
|
|
541
|
+
<h3>test_tooling</h3>\${missing}\${tooling}\`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function renderRun() {
|
|
545
|
+
const r = state.activeRun;
|
|
546
|
+
if (!r.active) return '<h2>Active run</h2><p class="muted">No active build run.</p>';
|
|
547
|
+
return \`<h2>Active run</h2>
|
|
548
|
+
<div class="card">
|
|
549
|
+
\${row('Run ID', r.runId)}
|
|
550
|
+
\${row('Kind', r.kind || '—')}
|
|
551
|
+
\${row('Top-level subject', (r.topLevel && r.topLevel.subject) || '—')}
|
|
552
|
+
\${row('Tasks tracked', String(r.taskCount))}
|
|
553
|
+
</div>\`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function renderDoctor() {
|
|
557
|
+
const d = state.doctor;
|
|
558
|
+
return \`<h2>Issues</h2>
|
|
559
|
+
<p class="sub">\${d.issueCount === 0 ? 'No issues detected.' : d.issueCount + ' issue(s) detected. Run <code>claudeflow doctor --fix</code> to repair.'}</p>
|
|
560
|
+
<div class="card">
|
|
561
|
+
\${d.checks.map(c => row(c.id, c.message, { kind: c.severity === 'ok' ? 'ok' : (c.severity === 'info' ? 'info' : 'warn'), text: c.severity })).join('')}
|
|
562
|
+
</div>\`;
|
|
563
|
+
}
|
|
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
|
+
|
|
605
|
+
document.getElementById('refresh').onclick = refresh;
|
|
606
|
+
document.getElementById('auto').onchange = (e) => {
|
|
607
|
+
if (timer) clearInterval(timer);
|
|
608
|
+
if (e.target.checked) timer = setInterval(refresh, 5000);
|
|
609
|
+
};
|
|
610
|
+
timer = setInterval(refresh, 5000);
|
|
611
|
+
refresh();
|
|
612
|
+
</script>
|
|
613
|
+
</body>
|
|
614
|
+
</html>`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ─── HTTP server ─────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
function readClaudeMdSafe(cwd) {
|
|
620
|
+
try { return fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8'); }
|
|
621
|
+
catch { return ''; }
|
|
622
|
+
}
|
|
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
|
+
|
|
642
|
+
function handler(cwd) {
|
|
643
|
+
return async (req, res) => {
|
|
644
|
+
try {
|
|
645
|
+
const url = new URL(req.url, 'http://localhost');
|
|
646
|
+
const route = url.pathname;
|
|
647
|
+
|
|
648
|
+
const send = (status, body, type) => {
|
|
649
|
+
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
650
|
+
res.end(body);
|
|
651
|
+
};
|
|
652
|
+
|
|
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
|
+
|
|
674
|
+
send(404, JSON.stringify({ error: 'not found' }), 'application/json');
|
|
675
|
+
} catch (err) {
|
|
676
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
677
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function openInBrowser(url) {
|
|
683
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
684
|
+
: process.platform === 'win32' ? 'start'
|
|
685
|
+
: 'xdg-open';
|
|
686
|
+
try {
|
|
687
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
688
|
+
} catch {}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function start({ cwd = process.cwd(), port = 0, openBrowser = true } = {}) {
|
|
692
|
+
const server = http.createServer(handler(cwd));
|
|
693
|
+
let lastActivity = Date.now();
|
|
694
|
+
server.on('request', () => { lastActivity = Date.now(); });
|
|
695
|
+
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
server.once('error', reject);
|
|
698
|
+
server.listen(port, '127.0.0.1', () => {
|
|
699
|
+
const addr = server.address();
|
|
700
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
701
|
+
const idleTimer = setInterval(() => {
|
|
702
|
+
if (Date.now() - lastActivity > IDLE_SHUTDOWN_MS) {
|
|
703
|
+
clearInterval(idleTimer);
|
|
704
|
+
server.close();
|
|
705
|
+
process.exit(0);
|
|
706
|
+
}
|
|
707
|
+
}, 60 * 1000);
|
|
708
|
+
idleTimer.unref();
|
|
709
|
+
if (openBrowser) openInBrowser(url);
|
|
710
|
+
resolve({ url, server });
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function run(argv = []) {
|
|
716
|
+
const cwd = process.cwd();
|
|
717
|
+
const noOpen = argv.includes('--no-open');
|
|
718
|
+
const portArg = argv.find((a) => a.startsWith('--port='));
|
|
719
|
+
const port = portArg ? parseInt(portArg.split('=')[1], 10) : 0;
|
|
720
|
+
|
|
721
|
+
ui.banner();
|
|
722
|
+
console.log(` Starting panel for ${ui.CYAN}${cwd}${ui.RESET}`);
|
|
723
|
+
const { url } = await start({ cwd, port, openBrowser: !noOpen });
|
|
724
|
+
console.log('');
|
|
725
|
+
console.log(` ${ui.GREEN}▸${ui.RESET} ${ui.CYAN}${url}${ui.RESET}`);
|
|
726
|
+
console.log(` ${ui.DIM}Ctrl+C to stop. Auto-shutdown after 30 min idle.${ui.RESET}`);
|
|
727
|
+
console.log('');
|
|
728
|
+
return new Promise(() => {}); // keep alive
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
module.exports = run;
|
|
732
|
+
module.exports.start = start;
|
|
733
|
+
module.exports.collectStatus = collectStatus;
|
|
734
|
+
module.exports.getClaudeMdInfo = getClaudeMdInfo;
|
|
735
|
+
module.exports.getHooksInfo = getHooksInfo;
|
|
736
|
+
module.exports.getMcpInfo = getMcpInfo;
|
|
737
|
+
module.exports.getSetupContextInfo = getSetupContextInfo;
|
|
738
|
+
module.exports.getActiveRunInfo = getActiveRunInfo;
|
|
739
|
+
module.exports.getDoctorInfo = getDoctorInfo;
|
|
740
|
+
module.exports.renderHtml = renderHtml;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiomatic-labs/claudeflow",
|
|
3
|
-
"version": "2.13.
|
|
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"
|