Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: test

on:
push:
branches: [main]
pull_request:

jobs:
hooks:
name: hooks (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Windows is included on purpose: hook commands run through Git Bash
# there, which is where the PowerShell-in-a-bash-shell bug surfaced.
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 22+: the test script relies on node's own --test glob expansion,
# since cmd.exe (npm's default script shell on Windows) can't glob.
node-version: 22
- name: Run tests
run: npm test
shell: bash
71 changes: 47 additions & 24 deletions bin/fableit.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ const OPENCODE_CONFIG = path.join(getOpencodeDir(), 'opencode.json');

// An entry/value is ours iff it references the installed copy — never match by
// the bare substring 'fableit', which could appear in a user's own paths.
const ownMarker = path.join(DEST, path.sep === '\\' ? 'hooks\\' : 'hooks/');
const isOurs = v => JSON.stringify(v).includes(JSON.stringify(ownMarker).slice(1, -1));
const norm = s => String(s).replace(/[\\/]+/g, '/');
const ownMarker = norm(path.join(DEST, 'hooks')) + '/';
const isOurs = v => norm(JSON.stringify(v)).includes(ownMarker);

function fatal(msg) {
console.error('fableit: ' + msg);
Expand Down Expand Up @@ -73,9 +74,11 @@ function hookEntries() {
const entry = {
hooks: hooks.map(h => ({
type: h.type,
command: (process.platform === 'win32' && h.commandWindows
? h.commandWindows.replace(/\$env:CLAUDE_PLUGIN_ROOT/g, DEST)
: h.command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, DEST)),
// hooks.json is the single source of truth and its `command` is bash;
// Claude Code runs every hook through bash (Git Bash on Windows), so
// substitute the plugin-root placeholder and normalise to '/'. node
// accepts forward slashes on Windows, keeping the command valid bash.
command: h.command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, norm(DEST)),
timeout: h.timeout,
})),
};
Expand All @@ -86,6 +89,25 @@ function hookEntries() {
return entries;
}

// Reconcile our hooks within a settings object: drop any previous fableit
// entries (including a stale or broken one written by an older version) and
// add the current correct ones. Makes re-running install idempotent and
// self-healing, so a user never has to hand-remove a bad entry.
function reconcileClaudeHooks(settings, desired) {
settings.hooks = settings.hooks || {};
for (const [event, entries] of Object.entries(desired)) {
const kept = (settings.hooks[event] || []).filter(e => !isOurs(e));
settings.hooks[event] = [...kept, ...entries];
}
// Also strip our entries from events we no longer register.
for (const event of Object.keys(settings.hooks)) {
if (desired[event]) continue;
settings.hooks[event] = settings.hooks[event].filter(e => !isOurs(e));
if (!settings.hooks[event].length) delete settings.hooks[event];
}
return settings;
}

function installClaude() {
copyPackage();

Expand All @@ -97,11 +119,7 @@ function installClaude() {
fs.copyFileSync(path.join(SRC, 'SKILL.md'), path.join(SKILLS_DIR, 'fableit', 'SKILL.md'));

const settings = readConfigOrDie(SETTINGS, 'Claude settings');
settings.hooks = settings.hooks || {};
for (const [event, entries] of Object.entries(hookEntries())) {
const list = (settings.hooks[event] = settings.hooks[event] || []);
if (!list.some(isOurs)) list.push(...entries);
}
reconcileClaudeHooks(settings, hookEntries());
// Statusline badge — only if the user has none; never clobber an existing one.
if (!settings.statusLine && process.platform !== 'win32') {
settings.statusLine = {
Expand Down Expand Up @@ -159,18 +177,23 @@ function uninstall() {
console.log('fableit uninstalled (Claude Code hooks, skill, flag, OpenCode entry, installed copy).');
}

const cmd = process.argv[2] || 'claude';
switch (cmd) {
case 'claude':
case 'install':
installClaude(); break;
case 'opencode':
installOpencode(); break;
case 'print':
console.log(getFableitInstructions(process.argv[3] || 'full')); break;
case 'uninstall':
uninstall(); break;
default:
console.log('usage: npx fableit [claude|opencode|print [lite|full|ultra]|uninstall]');
process.exit(1);
if (require.main === module) {
const cmd = process.argv[2] || 'claude';
switch (cmd) {
case 'claude':
case 'install':
installClaude(); break;
case 'opencode':
installOpencode(); break;
case 'print':
console.log(getFableitInstructions(process.argv[3] || 'full')); break;
case 'uninstall':
uninstall(); break;
default:
console.log('usage: npx fableit [claude|opencode|print [lite|full|ultra]|uninstall]');
process.exit(1);
}
}

// Exported for tests; the CLI above only runs when the file is executed directly.
module.exports = { hookEntries, reconcileClaudeHooks, isOurs };
3 changes: 0 additions & 3 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fableit-activate.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\fableit-activate.js\" }",
"timeout": 5,
"statusMessage": "Loading fableit mode..."
}
Expand All @@ -20,7 +19,6 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fableit-subagent.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\fableit-subagent.js\" }",
"timeout": 5,
"statusMessage": "Loading fableit mode..."
}
Expand All @@ -33,7 +31,6 @@
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fableit-mode-tracker.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\fableit-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking fableit mode..."
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"opencode.json"
],
"scripts": {
"test": "node --test tests/*.test.js",
"test": "node --test \"tests/*.test.js\"",
"publish:both": "npm publish && npm pkg set name=fableit && npm publish && npm pkg set name=@seedexr/fableit"
},
"publishConfig": {
Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/hooks.broken.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fableit-mode-tracker.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\fableit-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking fableit mode..."
}
]
}
]
}
}
141 changes: 141 additions & 0 deletions tests/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use strict';

// fableit — hooks.json integrity + hook-script behaviour.
//
// Why this file exists: Claude Code runs every hook command through bash on
// ALL platforms (including Windows/Git Bash). A hook whose command isn't valid
// bash, or a UserPromptSubmit hook that exits non-zero, silently BLOCKS every
// prompt. That is exactly the regression this suite guards against. See the
// self-check at the bottom, which asserts the guard actually has teeth against
// a known-bad fixture.

const test = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, execSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const ROOT = path.resolve(__dirname, '..');
const HOOKS_DIR = path.join(ROOT, 'hooks');
const HOOKS_JSON = path.join(HOOKS_DIR, 'hooks.json');

// Fields that Claude Code will hand to a shell. `commandWindows` is a legacy
// field some hosts pick up on Windows; if present it is ALSO run through bash,
// so it must be valid bash too. Validate every one of them.
const SHELL_FIELDS = ['command', 'commandWindows'];

function bashAvailable() {
try { execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' }); return true; }
catch { return false; }
}

// Returns a list of human-readable problems. Empty list = the file is safe.
function validateHooksFile(file) {
const problems = [];
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
} catch (e) {
return [`invalid JSON: ${e.message}`];
}
const events = parsed && parsed.hooks;
if (!events || typeof events !== 'object') return ['missing top-level "hooks" object'];

for (const [event, groups] of Object.entries(events)) {
if (!Array.isArray(groups)) { problems.push(`${event}: expected an array`); continue; }
groups.forEach((group, gi) => {
const handlers = group && group.hooks;
if (!Array.isArray(handlers)) { problems.push(`${event}[${gi}]: missing "hooks" array`); return; }
handlers.forEach((h, hi) => {
const where = `${event}[${gi}].hooks[${hi}]`;
if (h.type !== 'command') { problems.push(`${where}: type must be "command"`); return; }
if (typeof h.command !== 'string' || !h.command.trim()) {
problems.push(`${where}: "command" must be a non-empty string`);
}
// Every shell-bearing field must parse as bash. This is the check that
// catches PowerShell (or any non-bash) syntax like `if (...) { }`.
for (const field of SHELL_FIELDS) {
if (typeof h[field] !== 'string') continue;
try {
execFileSync('bash', ['-n', '-c', h[field]], { stdio: 'pipe' });
} catch (e) {
const msg = (e.stderr ? e.stderr.toString() : e.message).trim().split('\n').pop();
problems.push(`${where}.${field}: not valid bash -> ${msg}`);
}
}
// The referenced script must exist in hooks/.
const m = /([A-Za-z0-9_.-]+\.js)/.exec(h.command || '');
if (m && !fs.existsSync(path.join(HOOKS_DIR, m[1]))) {
problems.push(`${where}: references missing script hooks/${m[1]}`);
}
});
});
}
return problems;
}

// Run a hook script with a mock stdin payload in an isolated config dir, so we
// never touch the developer's real ~/.claude/.fableit-active flag.
function runHook(script, stdin) {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'fableit-test-'));
try {
const out = execSync(`node "${path.join(HOOKS_DIR, script)}"`, {
input: stdin,
env: { ...process.env, CLAUDE_CONFIG_DIR: sandbox, FABLEIT_DEFAULT_MODE: 'full' },
stdio: ['pipe', 'pipe', 'pipe'],
});
return { code: 0, stdout: out.toString() };
} catch (e) {
return { code: e.status == null ? 1 : e.status, stdout: (e.stdout || '').toString() };
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
}

test('bash is available to run the shell-syntax checks', () => {
assert.ok(bashAvailable(),
'bash not found on PATH. Claude Code runs hooks through bash, and so does ' +
'this test. On Windows, run via Git Bash (bundled with Git for Windows).');
});

test('hooks.json is present and valid (JSON, shape, bash-parseable, scripts exist)', () => {
assert.ok(fs.existsSync(HOOKS_JSON), 'hooks/hooks.json is missing');
const problems = validateHooksFile(HOOKS_JSON);
assert.deepEqual(problems, [], 'hooks.json problems:\n' + problems.join('\n'));
});

test('UserPromptSubmit hook never blocks a prompt (always exits 0)', () => {
const cases = [
'{"prompt":"/fableit ultra"}', // a real mode switch
'{"prompt":"how do I center a div"}', // an ordinary prompt
'{"prompt":""}', // empty prompt field
'this is not json at all', // garbage stdin
'', // empty stdin
];
for (const stdin of cases) {
const { code } = runHook('fableit-mode-tracker.js', stdin);
assert.equal(code, 0, `mode-tracker exited ${code} on stdin: ${JSON.stringify(stdin)}`);
}
});

test('mode switch emits guidance to stdout', () => {
const { code, stdout } = runHook('fableit-mode-tracker.js', '{"prompt":"/fableit ultra"}');
assert.equal(code, 0);
assert.ok(stdout.trim().length > 0, 'expected a ruleset/directive on stdout for a mode switch');
});

test('SessionStart and SubagentStart hooks exit 0', () => {
assert.equal(runHook('fableit-activate.js', '{}').code, 0, 'activate must exit 0');
assert.equal(runHook('fableit-subagent.js', '{}').code, 0, 'subagent must exit 0');
});

// Self-check: the guard must actually reject the pre-fix file. Without this,
// a validator that silently passes everything would look "green" forever.
test('validator rejects the known-bad fixture (PowerShell command run via bash)', () => {
const bad = path.join(__dirname, 'fixtures', 'hooks.broken.json');
const problems = validateHooksFile(bad);
assert.ok(problems.length > 0, 'expected the broken fixture to be rejected');
assert.ok(problems.some(p => /commandWindows/.test(p) && /not valid bash/.test(p)),
'expected a bash-syntax rejection on the commandWindows field, got:\n' + problems.join('\n'));
});
Loading
Loading