-
-
Notifications
You must be signed in to change notification settings - Fork 26
Description
Bug: Hooks migration silently drops subdirectories
Description
When running npx @iannuttall/dotagents with a hooks directory that contains subdirectories (e.g., dist/, src/, node_modules/), only top-level files are copied to ~/.agents/hooks/. All subdirectories are silently dropped, breaking any hooks setup that relies on compiled output or npm dependencies.
Environment
- dotagents version: Latest via npm (installed Jan 30, 2026)
- OS: macOS 15.3 (Sequoia)
- Node: v22.x
- Scope: Global (
~/.agents/)
Steps to Reproduce
- Create a hooks directory with subdirectories:
mkdir -p ~/.claude/hooks/{dist,src,node_modules}
echo 'export default {}' > ~/.claude/hooks/dist/test-hook.mjs
echo 'console.log("test")' > ~/.claude/hooks/test.sh- Run dotagents:
npx @iannuttall/dotagents-
Select Global scope, include Claude, proceed through migration
-
Check the result:
ls ~/.agents/hooks/
# Only test.sh exists — dist/ directory is MISSINGExpected Behavior
All contents of ~/.claude/hooks/ should be migrated to ~/.agents/hooks/, including subdirectories.
Actual Behavior
Only top-level files are copied. Subdirectories (dist/, src/, node_modules/, .git/, etc.) are silently dropped with no warning.
Root Cause
Click to expand code analysis
In src/core/migrate.ts, the listFiles() function explicitly filters out directories:
// migrate.ts lines 42-49
async function listFiles(dir: string): Promise {
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => path.join(dir, e.name));
// ^^^^^^^^^^^^ Only files, directories excluded
} catch {
return [];
}
}This is then used in the hooks migration loop:
// migrate.ts lines 136-143
for (const src of sources.hooks) {
if (!await pathExists(src.dir) || await isSymlink(src.dir)) continue;
const files = await listFiles(src.dir); // ← Only gets top-level files
for (const file of files) {
const targetPath = path.join(canonicalHooks, path.basename(file));
addCandidate({ label: src.label, targetPath, kind: 'file', action: 'copy', sourcePath: file });
}
}Impact
After migration, ~/.claude/hooks/ becomes a symlink to ~/.agents/hooks/. If settings.json references hooks in subdirectories:
{
"hooks": {
"PreToolUse": [
{ "command": "node ~/.claude/hooks/dist/my-hook.mjs" }
]
}
}This causes immediate failures:
Error: Cannot find module '/Users/user/.claude/hooks/dist/my-hook.mjs'
code: 'MODULE_NOT_FOUND'
All Claude Code sessions become unusable until manually fixed.
Suggested Fix
Option A (Recommended): Copy entire hooks directory recursively instead of file-by-file, similar to how skills directories are handled.
Option B: Add directory enumeration:
async function listEntries(dir: string): Promise {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
return {
files: entries.filter((e) => e.isFile()).map((e) => path.join(dir, e.name)),
dirs: entries.filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name))
};
}Workaround
Manually restore from the dotagents backup:
cp -r ~/.agents/backup//Users//.claude/hooks/dist ~/.agents/hooks/
cp -r ~/.agents/backup//Users//.claude/hooks/src ~/.agents/hooks/
cp -r ~/.agents/backup//Users//.claude/hooks/node_modules ~/.agents/hooks/Additional Context
The backup system works correctly — all data is preserved in ~/.agents/backup/<timestamp>/. The issue is purely in the migration logic.