From a64a10a68e67a7f33c93f5b8799aaae70c9c73fb Mon Sep 17 00:00:00 2001 From: Daniel Poelzleithner Date: Thu, 20 Aug 2026 21:06:55 +0200 Subject: [PATCH] feat: ship hooks as share/codedb/install-hooks.sh + add nix flake Hook setup used to live as an inline python heredoc inside install.sh, so it only ever ran during a curl install and could not be re-run, inspected, or fixed without re-downloading the installer. - install/install-hooks.sh: the hook installer as a standalone, packaged script. Carries the existing Claude Code logic verbatim (CODEDB_NO_HOOKS / ~/.codedb/no-hooks opt-out, the #658 deliberate-removal receipt, repo- scoped block-legacy guard, competitor-aware hook merge, additive mcp__codedb__* permission) and adds the Codex codedb_remote guard plus features.codex_hooks that docs/hooks-labs.md previously left to the user. - Generated hooks now bake in the resolved codedb path instead of relying on `command -v codedb`; hook subshells inherit a minimal PATH, which misses ~/bin and nix store paths. - build.zig installs it to share/codedb/install-hooks.sh; `codedb install-hooks` (src/install_hooks.zig) resolves it relative to the running binary and runs it, so hooks can be (re)installed any time. - install.sh copies or fetches the same script and delegates to it; a hook failure no longer fails the install. website/ copies kept in sync. - flake.nix/flake.lock: nix build/run/develop/flake check. Zig comes from zig-overlay pinned to the master snapshot matching minimum_zig_version; autoPatchelfHook fixes the ELF interpreter zig bakes in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TqTyTJVJ4cd1MzFF54Hrbh --- .gitignore | 4 + README.md | 14 +- build.zig | 1 + build.zig.zon | 1 + docs/hooks-labs.md | 5 +- docs/mcp.md | 9 +- flake.lock | 82 ++++++++ flake.nix | 137 +++++++++++++ install/install-hooks.sh | 348 ++++++++++++++++++++++++++++++++++ install/install.sh | 224 ++++------------------ scripts/codedb-cli | 2 +- src/cli_args.zig | 2 +- src/install_hooks.zig | 72 +++++++ src/main.zig | 7 + src/out.zig | 2 + website/app/install_script.sh | 224 ++++------------------ website/dist/install.sh | 224 ++++------------------ 17 files changed, 794 insertions(+), 564 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 install/install-hooks.sh create mode 100644 src/install_hooks.zig diff --git a/.gitignore b/.gitignore index b2aca59c..2713dd82 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ .zig-cache/ zig-out/ +# Nix build symlink +/result +/result-* + # Local/dev files .codedb/ diff --git a/README.md b/README.md index 31edc49d..0ebeb71e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ curl -fsSL https://codedb.codegraff.com/install.sh | bash ``` -Downloads the binary for your platform and auto-registers codedb as an MCP server in **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Windsurf**, and **Devin** — each written directly and additively into that tool's config (only when the tool is present). The installer prints the exact `codedb mcp` command it registered plus hook setup pointers for Codex and Claude Code. +Downloads the binary for your platform and auto-registers codedb as an MCP server in **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Windsurf**, and **Devin** — each written directly and additively into that tool's config (only when the tool is present). The installer prints the exact `codedb mcp` command it registered, and installs the packaged Claude Code and Codex hooks via `share/codedb/install-hooks.sh` (rerun any time with `codedb install-hooks`). On Windows, run this command inside WSL only if you want the Linux binary inside WSL. For the native Windows binary, use PowerShell below. @@ -254,6 +254,7 @@ backed by api.wiki.codes was removed; DeepWiki replaces that role.) | `codedb mcp [path]` | JSON-RPC/MCP server over stdio | | `codedb update` | Self-update to the latest release on macOS/Linux; on Windows rerun the PowerShell installer | | `codedb nuke` | Uninstall codedb, remove caches/snapshots, and deregister MCP integrations | +| `codedb install-hooks` | Install Claude Code and Codex hooks from the packaged `share/codedb/install-hooks.sh` script | | `codedb --version` | Print version | **Options:** `--no-telemetry` (or set `CODEDB_NO_TELEMETRY` env var) @@ -467,6 +468,17 @@ rm -f codedb.snapshot # remove snapshot from current project only **Requirements:** Zig `0.17.0-dev.813+2153f8143` (the exact tested development snapshot). See the [migration guide](docs/zig-0.17-migration.md) for reproducible zigup setup. +### Nix + +```bash +nix build # release build; binary at result/bin/codedb +nix run . -- --version # run the packaged CLI +nix develop # shell with the pinned Zig 0.17 dev snapshot +nix flake check # build + smoke-test the package +``` + +### Zig + ```bash git clone https://github.com/justrach/codedb.git cd codedb diff --git a/build.zig b/build.zig index 1c00cfb4..865bce8f 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,7 @@ pub fn build(b: *std.Build) void { const install_exe = b.addInstallArtifact(exe, .{}); b.getInstallStep().dependOn(&install_exe.step); + b.installFile("install/install-hooks.sh", "share/codedb/install-hooks.sh"); // #618: every macOS slice signs again. The x86_64 codesign crash (#504) // was missing load-command headerpad — codesign's appended diff --git a/build.zig.zon b/build.zig.zon index 7a071e16..303c4fc2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -11,6 +11,7 @@ .paths = .{ "src", "vendor/nanoregex", + "install/install-hooks.sh", "build.zig", "build.zig.zon", }, diff --git a/docs/hooks-labs.md b/docs/hooks-labs.md index bcf3fb71..67dd8a4b 100644 --- a/docs/hooks-labs.md +++ b/docs/hooks-labs.md @@ -5,8 +5,9 @@ or Claude Code can run hooks around MCP tool calls. Use hooks for local policy, logging, and guardrails around calls such as `codedb_search`; do not use them as the only security boundary. -The installer registers the MCP server. Hook configuration is separate because -hooks execute arbitrary commands with your user permissions. +The installer registers the MCP server. `codedb install-hooks` installs the +packaged Claude Code and Codex hook configuration; review hooks before enabling +them because they execute arbitrary commands with your user permissions. ## Lab 1: Codex Hooks diff --git a/docs/mcp.md b/docs/mcp.md index 107f48f6..184233f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -25,10 +25,11 @@ curl -fsSL https://codedb.codegraff.com/install.sh | bash ``` The installer downloads the binary for your platform, drops it in `~/bin` -(or `$CODEDB_DIR` when set), and auto-registers -codedb as an MCP server in every client it can find — Claude Code, Codex, -Gemini CLI, Cursor, Windsurf, and Devin. It prints the exact `codedb mcp` command it -registered. +(or `$CODEDB_DIR` when set), installs the packaged hook helper under +`share/codedb/install-hooks.sh`, and auto-registers codedb as an MCP server in +every client it can find — Claude Code, Codex, Gemini CLI, Cursor, Windsurf, and +Devin. It prints the exact `codedb mcp` command it registered and installs the +Claude Code and Codex hooks. ### Windows x86_64 (native) diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..a896ab67 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780749050, + "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "zig-overlay": "zig-overlay" + } + }, + "systems": { + "flake": false, + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1787185267, + "narHash": "sha256-1I+ewjAECdVTHSaeViyKOup7MK1pxLyfuJ5dlsMAEfU=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "6b5e8a65de00723fae1f3c6d6434469ac754715c", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..23dc13ee --- /dev/null +++ b/flake.nix @@ -0,0 +1,137 @@ +{ + description = "codedb - code intelligence server for AI agents"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # build.zig.zon pins a Zig nightly (minimum_zig_version). nixpkgs only + # carries tagged releases, so the toolchain comes from the overlay's + # master- snapshot that matches that exact nightly. + zig-overlay = { + url = "github:mitchellh/zig-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, zig-overlay }: + let + lib = nixpkgs.lib; + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = lib.genAttrs systems; + + # Snapshot providing 0.17.0-dev.813+2153f8143 — keep in step with + # `.minimum_zig_version` in build.zig.zon. + zigSnapshot = "master-2026-06-07"; + zigFor = system: zig-overlay.packages.${system}.${zigSnapshot}; + + # Single source of truth for the version: build.zig.zon. + version = + let + lines = lib.splitString "\n" (builtins.readFile ./build.zig.zon); + pattern = "[[:space:]]*\\.version = \"([^\"]+)\",?[[:space:]]*"; + matches = builtins.filter (m: m != null) (map (l: builtins.match pattern l) lines); + in + if matches == [ ] then "0.0.0" else builtins.head (builtins.head matches); + + zigFlags = [ + "-Doptimize=ReleaseFast" + "-Dcpu=baseline" + ]; + in + { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + zig = zigFor system; + in + rec { + default = codedb; + codedb = pkgs.stdenv.mkDerivation { + pname = "codedb"; + inherit version; + + src = lib.cleanSourceWith { + src = ./.; + filter = path: type: + let + name = builtins.baseNameOf path; + in + !(lib.elem name [ + ".git" + ".direnv" + ".zig-cache" + "zig-out" + "result" + "codedb.snapshot" + ]); + }; + + # zig links libc dynamically and bakes in the host's default ELF + # interpreter (/lib64/ld-linux-*), which does not exist on NixOS — + # autoPatchelfHook rewrites it to the glibc in buildInputs. + nativeBuildInputs = [ zig ] + ++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.autoPatchelfHook; + buildInputs = lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.stdenv.cc.libc; + strictDeps = true; + dontConfigure = true; + dontBuild = true; + + # All Zig dependencies are vendored as path deps in build.zig.zon, + # so the build needs no network access — only writable caches. + preInstall = '' + export ZIG_GLOBAL_CACHE_DIR="$TMPDIR/zig-global-cache" + export ZIG_LOCAL_CACHE_DIR="$TMPDIR/zig-local-cache" + mkdir -p "$ZIG_GLOBAL_CACHE_DIR" "$ZIG_LOCAL_CACHE_DIR" + ''; + + installPhase = '' + runHook preInstall + zig build install ${lib.escapeShellArgs zigFlags} --prefix "$out" --color off + runHook postInstall + ''; + + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + "$out/bin/codedb" --version + test -f "$out/share/codedb/install-hooks.sh" + runHook postInstallCheck + ''; + + meta = { + description = "Code intelligence server for AI agents"; + homepage = "https://github.com/justrach/codedb"; + license = lib.licenses.bsd3; + mainProgram = "codedb"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; + }; + }); + + apps = forAllSystems (system: { + default = { + type = "app"; + program = lib.getExe self.packages.${system}.default; + meta.description = "Run codedb"; + }; + }); + + checks = forAllSystems (system: { + default = self.packages.${system}.default; + }); + + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = pkgs.mkShell { + packages = [ (zigFor system) pkgs.jq pkgs.python3 ]; + }; + }); + }; +} diff --git a/install/install-hooks.sh b/install/install-hooks.sh new file mode 100755 index 00000000..5232e7e6 --- /dev/null +++ b/install/install-hooks.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# codedb hook installer — packaged as share/codedb/install-hooks.sh and run by +# `codedb install-hooks` (and by install.sh at the end of an install). +# +# Hooks execute arbitrary commands with your user permissions. They are a nudge +# toward codedb's tools, not a security boundary. Opt out with CODEDB_NO_HOOKS=1 +# (this run only) or CODEDB_PERSIST_NO_HOOKS=1 / `touch ~/.codedb/no-hooks`. +set -euo pipefail + +codedb_bin="${CODEDB_BIN:-codedb}" +while [ "$#" -gt 0 ]; do + case "$1" in + --codedb-bin) + if [ -z "${2:-}" ]; then + printf 'usage: install-hooks.sh [--codedb-bin /path/to/codedb]\n' >&2 + exit 1 + fi + codedb_bin="$2" + shift 2 + ;; + -h|--help) + printf 'usage: install-hooks.sh [--codedb-bin /path/to/codedb]\n' + exit 0 + ;; + *) + printf 'install-hooks.sh: unknown argument: %s\n' "$1" >&2 + exit 1 + ;; + esac +done + +if ! command -v python3 >/dev/null 2>&1; then + printf 'codedb: install-hooks requires python3\n' >&2 + exit 1 +fi + +python3 - "$codedb_bin" << 'PYEOF' +import json +import os +import re +import shlex +import stat +import sys + +codedb_bin = sys.argv[1] +codedb_bin_q = shlex.quote(codedb_bin) +home = os.path.expanduser("~") + +# Every generated hook script starts with this: prefer the exact binary the +# installer resolved (nix profiles and ~/bin are often absent from the minimal +# PATH a hook subshell inherits), fall back to PATH lookup, and fail open when +# codedb is not installed at all. +RESOLVE_BIN = '''CODEDB_BIN=@CODEDB_BIN@ +if [ -x "$CODEDB_BIN" ]; then + : +elif command -v "$CODEDB_BIN" >/dev/null 2>&1; then + CODEDB_BIN="$(command -v "$CODEDB_BIN")" +else + exit 0 +fi''' + + +def render(script): + return script.replace("@RESOLVE_BIN@", RESOLVE_BIN).replace("@CODEDB_BIN@", codedb_bin_q) + + +def write_executable(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def load_json(path): + try: + with open(path) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def write_json(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +# Merge codedb hooks without clobbering existing hooks from other tools. +# If a competing legacy-tools hook is already registered for the same +# event/matcher (e.g. muonry's block-legacy-tools.sh), insert codedb's +# entry at the FRONT of the list so its redirect wins the race; otherwise +# append. Re-runs will also reshuffle an already-registered codedb hook +# to the front if a competitor has appeared since the previous install. +COMPETITOR_MARKERS = ("block-legacy-tools", "muonry", "zigrep", "zigread") + + +def merge_hook(settings, event, new_entry, competitor_markers=()): + hooks = settings.setdefault("hooks", {}) + existing = hooks.get(event, []) + cmd = new_entry["hooks"][0]["command"] + matcher = new_entry.get("matcher", "") + competes = any( + e.get("matcher", "") == matcher + and any(any(m in h.get("command", "") for m in competitor_markers) for h in e.get("hooks", [])) + for e in existing + ) + idx = None + for i, e in enumerate(existing): + if any(cmd in h.get("command", "") for h in e.get("hooks", [])): + idx = i + break + if idx is not None: + if competes and idx != 0: + existing.insert(0, existing.pop(idx)) + hooks[event] = existing + return + if competes: + existing.insert(0, new_entry) + else: + existing.append(new_entry) + hooks[event] = existing + + +BLOCK_LEGACY = r'''#!/usr/bin/env bash +# codedb PreToolUse guard. Nudges agents from native file tools to codedb — +# but ONLY inside a codedb-indexed repo, and never for paths outside it. +# Fail-open by design (a nudge, not a wall). Disable entirely: CODEDB_NO_HOOKS=1. +[ -n "$CODEDB_NO_HOOKS" ] && exit 0 +[ -f "$HOME/.codedb/no-hooks" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +@RESOLVE_BIN@ + +INPUT=$(cat) +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +[ -z "$CMD" ] && exit 0 + +STRIPPED=$(echo "$CMD" | sed -E 's/^[[:space:]]*(env|sudo|command|builtin|exec|nohup)[[:space:]]+//') +STRIPPED=$(echo "$STRIPPED" | sed -E 's/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//') +FIRST=$(echo "$STRIPPED" | awk '{print $1}') + +case "$FIRST" in + grep|rg|egrep|fgrep|cat|head|tail|sed|awk|find) ;; + *) exit 0 ;; +esac + +# Scope 1: cwd must sit at/under a codedb-indexed root. Otherwise codedb has +# nothing to offer here — allow the native tool (no blocking outside repos, on +# unindexed dirs, or in ~/.claude). +PWD_ABS=$(pwd -P) +REPO_ROOT="" +for pt in "$HOME"/.codedb/projects/*/project.txt; do + [ -f "$pt" ] || continue + root=$(head -1 "$pt" 2>/dev/null) + [ -z "$root" ] && continue + [ "$root" = "/" ] && continue # degenerate root matches everything + [ "$root" = "$HOME" ] && continue # home indexed as a project would block all of ~ + if [ "$PWD_ABS" = "$root" ] || [ "${PWD_ABS#"$root"/}" != "$PWD_ABS" ]; then + REPO_ROOT="$root"; break + fi +done +[ -z "$REPO_ROOT" ] && exit 0 + +# Scope 2: if the command names an ABSOLUTE path outside this repo (cat /etc/hosts, +# grep x /tmp/f), codedb can't read it — allow. Relative paths are assumed +# in-repo (cwd is in-repo). Fail-open: any out-of-repo absolute arg -> allow. +set -f +for tok in $STRIPPED; do + case "$tok" in + /*) + if [ "$tok" = "$REPO_ROOT" ] || [ "${tok#"$REPO_ROOT"/}" != "$tok" ]; then :; else set +f; exit 0; fi + ;; + esac +done +set +f + +case "$FIRST" in + grep|rg|egrep|fgrep) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_search \"\" (codedb_word for an exact identifier, codedb_callers for call sites) instead of $FIRST — ranked + fewer tokens. Native $FIRST is allowed outside this repo or with CODEDB_NO_HOOKS=1." >&2; exit 2 ;; + cat) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= (codedb_outline first for a map) instead of cat. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; + head|tail) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= line_start=.. line_end=.. instead of $FIRST. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; + sed|awk) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_search/codedb_read to locate the lines, then your editor tool to change them, instead of $FIRST. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; + find) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_find (fuzzy names) or codedb_glob (patterns) instead of find. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; +esac +exit 0 +''' + +WARMUP = r'''#!/usr/bin/env bash +@RESOLVE_BIN@ +"$CODEDB_BIN" . status >/dev/null 2>&1 & +exit 0 +''' + +SEARCH_GUARD = r'''#!/usr/bin/env bash +# codedb Codex PreToolUse guard: deny only a deliberate over-fetch. A missing +# max_results is fine (the server defaults to 20) — this exists to stop a single +# call from dumping hundreds of ranked hits into the context window. +set -euo pipefail +command -v jq >/dev/null 2>&1 || exit 0 + +input="$(cat)" +max_results="$(printf '%s' "$input" | jq -r '.tool_input.max_results // empty')" + +case "$max_results" in + ''|*[!0-9]*) exit 0 ;; +esac + +if [ "$max_results" -gt 100 ]; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Use codedb_search with max_results of 100 or less, then page with offset." + } + }' +fi +''' + + +def install_claude_hooks(): + hooks_dir = os.path.join(home, ".claude", "hooks") + settings_path = os.path.join(home, ".claude", "settings.json") + os.makedirs(hooks_dir, exist_ok=True) + + # Opt-out: CODEDB_NO_HOOKS=1 skips (re-)registering the PreToolUse + # block-legacy hook for THIS run only. It is deliberately NOT persisted: the + # background auto-updater re-runs this installer with the environment it + # inherited from the codedb process (src/update.zig), so a transient export + # must never become a permanent on-disk opt-out. Persist it explicitly with + # CODEDB_PERSIST_NO_HOOKS=1 or `touch ~/.codedb/no-hooks`; `rm` that file to + # re-enable. A deliberate removal from settings.json also persists (#658). + no_hooks_marker = os.path.join(home, ".codedb", "no-hooks") + + def persist_no_hooks(): + os.makedirs(os.path.dirname(no_hooks_marker), exist_ok=True) + with open(no_hooks_marker, "w") as f: + f.write("") + + skip_pretooluse = bool(os.environ.get("CODEDB_NO_HOOKS")) or os.path.exists(no_hooks_marker) + if os.environ.get("CODEDB_PERSIST_NO_HOOKS") and not os.path.exists(no_hooks_marker): + persist_no_hooks() + skip_pretooluse = True + + settings = load_json(settings_path) + hooks = settings.setdefault("hooks", {}) + + # #658: honor a deliberate removal. If a previous run registered the + # PreToolUse hook (receipt exists) but its settings.json entry is gone, the + # user deleted it — persist the opt-out marker instead of re-adding the hook + # on the next unattended auto-update run. This runs BEFORE the scripts are + # written so the removed hook script does not reappear on disk either. + hooks_receipt = os.path.join(home, ".codedb", "hooks-registered") + + def pretooluse_entry_present(): + for e in hooks.get("PreToolUse", []): + for h in e.get("hooks", []): + if "codedb-block-legacy.sh" in h.get("command", ""): + return True + return False + + if not skip_pretooluse and os.path.exists(hooks_receipt) and not pretooluse_entry_present(): + persist_no_hooks() + skip_pretooluse = True + + if not skip_pretooluse: + write_executable(os.path.join(hooks_dir, "codedb-block-legacy.sh"), render(BLOCK_LEGACY)) + write_executable(os.path.join(hooks_dir, "codedb-warmup.sh"), render(WARMUP)) + + if not skip_pretooluse: + merge_hook(settings, "PreToolUse", { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-block-legacy.sh"}], + }, COMPETITOR_MARKERS) + os.makedirs(os.path.dirname(hooks_receipt), exist_ok=True) + with open(hooks_receipt, "w") as f: + f.write("") + merge_hook(settings, "SessionStart", { + "matcher": "", + "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-warmup.sh"}], + }) + + # Auto-allow codedb's own MCP tools so callers aren't prompted for every + # codedb_* call. Purely additive — we add only the codedb-scoped rule and + # never touch other servers' permissions. The "mcp__codedb__*" form (literal + # server prefix + tool glob) is the syntax Claude Code's permission validator + # accepts; a bare "mcp__*" is rejected and silently skipped. + perms = settings.setdefault("permissions", {}) + allow = perms.setdefault("allow", []) + if isinstance(allow, list) and "mcp__codedb__*" not in allow: + allow.append("mcp__codedb__*") + + write_json(settings_path, settings) + return skip_pretooluse + + +def ensure_codex_hooks_feature(config_path): + os.makedirs(os.path.dirname(config_path), exist_ok=True) + try: + with open(config_path) as f: + text = f.read() + except FileNotFoundError: + text = "" + + if re.search(r"(?m)^\s*codex_hooks\s*=", text): + text = re.sub(r"(?m)^\s*codex_hooks\s*=\s*[^\n#]*(.*)$", r"codex_hooks = true\1", text, count=1) + elif re.search(r"(?m)^\s*\[features\]\s*$", text): + text = re.sub(r"(?m)^(\s*\[features\]\s*)$", r"\1\ncodex_hooks = true", text, count=1) + else: + if text and not text.endswith("\n"): + text += "\n" + if text: + text += "\n" + text += "[features]\ncodex_hooks = true\n" + + with open(config_path, "w") as f: + f.write(text) + + +def install_codex_hooks(): + hooks_dir = os.path.join(home, ".codex", "hooks") + hooks_path = os.path.join(home, ".codex", "hooks.json") + config_path = os.path.join(home, ".codex", "config.toml") + + write_executable(os.path.join(hooks_dir, "codedb-search-guard.sh"), render(SEARCH_GUARD)) + ensure_codex_hooks_feature(config_path) + + settings = load_json(hooks_path) + merge_hook(settings, "PreToolUse", { + "matcher": "mcp__codedb__codedb_search", + "hooks": [{ + "type": "command", + "command": "/usr/bin/env bash \"$HOME/.codex/hooks/codedb-search-guard.sh\"", + "timeout": 5, + "statusMessage": "Checking codedb_search request", + }], + }) + write_json(hooks_path, settings) + + +skipped_pretooluse = install_claude_hooks() +install_codex_hooks() + +print("codedb hooks installed:") +if skipped_pretooluse: + print(" claude -> ~/.claude/hooks/codedb-warmup.sh (PreToolUse guard opted out)") +else: + print(" claude -> ~/.claude/hooks/ + ~/.claude/settings.json") +print(" codex -> ~/.codex/hooks/ + ~/.codex/hooks.json + codex_hooks=true") +PYEOF diff --git a/install/install.sh b/install/install.sh index a8b2d92a..167c5641 100644 --- a/install/install.sh +++ b/install/install.sh @@ -305,194 +305,48 @@ register_deepwiki() { } register_hooks() { - if ! command -v python3 >/dev/null 2>&1; then - printf " ${D}hooks: skip (python3 not found)${N}\n" - return + local codedb_bin="$1" + local bin_dir prefix share_dir hook_script local_script tmp hooks_url fallback_url + + bin_dir="$(dirname "$codedb_bin")" + prefix="$(cd "$bin_dir/.." 2>/dev/null && pwd -P || printf '%s/..' "$bin_dir")" + share_dir="$prefix/share/codedb" + hook_script="$share_dir/install-hooks.sh" + mkdir -p "$share_dir" + + local_script="" + if [ -n "${BASH_SOURCE[0]:-}" ]; then + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P || true)" + if [ -n "$script_dir" ] && [ -f "$script_dir/install-hooks.sh" ]; then + local_script="$script_dir/install-hooks.sh" + fi fi - python3 << 'PYEOF' -import json, os, stat - -home = os.path.expanduser("~") -hooks_dir = os.path.join(home, ".claude", "hooks") -settings_path = os.path.join(home, ".claude", "settings.json") -os.makedirs(hooks_dir, exist_ok=True) - -# Opt-out: CODEDB_NO_HOOKS=1 skips (re-)registering the PreToolUse -# block-legacy hook for THIS run only. It is deliberately NOT persisted: the -# background auto-updater re-runs this installer with the environment it -# inherited from the codedb process (src/update.zig), so a transient export -# must never become a permanent on-disk opt-out. Persist it explicitly with -# CODEDB_PERSIST_NO_HOOKS=1 or `touch ~/.codedb/no-hooks`; `rm` that file to -# re-enable. A deliberate removal from settings.json also persists (#658). -no_hooks_marker = os.path.join(home, ".codedb", "no-hooks") - -def persist_no_hooks(): - os.makedirs(os.path.dirname(no_hooks_marker), exist_ok=True) - with open(no_hooks_marker, "w") as f: - f.write("") - -skip_pretooluse = bool(os.environ.get("CODEDB_NO_HOOKS")) or os.path.exists(no_hooks_marker) -if os.environ.get("CODEDB_PERSIST_NO_HOOKS") and not os.path.exists(no_hooks_marker): - persist_no_hooks() - skip_pretooluse = True - -try: - with open(settings_path) as f: - data = json.load(f) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -hooks = data.setdefault("hooks", {}) - -# #658: honor a deliberate removal. If a previous run registered the -# PreToolUse hook (receipt exists) but its settings.json entry is gone, the -# user deleted it — persist the opt-out marker instead of re-adding the hook -# on the next unattended auto-update run. This runs BEFORE the scripts are -# written so the removed hook script does not reappear on disk either. -hooks_receipt = os.path.join(home, ".codedb", "hooks-registered") - -def pretooluse_entry_present(): - for e in hooks.get("PreToolUse", []): - for h in e.get("hooks", []): - if "codedb-block-legacy.sh" in h.get("command", ""): - return True - return False - -if not skip_pretooluse and os.path.exists(hooks_receipt) and not pretooluse_entry_present(): - persist_no_hooks() - skip_pretooluse = True - -scripts = { - "codedb-block-legacy.sh": r'''#!/bin/bash -# codedb PreToolUse guard. Nudges agents from native file tools to codedb — -# but ONLY inside a codedb-indexed repo, and never for paths outside it. -# Fail-open by design (a nudge, not a wall). Disable entirely: CODEDB_NO_HOOKS=1. -[ -n "$CODEDB_NO_HOOKS" ] && exit 0 -[ -f "$HOME/.codedb/no-hooks" ] && exit 0 -command -v jq >/dev/null 2>&1 || exit 0 -command -v codedb >/dev/null 2>&1 || exit 0 - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -[ -z "$CMD" ] && exit 0 - -STRIPPED=$(echo "$CMD" | sed -E 's/^[[:space:]]*(env|sudo|command|builtin|exec|nohup)[[:space:]]+//') -STRIPPED=$(echo "$STRIPPED" | sed -E 's/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//') -FIRST=$(echo "$STRIPPED" | awk '{print $1}') - -case "$FIRST" in - grep|rg|egrep|fgrep|cat|head|tail|sed|awk|find) ;; - *) exit 0 ;; -esac - -# Scope 1: cwd must sit at/under a codedb-indexed root. Otherwise codedb has -# nothing to offer here — allow the native tool (no blocking outside repos, on -# unindexed dirs, or in ~/.claude). -PWD_ABS=$(pwd -P) -REPO_ROOT="" -for pt in "$HOME"/.codedb/projects/*/project.txt; do - [ -f "$pt" ] || continue - root=$(head -1 "$pt" 2>/dev/null) - [ -z "$root" ] && continue - [ "$root" = "/" ] && continue # degenerate root matches everything - [ "$root" = "$HOME" ] && continue # home indexed as a project would block all of ~ - if [ "$PWD_ABS" = "$root" ] || [ "${PWD_ABS#"$root"/}" != "$PWD_ABS" ]; then - REPO_ROOT="$root"; break + if [ -n "$local_script" ]; then + cp "$local_script" "$hook_script" + else + tmp="/tmp/codedb-install-hooks.tmp.$$" + hooks_url="${CODEDB_HOOKS_URL:-https://raw.githubusercontent.com/justrach/codedb/v${version}/install/install-hooks.sh}" + fallback_url="https://raw.githubusercontent.com/justrach/codedb/main/install/install-hooks.sh" + if ! curl -fsSL -A 'codedb-installer' "$hooks_url" -o "$tmp" 2>/dev/null; then + if ! curl -fsSL -A 'codedb-installer' "$fallback_url" -o "$tmp" 2>/dev/null; then + rm -f "$tmp" + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh unavailable)${N}\n" + return + fi + fi + mv -f "$tmp" "$hook_script" fi -done -[ -z "$REPO_ROOT" ] && exit 0 - -# Scope 2: if the command names an ABSOLUTE path outside this repo (cat /etc/hosts, -# grep x /tmp/f), codedb can't read it — allow. Relative paths are assumed -# in-repo (cwd is in-repo). Fail-open: any out-of-repo absolute arg -> allow. -set -f -for tok in $STRIPPED; do - case "$tok" in - /*) - if [ "$tok" = "$REPO_ROOT" ] || [ "${tok#"$REPO_ROOT"/}" != "$tok" ]; then :; else set +f; exit 0; fi - ;; - esac -done -set +f - -case "$FIRST" in - grep|rg|egrep|fgrep) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_search \"\" (codedb_word for an exact identifier, codedb_callers for call sites) instead of $FIRST — ranked + fewer tokens. Native $FIRST is allowed outside this repo or with CODEDB_NO_HOOKS=1." >&2; exit 2 ;; - cat) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= (codedb_outline first for a map) instead of cat. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - head|tail) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= line_start=.. line_end=.. instead of $FIRST. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - sed|awk) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_edit (op=str_replace) instead of $FIRST for edits. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - find) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_find (fuzzy names) or codedb_glob (patterns) instead of find. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; -esac -exit 0 -''', - "codedb-warmup.sh": r'''#!/bin/bash -command -v codedb >/dev/null 2>&1 || exit 0 -codedb . status >/dev/null 2>&1 & -exit 0 -''', -} -for name, content in scripts.items(): - if name == "codedb-block-legacy.sh" and skip_pretooluse: - continue - path = os.path.join(hooks_dir, name) - with open(path, "w") as f: - f.write(content) - os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - -# Merge codedb hooks without clobbering existing hooks from other tools. -# If a competing legacy-tools hook is already registered for the same -# event/matcher (e.g. muonry's block-legacy-tools.sh), insert codedb's -# entry at the FRONT of the list so its redirect wins the race; otherwise -# append. Re-runs will also reshuffle an already-registered codedb hook -# to the front if a competitor has appeared since the previous install. -COMPETITOR_MARKERS = ("block-legacy-tools", "muonry", "zigrep", "zigread") - -def merge_hook(event, new_entry): - existing = hooks.get(event, []) - cmd = new_entry["hooks"][0]["command"] - matcher = new_entry.get("matcher", "") - competes = any( - e.get("matcher", "") == matcher - and any(any(m in h.get("command", "") for m in COMPETITOR_MARKERS) for h in e.get("hooks", [])) - for e in existing - ) - idx = None - for i, e in enumerate(existing): - if any(cmd in h.get("command", "") for h in e.get("hooks", [])): - idx = i - break - if idx is not None: - if competes and idx != 0: - existing.insert(0, existing.pop(idx)) - hooks[event] = existing - return - if competes: - existing.insert(0, new_entry) - else: - existing.append(new_entry) - hooks[event] = existing - -if not skip_pretooluse: - merge_hook("PreToolUse", {"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-block-legacy.sh"}]}) - os.makedirs(os.path.dirname(hooks_receipt), exist_ok=True) - with open(hooks_receipt, "w") as f: - f.write("") -merge_hook("SessionStart", {"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-warmup.sh"}]}) - -# Auto-allow codedb's own MCP tools so callers aren't prompted for every -# codedb_* call. Purely additive — we add only the codedb-scoped rule and -# never touch other servers' permissions. The "mcp__codedb__*" form (literal -# server prefix + tool glob) is the syntax Claude Code's permission validator -# accepts; a bare "mcp__*" is rejected and silently skipped. -perms = data.setdefault("permissions", {}) -allow = perms.setdefault("allow", []) -if isinstance(allow, list) and "mcp__codedb__*" not in allow: - allow.append("mcp__codedb__*") -with open(settings_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") -PYEOF - printf " ${G}✓${N} hooks ${D}→ ~/.claude/hooks/ + settings.json${N}\n" + chmod +x "$hook_script" + # A hook-install failure must never fail the whole install — the binary and + # the MCP registrations are already in place at this point. + if ! CODEDB_BIN="$codedb_bin" "$hook_script" --codedb-bin "$codedb_bin" >/dev/null 2>&1; then + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh failed; run: $codedb_bin install-hooks)${N}\n" + return + fi + printf " ${G}✓${N} hooks ${D}→ ~/.claude + ~/.codex (helper: $hook_script)${N}\n" } print_hook_notes() { @@ -606,7 +460,7 @@ main() { register_cursor "$dest" register_windsurf_devin "$dest" register_deepwiki - register_hooks + register_hooks "$dest" print_hook_notes "$dest" # Check PATH diff --git a/scripts/codedb-cli b/scripts/codedb-cli index 350ab0d8..b4ca834a 100755 --- a/scripts/codedb-cli +++ b/scripts/codedb-cli @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # codedb-cli — thin CLI client for codedb HTTP daemon # Usage: codedb-cli [project-root] [args...] set -euo pipefail diff --git a/src/cli_args.zig b/src/cli_args.zig index 4a59c9c9..41b6b38c 100644 --- a/src/cli_args.zig +++ b/src/cli_args.zig @@ -243,7 +243,7 @@ pub fn isValidMcpFlag(arg: []const u8) bool { fn isCommand(arg: []const u8) bool { // cli_query_cmds is the shared query-command table (see its doc); only the // non-query commands are listed here. - const commands = cli_query_cmds ++ [_][]const u8{ "snapshot", "serve", "mcp", "update", "nuke", "codex", "cli-daemon", "index" }; + const commands = cli_query_cmds ++ [_][]const u8{ "snapshot", "serve", "mcp", "update", "nuke", "codex", "install-hooks", "cli-daemon", "index" }; for (commands) |c| { if (std.mem.eql(u8, arg, c)) return true; } diff --git a/src/install_hooks.zig b/src/install_hooks.zig new file mode 100644 index 00000000..8296d45c --- /dev/null +++ b/src/install_hooks.zig @@ -0,0 +1,72 @@ +//! `codedb install-hooks` — runs the packaged hook installer that ships next to +//! the binary as `/share/codedb/install-hooks.sh` (see build.zig). +//! +//! Hook configuration is kept out of the binary on purpose: hooks execute +//! arbitrary commands with the user's permissions, so the exact script that +//! runs stays readable on disk and can be inspected before it is invoked. +const std = @import("std"); +const cio = @import("cio.zig"); +const sty = @import("style.zig"); +const Out = @import("out.zig").Out; + +/// Resolve `/share/codedb/install-hooks.sh` from the running executable +/// (`/bin/codedb`). Works for the curl installer (`~/bin` → `~/share`), +/// `zig build install`, and nix store paths alike. +pub fn packageScriptPath(allocator: std.mem.Allocator, self_exe: []const u8) ![]u8 { + const bin_dir = std.fs.path.dirname(self_exe) orelse return error.NoExecutableDir; + return std.fs.path.join(allocator, &.{ bin_dir, "..", "share", "codedb", "install-hooks.sh" }); +} + +pub fn run(io: std.Io, out: *Out, s: sty.Style, allocator: std.mem.Allocator, stdout: cio.File) void { + const self_exe = std.process.executablePathAlloc(io, allocator) catch |err| { + out.p("{s}\xe2\x9c\x97{s} cannot resolve codedb executable: {s}\n", .{ s.red, s.reset, @errorName(err) }); + out.exitWithFlush(1); + }; + defer allocator.free(self_exe); + + const script_path = packageScriptPath(allocator, self_exe) catch |err| { + out.p("{s}\xe2\x9c\x97{s} cannot resolve install-hooks script path: {s}\n", .{ s.red, s.reset, @errorName(err) }); + out.exitWithFlush(1); + }; + defer allocator.free(script_path); + + std.Io.Dir.cwd().access(io, script_path, .{}) catch { + out.p("{s}\xe2\x9c\x97{s} install-hooks script not found: {s}{s}{s}\n", .{ s.red, s.reset, s.bold, script_path, s.reset }); + out.p(" Reinstall codedb from a package that includes {s}share/codedb/install-hooks.sh{s}.\n", .{ s.bold, s.reset }); + out.exitWithFlush(1); + }; + + const argv = [_][]const u8{ "bash", script_path, "--codedb-bin", self_exe }; + const result = cio.runCapture(.{ + .allocator = allocator, + .argv = &argv, + .max_output_bytes = 4 * 1024 * 1024, + }) catch |err| { + out.p("{s}\xe2\x9c\x97{s} failed to run install-hooks script: {s}\n", .{ s.red, s.reset, @errorName(err) }); + out.exitWithFlush(1); + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + out.flush(); + if (result.stdout.len > 0) stdout.writeAll(result.stdout) catch {}; + if (result.stderr.len > 0) cio.File.stderr().writeAll(result.stderr) catch {}; + + switch (result.term) { + .Exited => |code| { + if (code != 0) std.process.exit(code); + }, + .Signal => |signal| { + out.p("{s}\xe2\x9c\x97{s} install-hooks script terminated by signal {d}\n", .{ s.red, s.reset, signal }); + out.exitWithFlush(1); + }, + .Stopped => |signal| { + out.p("{s}\xe2\x9c\x97{s} install-hooks script stopped by signal {d}\n", .{ s.red, s.reset, signal }); + out.exitWithFlush(1); + }, + .Unknown => |status| { + out.p("{s}\xe2\x9c\x97{s} install-hooks script exited with unknown status {d}\n", .{ s.red, s.reset, status }); + out.exitWithFlush(1); + }, + } +} diff --git a/src/main.zig b/src/main.zig index 1ab8c1a8..ec261f6e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,7 @@ const root_policy = @import("root_policy.zig"); const nuke_mod = @import("nuke.zig"); const update_mod = @import("update.zig"); const codex_mod = @import("codex_setup.zig"); +const install_hooks_mod = @import("install_hooks.zig"); const release_info = @import("release_info.zig"); const Config = @import("config.zig").Config; @@ -298,6 +299,12 @@ fn mainImpl(argv: []const [*:0]const u8) !void { return; } + // Handle install-hooks early — before root resolution so it works from anywhere. + if (std.mem.eql(u8, cmd, "install-hooks")) { + install_hooks_mod.run(io, &out, s, allocator, stdout); + return; + } + var root_buf: [std.fs.max_path_bytes]u8 = undefined; const abs_root = resolveRoot(io, root, &root_buf) catch { out.p("{s}\xe2\x9c\x97{s} cannot resolve root: {s}{s}{s}\n", .{ diff --git a/src/out.zig b/src/out.zig index 713ee77b..92b1055c 100644 --- a/src/out.zig +++ b/src/out.zig @@ -104,6 +104,7 @@ pub fn printUsage(out: *Out, s: sty.Style) void { \\ {s}update{s} self-update to the latest verified release \\ {s}nuke{s} uninstall codedb, clear caches, and deregister integrations \\ {s}codex{s} CodeDB-first Codex setup (install|uninstall|verify) + \\ {s}install-hooks{s} install Claude Code and Codex hooks from package share data \\ , .{ s.cyan, s.reset, @@ -121,6 +122,7 @@ pub fn printUsage(out: *Out, s: sty.Style) void { s.cyan, s.reset, s.cyan, s.reset, s.cyan, s.reset, + s.cyan, s.reset, }); out.p( \\ {s}options:{s} diff --git a/website/app/install_script.sh b/website/app/install_script.sh index a8b2d92a..167c5641 100644 --- a/website/app/install_script.sh +++ b/website/app/install_script.sh @@ -305,194 +305,48 @@ register_deepwiki() { } register_hooks() { - if ! command -v python3 >/dev/null 2>&1; then - printf " ${D}hooks: skip (python3 not found)${N}\n" - return + local codedb_bin="$1" + local bin_dir prefix share_dir hook_script local_script tmp hooks_url fallback_url + + bin_dir="$(dirname "$codedb_bin")" + prefix="$(cd "$bin_dir/.." 2>/dev/null && pwd -P || printf '%s/..' "$bin_dir")" + share_dir="$prefix/share/codedb" + hook_script="$share_dir/install-hooks.sh" + mkdir -p "$share_dir" + + local_script="" + if [ -n "${BASH_SOURCE[0]:-}" ]; then + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P || true)" + if [ -n "$script_dir" ] && [ -f "$script_dir/install-hooks.sh" ]; then + local_script="$script_dir/install-hooks.sh" + fi fi - python3 << 'PYEOF' -import json, os, stat - -home = os.path.expanduser("~") -hooks_dir = os.path.join(home, ".claude", "hooks") -settings_path = os.path.join(home, ".claude", "settings.json") -os.makedirs(hooks_dir, exist_ok=True) - -# Opt-out: CODEDB_NO_HOOKS=1 skips (re-)registering the PreToolUse -# block-legacy hook for THIS run only. It is deliberately NOT persisted: the -# background auto-updater re-runs this installer with the environment it -# inherited from the codedb process (src/update.zig), so a transient export -# must never become a permanent on-disk opt-out. Persist it explicitly with -# CODEDB_PERSIST_NO_HOOKS=1 or `touch ~/.codedb/no-hooks`; `rm` that file to -# re-enable. A deliberate removal from settings.json also persists (#658). -no_hooks_marker = os.path.join(home, ".codedb", "no-hooks") - -def persist_no_hooks(): - os.makedirs(os.path.dirname(no_hooks_marker), exist_ok=True) - with open(no_hooks_marker, "w") as f: - f.write("") - -skip_pretooluse = bool(os.environ.get("CODEDB_NO_HOOKS")) or os.path.exists(no_hooks_marker) -if os.environ.get("CODEDB_PERSIST_NO_HOOKS") and not os.path.exists(no_hooks_marker): - persist_no_hooks() - skip_pretooluse = True - -try: - with open(settings_path) as f: - data = json.load(f) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -hooks = data.setdefault("hooks", {}) - -# #658: honor a deliberate removal. If a previous run registered the -# PreToolUse hook (receipt exists) but its settings.json entry is gone, the -# user deleted it — persist the opt-out marker instead of re-adding the hook -# on the next unattended auto-update run. This runs BEFORE the scripts are -# written so the removed hook script does not reappear on disk either. -hooks_receipt = os.path.join(home, ".codedb", "hooks-registered") - -def pretooluse_entry_present(): - for e in hooks.get("PreToolUse", []): - for h in e.get("hooks", []): - if "codedb-block-legacy.sh" in h.get("command", ""): - return True - return False - -if not skip_pretooluse and os.path.exists(hooks_receipt) and not pretooluse_entry_present(): - persist_no_hooks() - skip_pretooluse = True - -scripts = { - "codedb-block-legacy.sh": r'''#!/bin/bash -# codedb PreToolUse guard. Nudges agents from native file tools to codedb — -# but ONLY inside a codedb-indexed repo, and never for paths outside it. -# Fail-open by design (a nudge, not a wall). Disable entirely: CODEDB_NO_HOOKS=1. -[ -n "$CODEDB_NO_HOOKS" ] && exit 0 -[ -f "$HOME/.codedb/no-hooks" ] && exit 0 -command -v jq >/dev/null 2>&1 || exit 0 -command -v codedb >/dev/null 2>&1 || exit 0 - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -[ -z "$CMD" ] && exit 0 - -STRIPPED=$(echo "$CMD" | sed -E 's/^[[:space:]]*(env|sudo|command|builtin|exec|nohup)[[:space:]]+//') -STRIPPED=$(echo "$STRIPPED" | sed -E 's/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//') -FIRST=$(echo "$STRIPPED" | awk '{print $1}') - -case "$FIRST" in - grep|rg|egrep|fgrep|cat|head|tail|sed|awk|find) ;; - *) exit 0 ;; -esac - -# Scope 1: cwd must sit at/under a codedb-indexed root. Otherwise codedb has -# nothing to offer here — allow the native tool (no blocking outside repos, on -# unindexed dirs, or in ~/.claude). -PWD_ABS=$(pwd -P) -REPO_ROOT="" -for pt in "$HOME"/.codedb/projects/*/project.txt; do - [ -f "$pt" ] || continue - root=$(head -1 "$pt" 2>/dev/null) - [ -z "$root" ] && continue - [ "$root" = "/" ] && continue # degenerate root matches everything - [ "$root" = "$HOME" ] && continue # home indexed as a project would block all of ~ - if [ "$PWD_ABS" = "$root" ] || [ "${PWD_ABS#"$root"/}" != "$PWD_ABS" ]; then - REPO_ROOT="$root"; break + if [ -n "$local_script" ]; then + cp "$local_script" "$hook_script" + else + tmp="/tmp/codedb-install-hooks.tmp.$$" + hooks_url="${CODEDB_HOOKS_URL:-https://raw.githubusercontent.com/justrach/codedb/v${version}/install/install-hooks.sh}" + fallback_url="https://raw.githubusercontent.com/justrach/codedb/main/install/install-hooks.sh" + if ! curl -fsSL -A 'codedb-installer' "$hooks_url" -o "$tmp" 2>/dev/null; then + if ! curl -fsSL -A 'codedb-installer' "$fallback_url" -o "$tmp" 2>/dev/null; then + rm -f "$tmp" + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh unavailable)${N}\n" + return + fi + fi + mv -f "$tmp" "$hook_script" fi -done -[ -z "$REPO_ROOT" ] && exit 0 - -# Scope 2: if the command names an ABSOLUTE path outside this repo (cat /etc/hosts, -# grep x /tmp/f), codedb can't read it — allow. Relative paths are assumed -# in-repo (cwd is in-repo). Fail-open: any out-of-repo absolute arg -> allow. -set -f -for tok in $STRIPPED; do - case "$tok" in - /*) - if [ "$tok" = "$REPO_ROOT" ] || [ "${tok#"$REPO_ROOT"/}" != "$tok" ]; then :; else set +f; exit 0; fi - ;; - esac -done -set +f - -case "$FIRST" in - grep|rg|egrep|fgrep) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_search \"\" (codedb_word for an exact identifier, codedb_callers for call sites) instead of $FIRST — ranked + fewer tokens. Native $FIRST is allowed outside this repo or with CODEDB_NO_HOOKS=1." >&2; exit 2 ;; - cat) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= (codedb_outline first for a map) instead of cat. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - head|tail) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= line_start=.. line_end=.. instead of $FIRST. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - sed|awk) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_edit (op=str_replace) instead of $FIRST for edits. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - find) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_find (fuzzy names) or codedb_glob (patterns) instead of find. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; -esac -exit 0 -''', - "codedb-warmup.sh": r'''#!/bin/bash -command -v codedb >/dev/null 2>&1 || exit 0 -codedb . status >/dev/null 2>&1 & -exit 0 -''', -} -for name, content in scripts.items(): - if name == "codedb-block-legacy.sh" and skip_pretooluse: - continue - path = os.path.join(hooks_dir, name) - with open(path, "w") as f: - f.write(content) - os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - -# Merge codedb hooks without clobbering existing hooks from other tools. -# If a competing legacy-tools hook is already registered for the same -# event/matcher (e.g. muonry's block-legacy-tools.sh), insert codedb's -# entry at the FRONT of the list so its redirect wins the race; otherwise -# append. Re-runs will also reshuffle an already-registered codedb hook -# to the front if a competitor has appeared since the previous install. -COMPETITOR_MARKERS = ("block-legacy-tools", "muonry", "zigrep", "zigread") - -def merge_hook(event, new_entry): - existing = hooks.get(event, []) - cmd = new_entry["hooks"][0]["command"] - matcher = new_entry.get("matcher", "") - competes = any( - e.get("matcher", "") == matcher - and any(any(m in h.get("command", "") for m in COMPETITOR_MARKERS) for h in e.get("hooks", [])) - for e in existing - ) - idx = None - for i, e in enumerate(existing): - if any(cmd in h.get("command", "") for h in e.get("hooks", [])): - idx = i - break - if idx is not None: - if competes and idx != 0: - existing.insert(0, existing.pop(idx)) - hooks[event] = existing - return - if competes: - existing.insert(0, new_entry) - else: - existing.append(new_entry) - hooks[event] = existing - -if not skip_pretooluse: - merge_hook("PreToolUse", {"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-block-legacy.sh"}]}) - os.makedirs(os.path.dirname(hooks_receipt), exist_ok=True) - with open(hooks_receipt, "w") as f: - f.write("") -merge_hook("SessionStart", {"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-warmup.sh"}]}) - -# Auto-allow codedb's own MCP tools so callers aren't prompted for every -# codedb_* call. Purely additive — we add only the codedb-scoped rule and -# never touch other servers' permissions. The "mcp__codedb__*" form (literal -# server prefix + tool glob) is the syntax Claude Code's permission validator -# accepts; a bare "mcp__*" is rejected and silently skipped. -perms = data.setdefault("permissions", {}) -allow = perms.setdefault("allow", []) -if isinstance(allow, list) and "mcp__codedb__*" not in allow: - allow.append("mcp__codedb__*") -with open(settings_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") -PYEOF - printf " ${G}✓${N} hooks ${D}→ ~/.claude/hooks/ + settings.json${N}\n" + chmod +x "$hook_script" + # A hook-install failure must never fail the whole install — the binary and + # the MCP registrations are already in place at this point. + if ! CODEDB_BIN="$codedb_bin" "$hook_script" --codedb-bin "$codedb_bin" >/dev/null 2>&1; then + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh failed; run: $codedb_bin install-hooks)${N}\n" + return + fi + printf " ${G}✓${N} hooks ${D}→ ~/.claude + ~/.codex (helper: $hook_script)${N}\n" } print_hook_notes() { @@ -606,7 +460,7 @@ main() { register_cursor "$dest" register_windsurf_devin "$dest" register_deepwiki - register_hooks + register_hooks "$dest" print_hook_notes "$dest" # Check PATH diff --git a/website/dist/install.sh b/website/dist/install.sh index a8b2d92a..167c5641 100644 --- a/website/dist/install.sh +++ b/website/dist/install.sh @@ -305,194 +305,48 @@ register_deepwiki() { } register_hooks() { - if ! command -v python3 >/dev/null 2>&1; then - printf " ${D}hooks: skip (python3 not found)${N}\n" - return + local codedb_bin="$1" + local bin_dir prefix share_dir hook_script local_script tmp hooks_url fallback_url + + bin_dir="$(dirname "$codedb_bin")" + prefix="$(cd "$bin_dir/.." 2>/dev/null && pwd -P || printf '%s/..' "$bin_dir")" + share_dir="$prefix/share/codedb" + hook_script="$share_dir/install-hooks.sh" + mkdir -p "$share_dir" + + local_script="" + if [ -n "${BASH_SOURCE[0]:-}" ]; then + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P || true)" + if [ -n "$script_dir" ] && [ -f "$script_dir/install-hooks.sh" ]; then + local_script="$script_dir/install-hooks.sh" + fi fi - python3 << 'PYEOF' -import json, os, stat - -home = os.path.expanduser("~") -hooks_dir = os.path.join(home, ".claude", "hooks") -settings_path = os.path.join(home, ".claude", "settings.json") -os.makedirs(hooks_dir, exist_ok=True) - -# Opt-out: CODEDB_NO_HOOKS=1 skips (re-)registering the PreToolUse -# block-legacy hook for THIS run only. It is deliberately NOT persisted: the -# background auto-updater re-runs this installer with the environment it -# inherited from the codedb process (src/update.zig), so a transient export -# must never become a permanent on-disk opt-out. Persist it explicitly with -# CODEDB_PERSIST_NO_HOOKS=1 or `touch ~/.codedb/no-hooks`; `rm` that file to -# re-enable. A deliberate removal from settings.json also persists (#658). -no_hooks_marker = os.path.join(home, ".codedb", "no-hooks") - -def persist_no_hooks(): - os.makedirs(os.path.dirname(no_hooks_marker), exist_ok=True) - with open(no_hooks_marker, "w") as f: - f.write("") - -skip_pretooluse = bool(os.environ.get("CODEDB_NO_HOOKS")) or os.path.exists(no_hooks_marker) -if os.environ.get("CODEDB_PERSIST_NO_HOOKS") and not os.path.exists(no_hooks_marker): - persist_no_hooks() - skip_pretooluse = True - -try: - with open(settings_path) as f: - data = json.load(f) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -hooks = data.setdefault("hooks", {}) - -# #658: honor a deliberate removal. If a previous run registered the -# PreToolUse hook (receipt exists) but its settings.json entry is gone, the -# user deleted it — persist the opt-out marker instead of re-adding the hook -# on the next unattended auto-update run. This runs BEFORE the scripts are -# written so the removed hook script does not reappear on disk either. -hooks_receipt = os.path.join(home, ".codedb", "hooks-registered") - -def pretooluse_entry_present(): - for e in hooks.get("PreToolUse", []): - for h in e.get("hooks", []): - if "codedb-block-legacy.sh" in h.get("command", ""): - return True - return False - -if not skip_pretooluse and os.path.exists(hooks_receipt) and not pretooluse_entry_present(): - persist_no_hooks() - skip_pretooluse = True - -scripts = { - "codedb-block-legacy.sh": r'''#!/bin/bash -# codedb PreToolUse guard. Nudges agents from native file tools to codedb — -# but ONLY inside a codedb-indexed repo, and never for paths outside it. -# Fail-open by design (a nudge, not a wall). Disable entirely: CODEDB_NO_HOOKS=1. -[ -n "$CODEDB_NO_HOOKS" ] && exit 0 -[ -f "$HOME/.codedb/no-hooks" ] && exit 0 -command -v jq >/dev/null 2>&1 || exit 0 -command -v codedb >/dev/null 2>&1 || exit 0 - -INPUT=$(cat) -CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -[ -z "$CMD" ] && exit 0 - -STRIPPED=$(echo "$CMD" | sed -E 's/^[[:space:]]*(env|sudo|command|builtin|exec|nohup)[[:space:]]+//') -STRIPPED=$(echo "$STRIPPED" | sed -E 's/^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+//') -FIRST=$(echo "$STRIPPED" | awk '{print $1}') - -case "$FIRST" in - grep|rg|egrep|fgrep|cat|head|tail|sed|awk|find) ;; - *) exit 0 ;; -esac - -# Scope 1: cwd must sit at/under a codedb-indexed root. Otherwise codedb has -# nothing to offer here — allow the native tool (no blocking outside repos, on -# unindexed dirs, or in ~/.claude). -PWD_ABS=$(pwd -P) -REPO_ROOT="" -for pt in "$HOME"/.codedb/projects/*/project.txt; do - [ -f "$pt" ] || continue - root=$(head -1 "$pt" 2>/dev/null) - [ -z "$root" ] && continue - [ "$root" = "/" ] && continue # degenerate root matches everything - [ "$root" = "$HOME" ] && continue # home indexed as a project would block all of ~ - if [ "$PWD_ABS" = "$root" ] || [ "${PWD_ABS#"$root"/}" != "$PWD_ABS" ]; then - REPO_ROOT="$root"; break + if [ -n "$local_script" ]; then + cp "$local_script" "$hook_script" + else + tmp="/tmp/codedb-install-hooks.tmp.$$" + hooks_url="${CODEDB_HOOKS_URL:-https://raw.githubusercontent.com/justrach/codedb/v${version}/install/install-hooks.sh}" + fallback_url="https://raw.githubusercontent.com/justrach/codedb/main/install/install-hooks.sh" + if ! curl -fsSL -A 'codedb-installer' "$hooks_url" -o "$tmp" 2>/dev/null; then + if ! curl -fsSL -A 'codedb-installer' "$fallback_url" -o "$tmp" 2>/dev/null; then + rm -f "$tmp" + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh unavailable)${N}\n" + return + fi + fi + mv -f "$tmp" "$hook_script" fi -done -[ -z "$REPO_ROOT" ] && exit 0 - -# Scope 2: if the command names an ABSOLUTE path outside this repo (cat /etc/hosts, -# grep x /tmp/f), codedb can't read it — allow. Relative paths are assumed -# in-repo (cwd is in-repo). Fail-open: any out-of-repo absolute arg -> allow. -set -f -for tok in $STRIPPED; do - case "$tok" in - /*) - if [ "$tok" = "$REPO_ROOT" ] || [ "${tok#"$REPO_ROOT"/}" != "$tok" ]; then :; else set +f; exit 0; fi - ;; - esac -done -set +f - -case "$FIRST" in - grep|rg|egrep|fgrep) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_search \"\" (codedb_word for an exact identifier, codedb_callers for call sites) instead of $FIRST — ranked + fewer tokens. Native $FIRST is allowed outside this repo or with CODEDB_NO_HOOKS=1." >&2; exit 2 ;; - cat) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= (codedb_outline first for a map) instead of cat. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - head|tail) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_read path= line_start=.. line_end=.. instead of $FIRST. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - sed|awk) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_edit (op=str_replace) instead of $FIRST for edits. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; - find) echo "BLOCKED in indexed repo ($REPO_ROOT): use codedb_find (fuzzy names) or codedb_glob (patterns) instead of find. CODEDB_NO_HOOKS=1 to disable." >&2; exit 2 ;; -esac -exit 0 -''', - "codedb-warmup.sh": r'''#!/bin/bash -command -v codedb >/dev/null 2>&1 || exit 0 -codedb . status >/dev/null 2>&1 & -exit 0 -''', -} -for name, content in scripts.items(): - if name == "codedb-block-legacy.sh" and skip_pretooluse: - continue - path = os.path.join(hooks_dir, name) - with open(path, "w") as f: - f.write(content) - os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - -# Merge codedb hooks without clobbering existing hooks from other tools. -# If a competing legacy-tools hook is already registered for the same -# event/matcher (e.g. muonry's block-legacy-tools.sh), insert codedb's -# entry at the FRONT of the list so its redirect wins the race; otherwise -# append. Re-runs will also reshuffle an already-registered codedb hook -# to the front if a competitor has appeared since the previous install. -COMPETITOR_MARKERS = ("block-legacy-tools", "muonry", "zigrep", "zigread") - -def merge_hook(event, new_entry): - existing = hooks.get(event, []) - cmd = new_entry["hooks"][0]["command"] - matcher = new_entry.get("matcher", "") - competes = any( - e.get("matcher", "") == matcher - and any(any(m in h.get("command", "") for m in COMPETITOR_MARKERS) for h in e.get("hooks", [])) - for e in existing - ) - idx = None - for i, e in enumerate(existing): - if any(cmd in h.get("command", "") for h in e.get("hooks", [])): - idx = i - break - if idx is not None: - if competes and idx != 0: - existing.insert(0, existing.pop(idx)) - hooks[event] = existing - return - if competes: - existing.insert(0, new_entry) - else: - existing.append(new_entry) - hooks[event] = existing - -if not skip_pretooluse: - merge_hook("PreToolUse", {"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-block-legacy.sh"}]}) - os.makedirs(os.path.dirname(hooks_receipt), exist_ok=True) - with open(hooks_receipt, "w") as f: - f.write("") -merge_hook("SessionStart", {"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/codedb-warmup.sh"}]}) - -# Auto-allow codedb's own MCP tools so callers aren't prompted for every -# codedb_* call. Purely additive — we add only the codedb-scoped rule and -# never touch other servers' permissions. The "mcp__codedb__*" form (literal -# server prefix + tool glob) is the syntax Claude Code's permission validator -# accepts; a bare "mcp__*" is rejected and silently skipped. -perms = data.setdefault("permissions", {}) -allow = perms.setdefault("allow", []) -if isinstance(allow, list) and "mcp__codedb__*" not in allow: - allow.append("mcp__codedb__*") -with open(settings_path, "w") as f: - json.dump(data, f, indent=2) - f.write("\n") -PYEOF - printf " ${G}✓${N} hooks ${D}→ ~/.claude/hooks/ + settings.json${N}\n" + chmod +x "$hook_script" + # A hook-install failure must never fail the whole install — the binary and + # the MCP registrations are already in place at this point. + if ! CODEDB_BIN="$codedb_bin" "$hook_script" --codedb-bin "$codedb_bin" >/dev/null 2>&1; then + printf " ${Y}!${N} hooks ${D}skip (install-hooks.sh failed; run: $codedb_bin install-hooks)${N}\n" + return + fi + printf " ${G}✓${N} hooks ${D}→ ~/.claude + ~/.codex (helper: $hook_script)${N}\n" } print_hook_notes() { @@ -606,7 +460,7 @@ main() { register_cursor "$dest" register_windsurf_devin "$dest" register_deepwiki - register_hooks + register_hooks "$dest" print_hook_notes "$dest" # Check PATH