Commit 1a0ac78a473a

Vincent Demeester <vincent@sbr.pm>
2026-02-06 10:05:26
feat(pi): added pre-commit and uv interceptor extensions
Added two new pi extensions for enforcing code quality and Python tooling: - pre-commit.ts: Intercepts git commits to run linters/formatters on staged files (nixfmt, gofmt, ruff, prettier, shellcheck). Auto-formats when possible and blocks commits on linting failures. - uv.ts: Enforces uv package manager by intercepting pip/poetry/pipenv/conda commands via PATH shims. Redirects python to uv run python and blocks legacy tools with helpful migration messages. - intercepted-commands/: Shell script shims for blocking Python tooling (pip, poetry, pipenv, virtualenv, conda) and redirecting to uv equivalents.
1 parent 4f1a2e8
dots/pi/agent/extensions/intercepted-commands/conda
@@ -0,0 +1,30 @@
+#!/bin/bash
+# Conda interception - only block package management commands
+# Allow conda for non-Python tasks if needed
+
+case "$1" in
+  install|update|remove|uninstall|create|env)
+    echo "Error: conda package management is disabled. Use uv instead:" >&2
+    echo "" >&2
+    echo "  conda install PACKAGE  -> uv add PACKAGE" >&2
+    echo "  conda create -n env    -> uv venv" >&2
+    echo "  conda env create       -> uv sync (with pyproject.toml)" >&2
+    echo "  conda update           -> uv lock --upgrade && uv sync" >&2
+    echo "  conda remove           -> uv remove PACKAGE" >&2
+    echo "" >&2
+    echo "For non-Python conda packages, use nix-shell or devenv instead." >&2
+    echo "" >&2
+    exit 1
+    ;;
+  *)
+    # Allow other conda commands (info, list, etc.) to pass through
+    # Find real conda, skipping this shim
+    real_conda=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "intercepted-commands" | tr '\n' ':') which conda 2>/dev/null)
+    if [ -n "$real_conda" ]; then
+      exec "$real_conda" "$@"
+    else
+      echo "Error: conda not found in PATH (outside of intercepted-commands)" >&2
+      exit 1
+    fi
+    ;;
+esac
dots/pi/agent/extensions/intercepted-commands/pip
@@ -0,0 +1,8 @@
+#!/bin/bash
+echo "Error: pip is disabled. Use uv instead:" >&2
+echo "" >&2
+echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+echo "  To add a dependency to the project: uv add PACKAGE" >&2
+echo "  To install from requirements.txt: uv pip install -r requirements.txt" >&2
+echo "" >&2
+exit 1
dots/pi/agent/extensions/intercepted-commands/pip3
@@ -0,0 +1,8 @@
+#!/bin/bash
+echo "Error: pip3 is disabled. Use uv instead:" >&2
+echo "" >&2
+echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+echo "  To add a dependency to the project: uv add PACKAGE" >&2
+echo "  To install from requirements.txt: uv pip install -r requirements.txt" >&2
+echo "" >&2
+exit 1
dots/pi/agent/extensions/intercepted-commands/pipenv
@@ -0,0 +1,10 @@
+#!/bin/bash
+echo "Error: pipenv is disabled. Use uv instead:" >&2
+echo "" >&2
+echo "  pipenv install   -> uv add / uv sync" >&2
+echo "  pipenv run       -> uv run" >&2
+echo "  pipenv shell     -> source .venv/bin/activate (after uv venv)" >&2
+echo "  pipenv lock      -> uv lock" >&2
+echo "  Pipfile          -> pyproject.toml (uv init)" >&2
+echo "" >&2
+exit 1
dots/pi/agent/extensions/intercepted-commands/poetry
@@ -0,0 +1,12 @@
+#!/bin/bash
+echo "Error: poetry is disabled. Use uv instead:" >&2
+echo "" >&2
+echo "  poetry init      -> uv init" >&2
+echo "  poetry add       -> uv add" >&2
+echo "  poetry install   -> uv sync" >&2
+echo "  poetry run       -> uv run" >&2
+echo "  poetry shell     -> source .venv/bin/activate (after uv venv)" >&2
+echo "  poetry lock      -> uv lock" >&2
+echo "  poetry update    -> uv lock --upgrade" >&2
+echo "" >&2
+exit 1
dots/pi/agent/extensions/intercepted-commands/python
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# Check for disallowed module invocations
+for arg in "$@"; do
+    case "$arg" in
+        -mpip|-m\ pip|pip)
+            echo "Error: 'python -m pip' is disabled. Use uv instead:" >&2
+            echo "" >&2
+            echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+            echo "  To add a dependency to the project: uv add PACKAGE" >&2
+            echo "" >&2
+            exit 1
+            ;;
+        -mvenv|-m\ venv|venv)
+            echo "Error: 'python -m venv' is disabled. Use uv instead:" >&2
+            echo "" >&2
+            echo "  To create a virtual environment: uv venv" >&2
+            echo "" >&2
+            exit 1
+            ;;
+    esac
+done
+
+# Check for -m flag followed by pip or venv
+prev=""
+for arg in "$@"; do
+    if [ "$prev" = "-m" ]; then
+        case "$arg" in
+            pip)
+                echo "Error: 'python -m pip' is disabled. Use uv instead:" >&2
+                echo "" >&2
+                echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+                echo "  To add a dependency to the project: uv add PACKAGE" >&2
+                echo "" >&2
+                exit 1
+                ;;
+            venv)
+                echo "Error: 'python -m venv' is disabled. Use uv instead:" >&2
+                echo "" >&2
+                echo "  To create a virtual environment: uv venv" >&2
+                echo "" >&2
+                exit 1
+                ;;
+        esac
+    fi
+    prev="$arg"
+done
+
+# Dispatch to uv run python
+exec uv run python "$@"
dots/pi/agent/extensions/intercepted-commands/python3
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# Check for disallowed module invocations
+for arg in "$@"; do
+    case "$arg" in
+        -mpip|-m\ pip|pip)
+            echo "Error: 'python3 -m pip' is disabled. Use uv instead:" >&2
+            echo "" >&2
+            echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+            echo "  To add a dependency to the project: uv add PACKAGE" >&2
+            echo "" >&2
+            exit 1
+            ;;
+        -mvenv|-m\ venv|venv)
+            echo "Error: 'python3 -m venv' is disabled. Use uv instead:" >&2
+            echo "" >&2
+            echo "  To create a virtual environment: uv venv" >&2
+            echo "" >&2
+            exit 1
+            ;;
+    esac
+done
+
+# Check for -m flag followed by pip or venv
+prev=""
+for arg in "$@"; do
+    if [ "$prev" = "-m" ]; then
+        case "$arg" in
+            pip)
+                echo "Error: 'python3 -m pip' is disabled. Use uv instead:" >&2
+                echo "" >&2
+                echo "  To install a package for a script: uv run --with PACKAGE python script.py" >&2
+                echo "  To add a dependency to the project: uv add PACKAGE" >&2
+                echo "" >&2
+                exit 1
+                ;;
+            venv)
+                echo "Error: 'python3 -m venv' is disabled. Use uv instead:" >&2
+                echo "" >&2
+                echo "  To create a virtual environment: uv venv" >&2
+                echo "" >&2
+                exit 1
+                ;;
+        esac
+    fi
+    prev="$arg"
+done
+
+# Dispatch to uv run python3
+exec uv run python3 "$@"
dots/pi/agent/extensions/intercepted-commands/virtualenv
@@ -0,0 +1,9 @@
+#!/bin/bash
+echo "Error: virtualenv is disabled. Use uv instead:" >&2
+echo "" >&2
+echo "  virtualenv .venv -> uv venv" >&2
+echo "  virtualenv env   -> uv venv env" >&2
+echo "" >&2
+echo "Then activate with: source .venv/bin/activate" >&2
+echo "" >&2
+exit 1
dots/pi/agent/extensions/pre-commit.ts
@@ -0,0 +1,298 @@
+/**
+ * Pi Extension: Pre-commit Linting and Formatting
+ *
+ * Intercepts git commit commands and runs appropriate linters/formatters
+ * before allowing the commit. Detects project type and runs the right tools.
+ *
+ * Detection and tools:
+ * - Nix files (.nix): nixfmt, deadnix, statix
+ * - Go files (.go): gofmt, go vet
+ * - Python files (.py): ruff format, ruff check
+ * - TypeScript/JavaScript: prettier
+ * - Shell scripts (.sh): shellcheck
+ *
+ * Also checks for:
+ * - pre-commit hooks (runs `pre-commit run --files` if .pre-commit-config.yaml exists)
+ * - Makefile targets (runs `make fmt` or `make format` if available)
+ *
+ * The extension only formats staged files, not the entire project.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { execSync, spawnSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { join, extname } from "node:path";
+
+// Check if a command exists
+function commandExists(cmd: string): boolean {
+  try {
+    execSync(`which ${cmd}`, { stdio: "ignore" });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// Get staged files from git
+function getStagedFiles(): string[] {
+  try {
+    const output = execSync("git diff --cached --name-only --diff-filter=ACMR", {
+      encoding: "utf-8",
+    });
+    return output.trim().split("\n").filter(Boolean);
+  } catch {
+    return [];
+  }
+}
+
+// Group files by extension/type
+function groupFilesByType(files: string[]): Map<string, string[]> {
+  const groups = new Map<string, string[]>();
+
+  for (const file of files) {
+    const ext = extname(file).toLowerCase();
+    let type: string;
+
+    switch (ext) {
+      case ".nix":
+        type = "nix";
+        break;
+      case ".go":
+        type = "go";
+        break;
+      case ".py":
+        type = "python";
+        break;
+      case ".ts":
+      case ".tsx":
+      case ".js":
+      case ".jsx":
+      case ".json":
+      case ".md":
+      case ".yaml":
+      case ".yml":
+        type = "prettier";
+        break;
+      case ".sh":
+      case ".bash":
+        type = "shell";
+        break;
+      case ".el":
+        type = "elisp";
+        break;
+      default:
+        continue;
+    }
+
+    if (!groups.has(type)) {
+      groups.set(type, []);
+    }
+    groups.get(type)!.push(file);
+  }
+
+  return groups;
+}
+
+// Run a formatter/linter on files
+function runTool(
+  cmd: string,
+  args: string[],
+  files: string[],
+  cwd: string
+): { success: boolean; output: string } {
+  if (files.length === 0) {
+    return { success: true, output: "" };
+  }
+
+  const result = spawnSync(cmd, [...args, ...files], {
+    cwd,
+    encoding: "utf-8",
+    stdio: ["pipe", "pipe", "pipe"],
+  });
+
+  const output = (result.stdout || "") + (result.stderr || "");
+  return {
+    success: result.status === 0,
+    output: output.trim(),
+  };
+}
+
+// Check if pre-commit is available and configured
+function hasPreCommitConfig(cwd: string): boolean {
+  return existsSync(join(cwd, ".pre-commit-config.yaml"));
+}
+
+// Check if Makefile has format target
+function hasMakeFormatTarget(cwd: string): boolean {
+  const makefilePath = join(cwd, "Makefile");
+  if (!existsSync(makefilePath)) return false;
+
+  try {
+    const content = readFileSync(makefilePath, "utf-8");
+    return /^(fmt|format):/m.test(content);
+  } catch {
+    return false;
+  }
+}
+
+export default function (pi: ExtensionAPI) {
+  // Intercept git commit commands
+  pi.on("tool_call", async (event, ctx) => {
+    if (event.toolName.toLowerCase() !== "bash") {
+      return undefined;
+    }
+
+    const command = event.input?.command;
+    if (!command || typeof command !== "string") {
+      return undefined;
+    }
+
+    // Check if this is a git commit command
+    const gitCommitPattern = /(^|&&|\|\||;|\||\$\()\s*git\s+commit(\s|$)/;
+    if (!gitCommitPattern.test(command)) {
+      return undefined;
+    }
+
+    // Skip if --no-verify flag is present (user explicitly wants to skip hooks)
+    if (/--no-verify/.test(command)) {
+      ctx.ui.notify("[pre-commit] Skipping checks (--no-verify)", "warning");
+      return undefined;
+    }
+
+    const cwd = process.cwd();
+    const stagedFiles = getStagedFiles();
+
+    if (stagedFiles.length === 0) {
+      return undefined; // No staged files, let git handle it
+    }
+
+    ctx.ui.notify(`[pre-commit] Checking ${stagedFiles.length} staged files...`, "info");
+
+    const errors: string[] = [];
+    const formatted: string[] = [];
+
+    // If pre-commit is configured, use it
+    if (hasPreCommitConfig(cwd) && commandExists("pre-commit")) {
+      ctx.ui.notify("[pre-commit] Running pre-commit hooks...", "info");
+      const result = runTool("pre-commit", ["run", "--files"], stagedFiles, cwd);
+      if (!result.success) {
+        errors.push(`pre-commit failed:\n${result.output}`);
+      } else {
+        formatted.push("pre-commit hooks passed");
+      }
+    } else {
+      // Run individual formatters based on file types
+      const fileGroups = groupFilesByType(stagedFiles);
+
+      // Nix files
+      const nixFiles = fileGroups.get("nix") || [];
+      if (nixFiles.length > 0) {
+        if (commandExists("nixfmt")) {
+          const result = runTool("nixfmt", ["--check"], nixFiles, cwd);
+          if (!result.success) {
+            // Try to format
+            const fmtResult = runTool("nixfmt", [], nixFiles, cwd);
+            if (fmtResult.success) {
+              formatted.push(`nixfmt: formatted ${nixFiles.length} files`);
+            } else {
+              errors.push(`nixfmt failed:\n${fmtResult.output}`);
+            }
+          }
+        }
+        if (commandExists("deadnix")) {
+          const result = runTool("deadnix", ["--fail"], nixFiles, cwd);
+          if (!result.success) {
+            errors.push(`deadnix found issues:\n${result.output}`);
+          }
+        }
+      }
+
+      // Go files
+      const goFiles = fileGroups.get("go") || [];
+      if (goFiles.length > 0 && commandExists("gofmt")) {
+        const result = runTool("gofmt", ["-l"], goFiles, cwd);
+        if (result.output) {
+          // Files need formatting
+          const fmtResult = runTool("gofmt", ["-w"], goFiles, cwd);
+          if (fmtResult.success) {
+            formatted.push(`gofmt: formatted ${goFiles.length} files`);
+          } else {
+            errors.push(`gofmt failed:\n${fmtResult.output}`);
+          }
+        }
+      }
+
+      // Python files
+      const pyFiles = fileGroups.get("python") || [];
+      if (pyFiles.length > 0 && commandExists("ruff")) {
+        // Format first
+        const fmtResult = runTool("ruff", ["format"], pyFiles, cwd);
+        if (fmtResult.success) {
+          formatted.push(`ruff format: ${pyFiles.length} files`);
+        }
+        // Then check
+        const checkResult = runTool("ruff", ["check", "--fix"], pyFiles, cwd);
+        if (!checkResult.success) {
+          errors.push(`ruff check found issues:\n${checkResult.output}`);
+        }
+      }
+
+      // Prettier files (JS/TS/JSON/MD/YAML)
+      const prettierFiles = fileGroups.get("prettier") || [];
+      if (prettierFiles.length > 0 && commandExists("prettier")) {
+        const result = runTool("prettier", ["--write"], prettierFiles, cwd);
+        if (result.success) {
+          formatted.push(`prettier: formatted ${prettierFiles.length} files`);
+        } else {
+          errors.push(`prettier failed:\n${result.output}`);
+        }
+      }
+
+      // Shell files
+      const shellFiles = fileGroups.get("shell") || [];
+      if (shellFiles.length > 0 && commandExists("shellcheck")) {
+        const result = runTool("shellcheck", [], shellFiles, cwd);
+        if (!result.success) {
+          errors.push(`shellcheck found issues:\n${result.output}`);
+        }
+      }
+    }
+
+    // Report results
+    if (formatted.length > 0) {
+      ctx.ui.notify(`[pre-commit] Formatted: ${formatted.join(", ")}`, "info");
+      // Re-stage formatted files
+      try {
+        execSync(`git add ${stagedFiles.join(" ")}`, { cwd, stdio: "ignore" });
+        ctx.ui.notify("[pre-commit] Re-staged formatted files", "info");
+      } catch {
+        ctx.ui.notify("[pre-commit] Warning: Could not re-stage files", "warning");
+      }
+    }
+
+    if (errors.length > 0) {
+      const errorMessage = [
+        "BLOCKED: Pre-commit checks failed!",
+        "",
+        ...errors,
+        "",
+        "Fix the issues above and try again.",
+        "Use --no-verify to skip checks (not recommended).",
+      ].join("\n");
+
+      ctx.ui.notify(errorMessage, "error");
+
+      return {
+        block: true,
+        reason: errorMessage,
+      };
+    }
+
+    ctx.ui.notify("[pre-commit] All checks passed ✓", "info");
+    return undefined; // Allow the commit
+  });
+
+  pi.on("session_start", (_event, ctx) => {
+    ctx.ui.notify("Pre-commit linting active", "info");
+  });
+}
dots/pi/agent/extensions/uv.ts
@@ -0,0 +1,39 @@
+/**
+ * UV Extension - Redirects Python tooling to uv equivalents
+ *
+ * This extension wraps the bash tool to prepend intercepted-commands to PATH,
+ * which contains shim scripts that intercept common Python tooling commands
+ * and redirect agents to use uv instead.
+ *
+ * Intercepted commands:
+ * - pip/pip3: Blocked with suggestions to use `uv add` or `uv run --with`
+ * - poetry: Blocked with uv equivalents (uv init, uv add, uv sync, uv run)
+ * - pipenv: Blocked with uv equivalents
+ * - virtualenv: Blocked with suggestion to use `uv venv`
+ * - conda: Blocked with uv equivalents (for Python package management)
+ * - python/python3: Redirected to `uv run python`, with special handling to
+ *   block `python -m pip` and `python -m venv`
+ *
+ * Based on mitsuhiko/agent-stuff uv.ts, enhanced with additional interceptors.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { createBashTool } from "@mariozechner/pi-coding-agent";
+import { dirname, join } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const interceptedCommandsPath = join(__dirname, "intercepted-commands");
+
+export default function (pi: ExtensionAPI) {
+  const cwd = process.cwd();
+  const bashTool = createBashTool(cwd, {
+    commandPrefix: `export PATH="${interceptedCommandsPath}:$PATH"`,
+  });
+
+  pi.on("session_start", (_event, ctx) => {
+    ctx.ui.notify("UV interceptor active (pip/poetry/pipenv/virtualenv/conda → uv)", "info");
+  });
+
+  pi.registerTool(bashTool);
+}