Skip to content
Documentation
Hook

Hooks

What filetree runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
filetree
1621 skill4 commands1 hook
Install
> /plugin marketplace add nekocode/filetree-skill
> /plugin install filetree@filetree

Ships with filetree. Installing the plugin gets these hooks.

What fires, and when

PreToolUse

  • MatchesBashsh -c 'p=$(cat); case "$p" in *commit*) printf "%s" "$p" | python3 "${CLAUDE_PLUGIN_ROOT}/hooks/commit_guard.py" ;; esac'
Read hooks/hooks.json

Where it lives

  • hooks/commit_guard.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """commit_guard.py — PreToolUse hook that blocks a stale-manifest commit.
    
    Wired by hooks/hooks.json on `PreToolUse` / matcher `Bash`. The flow:
    
        Claude runs `git commit`  ->  this guard runs `filetree.py lint`
          lint clean (exit 0)      ->  allow the commit
          lint drift (exit 1)      ->  deny, telling Claude to /filetree:update first
    
    A hook can only run shell, never an LLM skill. So the guard cannot *fix* the
    manifest; it blocks and hands the fix back to Claude, who runs the update skill
    and retries the commit. lint is the deterministic gate, update is the repair.
    
    Two deliberate properties:
      * Opt-in. No-op unless `.filetree.json` sets `"commit_guard": true`. Installing
        the plugin must never silently gate anyone's commits.
      * Fail-open. Any malfunction (bad stdin, non-git tree, broken config, lint
        crash) allows the commit. A guard bug must never wedge the user's workflow.
    
    Only fires for commits Claude issues through the Bash tool — a manual `git
    commit` in the user's own terminal never reaches a Claude Code hook.
    
    Two-stage filter, to keep this off the Bash hot path. A PreToolUse matcher can
    only match the tool *name* (Bash), not the command, so the hook would otherwise
    start python on every Bash call. hooks.json does a coarse shell pre-filter first
    (only a payload containing "commit" ever reaches python); this script then does
    the precise `git commit` check. So a non-commit Bash call never starts python.
    """
    
    from __future__ import annotations
    
    import json
    import os
    import re
    import shlex
    import subprocess
    import sys
    from pathlib import Path
    
    # scripts/ is not a package; put it on the path so the one config parser is reused
    # (never re-implement .filetree.json reading — see filetree_config.load_config).
    PLUGIN_ROOT = Path(__file__).resolve().parent.parent
    SCRIPTS_DIR = PLUGIN_ROOT / 'skills' / 'filetree' / 'scripts'
    sys.path.insert(0, str(SCRIPTS_DIR))
    
    LINT_SCRIPT = SCRIPTS_DIR / 'filetree.py'
    
    import filetree_config  # noqa: E402 — must follow the sys.path.insert above
    
    # Shell operators that separate one command from the next. We test each segment
    # independently so `git add . && git commit -m x` is recognized via its 2nd segment.
    _SEGMENT_SPLIT = re.compile(r'&&|\|\||[;\n|&]')
    
    # git global options (before the subcommand) that consume the FOLLOWING token as
    # their value; we must skip both to reach the subcommand. The `--opt=value` form
    # is a single token handled by the generic `startswith('-')` branch.
    _VALUE_OPTS = {'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix'}
    
    # Benign command wrappers that may precede `git`; skipped when finding the command
    # word so `env FOO=bar git commit` is still seen as a commit.
    _WRAPPERS = {'sudo', 'command', 'nice', 'env', 'time', 'nohup', 'stdbuf'}
    
    _ASSIGNMENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
    
    DENY_REASON = (
        'FILETREE.md is stale (filetree drift detected). Before committing, run '
        '/filetree:update to refresh the manifest, `git add` the regenerated '
        'FILETREE.md and FILETREE.hash.json, then retry the commit. '
        '(To turn off this gate, set "commit_guard": false in .filetree.json.)'
    )
    
    
    def _command_word_index(tokens: list[str]) -> int:
        """Index of a segment's real command word, skipping leading `VAR=val`
        assignments and benign wrappers (sudo/env/...). len(tokens) if none."""
        i = 0
        while i < len(tokens):
            tok = tokens[i]
            if _ASSIGNMENT.match(tok) or tok in _WRAPPERS:
                i += 1
                continue
            return i
        return i
    
    
    def _segment_is_git_commit(segment: str) -> bool:
        """True if a single command segment is a `git commit` invocation."""
        try:
            tokens = shlex.split(segment)
        except ValueError:
            # Unbalanced quotes etc. — fall back to a loose token split so an odd
            # command still gets a best-effort check rather than silently passing.
            tokens = segment.split()
        start = _command_word_index(tokens)
        if start >= len(tokens) or os.path.basename(tokens[start]) != 'git':
            return False
        i = start + 1
        # Walk past global options to the subcommand.
        while i < len(tokens):
            tok = tokens[i]
            if tok in _VALUE_OPTS:
                i += 2
                continue
            if tok.startswith('-'):
                i += 1
                continue
            return tok == 'commit'
        return False
    
    
    def is_git_commit(command: str) -> bool:
        """True if any segment of a (possibly compound) shell command is `git commit`."""
        return any(_segment_is_git_commit(seg) for seg in _SEGMENT_SPLIT.split(command))
    
    
    def _repo_root() -> str | None:
        """Repo top-level path, or None if not inside a git work tree."""
        try:
            out = subprocess.run(
                ['git', 'rev-parse', '--show-toplevel'],
                check=True, capture_output=True, encoding='utf-8',
            ).stdout.strip()
        except (subprocess.CalledProcessError, FileNotFoundError):
            return None
        return out or None
    
    
    def _lint_drift() -> bool:
        """Run `filetree.py lint`; True only on its exit-1 drift signal. Any other
        code (0 clean, 2+ crash) is treated as no-block to honor fail-open."""
        proc = subprocess.run(
            [sys.executable, str(LINT_SCRIPT), 'lint'],
            capture_output=True, encoding='utf-8',
        )
        return proc.returncode == 1
    
    
    def decide(raw_stdin: str) -> dict | None:
        """Map a PreToolUse stdin payload to a deny dict, or None to allow.
    
        None means allow at every off-ramp (not a commit, not git, guard disabled,
        manifest clean, or anything went wrong) — fail-open by construction.
    
        Side effect: on a guarded git commit this chdirs to the repo root, since
        both load_config (relative .filetree.json) and lint resolve from there. The
        hook runs as a short-lived subprocess, so the cwd change dies with it.
    
        The config+lint stage is wrapped in a catch-all that swallows BOTH SystemExit
        (load_config exits on a malformed .filetree.json) and ordinary

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withfiletree

A Claude Code plugin that maintains FILETREE.md — a one-line description per file with content hashes for staleness detection. Lets the LLM grasp repo layout in a few hundred tokens before touching code.

Get the whole plugin
Stats
162
Stars
13
Forks
Maintained
Maintenance
Python
Language
3mo ago
Last commit
4mo ago
Created
2h ago
Added

Repo: nekocode/filetree-skill