affaan-m/ECC · error · Error
Invalid hooks config at ${hooksSourcePath}: expected "hooks"
Error message
Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object What it means
Thrown by buildResolvedClaudeHooks in scripts/lib/install/apply.js. This function runs only when plan.adapter.target is 'claude' or 'claude-project'. It reads the source hooks.json, substitutes ${CLAUDE_PLUGIN_ROOT} placeholders via replacePluginRootPlaceholders, then requires the resulting hooks field to be a non-null, non-array object (matching Claude Code's hooks schema). If not, the installer refuses to write the hooks file.
Source
Thrown at scripts/lib/install/apply.js:247
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
return null;
}
const pluginRoot = plan.targetRoot;
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
if (!hooksOperation) {
return null;
}
const hooksSourcePath = hooksOperation.sourcePath;
if (!fs.existsSync(hooksSourcePath)) {
return null;
}
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
}
return {
hooksOperation,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
hooks: resolvedHooks,
},
};
}
function previewInstallPlan(plan) {
const migration = prepareClaudeSkillMigration(plan);
return {
...plan,
statePreview: migration.finalState,
plannedOperations: [...plan.operations],View on GitHub (pinned to 01e15490f0)
Solutions
- Open the file at hooksSourcePath and verify the top level has "hooks": { ... } (object).
- Restore the file from git: git checkout -- hooks/hooks.json.
- Validate against Claude Code's hooks schema before re-running.
- Confirm no pre-processor (e.g. placeholder substitution) is returning undefined when pluginRoot is empty.
Example fix
// before (hooks/hooks.json)
{
"hooks": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo hi" } ] }
]
}
// after
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "echo hi" } ] }
]
}
} Defensive patterns
Strategy: type-guard
Validate before calling
const cfg = JSON.parse(fs.readFileSync('hooks/hooks.json', 'utf8'));
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
throw new Error('hooks.json top level must be an object');
}
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
throw new Error('hooks.json must have a "hooks" object');
} Type guard
function isHooksConfig(value) {
return Boolean(
value && typeof value === 'object' && !Array.isArray(value)
&& value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)
);
} Prevention
- Treat hooks/hooks.json as schema-locked; never change its top-level shape.
- Add a pre-commit hook that validates the hooks file structure.
- Run git diff on hooks/hooks.json before any release.
When it happens
Trigger: ECC's bundled hooks/hooks.json (or a fork's equivalent) has its hooks field set to an array, null, or undefined after placeholder substitution; or a downstream fork restructured hooks as a list.
Common situations: Manual edit of the bundled hooks file; a bad merge that wrapped hooks in an array; a fork that switched to a list-based representation without updating the applier.
Related errors
- Failed to parse ${label} at ${filePath}: ${error.message}
- Invalid Claude hooks preference: ${claudeHooks}
- Path traversal rejected: ${relPath}
- Path traversal rejected: ${relPath}
- File path contains unsafe shell characters
AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13).
Data as JSON: /api/errors/fb375e6905ba4687.
Report an issue: GitHub.