Skip to content

timhanlon/cached-run

Repository files navigation

cached-run

Cache expensive verification commands so coding agents can't run the same check twice for the same source state.

Install

npm install -D cached-run

Works with any package manager (pnpm, yarn, bun); the examples below use npm.

Usage

cached-run run -- npm run typecheck
cached-run run --shell "npm run typecheck"
cached-run explain --shell "npm run typecheck"
# or one quoted argument (argv often contains flags the CLI would misread)
cached-run explain "npm run typecheck"
cached-run clean --older-than 7d

Claude Code hook

Add to .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "npx cached-run hook claude"
          }
        ]
      }
    ]
  }
}

Matching Bash commands are rewritten to node <absolute-path-to-this-cli> run -- … using the same binary that ran the hook (no PATH or extra config required). Optional binary in config overrides that invocation.

Guarantee

  • Same command + same source state → cached result (original exit code)
  • Changed source state → fresh run
  • Cache entry older than ttl (default 5m) → fresh run
  • Full logs kept on disk under .cache/cached-run (or CACHED_RUN_CACHE_DIR)
  • --force or CACHED_RUN_FORCE=1 bypasses cache

How it works

On each run, cached-run derives a key from two things: the command and the state of your source tree.

The command — the parsed argv, your config (cache dir and command patterns), and cached-run's own version. Upgrading the tool or editing config invalidates earlier entries.

The source state, read from git:

  • the current HEAD commit
  • a diff of every tracked file against HEAD, so uncommitted edits count
  • a content hash of every untracked file that isn't gitignored

These are folded into a SHA-256 digest (truncated to a short hex key). Two runs share a key only when the command and every byte of relevant source match.

From there:

  • Miss — the command runs. Combined stdout and stderr are captured to a log under cacheDir, with metadata (exit code, duration, error count, timestamp). The command's real exit code is returned.
  • Hit — the key matches an entry within ttl, so the command is skipped entirely; cached-run prints a summary and returns the original exit code.

Only commands matching a configured pattern are cached; the cache is just files on disk under cacheDir, with no daemon or global state.

Through the Claude Code hook

The hook is a PreToolUse rewriter, not a wrapper — it never runs your command or touches the cache itself. On each Bash tool call, Claude Code pipes the pending command to cached-run hook claude, which:

  1. Parses the command and matches it against your configured patterns.
  2. If it doesn't match — or can't be parsed safely (see Safety) — exits silently, and Claude runs the original command untouched.
  3. If it matches, emits a PreToolUse decision that rewrites the command to <cli> run -- <command>, reusing the exact binary that ran the hook (so there's no PATH dependency; binary in config can override it).

Claude then runs that rewritten command, and the caching above takes over.

Config

Create cached-run.config.json (or .mjs / .js / .ts):

{
  "cacheDir": ".cache/cached-run",
  "ttl": "5m",
  "commands": [
    "pnpm -r typecheck",
    "pnpm --filter * typecheck"
  ]
}

Each command is a shell string. * matches exactly one whitespace-separated segment (e.g. a package name); a pattern must match the whole command.

Safety

Conservative by design: redirects, &&, env prefixes, subshells, and already-wrapped commands pass through unchanged. Simple matching commands rewrite to cached-run run -- …. If the agent adds a single output pipe (| tail, | head, | grep), the hook prefixes cached-run and keeps the pipe: cached-run run -- npm test | tail -100. Exit codes through pipes are best-effort.

Caveats

  • cached-run captures stdout and stderr together into one combined log file.
  • On a cache hit, cached-run returns the original exit code and prints a short summary. It does not replay the cached command output to stdout.
  • Because cache hits print the summary instead of the original output, a trailing pipe such as | tail -50 sees the cached-run summary on cache hits, not the command log.
  • Use the LOG: path from the summary to inspect the full cached output.

About

Cache expensive verification commands so coding agents do not rerun them for the same source state.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors