@albinocrabs/feynman 0.2.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/.codex-plugin/plugin.json +43 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/feynman-lint.js +123 -0
- package/bin/feynman.js +559 -0
- package/hooks/feynman-activate.js +103 -0
- package/hooks/feynman-lint.js +96 -0
- package/hooks/hooks.json +17 -0
- package/hooks.json +16 -0
- package/install.sh +18 -0
- package/lib/lint/index.js +100 -0
- package/lib/lint/parser.js +229 -0
- package/lib/lint/rules.js +550 -0
- package/package.json +55 -0
- package/rules/feynman-activate.md +168 -0
- package/skills/feynman/SKILL.md +63 -0
- package/uninstall.sh +18 -0
package/bin/feynman.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/feynman.js — feynman unified CLI
|
|
3
|
+
// Subcommands: install, uninstall, doctor, lint, version, help
|
|
4
|
+
// Zero runtime deps. CJS only. Node >= 18.
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const NO_COLOR = !!process.env.NO_COLOR;
|
|
14
|
+
const c = {
|
|
15
|
+
bold: s => NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`,
|
|
16
|
+
green: s => NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`,
|
|
17
|
+
red: s => NO_COLOR ? s : `\x1b[31m${s}\x1b[0m`,
|
|
18
|
+
dim: s => NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const PKG = require('../package.json');
|
|
24
|
+
const VERSION = PKG.version;
|
|
25
|
+
|
|
26
|
+
// Resolve paths using os.homedir() — never tilde literal (bug #8810)
|
|
27
|
+
const HOME = os.homedir();
|
|
28
|
+
|
|
29
|
+
// Hook script lives relative to this file
|
|
30
|
+
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'feynman-activate.js');
|
|
31
|
+
const RULES_PATH = path.resolve(__dirname, '..', 'rules', 'feynman-activate.md');
|
|
32
|
+
|
|
33
|
+
const DEFAULT_STATE = { enabled: true, intensity: 'full', injections: 0 };
|
|
34
|
+
const VALID_TARGETS = ['claude', 'codex', 'both'];
|
|
35
|
+
|
|
36
|
+
function targetConfig(name) {
|
|
37
|
+
const dirName = name === 'codex' ? '.codex' : '.claude';
|
|
38
|
+
const rootDir = path.join(HOME, dirName);
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
label: name === 'codex' ? 'Codex' : 'Claude Code',
|
|
42
|
+
rootDir,
|
|
43
|
+
settingsPath: path.join(rootDir, name === 'codex' ? 'hooks.json' : 'settings.json'),
|
|
44
|
+
feynmanDir: path.join(rootDir, '.feynman'),
|
|
45
|
+
statePath: path.join(rootDir, '.feynman', 'state.json'),
|
|
46
|
+
flagPath: path.join(rootDir, '.feynman-active'),
|
|
47
|
+
commandsDir: name === 'claude' ? path.join(rootDir, 'commands') : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function targetNames(target) {
|
|
52
|
+
return target === 'both' ? ['claude', 'codex'] : [target];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseTarget(args, fallback = 'claude') {
|
|
56
|
+
let target = fallback;
|
|
57
|
+
const keep = [];
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const arg = args[i];
|
|
60
|
+
if (arg === '--target') {
|
|
61
|
+
target = args[++i];
|
|
62
|
+
} else if (arg.startsWith('--target=')) {
|
|
63
|
+
target = arg.slice('--target='.length);
|
|
64
|
+
} else {
|
|
65
|
+
keep.push(arg);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!VALID_TARGETS.includes(target)) {
|
|
69
|
+
console.error(`feynman: invalid --target '${target}' (expected claude, codex, or both)`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
return { target, args: keep };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hookCommandFor(target) {
|
|
76
|
+
const cfg = targetConfig(target);
|
|
77
|
+
return `FEYNMAN_HOME="${cfg.rootDir}" node "${HOOK_PATH}"`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Help text ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const HELP = `${c.bold('feynman')} v${VERSION} — auto-inject ASCII diagram rules into Claude Code and Codex
|
|
83
|
+
|
|
84
|
+
${c.bold('Usage:')}
|
|
85
|
+
feynman <command> [options]
|
|
86
|
+
|
|
87
|
+
${c.bold('Commands:')}
|
|
88
|
+
install Register feynman hook
|
|
89
|
+
uninstall Remove feynman hook (state preserved)
|
|
90
|
+
doctor Check installation health
|
|
91
|
+
lint <file> Lint a markdown file for diagram rule violations
|
|
92
|
+
version Print version number
|
|
93
|
+
help Show this help
|
|
94
|
+
|
|
95
|
+
${c.bold('Options:')}
|
|
96
|
+
--help, -h Show help for a command
|
|
97
|
+
--target claude | codex | both (default: claude)
|
|
98
|
+
--force (install) Re-register even if already installed
|
|
99
|
+
|
|
100
|
+
${c.bold('Examples:')}
|
|
101
|
+
npx feynman install
|
|
102
|
+
npx feynman install --target codex
|
|
103
|
+
npx feynman install --target both
|
|
104
|
+
npx feynman doctor
|
|
105
|
+
feynman lint response.md
|
|
106
|
+
feynman uninstall
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
const INSTALL_HELP = `${c.bold('feynman install')} — register feynman hook
|
|
110
|
+
|
|
111
|
+
${c.bold('Usage:')}
|
|
112
|
+
feynman install [--target claude|codex|both] [--force]
|
|
113
|
+
|
|
114
|
+
${c.bold('Options:')}
|
|
115
|
+
--target Install into Claude Code, Codex, or both (default: claude)
|
|
116
|
+
--force Re-register hook even if already installed
|
|
117
|
+
|
|
118
|
+
Claude creates:
|
|
119
|
+
~/.claude/.feynman/state.json — feynman state (enabled, intensity, injections)
|
|
120
|
+
~/.claude/.feynman-active — presence flag
|
|
121
|
+
|
|
122
|
+
Codex creates:
|
|
123
|
+
~/.codex/hooks.json — UserPromptSubmit hook registration
|
|
124
|
+
~/.codex/.feynman/state.json — feynman state (enabled, intensity, injections)
|
|
125
|
+
~/.codex/.feynman-active — presence flag
|
|
126
|
+
|
|
127
|
+
Idempotent by default: skips if feynman-activate.js entry already exists.
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const UNINSTALL_HELP = `${c.bold('feynman uninstall')} — remove feynman hook
|
|
131
|
+
|
|
132
|
+
${c.bold('Usage:')}
|
|
133
|
+
feynman uninstall [--target claude|codex|both]
|
|
134
|
+
|
|
135
|
+
Removes feynman hook entries from target config.
|
|
136
|
+
Preserves .feynman/state.json (user data).
|
|
137
|
+
Removes .feynman-active flag.
|
|
138
|
+
|
|
139
|
+
Idempotent: safe to run multiple times.
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const DOCTOR_HELP = `${c.bold('feynman doctor')} — check feynman installation health
|
|
143
|
+
|
|
144
|
+
${c.bold('Usage:')}
|
|
145
|
+
feynman doctor [--target claude|codex]
|
|
146
|
+
|
|
147
|
+
Checks:
|
|
148
|
+
1. target hook config present
|
|
149
|
+
2. UserPromptSubmit hook references feynman-activate.js
|
|
150
|
+
3. Hook script file exists and is readable
|
|
151
|
+
4. Rules file exists and is non-empty
|
|
152
|
+
5. state.json valid JSON with enabled field
|
|
153
|
+
6. .feynman-active flag present
|
|
154
|
+
7. (INFO) lint hook registered (optional)
|
|
155
|
+
|
|
156
|
+
Exit code: always 0 (advisory only).
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
const LINT_HELP = `${c.bold('feynman lint')} — lint a markdown file for ASCII diagram rule violations
|
|
160
|
+
|
|
161
|
+
${c.bold('Usage:')}
|
|
162
|
+
feynman lint <file.md>
|
|
163
|
+
feynman lint - (read from stdin)
|
|
164
|
+
feynman lint --json <file>
|
|
165
|
+
feynman lint --strict <file>
|
|
166
|
+
|
|
167
|
+
${c.bold('Options:')}
|
|
168
|
+
--json Output issues as JSON
|
|
169
|
+
--strict Treat warnings as errors (exit 1 on any issue)
|
|
170
|
+
|
|
171
|
+
Delegates to bin/feynman-lint.js. See feynman-lint --help for full docs.
|
|
172
|
+
|
|
173
|
+
Exit codes:
|
|
174
|
+
0 No errors
|
|
175
|
+
1 Lint failure
|
|
176
|
+
2 Usage error
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const VERSION_HELP = `${c.bold('feynman version')} — print version
|
|
180
|
+
|
|
181
|
+
${c.bold('Usage:')}
|
|
182
|
+
feynman version
|
|
183
|
+
|
|
184
|
+
Prints: ${VERSION}
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
// ─── Settings helpers ─────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function readSettings(target) {
|
|
190
|
+
const cfg = targetConfig(target);
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(fs.readFileSync(cfg.settingsPath, 'utf8'));
|
|
193
|
+
} catch (_) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writeSettings(target, settings) {
|
|
199
|
+
const cfg = targetConfig(target);
|
|
200
|
+
fs.mkdirSync(cfg.rootDir, { recursive: true });
|
|
201
|
+
fs.writeFileSync(cfg.settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasFeynmanHook(settings) {
|
|
205
|
+
return ((settings.hooks && settings.hooks.UserPromptSubmit) || []).some(g =>
|
|
206
|
+
g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-activate.js'))
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function removeFeynmanHooks(settings) {
|
|
211
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.UserPromptSubmit)) return settings;
|
|
212
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(g =>
|
|
213
|
+
!(g.hooks && g.hooks.some(h =>
|
|
214
|
+
h.command && (
|
|
215
|
+
h.command.includes('feynman-activate.js') ||
|
|
216
|
+
h.command.includes('feynman-lint.js')
|
|
217
|
+
)
|
|
218
|
+
))
|
|
219
|
+
);
|
|
220
|
+
if (settings.hooks.UserPromptSubmit.length === 0) {
|
|
221
|
+
delete settings.hooks.UserPromptSubmit;
|
|
222
|
+
}
|
|
223
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
224
|
+
delete settings.hooks;
|
|
225
|
+
}
|
|
226
|
+
return settings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function bootstrapState(target) {
|
|
230
|
+
const cfg = targetConfig(target);
|
|
231
|
+
fs.mkdirSync(cfg.feynmanDir, { recursive: true });
|
|
232
|
+
if (!fs.existsSync(cfg.statePath)) {
|
|
233
|
+
fs.writeFileSync(cfg.statePath, JSON.stringify(DEFAULT_STATE, null, 2) + '\n');
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(cfg.flagPath, DEFAULT_STATE.intensity);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function installClaudeCommand() {
|
|
239
|
+
const cfg = targetConfig('claude');
|
|
240
|
+
const skillSrc = path.resolve(__dirname, '..', 'skills', 'feynman', 'SKILL.md');
|
|
241
|
+
const commandDest = path.join(cfg.commandsDir, 'feynman.md');
|
|
242
|
+
if (fs.existsSync(skillSrc)) {
|
|
243
|
+
fs.mkdirSync(cfg.commandsDir, { recursive: true });
|
|
244
|
+
if (!fs.existsSync(commandDest)) {
|
|
245
|
+
fs.copyFileSync(skillSrc, commandDest);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Install ──────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function installOne(target, opts) {
|
|
253
|
+
const force = opts.force || false;
|
|
254
|
+
const tc = targetConfig(target);
|
|
255
|
+
|
|
256
|
+
// Read or create settings
|
|
257
|
+
const cfg = readSettings(target);
|
|
258
|
+
cfg.hooks = cfg.hooks || {};
|
|
259
|
+
cfg.hooks.UserPromptSubmit = cfg.hooks.UserPromptSubmit || [];
|
|
260
|
+
|
|
261
|
+
// Idempotency check
|
|
262
|
+
const already = hasFeynmanHook(cfg);
|
|
263
|
+
|
|
264
|
+
if (already && !force) {
|
|
265
|
+
bootstrapState(target);
|
|
266
|
+
if (target === 'claude') installClaudeCommand();
|
|
267
|
+
return { target, already: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If force + already, remove old entry first
|
|
271
|
+
if (already && force) {
|
|
272
|
+
removeFeynmanHooks(cfg);
|
|
273
|
+
cfg.hooks = cfg.hooks || {};
|
|
274
|
+
cfg.hooks.UserPromptSubmit = cfg.hooks.UserPromptSubmit || [];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Append hook entry
|
|
278
|
+
cfg.hooks.UserPromptSubmit.push({
|
|
279
|
+
hooks: [{
|
|
280
|
+
type: 'command',
|
|
281
|
+
command: hookCommandFor(target),
|
|
282
|
+
timeout: 5,
|
|
283
|
+
statusMessage: 'Injecting diagram rules...',
|
|
284
|
+
}]
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Write settings
|
|
288
|
+
writeSettings(target, cfg);
|
|
289
|
+
|
|
290
|
+
// Bootstrap state dir + state.json
|
|
291
|
+
bootstrapState(target);
|
|
292
|
+
|
|
293
|
+
// Install /feynman command to ~/.claude/commands/ (preserves user's existing skill)
|
|
294
|
+
if (target === 'claude') installClaudeCommand();
|
|
295
|
+
|
|
296
|
+
return { target, already: false, tc };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function cmdInstall(opts) {
|
|
300
|
+
const results = targetNames(opts.target).map(t => installOne(t, opts));
|
|
301
|
+
|
|
302
|
+
if (results.every(r => r.already)) {
|
|
303
|
+
const labels = results.map(r => targetConfig(r.target).label).join(' + ');
|
|
304
|
+
console.log(`hook: already installed (${labels})`);
|
|
305
|
+
process.exit(0);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Print status frame
|
|
309
|
+
console.log('');
|
|
310
|
+
console.log('┌─ feynman installed ──────────────────────────────────────────┐');
|
|
311
|
+
console.log(`│ hook: ${HOOK_PATH}`);
|
|
312
|
+
for (const result of results) {
|
|
313
|
+
const tc = targetConfig(result.target);
|
|
314
|
+
console.log(`│ target: ${tc.label}${result.already ? ' (already installed)' : ''}`);
|
|
315
|
+
console.log(`│ config: ${tc.settingsPath.replace(HOME, '~')}`);
|
|
316
|
+
console.log(`│ state: ${tc.statePath.replace(HOME, '~')}`);
|
|
317
|
+
console.log(`│ flag: ${tc.flagPath.replace(HOME, '~')}`);
|
|
318
|
+
}
|
|
319
|
+
console.log('└──────────────────────────────────────────────────────────────┘');
|
|
320
|
+
console.log('');
|
|
321
|
+
console.log('Restart Claude Code or Codex to activate feynman.');
|
|
322
|
+
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── Uninstall ────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function uninstallOne(target) {
|
|
329
|
+
const tc = targetConfig(target);
|
|
330
|
+
if (!fs.existsSync(tc.settingsPath)) {
|
|
331
|
+
if (fs.existsSync(tc.flagPath)) fs.unlinkSync(tc.flagPath);
|
|
332
|
+
return { target, missing: true };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const cfg = readSettings(target);
|
|
336
|
+
const hadHook = hasFeynmanHook(cfg);
|
|
337
|
+
removeFeynmanHooks(cfg);
|
|
338
|
+
writeSettings(target, cfg);
|
|
339
|
+
|
|
340
|
+
// Remove flag file (NOT state.json — user data per D-11)
|
|
341
|
+
if (fs.existsSync(tc.flagPath)) {
|
|
342
|
+
fs.unlinkSync(tc.flagPath);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { target, missing: false, hadHook };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cmdUninstall(opts) {
|
|
349
|
+
const results = targetNames(opts.target).map(uninstallOne);
|
|
350
|
+
const labels = results.map(r => targetConfig(r.target).label).join(' + ');
|
|
351
|
+
if (results.every(r => r.missing || !r.hadHook)) {
|
|
352
|
+
console.log(`feynman: no hook found for ${labels} — nothing to uninstall.`);
|
|
353
|
+
} else {
|
|
354
|
+
console.log(`feynman disabled for ${labels}. State preserved. Re-enable: npx feynman install --target ${opts.target}`);
|
|
355
|
+
}
|
|
356
|
+
process.exit(0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Doctor ───────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
function cmdDoctor(opts = {}) {
|
|
362
|
+
const target = opts.target || 'claude';
|
|
363
|
+
if (target === 'both') {
|
|
364
|
+
targetNames(target).forEach(t => cmdDoctor({ target: t, noExit: true }));
|
|
365
|
+
process.exit(0);
|
|
366
|
+
}
|
|
367
|
+
const tc = targetConfig(target);
|
|
368
|
+
const checks = [];
|
|
369
|
+
let failCount = 0;
|
|
370
|
+
|
|
371
|
+
function check(label, pass, info = false) {
|
|
372
|
+
const marker = info ? '[INFO]' : (pass ? '[OK] ' : '[FAIL]');
|
|
373
|
+
const colorFn = info ? c.dim : (pass ? c.green : c.red);
|
|
374
|
+
checks.push(colorFn(`${marker} ${label}`));
|
|
375
|
+
if (!info && !pass) failCount++;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 1. settings/hooks config exists
|
|
379
|
+
const settingsExists = fs.existsSync(tc.settingsPath);
|
|
380
|
+
check(`${tc.settingsPath.replace(HOME, '~')} present`, settingsExists);
|
|
381
|
+
|
|
382
|
+
// 2. UserPromptSubmit hook references feynman-activate.js
|
|
383
|
+
let hookRegistered = false;
|
|
384
|
+
let hookAbsPath = null;
|
|
385
|
+
if (settingsExists) {
|
|
386
|
+
const cfg = readSettings(target);
|
|
387
|
+
const entries = (cfg.hooks && cfg.hooks.UserPromptSubmit) || [];
|
|
388
|
+
const feynmanEntry = entries.find(g =>
|
|
389
|
+
g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-activate.js'))
|
|
390
|
+
);
|
|
391
|
+
hookRegistered = !!feynmanEntry;
|
|
392
|
+
if (feynmanEntry) {
|
|
393
|
+
// Extract the path from command: node "/abs/path/to/feynman-activate.js"
|
|
394
|
+
const hookCmd = feynmanEntry.hooks.find(h => h.command && h.command.includes('feynman-activate.js')).command;
|
|
395
|
+
const match = hookCmd.match(/"([^"]+feynman-activate\.js)"/);
|
|
396
|
+
if (match) hookAbsPath = match[1];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
check('hook registered (feynman-activate.js in UserPromptSubmit)', hookRegistered);
|
|
400
|
+
|
|
401
|
+
// 3. Hook script file exists + readable
|
|
402
|
+
let hookFileOk = false;
|
|
403
|
+
if (hookAbsPath) {
|
|
404
|
+
try {
|
|
405
|
+
fs.accessSync(hookAbsPath, fs.constants.R_OK);
|
|
406
|
+
hookFileOk = true;
|
|
407
|
+
} catch (_) {}
|
|
408
|
+
} else if (hookRegistered) {
|
|
409
|
+
// Hook registered but path extraction failed — check default location
|
|
410
|
+
try {
|
|
411
|
+
fs.accessSync(HOOK_PATH, fs.constants.R_OK);
|
|
412
|
+
hookFileOk = true;
|
|
413
|
+
} catch (_) {}
|
|
414
|
+
}
|
|
415
|
+
check('hook script file exists and is readable', hookFileOk);
|
|
416
|
+
|
|
417
|
+
// 4. Rules file exists + non-empty
|
|
418
|
+
let rulesOk = false;
|
|
419
|
+
try {
|
|
420
|
+
const stat = fs.statSync(RULES_PATH);
|
|
421
|
+
rulesOk = stat.size > 0;
|
|
422
|
+
} catch (_) {}
|
|
423
|
+
check('rules/feynman-activate.md exists and non-empty', rulesOk);
|
|
424
|
+
|
|
425
|
+
// 5. state.json valid JSON + has enabled field
|
|
426
|
+
let stateOk = false;
|
|
427
|
+
try {
|
|
428
|
+
const state = JSON.parse(fs.readFileSync(tc.statePath, 'utf8'));
|
|
429
|
+
stateOk = 'enabled' in state;
|
|
430
|
+
} catch (_) {}
|
|
431
|
+
check('state.json valid (has enabled field)', stateOk);
|
|
432
|
+
|
|
433
|
+
// 6. .feynman-active flag present
|
|
434
|
+
const flagPresent = fs.existsSync(tc.flagPath);
|
|
435
|
+
check('.feynman-active flag present', flagPresent);
|
|
436
|
+
|
|
437
|
+
// 7. (INFO) lint hook registered
|
|
438
|
+
let lintHookRegistered = false;
|
|
439
|
+
if (settingsExists) {
|
|
440
|
+
const cfg = readSettings(target);
|
|
441
|
+
// Check Stop hooks too
|
|
442
|
+
const allHookGroups = [
|
|
443
|
+
...((cfg.hooks && cfg.hooks.UserPromptSubmit) || []),
|
|
444
|
+
...((cfg.hooks && cfg.hooks.Stop) || []),
|
|
445
|
+
];
|
|
446
|
+
lintHookRegistered = allHookGroups.some(g =>
|
|
447
|
+
g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-lint.js'))
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const lintStatus = lintHookRegistered ? 'registered' : 'not registered (optional)';
|
|
451
|
+
check(`lint hook: ${lintStatus}`, true, true); // always INFO
|
|
452
|
+
|
|
453
|
+
// Compute frame width from longest stripped line (innerW = max content + 2 for "│ " and " │")
|
|
454
|
+
const strippedLines = checks.map(l => l.replace(/\x1b\[[0-9;]*m/g, ''));
|
|
455
|
+
const maxLen = Math.max(...strippedLines.map(l => l.length));
|
|
456
|
+
const innerW = Math.max(maxLen + 2, 48); // +2 for one space on each side; min 48
|
|
457
|
+
const titlePart = `feynman doctor ${target} `;
|
|
458
|
+
const border = '─'.repeat(innerW);
|
|
459
|
+
const topDashes = '─'.repeat(innerW - titlePart.length - 1);
|
|
460
|
+
console.log(`┌─ ${titlePart}${topDashes}┐`);
|
|
461
|
+
for (let i = 0; i < checks.length; i++) {
|
|
462
|
+
const stripped = strippedLines[i];
|
|
463
|
+
const pad = innerW - 1 - stripped.length; // 1 for leading space
|
|
464
|
+
console.log(`│ ${checks[i]}${' '.repeat(Math.max(0, pad))}│`);
|
|
465
|
+
}
|
|
466
|
+
console.log(`└${border}┘`);
|
|
467
|
+
|
|
468
|
+
const status = failCount === 0
|
|
469
|
+
? c.green('Status: OK')
|
|
470
|
+
: c.red(`Status: ISSUES (${failCount})`);
|
|
471
|
+
console.log(status);
|
|
472
|
+
|
|
473
|
+
if (!opts.noExit) process.exit(0);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ─── Lint (delegate to feynman-lint.js) ──────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
function cmdLint(args) {
|
|
479
|
+
// Re-invoke feynman-lint.js in-process by overriding process.argv
|
|
480
|
+
// and calling it via require (D-04: delegate by require, NOT spawn)
|
|
481
|
+
const lintArgs = args.filter(a => a !== '--help');
|
|
482
|
+
if (args.includes('--help') || lintArgs.length === 0) {
|
|
483
|
+
console.log(LINT_HELP);
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Override argv and invoke lint module
|
|
488
|
+
process.argv = ['node', 'feynman-lint.js', ...lintArgs];
|
|
489
|
+
require('./feynman-lint.js');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── Version ──────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
function cmdVersion(args) {
|
|
495
|
+
if (args.includes('--help')) {
|
|
496
|
+
console.log(VERSION_HELP);
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
console.log(VERSION);
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ─── Help ─────────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
function cmdHelp() {
|
|
506
|
+
console.log(HELP);
|
|
507
|
+
process.exit(0);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Dispatch ─────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
const argv = process.argv.slice(2);
|
|
513
|
+
const sub = argv[0];
|
|
514
|
+
const rest = argv.slice(1);
|
|
515
|
+
|
|
516
|
+
// Top-level --help / -h / no args
|
|
517
|
+
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
518
|
+
if (!sub) {
|
|
519
|
+
// No args → help + exit 2
|
|
520
|
+
console.log(HELP);
|
|
521
|
+
process.exit(2);
|
|
522
|
+
}
|
|
523
|
+
cmdHelp();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
switch (sub) {
|
|
527
|
+
case 'install': {
|
|
528
|
+
if (rest.includes('--help')) { console.log(INSTALL_HELP); process.exit(0); }
|
|
529
|
+
const parsed = parseTarget(rest);
|
|
530
|
+
const force = parsed.args.includes('--force');
|
|
531
|
+
cmdInstall({ force, target: parsed.target });
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case 'uninstall': {
|
|
535
|
+
if (rest.includes('--help')) { console.log(UNINSTALL_HELP); process.exit(0); }
|
|
536
|
+
const parsed = parseTarget(rest);
|
|
537
|
+
cmdUninstall({ target: parsed.target });
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 'doctor': {
|
|
541
|
+
if (rest.includes('--help')) { console.log(DOCTOR_HELP); process.exit(0); }
|
|
542
|
+
const parsed = parseTarget(rest);
|
|
543
|
+
cmdDoctor({ target: parsed.target });
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
case 'lint': {
|
|
547
|
+
cmdLint(rest);
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'version': {
|
|
551
|
+
cmdVersion(rest);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
default: {
|
|
555
|
+
console.error(`feynman: unknown subcommand '${sub}'`);
|
|
556
|
+
console.log(HELP);
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// feynman — UserPromptSubmit hook — injects ASCII diagram rules on every prompt
|
|
3
|
+
// Zero dependencies. CJS only (no ESM — CommonJS for zero-dep portability).
|
|
4
|
+
// Bug workarounds: #13912 (JSON stdout), #35713 (flag file), #8810 (os.homedir), #10225 (no hooks in plugin.json)
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// Path constants — use os.homedir(), never tilde strings (bug #8810).
|
|
12
|
+
// FEYNMAN_HOME lets the same hook serve Claude Code (~/.claude) and Codex (~/.codex).
|
|
13
|
+
const HOME = os.homedir();
|
|
14
|
+
const CLIENT_HOME = process.env.FEYNMAN_HOME || path.join(HOME, '.claude');
|
|
15
|
+
const FEYNMAN_DIR = path.join(CLIENT_HOME, '.feynman');
|
|
16
|
+
const STATE_PATH = path.join(FEYNMAN_DIR, 'state.json');
|
|
17
|
+
const FLAG_PATH = path.join(CLIENT_HOME, '.feynman-active');
|
|
18
|
+
const RULES_PATH = path.join(__dirname, '..', 'rules', 'feynman-activate.md');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_STATE = { enabled: true, intensity: 'full', injections: 0 };
|
|
21
|
+
|
|
22
|
+
// --- stdin accumulator: buffer all input before processing ---
|
|
23
|
+
let input = '';
|
|
24
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
25
|
+
process.stdin.on('end', () => {
|
|
26
|
+
try {
|
|
27
|
+
const data = JSON.parse(input);
|
|
28
|
+
|
|
29
|
+
// Step 1: session_id path-traversal guard (verified from gsd-context-monitor.js)
|
|
30
|
+
const sessionId = data.session_id || '';
|
|
31
|
+
if (sessionId && /[/\\]|\.\./.test(sessionId)) process.exit(0);
|
|
32
|
+
|
|
33
|
+
// Step 2: flag file + first-run bootstrap (D-05, D-07, bug #35713)
|
|
34
|
+
// True first run: neither flag nor state exists → bootstrap both, then fall through
|
|
35
|
+
// Intentionally disabled: flag absent but state exists → exit 0 (user ran /feynman off)
|
|
36
|
+
const flagExists = fs.existsSync(FLAG_PATH);
|
|
37
|
+
const stateExists = fs.existsSync(STATE_PATH);
|
|
38
|
+
if (!flagExists) {
|
|
39
|
+
if (!stateExists) {
|
|
40
|
+
// First install — bootstrap everything and activate
|
|
41
|
+
fs.mkdirSync(FEYNMAN_DIR, { recursive: true });
|
|
42
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(DEFAULT_STATE, null, 2));
|
|
43
|
+
fs.writeFileSync(FLAG_PATH, DEFAULT_STATE.intensity);
|
|
44
|
+
} else {
|
|
45
|
+
process.exit(0); // disabled intentionally by user
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Step 3: read state.json
|
|
50
|
+
let state;
|
|
51
|
+
try {
|
|
52
|
+
state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Corrupt state — fail safe per D-07
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Step 4: enabled check
|
|
59
|
+
if (!state.enabled) process.exit(0);
|
|
60
|
+
|
|
61
|
+
// Step 5: read rules file and extract the correct intensity variant (D-01, D-02, HOOK-04)
|
|
62
|
+
let rulesText;
|
|
63
|
+
try {
|
|
64
|
+
const rulesContent = fs.readFileSync(RULES_PATH, 'utf8');
|
|
65
|
+
const validIntensities = ['lite', 'full', 'ultra'];
|
|
66
|
+
const intensity = validIntensities.includes(state.intensity) ? state.intensity : 'full';
|
|
67
|
+
const openMarker = '<!-- ' + intensity + ' -->';
|
|
68
|
+
const closeMarker = '<!-- /' + intensity + ' -->';
|
|
69
|
+
const i1 = rulesContent.indexOf(openMarker);
|
|
70
|
+
const i2 = rulesContent.indexOf(closeMarker, i1);
|
|
71
|
+
if (i1 === -1 || i2 === -1) process.exit(0); // marker not found — malformed rules file
|
|
72
|
+
rulesText = rulesContent.slice(i1 + openMarker.length, i2).trim();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Rules file missing — self-heal silently (pitfall 6 in RESEARCH.md)
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!rulesText) process.exit(0);
|
|
79
|
+
|
|
80
|
+
// Step 6: increment injection counter and write state back (HOOK-05)
|
|
81
|
+
// Backward-compat: read legacy count field on first migration cycle
|
|
82
|
+
state.injections = (state.injections ?? state.count ?? 0) + 1;
|
|
83
|
+
delete state.count;
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Counter write failure is non-fatal — still inject rules
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Step 7: output rules as additionalContext (bug #13912 — JSON only, no trailing newline)
|
|
91
|
+
// Plain text stdout triggers "UserPromptSubmit hook error" red banner in Claude Code UI
|
|
92
|
+
process.stdout.write(JSON.stringify({
|
|
93
|
+
hookSpecificOutput: {
|
|
94
|
+
hookEventName: 'UserPromptSubmit',
|
|
95
|
+
additionalContext: rulesText
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Silent fail — never surface hook errors in Claude Code UI
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
});
|