Commit 1a0ac78a473a
Changed files (10)
dots
pi
agent
extensions
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);
+}