Skip to content

Bug: Hooks migration silently drops subdirectories #9

@rexplx

Description

@rexplx

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

  1. 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
  1. Run dotagents:
npx @iannuttall/dotagents
  1. Select Global scope, include Claude, proceed through migration

  2. Check the result:

ls ~/.agents/hooks/
# Only test.sh exists — dist/ directory is MISSING

Expected 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.


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions