├── settings_example ├── clojure-mcp-config.edn └── settings.local.json ├── deps.edn ├── .gitignore ├── bb.edn ├── capture-hook.sh ├── test ├── README.md └── clojure_mcp_light │ ├── stats_test.clj │ ├── hook_test.clj │ ├── nrepl_eval_test.clj │ ├── tmp_test.clj │ ├── delimiter_repair_test.clj │ └── nrepl_client_test.clj ├── skills └── clojure-eval │ ├── examples.md │ └── SKILL.md ├── GEMINI.md ├── CLAUDE.md ├── commands ├── clojure-nrepl.md └── start-nrepl.md ├── src └── clojure_mcp_light │ ├── stats.clj │ ├── paren_repair.clj │ ├── delimiter_repair.clj │ ├── nrepl_client.clj │ ├── tmp.clj │ └── hook.clj ├── scripts ├── test-parse-all.bb └── stats-summary.bb ├── LICENSE.md ├── README.md └── CHANGELOG.md /settings_example/clojure-mcp-config.edn: -------------------------------------------------------------------------------- 1 | {:enable-tools [:clojure_eval]} 2 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:aliases {:nrepl {:deps {nrepl/nrepl {:mvn/version "1.5.1"}} 2 | :main-opts ["-m" "nrepl.cmdline"]}}} 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Hook logs 2 | hook-logs/ 3 | .clojure-mcp-light-hooks.log 4 | 5 | # Clojure/LSP directories 6 | .clj-kondo/ 7 | .lsp/ 8 | .cpcache/ 9 | 10 | # ClojureMCP config 11 | .clojure-mcp/ 12 | 13 | # Temporary files 14 | *~ 15 | \#*\# 16 | .#* 17 | 18 | # nREPL port file 19 | .nrepl-port 20 | .nrepl-session 21 | 22 | # MCP tasks 23 | .mcp-tasks* 24 | 25 | # Claude Code local config 26 | .claude/ 27 | -------------------------------------------------------------------------------- /settings_example/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "PreToolUse": [ 4 | { 5 | "matcher": "Write|Edit", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "clj-paren-repair-claude-hook" 10 | } 11 | ] 12 | } 13 | ], 14 | "PostToolUse": [ 15 | { 16 | "matcher": "Edit|Write", 17 | "hooks": [ 18 | { 19 | "type": "command", 20 | "command": "clj-paren-repair-claude-hook" 21 | } 22 | ] 23 | } 24 | ], 25 | "SessionEnd": [ 26 | { 27 | "hooks": [ 28 | { 29 | "type": "command", 30 | "command": "clj-paren-repair-claude-hook" 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | 3 | :deps {parinferish/parinferish {:mvn/version "0.8.0"} 4 | dev.weavejester/cljfmt {:mvn/version "0.15.5"}} 5 | 6 | :tasks 7 | {test {:doc "Run tests" 8 | :extra-paths ["test"] 9 | :extra-deps {io.github.cognitect-labs/test-runner 10 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 11 | :task (exec 'cognitect.test-runner.api/test) 12 | :exec-args {:dirs ["test"]} 13 | :org.babashka/cli {:coerce {:nses [:symbol] 14 | :vars [:symbol]}}}} 15 | 16 | :bbin/bin {clj-paren-repair-claude-hook {:main-opts ["-m" "clojure-mcp-light.hook"]} 17 | clj-nrepl-eval {:main-opts ["-m" "clojure-mcp-light.nrepl-eval"]} 18 | clj-paren-repair {:main-opts ["-m" "clojure-mcp-light.paren-repair"]}}} 19 | -------------------------------------------------------------------------------- /capture-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Capture hook for debugging hook calls 3 | # This script captures the JSON input from hooks and logs it 4 | 5 | # Create logs directory if it doesn't exist 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | LOG_DIR="${SCRIPT_DIR}/hook-logs" 8 | mkdir -p "$LOG_DIR" 9 | 10 | # Generate timestamp-based log file 11 | TIMESTAMP=$(date +"%Y%m%d_%H%M%S_%N") 12 | LOG_FILE="${LOG_DIR}/hook-${TIMESTAMP}.json" 13 | 14 | # Read JSON from stdin and save it 15 | INPUT=$(cat) 16 | echo "$INPUT" > "$LOG_FILE" 17 | 18 | # Print the log file location to stderr (won't interfere with JSON output) 19 | echo "Hook data captured to: $LOG_FILE" >&2 20 | 21 | # Extract hook_event_name from input 22 | HOOK_EVENT=$(echo "$INPUT" | grep -o '"hook_event_name":"[^"]*"' | cut -d'"' -f4) 23 | 24 | # Output appropriate response based on hook event 25 | if [ "$HOOK_EVENT" = "SessionEnd" ]; then 26 | # SessionEnd hooks don't use hookSpecificOutput, just exit successfully 27 | exit 0 28 | else 29 | echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' 30 | exit 0 31 | fi 32 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This directory contains tests for clojure-mcp-light. 4 | 5 | ## Running Tests 6 | 7 | Run all tests: 8 | ```bash 9 | bb test 10 | ``` 11 | 12 | ## Test Structure 13 | 14 | - `delimiter_repair_test.clj` - Tests for delimiter detection and repair functionality 15 | - `hook_test.clj` - Tests for Claude Code hook processing 16 | - `nrepl_eval_test.clj` - Tests for nREPL evaluation utilities 17 | 18 | ## Test Coverage 19 | 20 | ### delimiter-repair namespace 21 | - ✅ Delimiter error detection 22 | - ✅ Delimiter repair with parinfer 23 | - ✅ Edge cases (empty strings, multiple forms) 24 | 25 | ### hook namespace 26 | - ✅ Clojure file detection 27 | - ✅ Backup path generation 28 | - ✅ Hook processing for Write operations 29 | - ✅ Hook processing for Edit operations 30 | - ✅ Auto-fixing delimiter errors 31 | 32 | ### nrepl-eval namespace 33 | - ✅ Byte to string conversion 34 | - ✅ Type coercion 35 | - ✅ Port and host resolution 36 | - ✅ Message parsing 37 | 38 | ## Adding New Tests 39 | 40 | 1. Create a new test file in `test/clojure_mcp_light/` 41 | 2. Add the namespace to the test task in `bb.edn` 42 | 3. Run `bb test` to verify 43 | 44 | Test files should follow the naming convention `*_test.clj` and use `clojure.test`. 45 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/stats_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.stats-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure.string :as str] 4 | [clojure-mcp-light.stats :as stats] 5 | [babashka.fs :as fs])) 6 | 7 | ;; ============================================================================ 8 | ;; Path Normalization Tests 9 | ;; ============================================================================ 10 | 11 | (deftest normalize-stats-path-test 12 | (testing "expands tilde to home directory" 13 | (let [result (stats/normalize-stats-path "~/my-stats.log") 14 | home (System/getProperty "user.home")] 15 | (is (str/starts-with? result home)) 16 | (is (str/ends-with? result "my-stats.log")) 17 | (is (not (str/includes? result "~"))))) 18 | 19 | (testing "converts relative paths to absolute" 20 | (let [result (stats/normalize-stats-path "../../test.log")] 21 | (is (str/starts-with? result "/")) 22 | (is (not (str/includes? result ".."))))) 23 | 24 | (testing "normalizes absolute paths" 25 | (let [result (stats/normalize-stats-path "/tmp/stats.log")] 26 | (is (= "/tmp/stats.log" result)))) 27 | 28 | (testing "resolves dot-relative paths" 29 | (let [result (stats/normalize-stats-path "./my-stats.log") 30 | cwd (str (fs/cwd))] 31 | (is (str/starts-with? result cwd)) 32 | (is (str/ends-with? result "my-stats.log")))) 33 | 34 | (testing "handles complex relative paths" 35 | (let [result (stats/normalize-stats-path "../../../stats/../test.log")] 36 | (is (str/starts-with? result "/")) 37 | (is (not (str/includes? result ".."))) 38 | (is (not (str/includes? result "/stats/"))))) 39 | 40 | (testing "returns a string" 41 | (is (string? (stats/normalize-stats-path "~/test.log"))) 42 | (is (string? (stats/normalize-stats-path "/tmp/test.log"))) 43 | (is (string? (stats/normalize-stats-path "./test.log"))))) 44 | -------------------------------------------------------------------------------- /skills/clojure-eval/examples.md: -------------------------------------------------------------------------------- 1 | # clj-nrepl-eval Examples 2 | 3 | ## Discovery 4 | 5 | ```bash 6 | clj-nrepl-eval --connected-ports 7 | ``` 8 | 9 | ## Heredoc for Multiline Code 10 | 11 | ```bash 12 | clj-nrepl-eval -p 7888 <<'EOF' 13 | (defn greet [name] 14 | (str "Hello, " name "!")) 15 | 16 | (greet "Claude") 17 | EOF 18 | ``` 19 | 20 | ### Heredoc Simplifies String Escaping 21 | 22 | Heredoc avoids shell escaping issues with quotes, backslashes, and special characters: 23 | 24 | ```bash 25 | # With heredoc - no escaping needed 26 | clj-nrepl-eval -p 7888 <<'EOF' 27 | (def regex #"\\d{3}-\\d{4}") 28 | (def message "She said \"Hello!\" and waved") 29 | (def path "C:\\Users\\name\\file.txt") 30 | (println message) 31 | EOF 32 | 33 | # Without heredoc - requires complex escaping 34 | clj-nrepl-eval -p 7888 "(def message \"She said \\\"Hello!\\\" and waved\")" 35 | ``` 36 | 37 | ## Working with Project Namespaces 38 | 39 | ```bash 40 | # Test a function after requiring 41 | clj-nrepl-eval -p 7888 <<'EOF' 42 | (require '[clojure-mcp-light.delimiter-repair :as dr] :reload) 43 | (dr/delimiter-error? "(defn foo [x]") 44 | EOF 45 | ``` 46 | 47 | ## Verify Compilation After Edit 48 | 49 | ```bash 50 | # If this returns nil, the file compiled successfully 51 | clj-nrepl-eval -p 7888 "(require 'clojure-mcp-light.hook :reload)" 52 | ``` 53 | 54 | ## Session Management 55 | 56 | ```bash 57 | # Reset session if state becomes corrupted 58 | clj-nrepl-eval -p 7888 --reset-session 59 | ``` 60 | 61 | ## Common Workflow Patterns 62 | 63 | ### Load, Test, Iterate 64 | 65 | ```bash 66 | # After editing a file, reload and test in one command 67 | clj-nrepl-eval -p 7888 <<'EOF' 68 | (require '[my.namespace :as ns] :reload) 69 | (ns/my-function test-data) 70 | EOF 71 | ``` 72 | 73 | ### Run Tests After Changes 74 | 75 | ```bash 76 | clj-nrepl-eval -p 7888 <<'EOF' 77 | (require '[my.project.core :as core] :reload) 78 | (require '[my.project.core-test :as test] :reload) 79 | (clojure.test/run-tests 'my.project.core-test) 80 | EOF 81 | ``` 82 | -------------------------------------------------------------------------------- /GEMINI.md: -------------------------------------------------------------------------------- 1 | # Project Overview 2 | 3 | This is a Clojure project that provides command-line tools to improve the Clojure development experience with an AI coding assistant called "Claude Code". The main features are automatic delimiter fixing and an nREPL evaluation tool. 4 | 5 | The project is built using Babashka, a Clojure scripting environment, and it has dependencies on `edamame` for parsing Clojure code, `cheshire` for JSON handling, `tools.cli` for command-line argument parsing, and `nrepl/bencode` for nREPL communication. 6 | 7 | The project provides two command-line tools: 8 | 9 | * `clj-paren-repair-claude-hook`: A hook for "Claude Code" that automatically fixes delimiter errors in Clojure files. 10 | * `clj-nrepl-eval`: A command-line nREPL client that can be used to evaluate Clojure code. 11 | 12 | # Building and Running 13 | 14 | The project is built using Babashka. The `bb.edn` file defines the project's dependencies and tasks. 15 | 16 | To run the tests, use the following command: 17 | 18 | ```bash 19 | bb test 20 | ``` 21 | 22 | The `bb.edn` file also defines two binaries, `clj-paren-repair-claude-hook` and `clj-nrepl-eval`. These can be installed using `bbin`, the Babashka package manager. 23 | 24 | # Development Conventions 25 | 26 | The project uses `cljfmt` for code formatting. The `clj-paren-repair-claude-hook` tool has an option to automatically format the code with `cljfmt` after fixing delimiter errors. 27 | 28 | # Parenthesis Repair 29 | 30 | The command `clj-paren-repair` is currently installed on your path. 31 | 32 | **IMPORTANT:** You SHOULD NOT try to manually repair parenthesis errors. If you encounter a file with unbalanced parentheses or delimiters, run the `clj-paren-repair` tool on that file instead of attempting to fix the delimiters yourself. IF the tool doesn't work report to the user that they need to fix the delimiter error. 33 | 34 | ### `clj-paren-repair` Tool Behavior 35 | 36 | The `clj-paren-repair` tool exhibits the following behavior: 37 | 38 | * **Delimiter Repair:** It successfully identifies and fixes common delimiter errors, such as unbalanced parentheses. 39 | * **Code Formatting (`cljfmt`):** The tool automatically formats files using `cljfmt` whenever it processes them, regardless of whether a delimiter error was fixed or not. 40 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | Instructions for Claude Code when working with this repository. 4 | 5 | ## Project Overview 6 | 7 | clojure-mcp-light provides CLI tooling for Clojure development in Claude Code: 8 | - **clj-paren-repair-claude-hook** - Auto-fixes delimiter errors in Clojure files via hooks 9 | - **clj-nrepl-eval** - nREPL evaluation with automatic delimiter repair 10 | 11 | ## Essential Commands 12 | 13 | ```bash 14 | # Run tests 15 | bb test 16 | 17 | # Lint 18 | clj-kondo --lint src --lint test --lint scripts 19 | 20 | # Install locally 21 | bbin install . 22 | bbin install . --as clj-nrepl-eval --main-opts '["-m" "clojure-mcp-light.nrepl-eval"]' 23 | 24 | # Test hook manually 25 | echo '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"test.clj","content":"(def x 1)"}}' | bb -m clojure-mcp-light.hook 26 | echo '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"test.clj","content":"(def x 1)"}}' | bb -m clojure-mcp-light.hook -- --cljfmt --stats 27 | 28 | # Show help 29 | bb -m clojure-mcp-light.hook -- --help 30 | 31 | # Quick eval/testing with bb and heredoc 32 | bb <<'EOF' 33 | (require '[edamame.core :as e]) 34 | (println (e/parse-string "{1 2 #?@(:cljs [3 4])}" {:all true :features #{:cljs} :read-cond :allow})) 35 | EOF 36 | ``` 37 | 38 | ## Core Modules 39 | 40 | **delimiter_repair.clj** - Detects and repairs delimiter errors using edamame parser. Uses parinfer-rust when available, falls back to parinferish (pure Clojure) 41 | 42 | **hook.clj** - Intercepts Write/Edit operations to auto-fix delimiter errors. For Write: fixes before writing. For Edit: creates backup, fixes after edit, restores if unfixable. Optional `--cljfmt` flag for formatting. Supports `--stats` for tracking delimiter events. 43 | 44 | **nrepl_eval.clj** - nREPL client with timeout handling, persistent sessions, and delimiter repair. Use `--connected-ports` to discover connections, `--port` to specify target. 45 | 46 | **tmp.clj** - Session-scoped temp file management with automatic cleanup via SessionEnd hook. 47 | 48 | ## Key Details 49 | 50 | - Hook processes Clojure files by extension: `.clj`, `.cljs`, `.cljc`, `.bb`, `.edn`, `.lpy` (case-insensitive) 51 | - Hook also detects Babashka scripts via shebang (`#!/usr/bin/env bb` or `#!/usr/bin/bb`) 52 | - `delimiter-error?` only detects delimiter errors, not general syntax errors 53 | - Logging enabled via `CML_ENABLE_LOGGING=true`, writes to `.clojure-mcp-light-hooks.log` 54 | - Stats tracked in `~/.clojure-mcp-light/stats.log` when using `--stats` flag 55 | - nREPL sessions persist per target in session-scoped temp dirs 56 | 57 | ## Dependencies 58 | 59 | External tools: 60 | - **parinfer-rust** (optional, recommended) - Faster delimiter repair when on PATH; falls back to parinferish if not available 61 | - **cljfmt** (optional) - For `--cljfmt` flag 62 | - **babashka** - For running scripts 63 | - **bbin** - For installation 64 | 65 | Clojure deps (bb.edn): edamame, cheshire, tools.cli, nrepl/bencode, parinferish 66 | - let's add a short note about using here doc with the bb tool for eval -------------------------------------------------------------------------------- /commands/clojure-nrepl.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Info on how to evaluate Clojure code via nREPL using clj-nrepl-eval 3 | --- 4 | 5 | When you need to evaluate Clojure code you can use the 6 | `clj-nrepl-eval` command (if installed via bbin) to evaluate code 7 | against an nREPL server. This means the state of the REPL session 8 | will persist between evaluations. 9 | 10 | You can require or load a file in one evaluation of the command and 11 | when you call the command again the namespace will still be available. 12 | 13 | ## Example uses 14 | 15 | You can evaluate clojure code to check if a file you just edited still compiles and loads. 16 | 17 | Whenever you require a namespace always use the `:reload` key. 18 | 19 | ## How to Use 20 | 21 | The following evaluates Clojure code via an nREPL connection. 22 | 23 | **Discover available nREPL servers:** 24 | ```bash 25 | clj-nrepl-eval --discover-ports 26 | ``` 27 | 28 | **Evaluate code (requires --port):** 29 | ```bash 30 | clj-nrepl-eval --port "" 31 | ``` 32 | 33 | ## Options 34 | 35 | - `-p, --port PORT` - nREPL port (required) 36 | - `-H, --host HOST` - nREPL host (default: 127.0.0.1) 37 | - `-t, --timeout MILLISECONDS` - Timeout in milliseconds (default: 120000) 38 | - `-r, --reset-session` - Reset the persistent nREPL session 39 | - `-c, --connected-ports` - List previously connected nREPL sessions 40 | - `-d, --discover-ports` - Discover nREPL servers in current directory 41 | - `-h, --help` - Show help message 42 | 43 | ## Workflow 44 | 45 | **1. Discover nREPL servers in current directory:** 46 | ```bash 47 | clj-nrepl-eval --discover-ports 48 | # Discovered nREPL servers: 49 | # 50 | # In current directory (/path/to/project): 51 | # localhost:7888 (clj) 52 | # localhost:7889 (bb) 53 | # 54 | # Total: 2 servers 55 | ``` 56 | 57 | **2. Check previously connected sessions (optional):** 58 | ```bash 59 | clj-nrepl-eval --connected-ports 60 | # Active nREPL connections: 61 | # 127.0.0.1:7888 (clj) (session: abc123...) 62 | # 63 | # Total: 1 active connection 64 | ``` 65 | 66 | **3. Evaluate code:** 67 | ```bash 68 | clj-nrepl-eval -p 7888 "(+ 1 2 3)" 69 | ``` 70 | 71 | ## Examples 72 | 73 | **Discover servers:** 74 | ```bash 75 | clj-nrepl-eval --discover-ports 76 | ``` 77 | 78 | **Basic evaluation:** 79 | ```bash 80 | clj-nrepl-eval -p 7888 "(+ 1 2 3)" 81 | ``` 82 | 83 | **With timeout:** 84 | ```bash 85 | clj-nrepl-eval -p 7888 --timeout 5000 "(Thread/sleep 10000)" 86 | ``` 87 | 88 | **Multiple expressions:** 89 | ```bash 90 | clj-nrepl-eval -p 7888 "(def x 10) (* x 2) (+ x 5)" 91 | ``` 92 | 93 | **Reset session:** 94 | ```bash 95 | clj-nrepl-eval -p 7888 --reset-session 96 | ``` 97 | 98 | ## Features 99 | 100 | - **Server discovery** - Use --discover-ports to find all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) in current directory 101 | - **Session tracking** - Use --connected-ports to see previously connected sessions 102 | - **Automatic delimiter repair** - fixes missing/mismatched parens before evaluation 103 | - **Timeout handling** - interrupts long-running evaluations 104 | - **Persistent sessions** - State persists across invocations 105 | -------------------------------------------------------------------------------- /commands/start-nrepl.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Start an nREPL server in the background 3 | --- 4 | 5 | When the user invokes this command, start an nREPL server in the background by following these steps: 6 | 7 | ## Step 1: Check CLAUDE.md or AGENTS.md for REPL Instructions 8 | 9 | First, check if a `CLAUDE.md` or `AGENTS.md` file exists in the project root: 10 | 11 | 1. If `CLAUDE.md` or `AGENTS.md` exists, read it and look for a section about starting the REPL or nREPL 12 | 2. Look for keywords like "REPL", "nREPL", "start", "run", or similar 13 | 3. If specific instructions are found, follow those instructions instead of the default steps below 14 | 15 | ## Step 2: Check Environment 16 | 17 | First, verify that nREPL is configured in the project: 18 | 19 | 1. Check if `deps.edn` exists and contains an `:nrepl` alias 20 | 2. Check if `project.clj` exists (Leiningen project) 21 | 22 | If neither file exists or nREPL is not configured: 23 | - Inform the user that nREPL is not configured 24 | - Ask if they want you to add the nREPL configuration to `deps.edn` 25 | 26 | ## Step 3: Check for Existing nREPL Servers 27 | 28 | Before starting a new server, check for existing servers in the current directory: 29 | 30 | 1. Run `clj-nrepl-eval --discover-ports` to find nREPL servers in current directory 31 | 2. If servers are found, inform the user and display the ports with their types (clj/bb/etc) 32 | 3. Ask if they want to start an additional server or use an existing one 33 | 4. Optionally also check `clj-nrepl-eval --connected-ports` to see previously connected sessions 34 | 35 | ## Step 4: Start nREPL Server 36 | 37 | Start the nREPL server in the background WITHOUT specifying a port (let nREPL auto-assign an available port): 38 | 39 | **For deps.edn projects:** 40 | ```bash 41 | clojure -M:nrepl 42 | ``` 43 | 44 | **For Leiningen projects:** 45 | ```bash 46 | lein repl :headless 47 | ``` 48 | 49 | Use the Bash tool with `run_in_background: true` to start the server. 50 | 51 | ## Step 5: Extract Port from Output 52 | 53 | 1. Wait 2-3 seconds for the server to start 54 | 2. Use the BashOutput tool to check the startup output 55 | 3. Parse the port number from output like: "nREPL server started on port 54321..." 56 | 4. Extract the numeric port value 57 | 58 | ## Step 6: Test Connection 59 | 60 | Verify the connection by running a test evaluation: 61 | ```bash 62 | clj-nrepl-eval -p PORT "(+ 1 2 3)" 63 | ``` 64 | 65 | ## Step 7: Report to User 66 | 67 | Display to the user: 68 | - The port number the server is running on 69 | - The connection URL (e.g., `nrepl://localhost:PORT`) 70 | - The background process ID 71 | - The command to use: `clj-nrepl-eval -p PORT "code"` 72 | - Mention that they can use `--connected-ports` to see this connection later 73 | 74 | ## Example Output to User 75 | 76 | ``` 77 | nREPL server started successfully! 78 | 79 | Port: 54321 80 | URL: nrepl://localhost:54321 81 | Background process: a12345 82 | 83 | To evaluate code: 84 | clj-nrepl-eval -p 54321 "(+ 1 2 3)" 85 | 86 | To see all connections: 87 | clj-nrepl-eval --connected-ports 88 | ``` 89 | 90 | ## Error Handling 91 | 92 | - If the server fails to start, display the error output 93 | - If port cannot be parsed, show the raw output and ask user to check manually 94 | - If `.nrepl-port` file cannot be created, inform the user 95 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/stats.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.stats 2 | "Statistics tracking for delimiter repair events" 3 | (:require [babashka.fs :as fs] 4 | [taoensso.timbre :as timbre]) 5 | (:import [java.time Instant])) 6 | 7 | ;; ============================================================================ 8 | ;; Configuration 9 | ;; ============================================================================ 10 | 11 | (defn normalize-stats-path 12 | "Normalize a stats file path, handling tilde expansion and relative paths. 13 | 14 | Parameters: 15 | - path: string path (can be relative, absolute, or use ~) 16 | 17 | Returns: normalized absolute path as string" 18 | [path] 19 | (-> path 20 | fs/expand-home 21 | fs/absolutize 22 | fs/normalize 23 | str)) 24 | 25 | (def ^:dynamic *enable-stats* false) 26 | 27 | (def ^:dynamic *stats-file-path* 28 | "Stats log file location - can be overridden via binding" 29 | (let [home (System/getProperty "user.home")] 30 | (str home "/.clojure-mcp-light/stats.log"))) 31 | 32 | ;; ============================================================================ 33 | ;; Event Logging 34 | ;; ============================================================================ 35 | 36 | (defn edn-output-fn 37 | "Timbre output function that produces pure EDN from first varg. 38 | This is used by the stats appender to write EDN entries directly." 39 | [{:keys [vargs]}] 40 | (when-let [data (first vargs)] 41 | (pr-str data))) 42 | 43 | (defn timestamp-iso8601 44 | "Generate ISO-8601 timestamp string" 45 | [] 46 | (.toString (Instant/now))) 47 | 48 | (defn ensure-parent-dir 49 | "Ensure parent directory exists for the given file path" 50 | [file-path] 51 | (when-let [parent-dir (fs/parent file-path)] 52 | (fs/create-dirs parent-dir))) 53 | 54 | (defn log-stats! 55 | "Low-level stats logging function that accepts arbitrary EDN data. 56 | 57 | Parameters: 58 | - event-type: keyword describing the event 59 | - data: map of additional data to include in the log entry 60 | 61 | Automatically adds :event-type and :timestamp to the data map." 62 | [event-type data] 63 | (when *enable-stats* 64 | (try 65 | (ensure-parent-dir *stats-file-path*) 66 | (let [entry (merge {:event-type event-type 67 | :timestamp (timestamp-iso8601)} 68 | data) 69 | stats-config {:min-level :trace 70 | :appenders {:stats (assoc 71 | (timbre/spit-appender {:fname *stats-file-path*}) 72 | :enabled? true 73 | :output-fn edn-output-fn)}}] 74 | (binding [timbre/*config* stats-config] 75 | (timbre/trace entry))) 76 | (catch Exception e 77 | ;; Use parent config for error logging 78 | (timbre/error "Failed to log stats event:" (.getMessage e)))))) 79 | 80 | (defn log-event! 81 | "Log a delimiter event to the stats file. 82 | 83 | Parameters: 84 | - event-type: keyword like :delimiter-error, :delimiter-fixed, :delimiter-fix-failed, :delimiter-ok 85 | - hook-event: string like \"PreToolUse:Write\" or \"PostToolUse:Edit\" 86 | - file-path: string path to the file being processed 87 | 88 | Uses log-stats! internally with hook-event and file-path context." 89 | [event-type hook-event file-path] 90 | (log-stats! event-type {:hook-event hook-event 91 | :file-path file-path})) 92 | 93 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/hook_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.hook-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure-mcp-light.hook :as hook] 4 | [babashka.fs :as fs])) 5 | 6 | (deftest clojure-file?-test 7 | (testing "identifies Clojure files by extension" 8 | (is (hook/clojure-file? "test.clj")) 9 | (is (hook/clojure-file? "test.cljs")) 10 | (is (hook/clojure-file? "test.cljc")) 11 | (is (hook/clojure-file? "test.bb")) 12 | (is (hook/clojure-file? "test.lpy")) 13 | (is (hook/clojure-file? "config.edn"))) 14 | 15 | (testing "case-insensitive extension matching" 16 | (is (hook/clojure-file? "test.CLJ")) 17 | (is (hook/clojure-file? "test.CLJS")) 18 | (is (hook/clojure-file? "test.EDN")) 19 | (is (hook/clojure-file? "test.LPY"))) 20 | 21 | (testing "identifies files with Babashka shebang" 22 | (let [temp-file (str (fs/create-temp-file {:prefix "test-bb-" :suffix ".sh"}))] 23 | (try 24 | (spit temp-file "#!/usr/bin/env bb\n(println \"hello\")" :encoding "UTF-8") 25 | (is (hook/clojure-file? temp-file)) 26 | (finally 27 | (fs/delete-if-exists temp-file)))) 28 | 29 | (let [temp-file (str (fs/create-temp-file {:prefix "test-bb-" :suffix ".sh"}))] 30 | (try 31 | (spit temp-file "#!/usr/bin/bb\n(println \"hello\")" :encoding "UTF-8") 32 | (is (hook/clojure-file? temp-file)) 33 | (finally 34 | (fs/delete-if-exists temp-file)))) 35 | 36 | (let [temp-file (str (fs/create-temp-file {:prefix "test-bb-" :suffix ".sh"}))] 37 | (try 38 | (spit temp-file "#!/usr/local/bin/bb --nrepl-server 1667\n(println \"hello\")" :encoding "UTF-8") 39 | (is (hook/clojure-file? temp-file)) 40 | (finally 41 | (fs/delete-if-exists temp-file))))) 42 | 43 | (testing "rejects files without Babashka shebang" 44 | (let [temp-file (str (fs/create-temp-file {:prefix "test-bash-" :suffix ".sh"}))] 45 | (try 46 | (spit temp-file "#!/bin/bash\necho \"hello\"" :encoding "UTF-8") 47 | (is (nil? (hook/clojure-file? temp-file))) 48 | (finally 49 | (fs/delete-if-exists temp-file))))) 50 | 51 | (testing "rejects non-Clojure files" 52 | (is (nil? (hook/clojure-file? "test.js"))) 53 | (is (nil? (hook/clojure-file? "test.py"))) 54 | (is (nil? (hook/clojure-file? "README.md"))) 55 | (is (nil? (hook/clojure-file? "package.json")))) 56 | 57 | (testing "handles nil file path" 58 | (is (nil? (hook/clojure-file? nil)))) 59 | 60 | (testing "handles non-existent file without error" 61 | (is (nil? (hook/clojure-file? "/nonexistent/file.xyz"))))) 62 | 63 | (deftest process-hook-test 64 | (testing "allows non-Clojure files through unchanged" 65 | (let [hook-input {:hook_event_name "PreToolUse" 66 | :tool_name "Write" 67 | :tool_input {:file_path "test.js" 68 | :content "console.log('hello')"}} 69 | result (hook/process-hook hook-input)] 70 | (is (nil? result)))) 71 | 72 | (testing "allows valid Clojure code through unchanged" 73 | (let [hook-input {:hook_event_name "PreToolUse" 74 | :tool_name "Write" 75 | :tool_input {:file_path "test.clj" 76 | :content "(def x 1)"}} 77 | result (hook/process-hook hook-input)] 78 | (is (nil? result)))) 79 | 80 | (testing "fixes delimiter errors in Write operations" 81 | (let [hook-input {:hook_event_name "PreToolUse" 82 | :tool_name "Write" 83 | :tool_input {:file_path "test.clj" 84 | :content "(def x 1"}} 85 | result (hook/process-hook hook-input)] 86 | (is (map? result)) 87 | (is (= "(def x 1)" 88 | (get-in result [:hookSpecificOutput :updatedInput :content]))))) 89 | 90 | (testing "allows Edit operations for Clojure files" 91 | (let [hook-input {:hook_event_name "PreToolUse" 92 | :tool_name "Edit" 93 | :tool_input {:file_path "test.clj" 94 | :old_string "(def x 1)" 95 | :new_string "(def x 2)"} 96 | :session_id "test-session"} 97 | result (hook/process-hook hook-input)] 98 | (is (nil? result))))) 99 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/paren_repair.clj: -------------------------------------------------------------------------------- 1 | (babashka.deps/add-deps '{:deps {dev.weavejester/cljfmt {:mvn/version "0.15.5"} 2 | parinferish/parinferish {:mvn/version "0.8.0"}}}) 3 | 4 | (ns clojure-mcp-light.paren-repair 5 | "Standalone CLI tool for fixing delimiter errors and formatting Clojure files" 6 | (:require [babashka.fs :as fs] 7 | [clojure.string :as string] 8 | [clojure-mcp-light.hook :as hook :refer [clojure-file? fix-and-format-file!]] 9 | [taoensso.timbre :as timbre])) 10 | 11 | ;; ============================================================================ 12 | ;; File Processing 13 | ;; ============================================================================ 14 | 15 | (defn process-file 16 | "Process a single file: fix delimiters and format. 17 | Returns a map with: 18 | - :success - boolean indicating overall success 19 | - :file-path - the processed file path 20 | - :message - human-readable message about what happened 21 | - :delimiter-fixed - boolean indicating if delimiter was fixed 22 | - :formatted - boolean indicating if file was formatted" 23 | [file-path] 24 | (cond 25 | (not (fs/exists? file-path)) 26 | {:success false 27 | :file-path file-path 28 | :message "File does not exist" 29 | :delimiter-fixed false 30 | :formatted false} 31 | 32 | (not (clojure-file? file-path)) 33 | {:success false 34 | :file-path file-path 35 | :message "Not a Clojure file (skipping)" 36 | :delimiter-fixed false 37 | :formatted false} 38 | 39 | :else 40 | ;; Use shared fix-and-format-file! from hook.clj 41 | (assoc (fix-and-format-file! file-path true "paren-repair") 42 | :file-path file-path))) 43 | 44 | ;; ============================================================================ 45 | ;; Main Entry Point 46 | ;; ============================================================================ 47 | 48 | (defn show-help [] 49 | (println "Usage: clj-paren-repair FILE [FILE ...]") 50 | (println) 51 | (println "Fix delimiter errors and format Clojure files.") 52 | (println) 53 | (println "Features enabled by default:") 54 | (println " - Delimiter error detection and repair") 55 | (println " - cljfmt formatting") 56 | (println) 57 | (println "Options:") 58 | (println " -h, --help Show this help message")) 59 | 60 | (defn -main [& args] 61 | (if (or (empty? args) 62 | (some #{"--help" "-h"} args)) 63 | (do 64 | (show-help) 65 | (System/exit (if (empty? args) 1 0))) 66 | 67 | ;; Disable logging for standalone tool 68 | (do 69 | (timbre/set-config! {:appenders {}}) 70 | 71 | (binding [hook/*enable-cljfmt* true] 72 | (try 73 | (let [results (doall (map process-file args)) 74 | successes (filter :success results) 75 | failures (filter (complement :success) results) 76 | success-count (count successes) 77 | failure-count (count failures)] 78 | 79 | ;; Print results 80 | (println) 81 | (println "clj-paren-repair Results") 82 | (println "========================") 83 | (println) 84 | 85 | (doseq [{:keys [file-path message delimiter-fixed formatted]} results] 86 | (let [tags (when (or delimiter-fixed formatted) 87 | (str " [" 88 | (string/join ", " 89 | (filter some? 90 | [(when delimiter-fixed "delimiter-fixed") 91 | (when formatted "formatted")])) 92 | "]"))] 93 | (println (str " " file-path ": " message tags)))) 94 | 95 | (println) 96 | (println "Summary:") 97 | (println " Success:" success-count) 98 | (println " Failed: " failure-count) 99 | (println) 100 | 101 | (if (zero? failure-count) 102 | (System/exit 0) 103 | (System/exit 1))) 104 | (catch Exception e 105 | (binding [*out* *err*] 106 | (println "Fatal error:" (.getMessage e))) 107 | (System/exit 1))))))) 108 | -------------------------------------------------------------------------------- /scripts/test-parse-all.bb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns test-parse-all 4 | "Test edamame parsing configuration against all Clojure files in a directory" 5 | (:require [edamame.core :as e] 6 | [clojure.java.io :as io] 7 | [clojure.string :as str])) 8 | 9 | (defn clojure-file? [^java.io.File f] 10 | (and (.isFile f) 11 | (let [name (.getName f)] 12 | (or (.endsWith name ".clj") 13 | (.endsWith name ".cljs") 14 | (.endsWith name ".cljc") 15 | (.endsWith name ".cljr") 16 | (.endsWith name ".bb"))))) 17 | 18 | (defn find-clojure-files [dir] 19 | (let [dir-file (io/file dir)] 20 | (if (.exists dir-file) 21 | (->> (file-seq dir-file) 22 | (filter clojure-file?) 23 | (sort-by #(.getPath ^java.io.File %))) 24 | []))) 25 | 26 | (defn parse-file [file] 27 | "Attempts to parse a file with the same config as delimiter-repair. 28 | Returns a map with :status (:success, :delimiter-error, :other-error) 29 | and :error if applicable." 30 | (let [path (.getPath file) 31 | content (slurp file)] 32 | (try 33 | (e/parse-string-all content {:all true 34 | :features #{:bb :clj :cljs :cljr :default} #_(constantly true) 35 | :read-cond :allow 36 | ;; TODO this is when we think bb has been updated 37 | ;; :features (constantly true) 38 | ;; :read-cond second 39 | :readers (fn [_tag] (fn [data] data)) 40 | :auto-resolve name}) 41 | {:status :success :file path} 42 | (catch clojure.lang.ExceptionInfo ex 43 | (let [data (ex-data ex)] 44 | (if (and (= :edamame/error (:type data)) 45 | (contains? data :edamame/opened-delimiter)) 46 | {:status :delimiter-error :file path :error (ex-message ex)} 47 | {:status :other-error :file path :error ex :data data}))) 48 | (catch Exception ex 49 | ;; Unknown reader tags and other non-delimiter errors 50 | (let [msg (ex-message ex)] 51 | (if (and msg (str/includes? msg "No reader function for tag")) 52 | ;; Extract the tag name from error message 53 | (let [tag-name (second (re-find #"No reader function for tag (\S+)" msg))] 54 | {:status :unknown-tag :file path :error ex :tag tag-name}) 55 | {:status :other-error :file path :error ex})))))) 56 | 57 | (defn print-report [result] 58 | (case (:status result) 59 | :success 60 | (println "✓" (:file result)) 61 | 62 | :delimiter-error 63 | (println "⚠" (:file result) "- delimiter error (expected):" (:error result)) 64 | 65 | :unknown-tag 66 | (do 67 | (println "\n❌ UNKNOWN TAG") 68 | (println "File:" (:file result)) 69 | (println "Tag:" (:tag result)) 70 | (println "\nAdd this to the :readers map:") 71 | (println (format " '%s (fn [x] x)" (:tag result)))) 72 | 73 | :other-error 74 | (do 75 | (println "\n❌ UNEXPECTED ERROR") 76 | (println "File:" (:file result)) 77 | (println "Error:" (ex-message (:error result))) 78 | (when-let [data (:data result)] 79 | (println "Ex-data:" data)) 80 | (println "\nStacktrace:") 81 | (.printStackTrace (:error result))))) 82 | 83 | (defn test-directory [dir] 84 | (let [files (find-clojure-files dir)] 85 | (println (format "Found %d Clojure files in %s\n" (count files) dir)) 86 | (loop [files files 87 | stats {:success 0 :delimiter-error 0 :unknown-tag 0 :other-error 0}] 88 | (if-let [file (first files)] 89 | (let [result (parse-file file)] 90 | (print-report result) 91 | (if (or (= :other-error (:status result)) 92 | (= :unknown-tag (:status result))) 93 | ;; Stop on first error 94 | (do 95 | (println "\n" (str "Stopped after processing " (inc (:success stats)) " successful files")) 96 | stats) 97 | ;; Continue 98 | (recur (rest files) 99 | (update stats (:status result) inc)))) 100 | ;; All done 101 | (do 102 | (println "\n=== RESULTS ===") 103 | (println (format "✓ Success: %d files" (:success stats))) 104 | (println (format "⚠ Delimiter errors: %d files" (:delimiter-error stats))) 105 | (println (format "❌ Unknown tags: %d files" (:unknown-tag stats))) 106 | (println (format "❌ Other errors: %d files" (:other-error stats))) 107 | stats))))) 108 | 109 | (defn -main [& args] 110 | (let [dir (or (first args) ".")] 111 | (if (.exists (io/file dir)) 112 | (test-directory dir) 113 | (do 114 | (println "Error: Directory does not exist:" dir) 115 | (System/exit 1))))) 116 | 117 | (when (= *file* (System/getProperty "babashka.file")) 118 | (apply -main *command-line-args*)) 119 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/delimiter_repair.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.delimiter-repair 2 | "Delimiter error detection and repair functions using edamame and parinfer-rust" 3 | (:require [edamame.core :as e] 4 | [clojure.java.shell :as shell] 5 | [cheshire.core :as json] 6 | [clojure-mcp-light.stats :as stats] 7 | [parinferish.core :as parinferish])) 8 | 9 | (def ^:dynamic *signal-on-bad-parse* true) 10 | 11 | (defn delimiter-error? 12 | "Returns true if the string has a delimiter error specifically. 13 | Checks both that it's an :edamame/error and has delimiter info. 14 | Uses :all true to enable all standard Clojure reader features: 15 | function literals, regex, quotes, syntax-quote, deref, var, etc. 16 | Also enables :read-cond :allow to support reader conditionals. 17 | Handles unknown data readers gracefully with a default reader fn." 18 | [s] 19 | (try 20 | (e/parse-string-all s {:all true 21 | ;; TODO this is when we think bb has been updated 22 | ;; :features (constantly true) 23 | ;; :read-cond second 24 | :features #{:bb :clj :cljs :cljr :default} #_(constantly true) 25 | :read-cond :allow 26 | :readers (fn [_tag] (fn [data] data)) 27 | :auto-resolve name}) 28 | false ; No error = no delimiter error 29 | (catch clojure.lang.ExceptionInfo ex 30 | (let [data (ex-data ex) 31 | result (and (= :edamame/error (:type data)) 32 | (contains? data :edamame/opened-delimiter))] 33 | (when-not result 34 | (when *signal-on-bad-parse* 35 | (stats/log-stats! :delimiter-parse-error 36 | {:ex-message (ex-message ex) 37 | :ex-data (ex-data ex)}))) 38 | result)) 39 | (catch Exception e 40 | (when *signal-on-bad-parse* 41 | (stats/log-stats! :delimiter-parse-error 42 | {:ex-message (ex-message e)})) 43 | 44 | ;; Experimentally going to return true in this case to 45 | ;; communication a parse failure we will run parinfer if this is 46 | ;; true just in case there is a delimiter error as well in the 47 | ;; file 48 | 49 | ;; running parinfer is a benign action most of the time 50 | 51 | *signal-on-bad-parse*))) 52 | 53 | (defn actual-delimiter-error? [s] 54 | (binding [*signal-on-bad-parse* false] 55 | (delimiter-error? s))) 56 | 57 | (defn parinferish-repair 58 | "Attempts to repair delimiter errors using parinferish (pure Clojure). 59 | Returns a map with: 60 | - :success - boolean indicating if repair was successful 61 | - :text - the repaired code (if successful) 62 | - :error - error message (if unsuccessful)" 63 | [s] 64 | (try 65 | (let [repaired (parinferish/flatten 66 | (parinferish/parse s {:mode :indent}))] 67 | {:success true 68 | :text repaired 69 | :error nil}) 70 | (catch Exception e 71 | {:success false 72 | :error (.getMessage e)}))) 73 | 74 | (def parinfer-rust-available? 75 | "Check if parinfer-rust binary is available on PATH. 76 | Result is memoized to avoid repeated shell calls." 77 | (memoize 78 | (fn [] 79 | (try 80 | (let [result (shell/sh "which" "parinfer-rust")] 81 | (zero? (:exit result))) 82 | (catch Exception _ 83 | false))))) 84 | 85 | (defn parinfer-repair 86 | "Attempts to repair delimiter errors using parinfer-rust. 87 | Returns a map with: 88 | - :success - boolean indicating if repair was successful 89 | - :repaired-text - the repaired code (if successful) 90 | - :error - error message (if unsuccessful)" 91 | [s] 92 | (let [result (shell/sh "parinfer-rust" 93 | "--mode" "indent" 94 | "--language" "clojure" 95 | "--output-format" "json" 96 | :in s) 97 | exit-code (:exit result)] 98 | (if (zero? exit-code) 99 | (try 100 | (json/parse-string (:out result) true) 101 | (catch Exception _ 102 | {:success false})) 103 | {:success false}))) 104 | 105 | (defn repair-delimiters 106 | "Unified delimiter repair function that automatically selects the best available backend. 107 | Prefers parinfer-rust (external tool) when available, falls back to parinferish (pure Clojure). 108 | Returns a map with: 109 | - :success - boolean indicating if repair was successful 110 | - :text - the repaired code (if successful) 111 | - :error - error message (if unsuccessful)" 112 | [s] 113 | (if (parinfer-rust-available?) 114 | (parinfer-repair s) 115 | (parinferish-repair s))) 116 | 117 | (defn fix-delimiters 118 | "Takes a Clojure string and attempts to fix delimiter errors. 119 | Returns the repaired string if successful, false otherwise. 120 | If no delimiter errors exist, returns the original string." 121 | [s] 122 | (if (delimiter-error? s) 123 | (let [{:keys [text success]} (repair-delimiters s)] 124 | (when (and success text (not (delimiter-error? text))) 125 | text)) 126 | s)) 127 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/nrepl_eval_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.nrepl-eval-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure-mcp-light.nrepl-eval :as ne] 4 | [clojure-mcp-light.nrepl-client :as nc] 5 | [clojure.java.io :as io] 6 | [clojure.string])) 7 | 8 | (deftest bytes->str-test 9 | (testing "converts bytes to string" 10 | (is (= "hello" (nc/bytes->str (.getBytes "hello")))) 11 | (is (= "test" (nc/bytes->str "test"))))) 12 | 13 | (deftest coerce-long-test 14 | (testing "converts string to long" 15 | (is (= 7888 (nc/coerce-long "7888"))) 16 | (is (= 1234 (nc/coerce-long 1234))))) 17 | 18 | (deftest next-id-test 19 | (testing "generates unique IDs" 20 | (let [id1 (nc/next-id) 21 | id2 (nc/next-id)] 22 | (is (string? id1)) 23 | (is (string? id2)) 24 | (is (not= id1 id2))))) 25 | 26 | (deftest slurp-nrepl-session-test 27 | (testing "reads session ID from per-target session file" 28 | (let [test-session-file ".nrepl-session-test" 29 | test-session-id "test-session-12345" 30 | test-host "localhost" 31 | test-port 7888] 32 | (try 33 | ;; Create a test session file 34 | (spit test-session-file (str test-session-id "\n") :encoding "UTF-8") 35 | ;; Test reading it 36 | (let [result (with-redefs [nc/slurp-nrepl-session 37 | (fn [_ _] 38 | (try 39 | (when (.exists (io/file test-session-file)) 40 | (clojure.string/trim (slurp test-session-file :encoding "UTF-8"))) 41 | (catch Exception _ 42 | nil)))] 43 | (nc/slurp-nrepl-session test-host test-port))] 44 | (is (= test-session-id result))) 45 | (finally 46 | ;; Clean up 47 | (io/delete-file test-session-file true))))) 48 | 49 | (testing "returns nil when file doesn't exist" 50 | (let [result (with-redefs [nc/slurp-nrepl-session 51 | (fn [_ _] 52 | (try 53 | (when (.exists (io/file ".nrepl-session-nonexistent")) 54 | (clojure.string/trim (slurp ".nrepl-session-nonexistent" :encoding "UTF-8"))) 55 | (catch Exception _ 56 | nil)))] 57 | (nc/slurp-nrepl-session "localhost" 7888))] 58 | (is (nil? result)))) 59 | 60 | (testing "returns nil on read error" 61 | (let [result (nc/slurp-nrepl-session "localhost" 7888)] 62 | ;; Without a file, should return nil 63 | (is (or (nil? result) (string? result) (map? result)))))) 64 | 65 | (deftest spit-nrepl-session-test 66 | (testing "writes session ID to per-target session file" 67 | (let [test-session-file ".nrepl-session-test" 68 | test-session-data {:session-id "test-session-67890"} 69 | test-host "localhost" 70 | test-port 7888] 71 | (try 72 | ;; Write session ID 73 | (with-redefs [nc/spit-nrepl-session 74 | (fn [session-data _ _] 75 | (spit test-session-file (str (:session-id session-data) "\n") :encoding "UTF-8"))] 76 | (nc/spit-nrepl-session test-session-data test-host test-port)) 77 | ;; Verify it was written correctly 78 | (let [content (clojure.string/trim (slurp test-session-file :encoding "UTF-8"))] 79 | (is (= (:session-id test-session-data) content))) 80 | (finally 81 | ;; Clean up 82 | (io/delete-file test-session-file true)))))) 83 | 84 | (deftest delete-nrepl-session-test 85 | (testing "deletes per-target session file when it exists" 86 | (let [test-session-file ".nrepl-session-test" 87 | test-host "localhost" 88 | test-port 7888] 89 | (try 90 | ;; Create a test file 91 | (spit test-session-file "test-session" :encoding "UTF-8") 92 | (is (.exists (io/file test-session-file))) 93 | ;; Delete it 94 | (with-redefs [nc/delete-nrepl-session 95 | (fn [_ _] 96 | (let [f (io/file test-session-file)] 97 | (when (.exists f) 98 | (.delete f))))] 99 | (nc/delete-nrepl-session test-host test-port)) 100 | ;; Verify it's gone 101 | (is (not (.exists (io/file test-session-file)))) 102 | (finally 103 | ;; Clean up in case test failed 104 | (io/delete-file test-session-file true))))) 105 | 106 | (testing "does nothing when file doesn't exist" 107 | (with-redefs [nc/delete-nrepl-session 108 | (fn [_ _] 109 | (let [f (io/file ".nrepl-session-nonexistent")] 110 | (when (.exists f) 111 | (.delete f))))] 112 | ;; Should not throw an error 113 | (is (nil? (nc/delete-nrepl-session "localhost" 7888)))))) 114 | 115 | (deftest get-host-test 116 | (testing "gets host from options" 117 | (is (= "custom-host" (ne/get-host {:host "custom-host"})))) 118 | 119 | (testing "falls back to default" 120 | (is (= "127.0.0.1" (ne/get-host {}))))) 121 | 122 | (deftest read-msg-test 123 | (testing "converts byte values to strings in message" 124 | (let [msg {"status" [(.getBytes "done")] 125 | "value" "42"} 126 | result (nc/read-msg msg)] 127 | (is (map? result)) 128 | (is (= "done" (first (:status result)))) 129 | (is (= "42" (:value result)))))) 130 | -------------------------------------------------------------------------------- /skills/clojure-eval/SKILL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: clojure-eval 3 | description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session. 4 | --- 5 | 6 | # Clojure REPL Evaluation 7 | 8 | ## When to Use This Skill 9 | 10 | Use this skill when you need to: 11 | - **Verify that edited Clojure files compile and load correctly** 12 | - Test function behavior interactively 13 | - Check the current state of the REPL 14 | - Debug code by evaluating expressions 15 | - Require or load namespaces for testing 16 | - Validate that code changes work before committing 17 | 18 | ## How It Works 19 | 20 | The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file. 21 | 22 | ## Instructions 23 | 24 | ### 0. Discover and select nREPL server 25 | 26 | First, discover what nREPL servers are running in the current directory: 27 | 28 | ```bash 29 | clj-nrepl-eval --discover-ports 30 | ``` 31 | 32 | This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory. 33 | 34 | **Then use the AskUserQuestion tool:** 35 | 36 | - **If ports are discovered:** Prompt user to select which nREPL port to use: 37 | - **question:** "Which nREPL port would you like to use?" 38 | - **header:** "nREPL Port" 39 | - **options:** Present each discovered port as an option with: 40 | - **label:** The port number 41 | - **description:** The server type and status (e.g., "Clojure nREPL server in current directory") 42 | - Include up to 4 discovered ports as options 43 | - The user can select "Other" to enter a custom port number 44 | 45 | - **If no ports are discovered:** Prompt user how to start an nREPL server: 46 | - **question:** "No nREPL servers found. How would you like to start one?" 47 | - **header:** "Start nREPL" 48 | - **options:** 49 | - **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn" 50 | - **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'" 51 | - The user can select "Other" for alternative methods or if they already have a server running on a specific port 52 | 53 | IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on. 54 | 55 | ### 1. Evaluate Clojure Code 56 | 57 | > Evaluation automatically connects to the given port 58 | 59 | Use the `-p` flag to specify the port and pass your Clojure code. 60 | 61 | **Recommended: Pass code as a command-line argument:** 62 | ```bash 63 | clj-nrepl-eval -p "(+ 1 2 3)" 64 | ``` 65 | 66 | **For multiple expressions (single line):** 67 | ```bash 68 | clj-nrepl-eval -p "(def x 10) (+ x 20)" 69 | ``` 70 | 71 | **Alternative: Using heredoc (may require permission approval for multiline commands):** 72 | ```bash 73 | clj-nrepl-eval -p <<'EOF' 74 | (def x 10) 75 | (+ x 20) 76 | EOF 77 | ``` 78 | 79 | **Alternative: Via stdin pipe:** 80 | ```bash 81 | echo "(+ 1 2 3)" | clj-nrepl-eval -p 82 | ``` 83 | 84 | ### 2. Display nREPL Sessions 85 | 86 | **Discover all nREPL servers in current directory:** 87 | ```bash 88 | clj-nrepl-eval --discover-ports 89 | ``` 90 | Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory. 91 | 92 | **Check previously connected sessions:** 93 | ```bash 94 | clj-nrepl-eval --connected-ports 95 | ``` 96 | Shows only connections you have made before (appears after first evaluation on a port). 97 | 98 | ### 3. Common Patterns 99 | 100 | **Require a namespace (always use :reload to pick up changes):** 101 | ```bash 102 | clj-nrepl-eval -p "(require '[my.namespace :as ns] :reload)" 103 | ``` 104 | 105 | **Test a function after requiring:** 106 | ```bash 107 | clj-nrepl-eval -p "(ns/my-function arg1 arg2)" 108 | ``` 109 | 110 | **Check if a file compiles:** 111 | ```bash 112 | clj-nrepl-eval -p "(require 'my.namespace :reload)" 113 | ``` 114 | 115 | **Multiple expressions:** 116 | ```bash 117 | clj-nrepl-eval -p "(def x 10) (* x 2) (+ x 5)" 118 | ``` 119 | 120 | **Complex multiline code (using heredoc):** 121 | ```bash 122 | clj-nrepl-eval -p <<'EOF' 123 | (def x 10) 124 | (* x 2) 125 | (+ x 5) 126 | EOF 127 | ``` 128 | *Note: Heredoc syntax may require permission approval.* 129 | 130 | **With custom timeout (in milliseconds):** 131 | ```bash 132 | clj-nrepl-eval -p --timeout 5000 "(long-running-fn)" 133 | ``` 134 | 135 | **Reset the session (clears all state):** 136 | ```bash 137 | clj-nrepl-eval -p --reset-session 138 | clj-nrepl-eval -p --reset-session "(def x 1)" 139 | ``` 140 | 141 | ## Available Options 142 | 143 | - `-p, --port PORT` - nREPL port (required) 144 | - `-H, --host HOST` - nREPL host (default: 127.0.0.1) 145 | - `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes) 146 | - `-r, --reset-session` - Reset the persistent nREPL session 147 | - `-c, --connected-ports` - List previously connected nREPL sessions 148 | - `-d, --discover-ports` - Discover nREPL servers in current directory 149 | - `-h, --help` - Show help message 150 | 151 | ## Important Notes 152 | 153 | - **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p "(+ 1 2 3)"` - works with existing permissions 154 | - **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval 155 | - **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used 156 | - **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses 157 | - **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes 158 | - **Default timeout:** 2 minutes (120000ms) - increase for long-running operations 159 | - **Input precedence:** Command-line arguments take precedence over stdin 160 | 161 | ## Typical Workflow 162 | 163 | 1. Discover nREPL servers: `clj-nrepl-eval --discover-ports` 164 | 2. Use **AskUserQuestion** tool to prompt user to select a port 165 | 3. Require namespace: 166 | ```bash 167 | clj-nrepl-eval -p "(require '[my.ns :as ns] :reload)" 168 | ``` 169 | 4. Test function: 170 | ```bash 171 | clj-nrepl-eval -p "(ns/my-fn ...)" 172 | ``` 173 | 5. Iterate: Make changes, re-require with `:reload`, test again 174 | -------------------------------------------------------------------------------- /scripts/stats-summary.bb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (ns stats-summary 4 | "Analyze delimiter event statistics from stats log file" 5 | (:require [clojure.edn :as edn] 6 | [clojure.string :as str] 7 | [clojure.java.io :as io])) 8 | 9 | (def stats-file 10 | (let [home (System/getProperty "user.home")] 11 | (str home "/.clojure-mcp-light/stats.log"))) 12 | 13 | (defn read-stats 14 | "Read and parse all EDN entries from stats log file" 15 | [file-path] 16 | (if (.exists (io/file file-path)) 17 | (try 18 | (->> (slurp file-path) 19 | (str/split-lines) 20 | (remove str/blank?) 21 | (map edn/read-string)) 22 | (catch Exception e 23 | (binding [*out* *err*] 24 | (println "Error reading stats file:" (.getMessage e))) 25 | [])) 26 | (do 27 | (binding [*out* *err*] 28 | (println "Stats file not found:" file-path)) 29 | []))) 30 | 31 | (defn count-by 32 | "Count entries grouped by a key" 33 | [k entries] 34 | (->> entries 35 | (group-by k) 36 | (map (fn [[k v]] [k (count v)])) 37 | (sort-by second >) 38 | (into {}))) 39 | 40 | (defn format-count 41 | "Format count with padding for alignment" 42 | [n width] 43 | (format (str "%" width "d") n)) 44 | 45 | (defn print-section 46 | "Print a section header" 47 | [title] 48 | (println) 49 | (println title) 50 | (println (str/join (repeat (count title) "=")))) 51 | 52 | (defn print-summary 53 | "Print summary statistics" 54 | [entries] 55 | (let [;; Separate delimiter and cljfmt events 56 | delimiter-events (filter :hook-event entries) 57 | cljfmt-events (filter #(str/starts-with? (name (:event-type %)) "cljfmt-") entries) 58 | parse-events (filter #(= :delimiter-parse-error (:event-type %)) entries) 59 | 60 | total (count entries) 61 | delimiter-total (count delimiter-events) 62 | cljfmt-total (count cljfmt-events) 63 | parse-total (count parse-events) 64 | 65 | by-event-type (count-by :event-type delimiter-events) 66 | by-hook-event (count-by :hook-event delimiter-events) 67 | by-file (->> delimiter-events 68 | (group-by :file-path) 69 | (map (fn [[k v]] [k (count v)])) 70 | (sort-by second >) 71 | (take 10))] 72 | 73 | ;; Calculate delimiter metrics 74 | (let [errors (get by-event-type :delimiter-error 0) 75 | fixed (get by-event-type :delimiter-fixed 0) 76 | failed (get by-event-type :delimiter-fix-failed 0) 77 | ok (get by-event-type :delimiter-ok 0) 78 | ;; Total unique operations: ok + errors (not ok + errors + fixed + failed) 79 | ;; because error operations generate BOTH error AND fixed/failed events 80 | total-operations (+ ok errors) 81 | fix-attempts (+ fixed failed) 82 | 83 | ;; Calculate cljfmt metrics 84 | cljfmt-by-type (count-by :event-type cljfmt-events) 85 | cljfmt-already-formatted (get cljfmt-by-type :cljfmt-already-formatted 0) 86 | cljfmt-needed-formatting (get cljfmt-by-type :cljfmt-needed-formatting 0) 87 | cljfmt-fix-succeeded (get cljfmt-by-type :cljfmt-fix-succeeded 0) 88 | cljfmt-fix-failed (get cljfmt-by-type :cljfmt-fix-failed 0) 89 | cljfmt-check-errors (get cljfmt-by-type :cljfmt-check-error 0) 90 | cljfmt-total-checked (+ cljfmt-already-formatted cljfmt-needed-formatting cljfmt-check-errors) 91 | cljfmt-total-fix-attempts (+ cljfmt-fix-succeeded cljfmt-fix-failed)] 92 | 93 | (println) 94 | (println "clojure-mcp-light Utility Validation") 95 | (println (str/join (repeat 60 "="))) 96 | 97 | ;; Delimiter Repair Metrics 98 | (print-section "Delimiter Repair Metrics") 99 | (println (format " Total Writes/Edits: %5d" total-operations)) 100 | (println (format " Clean Code (no errors): %5d (%5.1f%% of total)" 101 | ok 102 | (if (pos? total-operations) 103 | (* 100.0 (/ ok total-operations)) 104 | 0.0))) 105 | (println (format " Errors Detected: %5d (%5.1f%% of total)" 106 | errors 107 | (if (pos? total-operations) 108 | (* 100.0 (/ errors total-operations)) 109 | 0.0))) 110 | (println (format " Successfully Fixed: %5d (%5.1f%% of errors)" 111 | fixed 112 | (if (pos? errors) 113 | (* 100.0 (/ fixed errors)) 114 | 0.0))) 115 | (println (format " Failed to Fix: %5d (%5.1f%% of errors)" 116 | failed 117 | (if (pos? errors) 118 | (* 100.0 (/ failed errors)) 119 | 0.0))) 120 | (println (format " Parse Errors: %5d (%5.1f%% of fix attempts)" 121 | parse-total 122 | (if (pos? fix-attempts) 123 | (* 100.0 (/ parse-total fix-attempts)) 124 | 0.0))) 125 | 126 | ;; Cljfmt Metrics 127 | (print-section "Cljfmt Metrics") 128 | (println (format " Total Files Checked: %5d" cljfmt-total-checked)) 129 | (println (format " Already Formatted: %5d (%5.1f%% of total)" 130 | cljfmt-already-formatted 131 | (if (pos? cljfmt-total-checked) 132 | (* 100.0 (/ cljfmt-already-formatted cljfmt-total-checked)) 133 | 0.0))) 134 | (println (format " Needed Formatting: %5d (%5.1f%% of total)" 135 | cljfmt-needed-formatting 136 | (if (pos? cljfmt-total-checked) 137 | (* 100.0 (/ cljfmt-needed-formatting cljfmt-total-checked)) 138 | 0.0))) 139 | (println (format " Check Errors: %5d (%5.1f%% of total)" 140 | cljfmt-check-errors 141 | (if (pos? cljfmt-total-checked) 142 | (* 100.0 (/ cljfmt-check-errors cljfmt-total-checked)) 143 | 0.0))) 144 | (println (format " Fix Success Rate: %5d (%5.1f%% of fix attempts)" 145 | cljfmt-fix-succeeded 146 | (if (pos? cljfmt-total-fix-attempts) 147 | (* 100.0 (/ cljfmt-fix-succeeded cljfmt-total-fix-attempts)) 148 | 0.0))) 149 | (println (format " Fix Failures: %5d (%5.1f%% of fix attempts)" 150 | cljfmt-fix-failed 151 | (if (pos? cljfmt-total-fix-attempts) 152 | (* 100.0 (/ cljfmt-fix-failed cljfmt-total-fix-attempts)) 153 | 0.0))) 154 | 155 | ;; Category Breakdown 156 | (when (pos? total-operations) 157 | (print-section "Events by Type") 158 | (doseq [[event-type cnt] by-event-type] 159 | (let [pct (if (pos? total-operations) 160 | (format "%.1f%%" (* 100.0 (/ cnt total-operations))) 161 | "0.0%")] 162 | (println (format " %-30s %5d (%6s)" (name event-type) cnt pct))))) 163 | 164 | (println)))) 165 | 166 | (defn -main [& args] 167 | (let [file-path (or (first args) stats-file) 168 | entries (read-stats file-path)] 169 | (if (empty? entries) 170 | (do 171 | (println "No statistics found.") 172 | (println "Enable stats tracking with: clj-paren-repair-claude-hook --stats") 173 | (System/exit 1)) 174 | (do 175 | (print-summary entries) 176 | (System/exit 0))))) 177 | 178 | (when (= *file* (System/getProperty "babashka.file")) 179 | (apply -main *command-line-args*)) 180 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/nrepl_client.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.nrepl-client 2 | "nREPL client library based on lazy sequences of messages" 3 | (:require [babashka.fs :as fs] 4 | [bencode.core :as b] 5 | [clojure.edn :as edn] 6 | [clojure-mcp-light.tmp :as tmp])) 7 | 8 | ;; ============================================================================ 9 | ;; Message encoding/decoding 10 | ;; ============================================================================ 11 | 12 | (defmulti bytes->str 13 | "Recursively convert byte arrays to strings in nested structures" 14 | class) 15 | 16 | (defmethod bytes->str :default 17 | [x] 18 | x) 19 | 20 | (defmethod bytes->str (Class/forName "[B") 21 | [^bytes x] 22 | (String. x "UTF-8")) 23 | 24 | (defmethod bytes->str clojure.lang.IPersistentVector 25 | [v] 26 | (mapv bytes->str v)) 27 | 28 | (defmethod bytes->str clojure.lang.IPersistentMap 29 | [m] 30 | (->> m 31 | (map (fn [[k v]] [(bytes->str k) (bytes->str v)])) 32 | (into {}))) 33 | 34 | (defn read-msg 35 | "Decode a raw bencode message map into a Clojure map with keyword keys. 36 | Recursively converts all byte arrays to strings." 37 | [msg] 38 | (let [decoded (bytes->str msg)] 39 | (zipmap (map keyword (keys decoded)) 40 | (vals decoded)))) 41 | 42 | (defn coerce-long [x] 43 | (if (string? x) (Long/parseLong x) x)) 44 | 45 | (defn next-id [] 46 | (str (java.util.UUID/randomUUID))) 47 | 48 | (defn write-bencode-msg 49 | "Write bencode message to output stream and flush" 50 | [out msg] 51 | (b/write-bencode out msg) 52 | (.flush out)) 53 | 54 | ;; ============================================================================ 55 | ;; Lazy sequence API 56 | ;; ============================================================================ 57 | 58 | (defn message-seq 59 | "Create lazy sequence of raw bencode messages from input stream. 60 | Continues until EOF or error. Returns nils after stream ends." 61 | [in] 62 | (repeatedly 63 | #(b/read-bencode in))) 64 | 65 | (defn decode-messages 66 | "Map raw bencode message sequence through decoder. 67 | Stops at first nil (EOF/error)." 68 | [msg-seq] 69 | (map read-msg (take-while some? msg-seq))) 70 | 71 | (defn filter-id 72 | "Filter messages by message id." 73 | [id msg-seq] 74 | (filter #(= (:id %) id) msg-seq)) 75 | 76 | (defn filter-session 77 | "Filter messages by session id." 78 | [session-id msg-seq] 79 | (filter #(= (:session %) session-id) msg-seq)) 80 | 81 | (defn take-upto 82 | "Take elements from coll up to and including the first element 83 | where (pred element) is truthy." 84 | [pred coll] 85 | (lazy-seq 86 | (when-let [s (seq coll)] 87 | (let [x (first s)] 88 | (cons x (when-not (pred x) 89 | (take-upto pred (rest s)))))))) 90 | 91 | (defn take-until-done 92 | "Take messages up to and including first with 'done' status." 93 | [msg-seq] 94 | (take-upto #(some #{"done"} (:status %)) msg-seq)) 95 | 96 | ;; ============================================================================ 97 | ;; Socket and connection management 98 | ;; ============================================================================ 99 | 100 | (defn create-socket 101 | "Create and connect a socket with timeout." 102 | [host port timeout-ms] 103 | (doto (java.net.Socket.) 104 | (.connect (java.net.InetSocketAddress. host (coerce-long port)) timeout-ms) 105 | (.setSoTimeout timeout-ms))) 106 | 107 | (defn with-socket 108 | "Execute function f with connected socket and streams. 109 | f receives [socket out in] as arguments." 110 | [host port timeout-ms f] 111 | (with-open [s (create-socket host port timeout-ms)] 112 | (let [out (java.io.BufferedOutputStream. (.getOutputStream s)) 113 | in (java.io.PushbackInputStream. (.getInputStream s))] 114 | (f s out in)))) 115 | 116 | ;; ============================================================================ 117 | ;; Connection map utilities 118 | ;; ============================================================================ 119 | 120 | (defn make-connection 121 | "Create a connection map from socket and streams. 122 | Connection map: {:input :output :host :port :session-id :nrepl-env :socket}" 123 | [socket out in host port & {:keys [session-id nrepl-env]}] 124 | {:socket socket 125 | :input in 126 | :output out 127 | :host host 128 | :port port 129 | :session-id session-id 130 | :nrepl-env nrepl-env}) 131 | 132 | ;; ============================================================================ 133 | ;; Helper functions for collecting messages 134 | ;; ============================================================================ 135 | 136 | (defn messages-for-id 137 | "Send operation and collect all messages for the given id. 138 | Returns realized vector of all messages up to 'done' status. 139 | 140 | conn: connection map with :input, :output, and optionally :session-id 141 | op-map: operation map (e.g., {'op' 'describe'}) 142 | 143 | If op-map does not contain 'id', one will be generated. 144 | If op-map does not contain 'session' and conn has :session-id, it will be added." 145 | [conn op-map] 146 | (let [{:keys [input output session-id]} conn 147 | ;; Use provided id or generate new one 148 | id (get op-map "id" (next-id)) 149 | session (get op-map "session" session-id) 150 | msg (cond-> (assoc op-map "id" id) 151 | session (assoc "session" session)) 152 | _ (write-bencode-msg output msg) 153 | msgs (->> (message-seq input) 154 | (decode-messages) 155 | (filter-id id))] 156 | (take-until-done (cond->> msgs 157 | session (filter-session session))))) 158 | 159 | (defn merge-response 160 | "Combines the provided seq of response messages into a single response map. 161 | 162 | Certain message slots are combined in special ways: 163 | 164 | - only the last :ns is retained 165 | - :value is accumulated into an ordered collection 166 | - :status and :session are accumulated into a set 167 | - string values (associated with e.g. :out and :err) are concatenated" 168 | [responses] 169 | (reduce 170 | (fn [m [k v]] 171 | (case k 172 | (:id :ns) (assoc m k v) 173 | :value (update-in m [k] (fnil conj []) v) 174 | :status (update-in m [k] (fnil into #{}) v) 175 | :session (update-in m [k] (fnil conj #{}) v) 176 | (if (string? v) 177 | (update-in m [k] #(str % v)) 178 | (assoc m k v)))) 179 | {} (apply concat responses))) 180 | 181 | (defn send-op 182 | "Send operation and return merged response. 183 | 184 | conn: connection map 185 | op-map: operation map 186 | 187 | Returns merged response map with all accumulated values." 188 | [conn op-map] 189 | (merge-response (messages-for-id conn op-map))) 190 | 191 | ;; ============================================================================ 192 | ;; Session file I/O 193 | ;; ============================================================================ 194 | 195 | (defn slurp-nrepl-session 196 | "Read session data from nrepl session file for given host and port. 197 | Returns map with :session-id, :env-type, :host, and :port, or nil if file doesn't exist or on error." 198 | [host port] 199 | (try 200 | (let [ctx {} 201 | session-file (tmp/nrepl-target-file ctx {:host host :port port})] 202 | (when (fs/exists? session-file) 203 | (edn/read-string (slurp session-file :encoding "UTF-8")))) 204 | (catch Exception _ 205 | nil))) 206 | 207 | (defn spit-nrepl-session 208 | "Write session data to nrepl session file for given host and port. 209 | Takes a map with :session-id and optionally :env-type. Host and port are 210 | added to the session data for validation." 211 | [session-data host port] 212 | (let [ctx {} 213 | session-file (tmp/nrepl-target-file ctx {:host host :port port}) 214 | full-data (assoc session-data :host host :port port)] 215 | ;; Ensure parent directories exist 216 | (when-let [parent (fs/parent session-file)] 217 | (fs/create-dirs parent)) 218 | (spit session-file (str (pr-str full-data) "\n") :encoding "UTF-8"))) 219 | 220 | (defn delete-nrepl-session 221 | "Delete nrepl session file for given host and port if it exists." 222 | [host port] 223 | (let [ctx {} 224 | session-file (tmp/nrepl-target-file ctx {:host host :port port})] 225 | (fs/delete-if-exists session-file))) 226 | 227 | ;; ============================================================================ 228 | ;; Basic nREPL operations 229 | ;; ============================================================================ 230 | 231 | ;; Low-level operations that take a connection map 232 | 233 | (defn describe-nrepl* 234 | "Get nREPL server description using an existing connection. 235 | Returns description map." 236 | [conn] 237 | (send-op conn {"op" "describe"})) 238 | 239 | (defn eval-nrepl* 240 | "Evaluate code using an existing connection. 241 | Returns the full response map." 242 | [conn code] 243 | (send-op conn {"op" "eval" "code" code})) 244 | 245 | (defn clone-session* 246 | "Clone a new session using an existing connection. 247 | Returns the response map with :new-session." 248 | [conn] 249 | (send-op conn {"op" "clone"})) 250 | 251 | (defn ls-sessions* 252 | "List active sessions using an existing connection. 253 | Returns the response map with :sessions." 254 | [conn] 255 | (send-op conn {"op" "ls-sessions"})) 256 | 257 | ;; High-level convenience operations that create their own socket 258 | 259 | (defn ls-sessions 260 | "Get list of active session IDs from nREPL server. 261 | Returns nil if unable to connect or on error. 262 | Uses a 500ms timeout for connection and read operations." 263 | [host port] 264 | (try 265 | (with-socket host port 500 266 | (fn [socket out in] 267 | (let [conn (make-connection socket out in host port) 268 | response (ls-sessions* conn)] 269 | (:sessions response)))) 270 | (catch Exception _ 271 | nil))) 272 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/tmp.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.tmp 2 | "Unified temporary file management for Claude Code sessions. 3 | 4 | Provides consistent temp file paths for backups, nREPL sessions, and other 5 | temporary files with automatic cleanup support via SessionEnd hooks." 6 | (:require [babashka.fs :as fs] 7 | [clojure.edn :as edn] 8 | [clojure.string :as str])) 9 | 10 | ;; ============================================================================ 11 | ;; Config & Helpers 12 | ;; ============================================================================ 13 | 14 | (defn runtime-base-dir 15 | "Get base directory for runtime temporary files. 16 | Prefers XDG_RUNTIME_DIR if present, otherwise falls back to java.io.tmpdir." 17 | [] 18 | (or (System/getenv "XDG_RUNTIME_DIR") 19 | (System/getProperty "java.io.tmpdir"))) 20 | 21 | (defn sanitize 22 | "Sanitize a string for safe use in filesystem paths. 23 | Replaces non-alphanumeric characters (except ._-) with underscores, 24 | and collapses multiple underscores into one." 25 | [s] 26 | (-> s 27 | (str/replace #"[^\p{Alnum}._-]+" "_") 28 | (str/replace #"_{2,}" "_"))) 29 | 30 | (defn sha1 31 | "Compute SHA-1 hash of a string, returning hex digest." 32 | [^String s] 33 | (let [md (java.security.MessageDigest/getInstance "SHA-1")] 34 | (.update md (.getBytes s)) 35 | (format "%040x" (BigInteger. 1 (.digest md))))) 36 | 37 | (defn sha256 38 | "Compute SHA-256 hex digest of a string." 39 | ^String [^String s] 40 | (let [md (java.security.MessageDigest/getInstance "SHA-256")] 41 | (.update md (.getBytes s)) 42 | (format "%064x" (BigInteger. 1 (.digest md))))) 43 | 44 | (defn project-root-path 45 | "Get the project root path (current working directory). 46 | Returns absolute normalized path." 47 | [] 48 | (-> (System/getProperty "user.dir") 49 | fs/absolutize 50 | fs/normalize 51 | str)) 52 | 53 | (defn gpid-session-id 54 | "Get session identifier based on grandparent process ID. 55 | 56 | Returns a string in the format 'gpid-{pid}-{startInstant}' or 'gpid-{pid}' 57 | if start time is unavailable. Returns nil if grandparent process handle cannot 58 | be obtained or on any exception. 59 | 60 | Uses grandparent (parent of parent) to get a stable process ID that persists 61 | across multiple command invocations in Claude Code sessions." 62 | [] 63 | (try 64 | (when-let [gph (some-> (java.lang.ProcessHandle/current) 65 | .parent (.orElse nil) 66 | .parent (.orElse nil))] 67 | (let [pid (.pid gph) 68 | start (some-> (.info gph) .startInstant (.orElse nil) str)] 69 | (str "gpid-" pid (when start (str "-" start))))) 70 | (catch Exception _ 71 | nil))) 72 | 73 | (defn editor-scope-id 74 | "Get editor session scope identifier with fallback strategy. 75 | 76 | Tries in order: 77 | 1. Grandparent process ID with start time (gpid-{pid}-{startInstant}) 78 | 2. Literal string 'global' as last resort 79 | 80 | The GPID approach provides a stable identifier for the Claude Code session 81 | lifetime." 82 | [] 83 | (or (gpid-session-id) 84 | "global")) 85 | 86 | (defn get-possible-session-ids 87 | "Get all possible session IDs for cleanup purposes. 88 | 89 | Parameters: 90 | - :session-id - Optional explicit session ID (e.g., from hook input) 91 | - :gpid - Optional grandparent process ID for fallback calculation 92 | 93 | Returns a vector of unique session IDs that might have been used during 94 | this session. This ensures cleanup works regardless of which ID was actually 95 | used during file operations. 96 | 97 | Example: [{:session-id \"abc123\"}] might return [\"abc123\" \"gpid-1234-...\"]" 98 | [{:keys [session-id gpid]}] 99 | (let [gpid-id (if gpid 100 | (str "gpid-" gpid) 101 | (gpid-session-id))] 102 | (->> [session-id gpid-id] 103 | (filter some?) 104 | distinct 105 | vec))) 106 | 107 | ;; ============================================================================ 108 | ;; Unified Session/Project Root 109 | ;; ============================================================================ 110 | 111 | (defn session-root 112 | "Returns the unified root directory for this Claude Code session + project. 113 | 114 | Structure: 115 | {runtime-dir}/claude-code/{user}/{hostname}/{session-id}-proj-{hash}/ 116 | 117 | Parameters: 118 | - :project-root - Optional project root path (defaults to current directory) 119 | - :session-id - Optional session ID (defaults to editor-scope-id) 120 | 121 | The project is identified by SHA-1 hash of its absolute path for stability 122 | across different session invocations." 123 | [{:keys [project-root session-id]}] 124 | (let [runtime (runtime-base-dir) 125 | sess (or session-id (editor-scope-id)) 126 | proj (or project-root (project-root-path)) 127 | proj-id (sha1 proj)] 128 | (str (fs/path runtime 129 | "clojure-mcp-light" 130 | (str (sanitize sess) "-proj-" proj-id))))) 131 | 132 | ;; ============================================================================ 133 | ;; Convenience Subpaths 134 | ;; ============================================================================ 135 | 136 | (defn backups-dir 137 | "Get the backups directory for this session/project context. 138 | Creates directory if it doesn't exist." 139 | [ctx] 140 | (str (fs/create-dirs (fs/path (session-root ctx) "backups")))) 141 | 142 | (defn nrepl-dir 143 | "Get the nREPL directory for this session/project context. 144 | Creates directory if it doesn't exist." 145 | [ctx] 146 | (str (fs/create-dirs (fs/path (session-root ctx) "nrepl")))) 147 | 148 | ;; ============================================================================ 149 | ;; Specific File Paths 150 | ;; ============================================================================ 151 | 152 | (defn nrepl-session-file 153 | "Get path to nREPL session file. 154 | This file stores the persistent nREPL session ID." 155 | [ctx] 156 | (str (fs/path (nrepl-dir ctx) "session.edn"))) 157 | 158 | (defn nrepl-target-file 159 | "Get path to nREPL session file for a specific target (host:port combination). 160 | Each host:port gets its own session file for independent session management." 161 | [ctx {:keys [host port]}] 162 | (let [hid (sanitize (or host "127.0.0.1")) 163 | pid (str port)] 164 | (str (fs/path (nrepl-dir ctx) (format "target-%s-%s.edn" hid pid))))) 165 | 166 | (defn backup-path 167 | "Deterministic backup path: {backups-dir}/{h0}{h1}/{h2}{h3}/{hash}--{filename} 168 | 169 | - Hash is SHA-256 of the absolute, normalized file path. 170 | - Keeps the original filename for readability. 171 | - Uses a 2-level shard to avoid directory overload." 172 | [ctx ^String absolute-file] 173 | (let [abs-path (-> absolute-file fs/absolutize fs/normalize) 174 | abs (str abs-path) 175 | h (sha256 abs) 176 | fname (or (fs/file-name abs-path) "unnamed") 177 | shard1 (subs h 0 2) 178 | shard2 (subs h 2 4) 179 | out (str h "--" (sanitize fname))] 180 | (str (fs/path (backups-dir ctx) shard1 shard2 out)))) 181 | 182 | (defn list-nrepl-session-files 183 | "List all stored nREPL session files for the current context. 184 | 185 | Scans the nREPL directory for target-*.edn files and reads session data 186 | containing host, port, session ID, and env-type. 187 | 188 | Returns a vector of maps with keys: 189 | - :host - Host string from session data 190 | - :port - Port number from session data 191 | - :file-path - Absolute path to session file 192 | - :session-id - Session ID string (or nil if file is empty/invalid) 193 | - :env-type - Environment type (or nil if not available) 194 | 195 | Returns empty vector if: 196 | - nREPL directory doesn't exist 197 | - No session files found 198 | - Permission errors reading directory" 199 | [ctx] 200 | (try 201 | (let [nrepl-path (nrepl-dir ctx)] 202 | (if (fs/exists? nrepl-path) 203 | (->> (fs/list-dir nrepl-path) 204 | (filter #(re-matches #"target-.*\.edn" (str (fs/file-name %)))) 205 | (keep (fn [file-path] 206 | (try 207 | (when (fs/exists? file-path) 208 | (let [content (slurp (str file-path) :encoding "UTF-8") 209 | session-data (when content (edn/read-string content))] 210 | (when session-data 211 | (assoc session-data :file-path (str file-path))))) 212 | (catch Exception _ 213 | nil)))) 214 | vec) 215 | [])) 216 | (catch Exception _ 217 | []))) 218 | 219 | ;; ============================================================================ 220 | ;; Session Cleanup 221 | ;; ============================================================================ 222 | 223 | (defn cleanup-session! 224 | "Clean up temporary files for this Claude Code session. 225 | 226 | Attempts to delete session directories for all possible session IDs 227 | (both env-based and GPID-based) to ensure cleanup works regardless 228 | of which ID was actually used during the session. 229 | 230 | Parameters: 231 | - :session-id - Optional explicit session ID (e.g., from SessionEnd hook) 232 | - :gpid - Optional grandparent process ID 233 | 234 | Returns a cleanup report map: 235 | - :attempted - List of session IDs for which cleanup was attempted 236 | - :deleted - List of successfully deleted directory paths 237 | - :errors - List of {:path path :error error-msg} maps for failures 238 | - :skipped - List of paths that didn't exist (skipped silently)" 239 | [{:keys [session-id gpid]}] 240 | (let [session-ids (get-possible-session-ids {:session-id session-id :gpid gpid}) 241 | results (atom {:attempted session-ids 242 | :deleted [] 243 | :errors [] 244 | :skipped []})] 245 | (doseq [sess-id session-ids] 246 | (let [sess-dir (session-root {:session-id sess-id})] 247 | (try 248 | (if (fs/exists? sess-dir) 249 | (do 250 | (fs/delete-tree sess-dir) 251 | (swap! results update :deleted conj sess-dir)) 252 | (swap! results update :skipped conj sess-dir)) 253 | (catch Exception e 254 | (swap! results update :errors conj 255 | {:path sess-dir 256 | :error (.getMessage e)}))))) 257 | @results)) 258 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/tmp_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.tmp-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure.string :as str] 4 | [clojure-mcp-light.tmp :as tmp] 5 | [babashka.fs :as fs])) 6 | 7 | ;; ============================================================================ 8 | ;; Helper Function Tests 9 | ;; ============================================================================ 10 | 11 | (deftest sanitize-test 12 | (testing "replaces special characters with underscores" 13 | (is (= "hello_world" (tmp/sanitize "hello/world"))) 14 | (is (= "foo_bar_baz" (tmp/sanitize "foo:bar:baz"))) 15 | (is (= "test_123" (tmp/sanitize "test@123"))) 16 | (is (= "a_b_c" (tmp/sanitize "a b c")))) 17 | 18 | (testing "preserves allowed characters" 19 | (is (= "file.txt" (tmp/sanitize "file.txt"))) 20 | (is (= "my-file" (tmp/sanitize "my-file"))) 21 | (is (= "file_name" (tmp/sanitize "file_name"))) 22 | (is (= "abc123" (tmp/sanitize "abc123")))) 23 | 24 | (testing "collapses multiple underscores" 25 | (is (= "foo_bar" (tmp/sanitize "foo___bar"))) 26 | (is (= "a_b" (tmp/sanitize "a____b"))) 27 | (is (= "test_case" (tmp/sanitize "test//case")))) 28 | 29 | (testing "handles edge cases" 30 | (is (= "_" (tmp/sanitize "/"))) 31 | (is (= "_" (tmp/sanitize "@#$%"))) 32 | (is (string? (tmp/sanitize "")))) 33 | 34 | (testing "handles unicode characters" 35 | (is (= "_hello_" (tmp/sanitize "«hello»"))) 36 | (is (= "_test_" (tmp/sanitize "→test←"))))) 37 | 38 | (deftest sha1-test 39 | (testing "produces consistent hashes" 40 | (let [input "test-string" 41 | hash1 (tmp/sha1 input) 42 | hash2 (tmp/sha1 input)] 43 | (is (= hash1 hash2)) 44 | (is (= 40 (count hash1))))) 45 | 46 | (testing "produces different hashes for different inputs" 47 | (let [hash1 (tmp/sha1 "input1") 48 | hash2 (tmp/sha1 "input2")] 49 | (is (not= hash1 hash2)))) 50 | 51 | (testing "produces valid hex strings" 52 | (let [hash (tmp/sha1 "test")] 53 | (is (re-matches #"[0-9a-f]{40}" hash)))) 54 | 55 | (testing "handles empty strings" 56 | (let [hash (tmp/sha1 "")] 57 | (is (string? hash)) 58 | (is (= 40 (count hash))))) 59 | 60 | (testing "produces known hash for test input" 61 | ;; SHA-1 of "hello" is known 62 | (is (= "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" 63 | (tmp/sha1 "hello"))))) 64 | 65 | (deftest sha256-test 66 | (testing "produces consistent hashes" 67 | (let [input "test-string" 68 | hash1 (tmp/sha256 input) 69 | hash2 (tmp/sha256 input)] 70 | (is (= hash1 hash2)) 71 | (is (= 64 (count hash1))))) 72 | 73 | (testing "produces different hashes for different inputs" 74 | (let [hash1 (tmp/sha256 "input1") 75 | hash2 (tmp/sha256 "input2")] 76 | (is (not= hash1 hash2)))) 77 | 78 | (testing "produces valid hex strings" 79 | (let [hash (tmp/sha256 "test")] 80 | (is (re-matches #"[0-9a-f]{64}" hash)))) 81 | 82 | (testing "handles empty strings" 83 | (let [hash (tmp/sha256 "")] 84 | (is (string? hash)) 85 | (is (= 64 (count hash))))) 86 | 87 | (testing "produces known hash for test input" 88 | ;; SHA-256 of "hello" is known 89 | (is (= "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" 90 | (tmp/sha256 "hello"))))) 91 | 92 | (deftest runtime-base-dir-test 93 | (testing "returns a valid directory path" 94 | (let [result (tmp/runtime-base-dir)] 95 | (is (string? result)) 96 | (is (pos? (count result))))) 97 | 98 | (testing "returns either XDG_RUNTIME_DIR or tmpdir" 99 | (let [result (tmp/runtime-base-dir) 100 | tmpdir (System/getProperty "java.io.tmpdir")] 101 | ;; Result should be either XDG_RUNTIME_DIR (if set) or tmpdir 102 | (is (or (= result (System/getenv "XDG_RUNTIME_DIR")) 103 | (= result tmpdir)))))) 104 | 105 | (deftest editor-scope-id-test 106 | (testing "returns a string" 107 | (is (string? (tmp/editor-scope-id)))) 108 | 109 | (testing "returns non-empty value" 110 | (is (pos? (count (tmp/editor-scope-id))))) 111 | 112 | (testing "returns valid session identifier" 113 | (let [result (tmp/editor-scope-id)] 114 | ;; Should be either gpid-based or "global" 115 | (is (or (str/starts-with? result "gpid-") 116 | (= result "global")))))) 117 | 118 | (deftest project-root-path-test 119 | (testing "returns absolute path" 120 | (let [path (tmp/project-root-path)] 121 | (is (string? path)) 122 | (is (fs/absolute? path)))) 123 | 124 | (testing "returns normalized path" 125 | (let [path (tmp/project-root-path)] 126 | (is (= path (str (fs/normalize path))))))) 127 | 128 | ;; ============================================================================ 129 | ;; Session Root Tests 130 | ;; ============================================================================ 131 | 132 | (deftest session-root-test 133 | (testing "generates session root with default context" 134 | (let [root (tmp/session-root {})] 135 | (is (string? root)) 136 | (is (str/includes? root "clojure-mcp-light")) 137 | (is (str/includes? root "proj-")))) 138 | 139 | (testing "uses custom session-id when provided" 140 | (let [custom-session "my-custom-session" 141 | root (tmp/session-root {:session-id custom-session})] 142 | (is (str/includes? root custom-session)))) 143 | 144 | (testing "uses custom project-root when provided" 145 | (let [custom-project "/custom/project/path" 146 | root (tmp/session-root {:project-root custom-project}) 147 | expected-hash (tmp/sha1 custom-project)] 148 | (is (str/includes? root (str "proj-" expected-hash))))) 149 | 150 | (testing "produces consistent paths for same inputs" 151 | (let [ctx {:session-id "test-123" :project-root "/test/path"} 152 | root1 (tmp/session-root ctx) 153 | root2 (tmp/session-root ctx)] 154 | (is (= root1 root2)))) 155 | 156 | (testing "produces different paths for different sessions" 157 | (let [root1 (tmp/session-root {:session-id "session-1"}) 158 | root2 (tmp/session-root {:session-id "session-2"})] 159 | (is (not= root1 root2)))) 160 | 161 | (testing "produces different paths for different projects" 162 | (let [root1 (tmp/session-root {:project-root "/project1"}) 163 | root2 (tmp/session-root {:project-root "/project2"})] 164 | (is (not= root1 root2)))) 165 | 166 | (testing "sanitizes session-id in path" 167 | (let [root (tmp/session-root {:session-id "my/special:session"})] 168 | (is (str/includes? root "my_special_session")) 169 | (is (not (str/includes? root "my/special:session")))))) 170 | 171 | ;; ============================================================================ 172 | ;; Convenience Directory Tests 173 | ;; ============================================================================ 174 | 175 | (deftest backups-dir-test 176 | (testing "creates and returns backups directory" 177 | (let [ctx {:session-id "test-backup-session" 178 | :project-root "/test/backup/project"} 179 | backup-dir (tmp/backups-dir ctx)] 180 | (is (string? backup-dir)) 181 | (is (str/ends-with? backup-dir "backups")) 182 | (is (fs/exists? backup-dir)) 183 | (is (fs/directory? backup-dir)))) 184 | 185 | (testing "is idempotent" 186 | (let [ctx {:session-id "test-backup-idempotent" 187 | :project-root "/test/backup/project2"} 188 | dir1 (tmp/backups-dir ctx) 189 | dir2 (tmp/backups-dir ctx)] 190 | (is (= dir1 dir2)) 191 | (is (fs/exists? dir1))))) 192 | 193 | (deftest nrepl-dir-test 194 | (testing "creates and returns nrepl directory" 195 | (let [ctx {:session-id "test-nrepl-session" 196 | :project-root "/test/nrepl/project"} 197 | nrepl-dir (tmp/nrepl-dir ctx)] 198 | (is (string? nrepl-dir)) 199 | (is (str/ends-with? nrepl-dir "nrepl")) 200 | (is (fs/exists? nrepl-dir)) 201 | (is (fs/directory? nrepl-dir)))) 202 | 203 | (testing "is idempotent" 204 | (let [ctx {:session-id "test-nrepl-idempotent" 205 | :project-root "/test/nrepl/project2"} 206 | dir1 (tmp/nrepl-dir ctx) 207 | dir2 (tmp/nrepl-dir ctx)] 208 | (is (= dir1 dir2)) 209 | (is (fs/exists? dir1))))) 210 | 211 | ;; ============================================================================ 212 | ;; File Path Tests 213 | ;; ============================================================================ 214 | 215 | (deftest nrepl-session-file-test 216 | (testing "generates session file path" 217 | (let [ctx {:session-id "test-file-session" 218 | :project-root "/test/file/project"} 219 | file-path (tmp/nrepl-session-file ctx)] 220 | (is (string? file-path)) 221 | (is (str/includes? file-path "nrepl")) 222 | (is (str/ends-with? file-path "session.edn")))) 223 | 224 | (testing "path is under nrepl directory" 225 | (let [ctx {:session-id "test-file-session2" 226 | :project-root "/test/file/project2"} 227 | file-path (tmp/nrepl-session-file ctx) 228 | nrepl-dir (tmp/nrepl-dir ctx)] 229 | (is (str/starts-with? file-path nrepl-dir))))) 230 | 231 | (deftest backup-path-test 232 | (testing "generates hash-based backup path" 233 | (let [ctx {:session-id "test-backup-path" 234 | :project-root "/test/project"} 235 | source-file "/Users/bruce/test.clj" 236 | backup (tmp/backup-path ctx source-file)] 237 | (is (string? backup)) 238 | (is (str/includes? backup "backups")) 239 | (is (str/ends-with? backup "test.clj")))) 240 | 241 | (testing "uses 2-level sharding structure" 242 | (let [ctx {:session-id "test-backup-structure" 243 | :project-root "/test/project"} 244 | source-file "/Users/bruce/test.clj" 245 | backup (tmp/backup-path ctx source-file) 246 | hash (tmp/sha256 (str (fs/absolutize (fs/normalize source-file)))) 247 | shard1 (subs hash 0 2) 248 | shard2 (subs hash 2 4)] 249 | ;; Should contain shard directories 250 | (is (str/includes? backup (str "/" shard1 "/" shard2 "/"))) 251 | ;; Should contain hash prefix in filename 252 | (is (str/includes? backup (str shard1 shard2))))) 253 | 254 | (testing "filename format is hash--basename" 255 | (let [ctx {:session-id "test-backup-format" 256 | :project-root "/test/project"} 257 | source-file "/Users/bruce/my-file.clj" 258 | backup (tmp/backup-path ctx source-file) 259 | filename (str (fs/file-name backup))] 260 | ;; Format: {hash}--{sanitized-basename} 261 | (is (str/includes? filename "--")) 262 | (is (str/ends-with? filename "--my-file.clj")))) 263 | 264 | (testing "produces consistent paths for same input" 265 | (let [ctx {:session-id "test-backup-consistent" 266 | :project-root "/test/project"} 267 | source-file "/Users/bruce/test.clj" 268 | backup1 (tmp/backup-path ctx source-file) 269 | backup2 (tmp/backup-path ctx source-file)] 270 | (is (= backup1 backup2)))) 271 | 272 | (testing "produces different paths for different source files" 273 | (let [ctx {:session-id "test-backup-different" 274 | :project-root "/test/project"} 275 | backup1 (tmp/backup-path ctx "/Users/bruce/file1.clj") 276 | backup2 (tmp/backup-path ctx "/Users/bruce/file2.clj")] 277 | (is (not= backup1 backup2)))) 278 | 279 | (testing "sanitizes special characters in filename" 280 | (let [ctx {:session-id "test-backup-special" 281 | :project-root "/test/project"} 282 | source-file "/Users/bruce/my file with spaces.clj" 283 | backup (tmp/backup-path ctx source-file)] 284 | (is (string? backup)) 285 | ;; Spaces should be sanitized to underscores 286 | (is (str/ends-with? backup "my_file_with_spaces.clj")) 287 | (is (not (str/includes? backup " "))))) 288 | 289 | (testing "handles deep paths correctly" 290 | (let [ctx {:session-id "test-backup-deep" 291 | :project-root "/test/project"} 292 | source-file "/a/b/c/d/e/f/deep-file.clj" 293 | backup (tmp/backup-path ctx source-file) 294 | filename (str (fs/file-name backup))] 295 | ;; Should only have basename in filename, not full path 296 | (is (str/ends-with? backup "deep-file.clj")) 297 | ;; Should not contain intermediate directories a, b, c, d, e, f 298 | (is (not (str/includes? filename "/")))))) 299 | 300 | ;; ============================================================================ 301 | ;; Integration Tests 302 | ;; ============================================================================ 303 | 304 | (deftest integration-full-workflow-test 305 | (testing "full workflow creates proper directory structure" 306 | (let [ctx {:session-id "integration-test-session" 307 | :project-root "/test/integration/project"} 308 | root (tmp/session-root ctx) 309 | backups (tmp/backups-dir ctx) 310 | nrepl (tmp/nrepl-dir ctx) 311 | session-file (tmp/nrepl-session-file ctx) 312 | backup (tmp/backup-path ctx "/Users/test/file.clj")] 313 | 314 | ;; Verify all paths are strings 315 | (is (string? root)) 316 | (is (string? backups)) 317 | (is (string? nrepl)) 318 | (is (string? session-file)) 319 | (is (string? backup)) 320 | 321 | ;; Verify directory structure 322 | (is (str/starts-with? backups root)) 323 | (is (str/starts-with? nrepl root)) 324 | (is (str/starts-with? session-file nrepl)) 325 | (is (str/starts-with? backup backups)) 326 | 327 | ;; Verify directories exist 328 | (is (fs/exists? backups)) 329 | (is (fs/exists? nrepl)) 330 | (is (fs/directory? backups)) 331 | (is (fs/directory? nrepl))))) 332 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. 278 | 279 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/delimiter_repair_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.delimiter-repair-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure-mcp-light.delimiter-repair :as dr])) 4 | 5 | (deftest delimiter-error?-test 6 | (testing "detects no error in valid code" 7 | (is (false? (dr/delimiter-error? "(def x 1)"))) 8 | (is (false? (dr/delimiter-error? "(defn foo [x] (* x 2))"))) 9 | (is (false? (dr/delimiter-error? "(let [x 1 y 2] (+ x y))")))) 10 | 11 | (testing "detects delimiter errors" 12 | (is (true? (dr/delimiter-error? "(def x 1"))) 13 | (is (true? (dr/delimiter-error? "(defn foo [x (* x 2))"))) 14 | (is (true? (dr/delimiter-error? "(let [x 1 y 2] (+ x y)")))) 15 | 16 | (testing "handles empty strings" 17 | (is (false? (dr/delimiter-error? "")))) 18 | 19 | (testing "handles multiple forms" 20 | (is (false? (dr/delimiter-error? "(def x 1) (def y 2)"))) 21 | (is (true? (dr/delimiter-error? "(def x 1) (def y 2"))))) 22 | 23 | (deftest fix-delimiters-test 24 | (testing "returns original string when no errors" 25 | (is (= "(def x 1)" (dr/fix-delimiters "(def x 1)"))) 26 | (is (= "(defn foo [x] (* x 2))" (dr/fix-delimiters "(defn foo [x] (* x 2))")))) 27 | 28 | (testing "fixes missing closing delimiters" 29 | (is (= "(def x 1)" (dr/fix-delimiters "(def x 1"))) 30 | (is (= "(+ 1 2 3)" (dr/fix-delimiters "(+ 1 2 3")))) 31 | 32 | (testing "fixes nested delimiter errors" 33 | (let [result (dr/fix-delimiters "(let [x 1] (+ x 2")] 34 | (is (string? result)) 35 | (is (false? (dr/delimiter-error? result))))) 36 | 37 | (testing "returns string for valid input" 38 | (is (string? (dr/fix-delimiters "(def x 1)"))))) 39 | 40 | (deftest parinfer-repair-test 41 | (testing "returns success map for fixable code" 42 | (let [result (dr/parinfer-repair "(def x 1")] 43 | (is (map? result)) 44 | (is (contains? result :success)) 45 | (is (contains? result :text))))) 46 | 47 | (deftest parinferish-repair-test 48 | (testing "returns success map for fixable code" 49 | (let [result (dr/parinferish-repair "(def x 1")] 50 | (is (map? result)) 51 | (is (contains? result :success)) 52 | (is (contains? result :text)) 53 | (is (true? (:success result))) 54 | (is (= "(def x 1)" (:text result))))) 55 | 56 | (testing "handles complex delimiter errors" 57 | (let [result (dr/parinferish-repair "(let [x 1\n y 2\n (+ x y))")] 58 | (is (true? (:success result))) 59 | (is (= "(let [x 1\n y 2]\n (+ x y))" (:text result))))) 60 | 61 | (testing "handles nested missing delimiters" 62 | (let [result (dr/parinferish-repair "(defn baz [x]\n (let [y (* x 2]\n (+ y 1)))")] 63 | (is (true? (:success result))) 64 | (is (false? (dr/delimiter-error? (:text result)))))) 65 | 66 | (testing "returns error map on failure" 67 | (let [result (dr/parinferish-repair nil)] 68 | (is (map? result)) 69 | (is (contains? result :success)) 70 | (is (false? (:success result))) 71 | (is (contains? result :error))))) 72 | 73 | (deftest repair-delimiters-test 74 | (testing "returns success map for fixable code" 75 | (let [result (dr/repair-delimiters "(def x 1")] 76 | (is (map? result)) 77 | (is (contains? result :success)) 78 | (is (contains? result :text)) 79 | (is (true? (:success result))))) 80 | 81 | (testing "works with complex nested errors" 82 | (let [result (dr/repair-delimiters "(defn foo [x]\n (let [y (* x 2]\n (+ y 1)))")] 83 | (is (true? (:success result))) 84 | (is (false? (dr/delimiter-error? (:text result)))))) 85 | 86 | (testing "returns repaired text that passes delimiter check" 87 | (let [result (dr/repair-delimiters "(+ 1 2 3")] 88 | (is (true? (:success result))) 89 | (is (= "(+ 1 2 3)" (:text result)))))) 90 | 91 | (deftest delimiter-error-with-function-literals-test 92 | (testing "does not error on valid function literals" 93 | (is (false? (dr/delimiter-error? "#(+ % 1)"))) 94 | (is (false? (dr/delimiter-error? "(map #(* % 2) [1 2 3])"))) 95 | (is (false? (dr/delimiter-error? "(filter #(> % 10) nums)")))) 96 | 97 | (testing "detects delimiter errors in code with function literals" 98 | (is (true? (dr/delimiter-error? "#(+ % 1"))) 99 | (is (true? (dr/delimiter-error? "(map #(* % 2) [1 2 3"))) 100 | (is (true? (dr/delimiter-error? "(filter #(> % 10 nums)"))))) 101 | 102 | (deftest delimiter-error-with-regex-test 103 | (testing "does not error on valid regex literals" 104 | (is (false? (dr/delimiter-error? "#\"pattern\""))) 105 | (is (false? (dr/delimiter-error? "(re-find #\"[0-9]+\" s)"))) 106 | (is (false? (dr/delimiter-error? "#\"\\s+\"")))) 107 | 108 | (testing "detects delimiter errors in code with regex literals" 109 | (is (true? (dr/delimiter-error? "(re-find #\"[0-9]+\" s"))) 110 | (is (true? (dr/delimiter-error? "(if (re-matches #\"test\" x) true"))))) 111 | 112 | (deftest delimiter-error-with-quotes-test 113 | (testing "does not error on valid quoted forms" 114 | (is (false? (dr/delimiter-error? "'(1 2 3)"))) 115 | (is (false? (dr/delimiter-error? "`(foo ~bar)"))) 116 | (is (false? (dr/delimiter-error? "(quote (a b c))")))) 117 | 118 | (testing "detects delimiter errors in code with quotes" 119 | (is (true? (dr/delimiter-error? "'(1 2 3"))) 120 | (is (true? (dr/delimiter-error? "`(foo ~bar"))))) 121 | 122 | (deftest fix-delimiters-with-function-literals-test 123 | (testing "fixes delimiter errors in code with function literals" 124 | (let [result (dr/fix-delimiters "(map #(* % 2) [1 2 3")] 125 | (is (string? result)) 126 | (is (false? (dr/delimiter-error? result))))) 127 | 128 | (testing "preserves function literals when fixing" 129 | (let [code "(defn process [xs] (map #(inc %) xs" 130 | fixed (dr/fix-delimiters code)] 131 | (is (string? fixed)) 132 | (is (false? (dr/delimiter-error? fixed))) 133 | (is (re-find #"#\(" fixed)))) ; Function literal preserved 134 | 135 | (testing "returns original when no errors in code with function literals" 136 | (is (= "(map #(+ % 1) [1 2 3])" 137 | (dr/fix-delimiters "(map #(+ % 1) [1 2 3])"))))) 138 | 139 | (deftest delimiter-error-with-deref-test 140 | (testing "does not error on valid deref forms" 141 | (is (false? (dr/delimiter-error? "@foo"))) 142 | (is (false? (dr/delimiter-error? "(swap! @atom inc)"))) 143 | (is (false? (dr/delimiter-error? "@(future (+ 1 2))")))) 144 | 145 | (testing "detects delimiter errors in code with deref" 146 | (is (true? (dr/delimiter-error? "(swap! @atom inc"))) 147 | (is (true? (dr/delimiter-error? "@(future (+ 1 2"))))) 148 | 149 | (deftest delimiter-error-with-var-test 150 | (testing "does not error on valid var forms" 151 | (is (false? (dr/delimiter-error? "#'foo"))) 152 | (is (false? (dr/delimiter-error? "(alter-var-root #'foo inc)"))) 153 | (is (false? (dr/delimiter-error? "#'clojure.core/+")))) 154 | 155 | (testing "detects delimiter errors in code with var" 156 | (is (true? (dr/delimiter-error? "(alter-var-root #'foo inc"))) 157 | (is (true? (dr/delimiter-error? "(let [x #'foo] (x 1 2"))))) 158 | 159 | (deftest delimiter-error-with-reader-conditionals-test 160 | (testing "does not error on valid reader conditional forms" 161 | (is (false? (dr/delimiter-error? "#?(:clj 1 :cljs 2)"))) 162 | (is (false? (dr/delimiter-error? "#?@(:clj [1 2] :cljs [3 4])"))) 163 | (is (false? (dr/delimiter-error? "[1 2 #?(:clj 3)]")))) 164 | 165 | (testing "detects delimiter errors in surrounding code with reader conditionals" 166 | (is (true? (dr/delimiter-error? "(let [x #?(:clj 1 :cljs 2)] x"))) 167 | (is (true? (dr/delimiter-error? "[1 2 #?(:clj 3) 4"))))) 168 | 169 | (deftest delimiter-error-with-reader-conditional-splicing-test 170 | (testing "parses reader conditional splicing with known features" 171 | (is (false? (dr/delimiter-error? "{1 2 #?@(:clj [3 4])}"))) 172 | (is (false? (dr/delimiter-error? "{1 2 #?@(:cljs [3 4])}"))) 173 | (is (false? (dr/delimiter-error? "{1 2 #?@(:bb [3 4])}"))) 174 | (is (false? (dr/delimiter-error? "(def x [1 2 #?@(:clj [3 4])])")))) 175 | 176 | (testing "parses reader conditional splicing with unknown features" 177 | (is (false? (dr/delimiter-error? "{1 2 #?@(:my-custom-feature [3 4])}"))) 178 | (is (false? (dr/delimiter-error? "{1 2 #?@(:unknown [3 4])}"))) 179 | (is (false? (dr/delimiter-error? "(def x [1 2 #?@(:custom-lang [3 4])])")))) 180 | 181 | (testing "detects delimiter errors with reader conditional splicing and known features" 182 | (is (true? (dr/delimiter-error? "{1 2 #?@(:clj [3 4]"))) 183 | (is (true? (dr/delimiter-error? "{1 2 #?@(:cljs [3 4)"))) 184 | (is (true? (dr/delimiter-error? "(def x [1 2 #?@(:clj [3 4])]")))) 185 | 186 | (testing "handles real-world Transit reader map with splicing" 187 | (is (false? (dr/delimiter-error? 188 | "{\"DateTime\" (transit/read-handler read-date-time) 189 | \"Date\" (transit/read-handler read-local-date) 190 | \"AVLMap\" (transit/read-handler #(into (avl/sorted-map) %)) 191 | #?@(:cljs [\"u\" cljs.core/uuid])}")))) 192 | 193 | (testing "detects delimiter errors in Transit reader map" 194 | (is (true? (dr/delimiter-error? 195 | "{\"DateTime\" (transit/read-handler read-date-time) 196 | \"Date\" (transit/read-handler read-local-date) 197 | #?@(:cljs [\"u\" cljs.core/uuid]")))) 198 | 199 | (testing "handles multiple reader conditional splicing forms" 200 | (is (false? (dr/delimiter-error? 201 | "{:a 1 202 | #?@(:clj [:b 2]) 203 | #?@(:cljs [:c 3]) 204 | :d 4}"))) 205 | (is (false? (dr/delimiter-error? 206 | "[1 2 #?@(:bb [3 4]) 5 #?@(:clj [6 7])]")))) 207 | 208 | (testing "known limitation: missing ) on reader conditional with unknown feature at EOF" 209 | ;; Specific edge case: when the reader conditional form itself (#?@(...)) 210 | ;; is missing its closing ) at EOF and the feature is unknown, edamame 211 | ;; doesn't provide delimiter info (though it still reports an error). 212 | ;; Use actual-delimiter-error? to avoid logging the expected error. 213 | (is (false? (dr/actual-delimiter-error? "{1 2 #?@(:unknown [3 4]"))) 214 | ;; But delimiter errors within the vector ARE detected even with unknown features: 215 | (is (true? (dr/actual-delimiter-error? "{1 2 #?@(:unknown [3 4"))) 216 | ;; And errors with known features provide full delimiter info: 217 | (is (true? (dr/actual-delimiter-error? "{1 2 #?@(:cljs [3 4]"))) 218 | (is (true? (dr/actual-delimiter-error? "{1 2 #?@(:cljs [3 4"))))) 219 | 220 | (deftest delimiter-error-with-metadata-test 221 | (testing "does not error on valid metadata forms" 222 | (is (false? (dr/delimiter-error? "^:private foo"))) 223 | (is (false? (dr/delimiter-error? "^{:doc \"test\"} bar"))) 224 | (is (false? (dr/delimiter-error? "(defn ^:private foo [] 1)")))) 225 | 226 | (testing "detects delimiter errors in code with metadata" 227 | (is (true? (dr/delimiter-error? "^{:doc \"test\"} (defn foo [] 1"))) 228 | (is (true? (dr/delimiter-error? "(defn ^:private foo [] 1"))))) 229 | 230 | (deftest delimiter-error-comprehensive-test 231 | (testing "handles complex real-world Clojure code without errors" 232 | (is (false? (dr/delimiter-error? 233 | "(ns foo.bar 234 | (:require [clojure.string :as str])) 235 | 236 | (defn ^:private process [data] 237 | (let [result @(future 238 | (map #(* % 2) 239 | (filter #(> % 10) data)))] 240 | (when-let [x (first result)] 241 | #?(:clj (str/upper-case x) 242 | :cljs (.toUpperCase x)))))")))) 243 | 244 | (testing "detects delimiter errors in complex nested code" 245 | (is (true? (dr/delimiter-error? 246 | "(ns foo.bar 247 | (:require [clojure.string :as str])) 248 | 249 | (defn process [data] 250 | (let [result @(future 251 | (map #(* % 2) 252 | (filter #(> % 10) data)))] 253 | (when-let [x (first result)] 254 | (str/upper-case x"))))) ; Missing multiple closing parens 255 | 256 | (deftest delimiter-error-with-data-readers-test 257 | (testing "does not error on standard EDN data readers" 258 | (is (false? (dr/delimiter-error? "#inst \"2023-01-01\""))) 259 | (is (false? (dr/delimiter-error? "#uuid \"550e8400-e29b-41d4-a716-446655440000\""))) 260 | (is (false? (dr/delimiter-error? "(def x #inst \"2023-01-01\")"))) 261 | (is (false? (dr/delimiter-error? "(def y #uuid \"550e8400-e29b-41d4-a716-446655440000\")"))) 262 | (is (false? (dr/delimiter-error? "#my/custom {:a 1}"))) 263 | (is (false? (dr/delimiter-error? "(def z #custom/tag \"value\")")))) 264 | 265 | (testing "handles known ClojureScript data readers" 266 | (is (false? (dr/delimiter-error? "#js {:x 1 :y 2}")))) 267 | 268 | (testing "detects delimiter errors in code with data readers" 269 | (is (true? (dr/delimiter-error? "(def x #inst \"2023-01-01\""))) 270 | (is (true? (dr/delimiter-error? "(let [t #uuid \"550e8400-e29b-41d4-a716-446655440000\"] t"))) 271 | (is (true? (dr/delimiter-error? "(def x #my/custom {:a 1"))))) 272 | 273 | (deftest clojurescript-tagged-literals-test 274 | (testing "handles #js tagged literals" 275 | (is (false? (dr/delimiter-error? "#js {:foo 1}"))) 276 | (is (false? (dr/delimiter-error? "#js [1 2 3]"))) 277 | (is (false? (dr/delimiter-error? "(def obj #js {:x 1 :y 2})"))) 278 | (is (false? (dr/delimiter-error? "(defn foo [] #js {:bar \"baz\"})")))) 279 | 280 | (testing "handles #jsx tagged literals" 281 | (is (false? (dr/delimiter-error? "#jsx [:div \"hello\"]"))) 282 | (is (false? (dr/delimiter-error? "(defn component [] #jsx [:div {:class \"foo\"} \"text\"])")))) 283 | 284 | (testing "handles #queue tagged literals" 285 | (is (false? (dr/delimiter-error? "#queue [1 2 3]"))) 286 | (is (false? (dr/delimiter-error? "(def q #queue [])")))) 287 | 288 | (testing "handles #date tagged literals" 289 | (is (false? (dr/delimiter-error? "#date \"2024-01-01\""))) 290 | (is (false? (dr/delimiter-error? "(def d #date \"2024-12-31\")")))) 291 | 292 | (testing "detects delimiter errors with tagged literals" 293 | (is (true? (dr/delimiter-error? "(def obj #js {:x 1"))) 294 | (is (true? (dr/delimiter-error? "#js [1 2 3"))) 295 | (is (true? (dr/delimiter-error? "(defn foo [] #jsx [:div \"hello\""))) 296 | (is (true? (dr/delimiter-error? "(def q #queue [1 2 3")))) 297 | 298 | (testing "handles nested tagged literals" 299 | (is (false? (dr/delimiter-error? "#js {:a #js [1 2 3]}"))) 300 | (is (false? (dr/delimiter-error? "(def data #js {:nested #js {:deep true}})"))) 301 | (is (true? (dr/delimiter-error? "#js {:a #js [1 2 3"))) 302 | (is (true? (dr/delimiter-error? "(def data #js {:nested #js {:deep true}")))) 303 | 304 | (testing "handles multiple tagged literals in same form" 305 | (is (false? (dr/delimiter-error? "(def data [#js {:x 1} #date \"2024-01-01\" #queue [1]])"))) 306 | (is (true? (dr/delimiter-error? "(def data [#js {:x 1} #date \"2024-01-01\" #queue [1]"))))) 307 | 308 | (deftest clojurescript-features-test 309 | (testing "handles namespaced keywords" 310 | (is (false? (dr/delimiter-error? "::foo"))) 311 | (is (false? (dr/delimiter-error? "::foo/bar"))) 312 | (is (false? (dr/delimiter-error? "{::id 1 ::name \"test\"}"))) 313 | (is (false? (dr/delimiter-error? "(defn foo [x] {::result (* x 2)})")))) 314 | 315 | (testing "detects delimiter errors with namespaced keywords" 316 | (is (true? (dr/delimiter-error? "{::id 1 ::name \"test\""))) 317 | (is (true? (dr/delimiter-error? "(defn foo [x] {::result (* x 2})")))) 318 | 319 | (testing "handles ClojureScript destructuring with namespaced keywords" 320 | (is (false? (dr/delimiter-error? "(let [{::keys [foo bar]} data] foo)"))) 321 | (is (false? (dr/delimiter-error? "(defn process [{::keys [id name]}] id)")))) 322 | 323 | (testing "detects delimiter errors in destructuring" 324 | (is (true? (dr/delimiter-error? "(let [{::keys [foo bar]} data] foo"))) 325 | (is (true? (dr/delimiter-error? "(defn process [{::keys [id name]} id"))))) 326 | 327 | (deftest mixed-clj-cljs-features-test 328 | (testing "handles mixed Clojure and ClojureScript features" 329 | (is (false? (dr/delimiter-error? 330 | "(ns app.core 331 | (:require [clojure.string :as str])) 332 | 333 | (defn process [data] 334 | (let [obj #js {:name \"test\"} 335 | result (map #(* % 2) data) 336 | date #date \"2024-01-01\"] 337 | {::obj obj 338 | ::result result 339 | ::date date}))")))) 340 | 341 | (testing "detects delimiter errors in mixed code" 342 | (is (true? (dr/delimiter-error? 343 | "(ns app.core 344 | (:require [clojure.string :as str])) 345 | 346 | (defn process [data] 347 | (let [obj #js {:name \"test\"} 348 | result (map #(* % 2) data] 349 | {::obj obj 350 | ::result result")))) 351 | 352 | (testing "handles reader conditionals with tagged literals" 353 | (is (false? (dr/delimiter-error? 354 | "#?(:clj {:type :jvm} 355 | :cljs #js {:type \"browser\"})"))) 356 | (is (false? (dr/delimiter-error? 357 | "(def config #?(:clj (read-string slurp \"config.edn\") 358 | :cljs #js {:env \"dev\"}))")))) 359 | 360 | (testing "detects delimiter errors in reader conditionals with tagged literals" 361 | (is (true? (dr/delimiter-error? 362 | "#?(:clj {:type :jvm} 363 | :cljs #js {:type \"browser\")"))) 364 | (is (true? (dr/delimiter-error? 365 | "(def config #?(:clj (read-string slurp \"config.edn\") 366 | :cljs #js {:env \"dev\""))))) 367 | -------------------------------------------------------------------------------- /test/clojure_mcp_light/nrepl_client_test.clj: -------------------------------------------------------------------------------- 1 | (ns clojure-mcp-light.nrepl-client-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [clojure-mcp-light.nrepl-client :as nrepl])) 4 | 5 | ;; ============================================================================ 6 | ;; Message encoding/decoding tests 7 | ;; ============================================================================ 8 | 9 | (deftest bytes->str-test 10 | (testing "converts byte arrays to strings" 11 | (is (= "hello" (nrepl/bytes->str (.getBytes "hello")))) 12 | (is (= "test" (nrepl/bytes->str (.getBytes "test"))))) 13 | 14 | (testing "passes through strings unchanged" 15 | (is (= "already-string" (nrepl/bytes->str "already-string")))) 16 | 17 | (testing "passes through other types unchanged" 18 | (is (= 42 (nrepl/bytes->str 42))) 19 | (is (= :keyword (nrepl/bytes->str :keyword)))) 20 | 21 | (testing "recursively converts nested byte arrays in vectors" 22 | (let [result (nrepl/bytes->str [(.getBytes "a") (.getBytes "b") "c"])] 23 | (is (= ["a" "b" "c"] result)))) 24 | 25 | (testing "recursively converts nested byte arrays in maps" 26 | (let [result (nrepl/bytes->str {"key" (.getBytes "value") 27 | (.getBytes "bkey") "string"})] 28 | (is (= {"key" "value" "bkey" "string"} result))))) 29 | 30 | (deftest read-msg-test 31 | (testing "converts string keys to keywords" 32 | (let [msg {"op" "eval" "code" "(+ 1 2)"} 33 | result (nrepl/read-msg msg)] 34 | (is (= "eval" (:op result))) 35 | (is (= "(+ 1 2)" (:code result))))) 36 | 37 | (testing "converts byte values to strings" 38 | (let [msg {"value" (.getBytes "42")} 39 | result (nrepl/read-msg msg)] 40 | (is (= "42" (:value result))))) 41 | 42 | (testing "converts status byte vector to string vector" 43 | (let [msg {"status" [(.getBytes "done") (.getBytes "ok")]} 44 | result (nrepl/read-msg msg)] 45 | (is (= ["done" "ok"] (:status result))))) 46 | 47 | (testing "converts sessions byte vector to string vector" 48 | (let [msg {"sessions" [(.getBytes "session-1") (.getBytes "session-2")]} 49 | result (nrepl/read-msg msg)] 50 | (is (= ["session-1" "session-2"] (:sessions result))))) 51 | 52 | (testing "handles mixed byte and string values" 53 | (let [msg {"id" "abc123" 54 | "value" (.getBytes "result") 55 | "status" [(.getBytes "done")]} 56 | result (nrepl/read-msg msg)] 57 | (is (= "abc123" (:id result))) 58 | (is (= "result" (:value result))) 59 | (is (= ["done"] (:status result)))))) 60 | 61 | (deftest coerce-long-test 62 | (testing "parses string to long" 63 | (is (= 7888 (nrepl/coerce-long "7888"))) 64 | (is (= 1234 (nrepl/coerce-long "1234")))) 65 | 66 | (testing "passes through long unchanged" 67 | (is (= 7888 (nrepl/coerce-long 7888))) 68 | (is (= 1234 (nrepl/coerce-long 1234))))) 69 | 70 | (deftest next-id-test 71 | (testing "generates unique IDs" 72 | (let [id1 (nrepl/next-id) 73 | id2 (nrepl/next-id)] 74 | (is (string? id1)) 75 | (is (string? id2)) 76 | (is (not= id1 id2)))) 77 | 78 | (testing "generates UUID format" 79 | (let [id (nrepl/next-id)] 80 | (is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" id))))) 81 | 82 | ;; ============================================================================ 83 | ;; Lazy sequence function tests 84 | ;; ============================================================================ 85 | 86 | (deftest decode-messages-test 87 | (testing "decodes raw bencode messages" 88 | (let [raw-msgs [{"op" "eval"} {"value" (.getBytes "42")} nil] 89 | decoded (nrepl/decode-messages raw-msgs)] 90 | (is (= 2 (count decoded))) 91 | (is (= "eval" (:op (first decoded)))) 92 | (is (= "42" (:value (second decoded)))))) 93 | 94 | (testing "stops at first nil" 95 | (let [raw-msgs [{"op" "eval"} nil {"value" "should-not-appear"}] 96 | decoded (nrepl/decode-messages raw-msgs)] 97 | (is (= 1 (count decoded))) 98 | (is (= "eval" (:op (first decoded))))))) 99 | 100 | (deftest filter-id-test 101 | (testing "filters messages by id" 102 | (let [msgs [{:id "abc" :value "1"} 103 | {:id "def" :value "2"} 104 | {:id "abc" :status ["done"]}] 105 | filtered (nrepl/filter-id "abc" msgs)] 106 | (is (= 2 (count filtered))) 107 | (is (every? #(= "abc" (:id %)) filtered)))) 108 | 109 | (testing "returns empty when no matches" 110 | (let [msgs [{:id "abc" :value "1"}] 111 | filtered (nrepl/filter-id "xyz" msgs)] 112 | (is (empty? filtered))))) 113 | 114 | (deftest filter-session-test 115 | (testing "filters messages by session" 116 | (let [msgs [{:session "s1" :value "1"} 117 | {:session "s2" :value "2"} 118 | {:session "s1" :status ["done"]}] 119 | filtered (nrepl/filter-session "s1" msgs)] 120 | (is (= 2 (count filtered))) 121 | (is (every? #(= "s1" (:session %)) filtered)))) 122 | 123 | (testing "returns empty when no matches" 124 | (let [msgs [{:session "s1" :value "1"}] 125 | filtered (nrepl/filter-session "s2" msgs)] 126 | (is (empty? filtered))))) 127 | 128 | (deftest take-upto-test 129 | (testing "takes up to and including first match" 130 | (let [coll [1 2 3 4 5 6] 131 | result (nrepl/take-upto #(> % 3) coll)] 132 | (is (= [1 2 3 4] result)))) 133 | 134 | (testing "takes all when no match" 135 | (let [coll [1 2 3] 136 | result (nrepl/take-upto #(> % 10) coll)] 137 | (is (= [1 2 3] result)))) 138 | 139 | (testing "takes only first element when first matches" 140 | (let [coll [5 6 7] 141 | result (nrepl/take-upto #(> % 3) coll)] 142 | (is (= [5] result)))) 143 | 144 | (testing "works with predicates on maps" 145 | (let [coll [{:done false} {:done false} {:done true} {:done false}] 146 | result (nrepl/take-upto :done coll)] 147 | (is (= 3 (count result))) 148 | (is (= true (:done (last result)))))) 149 | 150 | (testing "is lazy" 151 | (let [counter (atom 0) 152 | coll (map (fn [x] (swap! counter inc) x) [1 2 3 4 5]) 153 | result (nrepl/take-upto #(> % 2) coll)] 154 | ;; Before realization, counter should be 0 155 | (is (= 0 @counter)) 156 | ;; Force realization 157 | (doall result) 158 | ;; Should have processed at least up to the matching element 159 | ;; Implementation may process a few extra elements due to chunking 160 | (is (<= 3 @counter 5))))) 161 | 162 | (deftest take-until-done-test 163 | (testing "takes messages until done status" 164 | (let [msgs [{:id "1" :value "42"} 165 | {:id "1" :out "output"} 166 | {:id "1" :status ["done"]} 167 | {:id "1" :value "should-not-appear"}] 168 | result (nrepl/take-until-done msgs)] 169 | (is (= 3 (count result))) 170 | (is (= ["done"] (:status (last result)))))) 171 | 172 | (testing "takes all when no done status" 173 | (let [msgs [{:id "1" :value "42"} 174 | {:id "1" :out "output"}] 175 | result (nrepl/take-until-done msgs)] 176 | (is (= 2 (count result))))) 177 | 178 | (testing "handles done with other statuses" 179 | (let [msgs [{:id "1" :value "42"} 180 | {:id "1" :status ["done" "ok"]}] 181 | result (nrepl/take-until-done msgs)] 182 | (is (= 2 (count result))) 183 | (is (some #{"done"} (:status (last result))))))) 184 | 185 | ;; ============================================================================ 186 | ;; merge-response tests 187 | ;; ============================================================================ 188 | 189 | (deftest merge-response-test 190 | (testing "merges single value message" 191 | (let [msgs [{:id "1" :value "42" :ns "user"}] 192 | result (nrepl/merge-response msgs)] 193 | (is (= ["42"] (:value result))) 194 | (is (= "user" (:ns result))))) 195 | 196 | (testing "preserves custom fields from describe response" 197 | (let [msgs [{:id "1" :versions {:clojure {:major 1 :minor 12} :nrepl {:major 1 :minor 3}} 198 | :ops {:eval {} :load-file {}} 199 | :aux {:some-data "value"} 200 | :status ["done"]}] 201 | result (nrepl/merge-response msgs)] 202 | (is (= {:clojure {:major 1 :minor 12} :nrepl {:major 1 :minor 3}} (:versions result))) 203 | (is (= {:eval {} :load-file {}} (:ops result))) 204 | (is (= {:some-data "value"} (:aux result))) 205 | (is (contains? (:status result) "done")))) 206 | 207 | (testing "merges multiple value messages" 208 | (let [msgs [{:id "1" :value "first"} 209 | {:id "1" :value "second"} 210 | {:id "1" :value "third"}] 211 | result (nrepl/merge-response msgs)] 212 | (is (= ["first" "second" "third"] (:value result))))) 213 | 214 | (testing "concatenates output streams" 215 | (let [msgs [{:id "1" :out "line1\n"} 216 | {:id "1" :out "line2\n"} 217 | {:id "1" :err "error\n"}] 218 | result (nrepl/merge-response msgs)] 219 | (is (= "line1\nline2\n" (:out result))) 220 | (is (= "error\n" (:err result))))) 221 | 222 | (testing "merges status sets" 223 | (let [msgs [{:id "1" :status ["evaluating"]} 224 | {:id "1" :status ["done"]} 225 | {:id "1" :status ["ok"]}] 226 | result (nrepl/merge-response msgs)] 227 | (is (= #{"evaluating" "done" "ok"} (:status result))))) 228 | 229 | (testing "preserves last namespace" 230 | (let [msgs [{:id "1" :ns "user" :value "1"} 231 | {:id "1" :ns "foo.bar" :value "2"} 232 | {:id "1" :ns "baz.qux" :value "3"}] 233 | result (nrepl/merge-response msgs)] 234 | (is (= "baz.qux" (:ns result))) 235 | (is (= ["1" "2" "3"] (:value result))))) 236 | 237 | (testing "handles exception fields" 238 | (let [msgs [{:id "1" :ex "NPE" :root-ex "RootException"}] 239 | result (nrepl/merge-response msgs)] 240 | (is (= "NPE" (:ex result))) 241 | (is (= "RootException" (:root-ex result))))) 242 | 243 | (testing "merges complete eval sequence" 244 | (let [msgs [{:id "123" :out "printing...\n"} 245 | {:id "123" :ns "user" :value "nil"} 246 | {:id "123" :ns "user" :value "42"} 247 | {:id "123" :status ["done"]}] 248 | result (nrepl/merge-response msgs)] 249 | (is (= ["nil" "42"] (:value result))) 250 | (is (= "printing...\n" (:out result))) 251 | (is (= "user" (:ns result))) 252 | (is (contains? (:status result) "done")))) 253 | 254 | (testing "handles empty message sequence" 255 | (let [result (nrepl/merge-response [])] 256 | (is (map? result)) 257 | (is (nil? (:value result)))))) 258 | 259 | ;; ============================================================================ 260 | ;; Connection map tests 261 | ;; ============================================================================ 262 | 263 | (deftest make-connection-test 264 | (testing "creates connection map with required fields" 265 | (let [socket (java.net.Socket.) 266 | out (java.io.ByteArrayOutputStream.) 267 | in (java.io.ByteArrayInputStream. (.getBytes "test")) 268 | conn (nrepl/make-connection socket out in "localhost" 7888)] 269 | (is (= socket (:socket conn))) 270 | (is (= out (:output conn))) 271 | (is (= in (:input conn))) 272 | (is (= "localhost" (:host conn))) 273 | (is (= 7888 (:port conn))))) 274 | 275 | (testing "creates connection map with optional fields" 276 | (let [socket (java.net.Socket.) 277 | out (java.io.ByteArrayOutputStream.) 278 | in (java.io.ByteArrayInputStream. (.getBytes "test")) 279 | conn (nrepl/make-connection socket out in "localhost" 7888 280 | :session-id "session-123" 281 | :nrepl-env :clj)] 282 | (is (= "session-123" (:session-id conn))) 283 | (is (= :clj (:nrepl-env conn)))))) 284 | 285 | ;; ============================================================================ 286 | ;; Lazy sequence pipeline integration tests 287 | ;; ============================================================================ 288 | 289 | (deftest lazy-pipeline-integration-test 290 | (testing "full lazy pipeline from raw messages to filtered results" 291 | (let [;; Simulate raw bencode messages 292 | raw-msgs [{"id" "abc" "value" (.getBytes "1")} 293 | {"id" "def" "value" (.getBytes "2")} 294 | {"id" "abc" "status" [(.getBytes "done")]} 295 | nil] 296 | ;; Build lazy pipeline 297 | result (->> raw-msgs 298 | (nrepl/decode-messages) 299 | (nrepl/filter-id "abc") 300 | (nrepl/take-until-done) 301 | (doall))] 302 | (is (= 2 (count result))) 303 | (is (= "1" (:value (first result)))) 304 | (is (= ["done"] (:status (second result)))))) 305 | 306 | (testing "pipeline with session and id filtering" 307 | (let [raw-msgs [{"id" "1" "session" "s1" "value" (.getBytes "a")} 308 | {"id" "2" "session" "s1" "value" (.getBytes "b")} 309 | {"id" "1" "session" "s1" "status" [(.getBytes "done")]} 310 | {"id" "1" "session" "s2" "value" (.getBytes "c")} 311 | nil] 312 | result (->> raw-msgs 313 | (nrepl/decode-messages) 314 | (nrepl/filter-session "s1") 315 | (nrepl/filter-id "1") 316 | (nrepl/take-until-done) 317 | (doall))] 318 | (is (= 2 (count result))) 319 | (is (= "a" (:value (first result)))) 320 | (is (= ["done"] (:status (second result)))))) 321 | 322 | (testing "pipeline with merge-response" 323 | (let [raw-msgs [{"id" "x" "out" "line1\n"} 324 | {"id" "x" "value" (.getBytes "42")} 325 | {"id" "x" "status" [(.getBytes "done")]} 326 | nil] 327 | result (->> raw-msgs 328 | (nrepl/decode-messages) 329 | (nrepl/filter-id "x") 330 | (nrepl/take-until-done) 331 | (nrepl/merge-response))] 332 | (is (= ["42"] (:value result))) 333 | (is (= "line1\n" (:out result))) 334 | (is (contains? (:status result) "done"))))) 335 | 336 | ;; ============================================================================ 337 | ;; Connection-based API tests (with * suffix) 338 | ;; ============================================================================ 339 | 340 | (deftest connection-based-api-test 341 | (testing "describe-nrepl* with connection" 342 | (let [raw-msgs [{"id" "1" "versions" {:clojure {:major 1}} "ops" {:eval {}} "status" [(.getBytes "done")]}] 343 | msgs (nrepl/decode-messages raw-msgs) 344 | conn {:input nil :output nil :host "localhost" :port 7888} 345 | result (with-redefs [nrepl/messages-for-id (fn [_ _] msgs)] 346 | (nrepl/describe-nrepl* conn))] 347 | (is (= {:clojure {:major 1}} (:versions result))) 348 | (is (= {:eval {}} (:ops result))))) 349 | 350 | (testing "eval-nrepl* with connection" 351 | (let [raw-msgs [{"id" "1" "value" "42" "ns" "user" "status" [(.getBytes "done")]}] 352 | msgs (nrepl/decode-messages raw-msgs) 353 | conn {:input nil :output nil :host "localhost" :port 7888} 354 | result (with-redefs [nrepl/messages-for-id (fn [_ _] msgs)] 355 | (nrepl/eval-nrepl* conn "(+ 1 2)"))] 356 | (is (= ["42"] (:value result))) 357 | (is (= "user" (:ns result))))) 358 | 359 | (testing "clone-session* with connection" 360 | (let [raw-msgs [{"id" "1" "new-session" "session-123" "status" [(.getBytes "done")]}] 361 | msgs (nrepl/decode-messages raw-msgs) 362 | conn {:input nil :output nil :host "localhost" :port 7888} 363 | result (with-redefs [nrepl/messages-for-id (fn [_ _] msgs)] 364 | (nrepl/clone-session* conn))] 365 | (is (= "session-123" (:new-session result))))) 366 | 367 | (testing "ls-sessions* with connection" 368 | (let [raw-msgs [{"id" "1" "sessions" [(.getBytes "s1") (.getBytes "s2")] "status" [(.getBytes "done")]}] 369 | msgs (nrepl/decode-messages raw-msgs) 370 | conn {:input nil :output nil :host "localhost" :port 7888} 371 | result (with-redefs [nrepl/messages-for-id (fn [_ _] msgs)] 372 | (nrepl/ls-sessions* conn))] 373 | (is (= ["s1" "s2"] (:sessions result)))))) 374 | 375 | ;; ============================================================================ 376 | ;; Edge cases and error handling 377 | ;; ============================================================================ 378 | 379 | (deftest edge-cases-test 380 | (testing "handles nil in message sequence gracefully" 381 | (let [msgs [nil {:id "1"} nil] 382 | decoded (nrepl/decode-messages msgs)] 383 | (is (= 0 (count decoded))))) 384 | 385 | (testing "handles empty message sequence" 386 | (let [msgs [] 387 | result (->> msgs 388 | (nrepl/decode-messages) 389 | (nrepl/filter-id "x") 390 | (nrepl/take-until-done) 391 | (doall))] 392 | (is (empty? result)))) 393 | 394 | (testing "handles messages without expected fields" 395 | (let [msgs [{:id "1"} ; no value 396 | {:value "42"} ; no id 397 | {:id "1" :status ["done"]}] 398 | result (->> msgs 399 | (nrepl/filter-id "1") 400 | (nrepl/take-until-done) 401 | (nrepl/merge-response))] 402 | (is (map? result)) 403 | (is (nil? (:value result))) 404 | (is (contains? (:status result) "done")))) 405 | 406 | (testing "handles multiple done statuses" 407 | (let [msgs [{:id "1" :value "1" :status ["done"]} 408 | {:id "1" :value "2" :status ["done"]}] 409 | result (nrepl/take-until-done msgs)] 410 | ;; Should stop at first done 411 | (is (= 1 (count result))) 412 | (is (= "1" (:value (first result)))))) 413 | 414 | (testing "preserves laziness through pipeline" 415 | (let [counter (atom 0) 416 | msgs (map (fn [m] (swap! counter inc) m) 417 | [{:id "1" :value "a"} 418 | {:id "1" :value "b"} 419 | {:id "1" :status ["done"]} 420 | {:id "1" :value "should-not-process"}]) 421 | ;; Build lazy pipeline without forcing 422 | pipeline (nrepl/take-until-done msgs)] 423 | ;; Before realization 424 | (is (= 0 @counter)) 425 | ;; Force realization 426 | (doall pipeline) 427 | ;; Should have processed only up to done (may process 1 extra due to chunking) 428 | (is (<= 3 @counter 4))))) 429 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clojure-mcp-light 2 | 3 | > This is not an MCP. 4 | 5 | Simple CLI tools for LLM coding assistants working with Clojure. 6 | 7 | **TL;DR:** Three CLI tools for Clojure development with LLM coding assistants: 8 | - [`clj-nrepl-eval`](#clj-nrepl-eval-llm-nrepl-connection-without-an-mcp) - nREPL evaluation from command line 9 | - [`clj-paren-repair-claude-hook`](#clj-paren-repair-claude-hook) - Auto-fix delimiters via hooks (Claude Code) 10 | - [`clj-paren-repair`](#clj-paren-repair) - On-demand delimiter fix (Gemini CLI, Codex, etc.) 11 | 12 | ## The Problem 13 | 14 | LLMs produce delimiter errors when editing Clojure code - mismatched parentheses, brackets, and braces. This leads to the **"Paren Edit Death Loop"** where the AI repeatedly fails to fix delimiter errors, wasting tokens and blocking progress. 15 | 16 | Secondary problem: LLM coding assistants need to connect to a stateful Clojure REPL for evaluation. 17 | 18 | These tools solve both problems. 19 | 20 | ## Quick Reference 21 | 22 | | Tool | Use Case | 23 | |------|----------| 24 | | [`clj-nrepl-eval`](#clj-nrepl-eval-llm-nrepl-connection-without-an-mcp) | REPL evaluation from any LLM | 25 | | [`clj-paren-repair-claude-hook`](#clj-paren-repair-claude-hook) | Claude Code (or any LLM that supports Claude hooks) | 26 | | [`clj-paren-repair`](#clj-paren-repair) | Gemini CLI, Codex CLI, any LLM with shell | 27 | 28 | ## Quick Install 29 | 30 | **Install hook tool:** 31 | ```bash 32 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 33 | ``` 34 | **Note:** The hook will not work unless configured in `~/.claude/settings.json` - see configuration section below. 35 | 36 | **Install nREPL eval tool:** 37 | ```bash 38 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-nrepl-eval --main-opts '["-m" "clojure-mcp-light.nrepl-eval"]' 39 | ``` 40 | 41 | **Install on-demand repair tool:** 42 | ```bash 43 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-paren-repair --main-opts '["-m" "clojure-mcp-light.paren-repair"]' 44 | ``` 45 | 46 | See individual tool sections below for important configuration and usage details. 47 | 48 | ## Requirements 49 | 50 | - [Babashka](https://github.com/babashka/babashka) - Fast Clojure scripting (includes cljfmt) 51 | - **Note:** Version 1.12.212 or later is required when working with Codex and other tools that sandbox bash execution 52 | - [bbin](https://github.com/babashka/bbin) - Babashka package manager 53 | 54 | **Optional:** 55 | - [parinfer-rust](https://github.com/eraserhd/parinfer-rust) - Faster delimiter repair when available 56 | 57 | --- 58 | 59 | ## `clj-nrepl-eval` LLM nREPL connection without an MCP 60 | 61 | nREPL client for evaluating Clojure code from the command line. 62 | 63 | This provides coding assistants access to REPL eval via shell 64 | calls. It is specifically designed for LLM interaction and allows 65 | the LLM to discover and manage its REPL sessions without needing 66 | to configure an MCP server. 67 | 68 | ### How it helps 69 | 70 | - Lets LLMs evaluate code in a running REPL 71 | - Maintains persistent sessions per target 72 | - Auto-discovers nREPL ports 73 | - Auto-repairs delimiters before evaluation 74 | - Helpful output that guides LLMs 75 | 76 | ### Installation 77 | 78 | Installation is in two steps, installing the command line tool and 79 | then telling the coding assistants about `clj-nrepl-eval`. 80 | 81 | ```bash 82 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-nrepl-eval --main-opts '["-m" "clojure-mcp-light.nrepl-eval"]' 83 | ``` 84 | 85 | Or from local checkout: 86 | ```bash 87 | bbin install . --as clj-nrepl-eval --main-opts '["-m" "clojure-mcp-light.nrepl-eval"]' 88 | ``` 89 | 90 | Verify that it is installed and working by starting a nREPL and then doing a test eval. 91 | 92 | ```bash 93 | # this missing paren is there to demonstrate that delimiters are repaired automatically 94 | clj-nrepl-eval -p 7888 "(+ 1 2 3" 95 | # => 6 96 | ``` 97 | 98 | ### Telling the LLM about `clj-nrepl-eval` 99 | 100 | Choose one or more of these approaches. Each has trade-offs: 101 | 102 | | Approach | Availability | When info is used | Best for | 103 | |----------|--------------|-------------------|----------| 104 | | Custom instructions | All LLM clients | Always in context | Simplest, most effective | 105 | | Slash commands | Most coding assistants | When you invoke it | On-demand awareness | 106 | | Skills | Claude Code only | LLM pulls when needed | Automatic, context-aware | 107 | 108 | Each can be installed **locally** (per-project) or **globally** (all projects). 109 | 110 | #### Custom instructions 111 | 112 | The simplest and perhaps most effective approach. Works with all LLM coding assistants. 113 | 114 | Add to your custom instructions file: 115 | - **Global**: `~/.claude/CLAUDE.md`, `~/.gemini/GEMINI.md`, `~/.codex/AGENTS.md` 116 | - **Local**: `./CLAUDE.md`, `./GEMINI.md`, `./AGENTS.md` in project root 117 | 118 | ```markdown 119 | # Clojure REPL Evaluation 120 | 121 | The command `clj-nrepl-eval` is installed on your path for evaluating Clojure code via nREPL. 122 | 123 | **Discover nREPL servers:** 124 | 125 | `clj-nrepl-eval --discover-ports` 126 | 127 | **Evaluate code:** 128 | 129 | `clj-nrepl-eval -p ""` 130 | 131 | With timeout (milliseconds) 132 | 133 | `clj-nrepl-eval -p --timeout 5000 ""` 134 | 135 | The REPL session persists between evaluations - namespaces and state are maintained. 136 | Always use `:reload` when requiring namespaces to pick up changes. 137 | ``` 138 | 139 | #### Slash commands 140 | 141 | Lets you interject REPL awareness into the conversation when you need it. Available in most coding assistants (installation varies by client). 142 | 143 | - **/start-nrepl** - Starts an nREPL server in the background and reports the port 144 | - **/clojure-nrepl** - Provides detailed usage info for `clj-nrepl-eval` 145 | 146 | **Claude Code** - uses `.md` files in `commands/` directory: 147 | 148 | ```bash 149 | # Global: ~/.claude/commands/ 150 | # Local: .claude/commands/ 151 | mkdir -p ~/.claude/commands 152 | cp commands/*.md ~/.claude/commands/ 153 | ``` 154 | 155 | **Gemini CLI** - uses `.toml` files ([docs](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html)): 156 | 157 | ```bash 158 | # Global: ~/.gemini/commands/ 159 | # Local: .gemini/commands/ 160 | ``` 161 | 162 | **Codex CLI** - uses `.md` files ([docs](https://developers.openai.com/codex/guides/slash-commands)): 163 | 164 | ```bash 165 | # Global: ~/.codex/prompts/ 166 | ``` 167 | 168 | #### Skills 169 | 170 | Allows the LLM to pull in REPL information when it's actually needed. Currently Claude Code only. 171 | 172 | ```bash 173 | # Global (all projects) 174 | mkdir -p ~/.claude/skills 175 | cp -r skills/clojure-eval ~/.claude/skills/ 176 | 177 | # Local (this project only) 178 | mkdir -p .claude/skills 179 | cp -r skills/clojure-eval .claude/skills/ 180 | ``` 181 | 182 | ### Usage Tips 183 | 184 | **Easiest strategy:** Start nREPL before your coding session 185 | 186 | Start an nREPL before asking the LLM to use one. This way it can 187 | simply discover the port of the server in the current project 188 | with `--discover-ports`. Minimal ceremony to start interacting with the REPL. 189 | 190 | **Advanced:** Have the LLM start and manage your nREPL sessions 191 | 192 | Claude and other LLMs are perfectly capable of starting your nREPL 193 | server in the background and reading the port from the output. They 194 | can also kill the server if it gets hung on a bad eval. 195 | 196 | ### Customize to your workflow 197 | 198 | Once you start working with `clj-nrepl-eval` inside a coding assistant, 199 | it will quickly become clear how to adjust the above prompts to fit 200 | your specific projects and workflow. 201 | 202 | 203 | --- 204 | 205 | ## clj-paren-repair-claude-hook 206 | 207 | [Claude Code Hooks](https://code.claude.com/docs/en/hooks) let you run 208 | shell commands before or after Claude's tool calls. This hook 209 | intercepts Write/Edit operations and automatically fixes delimiter 210 | errors before they hit the filesystem. 211 | 212 | > In my usage these Hooks have fixed 100% of the errors detected. 213 | 214 | **Note:** The intention is to create and release client-specific hook tools as other LLM clients add hook support. For example, when Gemini CLI adds hooks, a `clj-paren-repair-gemini-hook` tool will be made available. 215 | 216 | **Why hooks instead of MCP tools?** 217 | 218 | With MCP-based editing tools, you lose Claude Code's native UI 219 | integration — tool calls are poorly formatted and difficult to 220 | read. Hooks let Claude Code operate normally with its native 221 | Edit/Write tools, preserving the clean diff UI you're used to, while 222 | transparently fixing delimiter errors behind the scenes. 223 | 224 | ### How it helps 225 | 226 | - Fixes errors transparently before they're written to disk 227 | - Uses **zero tokens** - happens outside LLM invocation 228 | - Preserves Claude Code's native diff UI and tool integration 229 | - Install once globally, works on all Clojure file edits 230 | 231 | ### Installation 232 | 233 | ```bash 234 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 235 | ``` 236 | 237 | Or from local checkout: 238 | ```bash 239 | bbin install . 240 | ``` 241 | 242 | ### Configuration 243 | 244 | Add to `~/.claude/settings.json`: 245 | 246 | ```json 247 | { 248 | "hooks": { 249 | "PreToolUse": [ 250 | { 251 | "matcher": "Write|Edit", 252 | "hooks": [ 253 | { 254 | "type": "command", 255 | "command": "clj-paren-repair-claude-hook --cljfmt" 256 | } 257 | ] 258 | } 259 | ], 260 | "PostToolUse": [ 261 | { 262 | "matcher": "Edit|Write", 263 | "hooks": [ 264 | { 265 | "type": "command", 266 | "command": "clj-paren-repair-claude-hook --cljfmt" 267 | } 268 | ] 269 | } 270 | ], 271 | "SessionEnd": [ 272 | { 273 | "hooks": [ 274 | { 275 | "type": "command", 276 | "command": "clj-paren-repair-claude-hook --cljfmt" 277 | } 278 | ] 279 | } 280 | ] 281 | } 282 | } 283 | ``` 284 | 285 | ### Options 286 | 287 | - `--cljfmt` - Enable automatic code formatting with cljfmt 288 | - `--stats` - Enable statistics tracking (logs to `~/.clojure-mcp-light/stats.log`) 289 | - `--log-level LEVEL` - Set log level (trace, debug, info, warn, error) 290 | - `--log-file PATH` - Path to log file (default: `./.clojure-mcp-light-hooks.log`) 291 | - `-h, --help` - Show help message 292 | 293 | ### How It Works 294 | 295 | - **PreToolUse hooks** run before Write/Edit operations, fixing content before it's written 296 | - **PostToolUse hooks** run after Edit operations, fixing any issues introduced 297 | - **SessionEnd hook** cleans up temporary files when Claude Code sessions end 298 | 299 | **Write operations**: If delimiter errors are detected, the content is fixed via parinfer before writing. If unfixable, the write is blocked. 300 | 301 | **Edit operations**: A backup is created before the edit. After the edit, if delimiter errors exist, they're fixed automatically. If unfixable, the file is restored from backup. 302 | 303 | ### Statistics Tracking 304 | 305 | Statistics tracking helps validate that these tools are working well. 306 | At some point Clojurists may not need them—either because models stop 307 | producing delimiter errors, or because assistants include parinfer 308 | internally. Tracking helps us know when that day comes. 309 | 310 | Add `--stats` to track delimiter events: 311 | 312 | ```bash 313 | clj-paren-repair-claude-hook --cljfmt --stats 314 | ``` 315 | 316 | Stats are written to `~/.clojure-mcp-light/stats.log` as EDN: 317 | 318 | ```clojure 319 | {:event-type :delimiter-error, :hook-event "PreToolUse", :timestamp "2025-11-09T14:23:45.123Z", :file-path "/path/to/file.clj"} 320 | {:event-type :delimiter-fixed, :hook-event "PreToolUse", :timestamp "2025-11-09T14:23:45.234Z", :file-path "/path/to/file.clj"} 321 | ``` 322 | 323 | Use the included stats summary script: 324 | 325 | ```bash 326 | ./scripts/stats-summary.bb 327 | ``` 328 | 329 | Sample output: 330 | 331 | ``` 332 | clojure-mcp-light Utility Validation 333 | ============================================================ 334 | 335 | Delimiter Repair Metrics 336 | ======================== 337 | Total Writes/Edits: 829 338 | Clean Code (no errors): 794 ( 95.8% of total) 339 | Errors Detected: 35 ( 4.2% of total) 340 | Successfully Fixed: 35 (100.0% of errors) 341 | Failed to Fix: 0 ( 0.0% of errors) 342 | Parse Errors: 0 ( 0.0% of fix attempts) 343 | 344 | ``` 345 | 346 | ### Logging 347 | 348 | Enable logging for debugging: 349 | 350 | ```bash 351 | # Debug level 352 | clj-paren-repair-claude-hook --log-level debug --cljfmt 353 | 354 | # Trace level (maximum verbosity) 355 | clj-paren-repair-claude-hook --log-level trace --log-file ~/hook-debug.log 356 | ``` 357 | 358 | ### Verify Installation 359 | 360 | ```bash 361 | echo '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"test.clj","content":"(def x 1)"}}' | clj-paren-repair-claude-hook 362 | ``` 363 | 364 | **Verify hooks are running in Claude Code:** 365 | 366 | After Claude Code edits a Clojure file, expand the tool output to see 367 | hook messages. In the terminal, press `ctrl-r` (or click the edit) and 368 | look for these messages surrounding the diff: 369 | 370 | ``` 371 | ⎿ PreToolUse:Edit hook succeeded: 372 | ... edit diff ... 373 | ⎿ PostToolUse:Edit hook succeeded: 374 | ``` 375 | 376 | If you don't see these messages, check that your `~/.claude/settings.json` 377 | hook configuration is correct. 378 | 379 | **Test delimiter repair:** 380 | 381 | Prompt Claude Code to intentionally write malformed Clojure (e.g., missing 382 | closing paren) to verify the hook fixes it automatically. 383 | 384 | 385 | ### Pro tip 386 | 387 | Combine with `clj-paren-repair` for complete coverage - hooks handle Edit/Write tools, but LLMs can also edit via Bash (sed, awk). Having both tools catches all cases. 388 | 389 | --- 390 | 391 | ## clj-paren-repair 392 | 393 | A shell command for LLM coding assistants that don't support hooks 394 | (like Gemini CLI and Codex CLI). When the LLM encounters a delimiter 395 | error, it calls this tool to fix it instead of trying to repair it manually. 396 | 397 | **The key insight:** When we observe an AI in the "Paren Edit Death Loop"—repeatedly 398 | failing to fix delimiter errors—we're witnessing a desperate search for a solution. 399 | `clj-paren-repair` provides an escape route that short-circuits this behavior. 400 | 401 | **Why this works:** Modern SOTA models produce very accurate edits with only 402 | small delimiter discrepancies. The errors are minor enough that parinfer 403 | can reliably fix them. This simple solution works surprisingly well. 404 | 405 | **Hooks vs clj-paren-repair:** Hooks are the clear winner when available—they 406 | use zero tokens and happen without LLM invocation. However, `clj-paren-repair` 407 | works universally with any LLM that has shell access. When Gemini CLI gets 408 | hooks support, we should use them. Until then, `clj-paren-repair` is sufficient. 409 | 410 | **Using both together:** Even with hooks configured, having `clj-paren-repair` 411 | available provides complete coverage. Hooks handle Edit/Write tools, but LLMs 412 | can also edit files via Bash (sed, awk, etc.). Having both tools catches all cases. 413 | 414 | ### How it helps 415 | 416 | - Provides escape route from "Paren Edit Death Loop" 417 | - LLM calls it when it encounters delimiter errors 418 | - Works with **any** LLM that has shell access 419 | - Automatically formats files with cljfmt 420 | 421 | ### Installation 422 | 423 | ```bash 424 | bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-paren-repair --main-opts '["-m" "clojure-mcp-light.paren-repair"]' 425 | ``` 426 | 427 | Or from local checkout: 428 | ```bash 429 | bbin install . --as clj-paren-repair --main-opts '["-m" "clojure-mcp-light.paren-repair"]' 430 | ``` 431 | 432 | ### Usage 433 | 434 | ```bash 435 | clj-paren-repair path/to/file.clj 436 | clj-paren-repair src/core.clj src/util.clj test/core_test.clj 437 | clj-paren-repair --help 438 | ``` 439 | 440 | ### Setup: Custom Instructions 441 | 442 | Add to your global or local custom instructions file 443 | (`GEMINI.md`, `AGENTS.md`, `CLAUDE.md` etc.): 444 | 445 | ```markdown 446 | # Clojure Parenthesis Repair 447 | 448 | The command `clj-paren-repair` is installed on your path. 449 | 450 | Examples: 451 | `clj-paren-repair ` 452 | `clj-paren-repair path/to/file1.clj path/to/file2.clj path/to/file3.clj` 453 | 454 | **IMPORTANT:** Do NOT try to manually repair parenthesis errors. 455 | If you encounter unbalanced delimiters, run `clj-paren-repair` on the file 456 | instead of attempting to fix them yourself. If the tool doesn't work, 457 | report to the user that they need to fix the delimiter error manually. 458 | 459 | The tool automatically formats files with cljfmt when it processes them. 460 | ``` 461 | 462 | --- 463 | 464 | ## Using Multiple Tools Together 465 | 466 | **Best practice for Claude Code users:** 467 | 1. Configure hooks for automatic fixing (zero tokens) 468 | 2. Also have `clj-paren-repair` available for Bash-based edits 469 | 3. Use `clj-nrepl-eval` for REPL evaluation 470 | 471 | **For other LLM clients (Gemini CLI, Codex, etc.):** 472 | 1. Install `clj-paren-repair` and add custom instructions 473 | 2. Use `clj-nrepl-eval` for REPL evaluation 474 | 475 | --- 476 | 477 | ## What These Tools Solve (and Don't) 478 | 479 | **Problem A: Bad delimiters in output** - SOLVED 480 | 481 | These tools fix mismatched/missing parentheses in edit results. 482 | 483 | **Problem B: old_string matching failures** - NOT SOLVED 484 | 485 | Sometimes LLMs struggle to produce an `old_string` that exactly matches the file content, causing edit failures. This is less common with newer models. 486 | 487 | For full solution to Problem B: [ClojureMCP](https://github.com/bhauman/clojure-mcp) sexp-editing tools. 488 | 489 | --- 490 | 491 | ## Why Not Just Use ClojureMCP? 492 | 493 | [ClojureMCP](https://github.com/bhauman/clojure-mcp) provides comprehensive Clojure tooling, but: 494 | 495 | - ClojureMCP tools aren't native to the client - no diff UI, no integrated output formatting 496 | - ClojureMCP duplicates/conflicts with tools the client already has 497 | - These CLI tools work *with* the client's native tools instead of replacing them 498 | 499 | You can use both together. Configure ClojureMCP to expose only `:clojure_eval` if desired: 500 | 501 | ```clojure 502 | ;; .clojure-mcp/config.edn 503 | {:enable-tools [:clojure_eval] 504 | :enable-prompts [] 505 | :enable-resources []} 506 | ``` 507 | 508 | You can also use ClojureMCP's prompts, resources, and agents 509 | functionality to create a suite of tools that work across LLM clients. 510 | 511 | --- 512 | 513 | ## Contributing 514 | 515 | Contributions and ideas are welcome! Feel free to: 516 | 517 | - Open issues with suggestions or bug reports 518 | - Submit PRs with improvements 519 | - Share your experiments and what works (or doesn't work) 520 | 521 | ## License 522 | 523 | Eclipse Public License - v 2.0 (EPL-2.0) 524 | 525 | See [LICENSE.md](LICENSE.md) for the full license text. 526 | -------------------------------------------------------------------------------- /src/clojure_mcp_light/hook.clj: -------------------------------------------------------------------------------- 1 | (babashka.deps/add-deps '{:deps {dev.weavejester/cljfmt {:mvn/version "0.15.5"} 2 | parinferish/parinferish {:mvn/version "0.8.0"}}}) 3 | 4 | (ns clojure-mcp-light.hook 5 | "Claude Code hook for delimiter error detection and repair" 6 | (:require [babashka.fs :as fs] 7 | [cheshire.core :as json] 8 | [cljfmt.core :as cljfmt] 9 | [cljfmt.main] 10 | [clojure.string :as string] 11 | [clojure.java.io :as io] 12 | [clojure.tools.cli :refer [parse-opts]] 13 | [clojure-mcp-light.delimiter-repair 14 | :refer [delimiter-error? fix-delimiters actual-delimiter-error?]] 15 | [clojure-mcp-light.stats :as stats] 16 | [clojure-mcp-light.tmp :as tmp] 17 | [taoensso.timbre :as timbre])) 18 | 19 | ;; ============================================================================ 20 | ;; Configuration 21 | ;; ============================================================================ 22 | 23 | (def ^:dynamic *enable-cljfmt* false) 24 | (def ^:dynamic *enable-revert* true) 25 | 26 | ;; ============================================================================ 27 | ;; CLI Options 28 | ;; ============================================================================ 29 | 30 | (def cli-options 31 | [[nil "--cljfmt" "Enable cljfmt formatting on files after edit/write"] 32 | [nil "--no-revert" "Disable automatic file revert on unfixable delimiter errors" 33 | :id :no-revert 34 | :default false] 35 | [nil "--stats" "Enable statistics tracking for delimiter events (default: ~/.clojure-mcp-light/stats.log)" 36 | :id :stats 37 | :default false] 38 | [nil "--stats-file PATH" "Path to stats file (only used when --stats is enabled)" 39 | :id :stats-file 40 | :default (str (fs/path (fs/home) ".clojure-mcp-light" "stats.log"))] 41 | [nil "--log-level LEVEL" "Set log level for file logging" 42 | :id :log-level 43 | :parse-fn keyword 44 | :validate [#{:trace :debug :info :warn :error :fatal :report} 45 | "Must be one of: trace, debug, info, warn, error, fatal, report"]] 46 | [nil "--log-file PATH" "Path to log file" 47 | :id :log-file 48 | :default "./.clojure-mcp-light-hooks.log"] 49 | ["-h" "--help" "Show help message"]]) 50 | 51 | (defn usage [] 52 | (str "clj-paren-repair-claude-hook - Claude Code hook for Clojure delimiter repair\n" 53 | "\n" 54 | "Usage: clj-paren-repair-claude-hook [OPTIONS]\n" 55 | "\n" 56 | "Options:\n" 57 | " --cljfmt Enable cljfmt formatting on files after edit/write\n" 58 | " --no-revert Disable automatic file revert on unfixable delimiter errors\n" 59 | " --stats Enable statistics tracking for delimiter events\n" 60 | " (default: ~/.clojure-mcp-light/stats.log)\n" 61 | " --stats-file PATH Path to stats file (only used when --stats is enabled)\n" 62 | " --log-level LEVEL Set log level for file logging\n" 63 | " Levels: trace, debug, info, warn, error, fatal, report\n" 64 | " --log-file PATH Path to log file (default: ./.clojure-mcp-light-hooks.log)\n" 65 | " -h, --help Show this help message")) 66 | 67 | (defn error-msg [errors] 68 | (str "The following errors occurred while parsing command:\n\n" 69 | (string/join \newline errors))) 70 | 71 | (defn handle-cli-args 72 | "Parse CLI arguments and handle help/errors. Returns options map or exits." 73 | [args] 74 | (let [actual-args (if (seq args) args *command-line-args*) 75 | {:keys [options errors]} (parse-opts actual-args cli-options)] 76 | (cond 77 | (:help options) 78 | (do 79 | (println (usage)) 80 | (System/exit 0)) 81 | 82 | errors 83 | (do 84 | (binding [*out* *err*] 85 | (println (error-msg errors)) 86 | (println) 87 | (println (usage))) 88 | (System/exit 1)) 89 | 90 | :else 91 | options))) 92 | 93 | ;; ============================================================================ 94 | ;; Claude Code Hook Functions 95 | ;; ============================================================================ 96 | 97 | (defn- babashka-shebang? 98 | "Checks if a file starts with a Babashka shebang. 99 | Returns true if the first line matches a Babashka shebang pattern." 100 | [file-path] 101 | (when (fs/exists? file-path) 102 | (try 103 | (with-open [r (io/reader file-path)] 104 | (let [line (-> r line-seq first)] 105 | (and line 106 | (re-matches #"^#!/[^\s]+/(bb|env\s{1,3}bb)(\s.*)?$" line)))) 107 | (catch Exception _ false)))) 108 | 109 | (defn clojure-file? 110 | "Checks if a file path has a Clojure-related extension or Babashka shebang. 111 | 112 | Supported extensions: 113 | - .clj (Clojure) 114 | - .cljs (ClojureScript) 115 | - .cljc (Clojure/ClojureScript shared) 116 | - .bb (Babashka) 117 | - .edn (Extensible Data Notation) 118 | - .lpy (Basilisp) 119 | 120 | Also detects files starting with a Babashka shebang (`bb`)." 121 | [file-path] 122 | (when file-path 123 | (let [lower-path (string/lower-case file-path)] 124 | (or (string/ends-with? lower-path ".clj") 125 | (string/ends-with? lower-path ".cljs") 126 | (string/ends-with? lower-path ".cljc") 127 | (string/ends-with? lower-path ".bb") 128 | (string/ends-with? lower-path ".lpy") 129 | (string/ends-with? lower-path ".edn") 130 | (babashka-shebang? file-path))))) 131 | 132 | (defn run-cljfmt 133 | "Check if file needs formatting using cljfmt.core, then format with cljfmt.main. 134 | This avoids shell spawn for check while respecting user's cljfmt config for formatting. 135 | Returns true if file was formatted, false otherwise." 136 | [file-path] 137 | (when *enable-cljfmt* 138 | (stats/log-stats! :cljfmt-run {:file-path file-path}) 139 | (try 140 | (let [original (slurp file-path :encoding "UTF-8") 141 | formatted (cljfmt/reformat-string original)] 142 | (if (not= original formatted) 143 | (do 144 | (stats/log-stats! :cljfmt-needed-formatting {:file-path file-path}) 145 | (timbre/debug "Running cljfmt fix on:" file-path) 146 | ;; Use cljfmt.main to respect user's environment config 147 | (cljfmt.main/-main "fix" file-path) 148 | (stats/log-stats! :cljfmt-fix-succeeded {:file-path file-path}) 149 | (timbre/debug " cljfmt succeeded") 150 | true) 151 | (do 152 | (stats/log-stats! :cljfmt-already-formatted {:file-path file-path}) 153 | (timbre/debug " No formatting needed") 154 | false))) 155 | (catch Exception e 156 | (stats/log-stats! :cljfmt-fix-failed {:file-path file-path 157 | :ex-message (ex-message e)}) 158 | (timbre/debug " cljfmt error:" (.getMessage e)) 159 | false)))) 160 | 161 | (defn backup-file 162 | "Backup file to temp location, returns backup path" 163 | [file-path session-id] 164 | (let [ctx {:session-id session-id} 165 | backup (tmp/backup-path ctx file-path) 166 | content (slurp file-path :encoding "UTF-8")] 167 | ;; Ensure parent directories exist 168 | (when-let [parent (fs/parent backup)] 169 | (fs/create-dirs parent)) 170 | (spit backup content :encoding "UTF-8") 171 | backup)) 172 | 173 | (defn restore-file 174 | "Restore file from backup and delete backup" 175 | [file-path backup-path] 176 | (when (fs/exists? backup-path) 177 | (try 178 | (let [backup-content (slurp backup-path :encoding "UTF-8")] 179 | (spit file-path backup-content :encoding "UTF-8") 180 | true) 181 | (finally 182 | (io/delete-file backup-path))))) 183 | 184 | (defn delete-backup 185 | "Delete backup file if it exists" 186 | [backup-path] 187 | (fs/delete-if-exists backup-path)) 188 | 189 | (defn fix-and-format-file! 190 | "Core logic for fixing delimiters and formatting a Clojure file in-place. 191 | This is the shared implementation used by both the hook and standalone tools. 192 | 193 | Parameters: 194 | - file-path: path to the file to process 195 | - enable-cljfmt: boolean to enable cljfmt formatting after delimiter fix 196 | - stats-event-prefix: string prefix for stats events (e.g., 'PostToolUse:Edit' or 'paren-repair') 197 | 198 | Returns map with: 199 | - :success - true if file was processed successfully (no unfixable errors) 200 | - :delimiter-fixed - true if a delimiter error was detected and fixed 201 | - :formatted - true if file was formatted with cljfmt 202 | - :message - human-readable message describing what happened" 203 | [file-path enable-cljfmt stats-event-prefix] 204 | (try 205 | (let [file-content (slurp file-path :encoding "UTF-8") 206 | has-delimiter-error? (delimiter-error? file-content) 207 | actual-error? (when has-delimiter-error? 208 | (actual-delimiter-error? file-content))] 209 | 210 | (when (and has-delimiter-error? actual-error?) 211 | (stats/log-event! :delimiter-error stats-event-prefix file-path)) 212 | 213 | (timbre/debug " Delimiter error:" has-delimiter-error?) 214 | 215 | (if has-delimiter-error? 216 | ;; Has delimiter error - try to fix 217 | (do 218 | (timbre/debug " Delimiter error detected, attempting fix") 219 | (if-let [fixed-content (fix-delimiters file-content)] 220 | (do 221 | (when actual-error? 222 | (stats/log-event! :delimiter-fixed stats-event-prefix file-path)) 223 | (timbre/debug " Fix successful, applying fix") 224 | (spit file-path fixed-content :encoding "UTF-8") 225 | (let [formatted? (when enable-cljfmt 226 | (run-cljfmt file-path))] 227 | {:success true 228 | :delimiter-fixed true 229 | :formatted (boolean formatted?) 230 | :message "Delimiter errors fixed and formatted"})) 231 | (do 232 | (when actual-error? 233 | (stats/log-event! :delimiter-fix-failed stats-event-prefix file-path)) 234 | (timbre/error " Delimiter fix failed") 235 | {:success false 236 | :delimiter-fixed false 237 | :formatted false 238 | :message "Could not fix delimiter errors"}))) 239 | ;; No delimiter error - just format if enabled 240 | (do 241 | (stats/log-event! :delimiter-ok stats-event-prefix file-path) 242 | (timbre/debug " No delimiter errors") 243 | (let [formatted? (when enable-cljfmt 244 | (run-cljfmt file-path))] 245 | {:success true 246 | :delimiter-fixed false 247 | :formatted (boolean formatted?) 248 | :message (if formatted? "Formatted" "No changes needed")})))) 249 | (catch Exception e 250 | (timbre/error " Unexpected error processing file:" (.getMessage e)) 251 | {:success false 252 | :delimiter-fixed false 253 | :formatted false 254 | :message (str "Error: " (.getMessage e))}))) 255 | 256 | (defn process-pre-write 257 | "Process content before write operation. 258 | Returns fixed content if Clojure file has delimiter errors, nil otherwise." 259 | [file-path content] 260 | (when (and (clojure-file? file-path) (delimiter-error? content)) 261 | (fix-delimiters content))) 262 | 263 | (defn process-pre-edit 264 | "Process file before edit operation. 265 | Creates a backup of Clojure files, returns backup path if created." 266 | [file-path session-id] 267 | (when (clojure-file? file-path) 268 | (backup-file file-path session-id))) 269 | 270 | (defn process-post-edit 271 | "Process file after edit operation. 272 | Compares edited file with backup, fixes delimiters if content changed, 273 | and cleans up backup file." 274 | [file-path session-id] 275 | (when (clojure-file? file-path) 276 | (let [ctx {:session-id session-id} 277 | backup-file (tmp/backup-path ctx file-path)] 278 | (try 279 | (let [backup-content (try (slurp backup-file :encoding "UTF-8") (catch Exception _ nil)) 280 | file-content (slurp file-path :encoding "UTF-8")] 281 | (when (not= backup-content file-content) 282 | (process-pre-write file-path file-content))) 283 | (finally 284 | (delete-backup backup-file)))))) 285 | 286 | (defmulti process-hook 287 | (fn [hook-input] 288 | [(:hook_event_name hook-input) (:tool_name hook-input)])) 289 | 290 | (defmethod process-hook :default [_] nil) 291 | 292 | (defmethod process-hook ["PreToolUse" "Write"] 293 | [{:keys [tool_input]}] 294 | (let [{:keys [file_path content]} tool_input] 295 | (when (clojure-file? file_path) 296 | (timbre/debug "PreWrite: clojure" file_path) 297 | (if (delimiter-error? content) 298 | (let [actual-error? (actual-delimiter-error? content)] 299 | (when actual-error? 300 | (stats/log-event! :delimiter-error "PreToolUse:Write" file_path)) 301 | (timbre/debug " Delimiter error detected, attempting fix") 302 | (if-let [fixed-content (fix-delimiters content)] 303 | (do 304 | (when actual-error? 305 | (stats/log-event! :delimiter-fixed "PreToolUse:Write" file_path)) 306 | (timbre/debug " Fix successful, allowing write with updated content") 307 | {:hookSpecificOutput 308 | {:hookEventName "PreToolUse" 309 | :updatedInput {:file_path file_path 310 | :content fixed-content}}}) 311 | (do 312 | (when actual-error? 313 | (stats/log-event! :delimiter-fix-failed "PreToolUse:Write" file_path)) 314 | (timbre/debug " Fix failed, denying write") 315 | {:hookSpecificOutput 316 | {:hookEventName "PreToolUse" 317 | :permissionDecision "deny" 318 | :permissionDecisionReason "Delimiter errors found and could not be auto-fixed"}}))) 319 | (do 320 | (stats/log-event! :delimiter-ok "PreToolUse:Write" file_path) 321 | (timbre/debug " No delimiter errors, allowing write") 322 | nil))))) 323 | 324 | (defmethod process-hook ["PreToolUse" "Edit"] 325 | [{:keys [tool_input session_id]}] 326 | (let [{:keys [file_path]} tool_input] 327 | (when (clojure-file? file_path) 328 | (timbre/debug "PreEdit: clojure" file_path) 329 | 330 | ;; Only create backup if revert is enabled 331 | (when *enable-revert* 332 | (try 333 | (let [backup (backup-file file_path session_id)] 334 | (timbre/debug " Created backup:" backup) 335 | nil) 336 | (catch Exception e 337 | (timbre/debug " Edit processing failed:" (.getMessage e)) 338 | nil)))))) 339 | 340 | (defmethod process-hook ["PostToolUse" "Write"] 341 | [{:keys [tool_input tool_response]}] 342 | (let [{:keys [file_path]} tool_input] 343 | (when (and (clojure-file? file_path) tool_response *enable-cljfmt*) 344 | (timbre/debug "PostWrite: clojure cljfmt" file_path) 345 | (run-cljfmt file_path) 346 | nil))) 347 | 348 | (defmethod process-hook ["PostToolUse" "Edit"] 349 | [{:keys [tool_input tool_response session_id]}] 350 | (let [{:keys [file_path]} tool_input] 351 | (when (and (clojure-file? file_path) tool_response) 352 | (timbre/debug "PostEdit: clojure" file_path) 353 | (let [backup (tmp/backup-path {:session-id session_id} file_path) 354 | backup-exists? (fs/exists? backup) 355 | result (fix-and-format-file! file_path *enable-cljfmt* "PostToolUse:Edit")] 356 | 357 | (try 358 | (if (:success result) 359 | ;; Success - delete backup and return nil 360 | (do 361 | (timbre/debug " Processing successful, deleting backup") 362 | nil) 363 | ;; Failure - handle backup restore based on revert setting 364 | (if (and *enable-revert* backup-exists?) 365 | (do 366 | (timbre/debug " Fix failed, restoring from backup:" backup) 367 | (restore-file file_path backup) 368 | {:decision "block" 369 | :reason (str "Delimiter errors could not be auto-fixed. File was restored from backup to previous state: " file_path) 370 | :hookSpecificOutput 371 | {:hookEventName "PostToolUse" 372 | :additionalContext "There are delimiter errors in the file. So we restored from backup."}}) 373 | (do 374 | (timbre/debug " Fix failed, revert disabled - blocking without restore") 375 | {:decision "block" 376 | :reason (str "Delimiter errors could not be auto-fixed in file: " file_path) 377 | :hookSpecificOutput 378 | {:hookEventName "PostToolUse" 379 | :additionalContext "There are delimiter errors in the file. Revert is disabled, so the file was not restored."}}))) 380 | (finally 381 | (when backup-exists? 382 | (delete-backup backup)))))))) 383 | 384 | (defmethod process-hook ["SessionEnd" nil] 385 | [{:keys [session_id]}] 386 | (timbre/info "SessionEnd: cleaning up session" session_id) 387 | (try 388 | (let [report (tmp/cleanup-session! {:session-id session_id})] 389 | (timbre/info " Cleanup attempted for session IDs:" (:attempted report)) 390 | (timbre/info " Deleted directories:" (:deleted report)) 391 | (timbre/info " Skipped (non-existent):" (:skipped report)) 392 | (when (seq (:errors report)) 393 | (timbre/warn " Errors during cleanup:") 394 | (doseq [{:keys [path error]} (:errors report)] 395 | (timbre/warn " " path "-" error))) 396 | nil) 397 | (catch Exception e 398 | (timbre/error " Unexpected error during cleanup:" (.getMessage e)) 399 | nil))) 400 | 401 | (defn -main [& args] 402 | (let [options (handle-cli-args args) 403 | log-level (:log-level options) 404 | log-file (:log-file options) 405 | enable-logging? (some? log-level) 406 | enable-stats? (:stats options) 407 | stats-path (stats/normalize-stats-path (:stats-file options))] 408 | 409 | (timbre/set-config! 410 | {:appenders {:spit (assoc 411 | (timbre/spit-appender {:fname log-file}) 412 | :enabled? enable-logging? 413 | :min-level (or log-level :report) 414 | :ns-filter (if enable-logging? 415 | {:allow "clojure-mcp-light.*"} 416 | {:deny "*"}))}}) 417 | 418 | ;; Set cljfmt, revert, and stats flags from CLI options 419 | (binding [*enable-cljfmt* (:cljfmt options) 420 | *enable-revert* (not (:no-revert options)) 421 | stats/*enable-stats* enable-stats? 422 | stats/*stats-file-path* stats-path] 423 | (try 424 | (let [input-json (slurp *in*) 425 | _ (timbre/debug "INPUT:" input-json) 426 | _ (when *enable-cljfmt* 427 | (timbre/debug "cljfmt formatting is ENABLED")) 428 | _ (when stats/*enable-stats* 429 | (timbre/debug "stats tracking is ENABLED, writing to:" stats/*stats-file-path*)) 430 | hook-input (json/parse-string input-json true) 431 | response (process-hook hook-input) 432 | _ (timbre/debug "OUTPUT:" (json/generate-string response))] 433 | (when response 434 | (println (json/generate-string response))) 435 | (System/exit 0)) 436 | (catch Exception e 437 | (timbre/error "Hook error:" (.getMessage e)) 438 | (timbre/error "Stack trace:" (with-out-str (.printStackTrace e))) 439 | (binding [*out* *err*] 440 | (println "Hook error:" (.getMessage e)) 441 | (println "Stack trace:" (with-out-str (.printStackTrace e)))) 442 | (System/exit 2)))))) 443 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.2.1] - 2025-11-27 6 | 7 | This release adds a new standalone `clj-paren-repair` tool for LLM clients without hook support (Gemini CLI, Codex CLI), significantly improves nREPL port discovery performance, and includes comprehensive documentation updates that reorganize the README around the three CLI tools. 8 | 9 | ### Added 10 | - **clj-paren-repair standalone tool** - New command for LLMs without hook support 11 | - Works with Gemini CLI, Codex CLI, and any LLM with shell access 12 | - Provides escape route from "Paren Edit Death Loop" 13 | - Automatically formats with cljfmt when processing files 14 | - Shared delimiter repair logic with hook tool 15 | 16 | - **Help flag for clj-paren-repair** - Added `-h`/`--help` support 17 | 18 | - **AGENTS.md support** - Now includes AGENTS.md in prompts for Codex CLI compatibility 19 | 20 | ### Changed 21 | - **Parallelized port discovery** - nREPL port discovery now runs in parallel with reduced timeout (250ms) 22 | - Much faster `--discover-ports` execution 23 | - Better responsiveness when scanning multiple ports 24 | 25 | - **Documentation reorganization** - README now structured around three CLI tools 26 | - Clearer quick reference table 27 | - Expanded rationale for clj-paren-repair approach 28 | - Better installation and configuration instructions 29 | - Updated GEMINI.md with project overview 30 | 31 | ### Fixed 32 | - **cljfmt integration** - Fixed clj-paren-repair cljfmt integration issues 33 | 34 | ## [0.2.0] - 2025-11-17 35 | 36 | ### Summary 37 | 38 | This release makes parinfer-rust optional by adding parinferish as a pure Clojure fallback, eliminating external dependencies while maintaining full delimiter repair functionality. The release also includes significant nREPL improvements with better port discovery, enhanced session management, and comprehensive refactoring for cleaner architecture. 39 | 40 | ### Added 41 | - **Parinferish fallback** - Pure Clojure delimiter repair when parinfer-rust unavailable 42 | - Automatic backend selection: prefers parinfer-rust, falls back to parinferish 43 | - New `parinferish-repair` function for pure Clojure delimiter fixing 44 | - New `parinfer-rust-available?` function to detect parinfer-rust on PATH 45 | - Unified `repair-delimiters` function handles backend selection automatically 46 | - No external dependencies required for basic delimiter repair 47 | - Comprehensive test coverage for new repair functions 48 | 49 | - **Enhanced nREPL port discovery** - Better workflow for finding and connecting to REPL servers 50 | - `--discover-ports` now shows servers grouped by directory 51 | - Shadow-cljs detection using `:ns` field from eval 52 | - Cross-platform UTF8 encoding protection for reliable output 53 | - Improved directory-based server organization 54 | 55 | - **nREPL namespace differentiation** - Better LLM understanding of evaluation context 56 | - Namespace information included in output dividers 57 | - Helps Claude understand which namespace code was evaluated in 58 | - Improved context for multi-namespace projects 59 | 60 | ### Changed 61 | - **Bundled cljfmt** - Now uses cljfmt from Babashka instead of external binary 62 | - Eliminates another external dependency 63 | - Consistent behavior across platforms 64 | - Simpler installation process 65 | 66 | - **Improved nREPL architecture** - Major refactoring for better maintainability 67 | - Extracted nrepl-client library with lazy sequence API 68 | - Connection-based API with `*` suffix pattern for stateful operations 69 | - Consolidated session validation following `*` suffix pattern 70 | - Simplified `eval-expr-with-timeout` using connection-based API 71 | - Optimized `discover-nrepl-ports` to use single connection per port 72 | - Better separation of concerns between client and evaluation logic 73 | 74 | - **Enhanced file detection** - Broader Clojure file support 75 | - Added `.lpy` support for Lispy Python files 76 | - Case-insensitive extension matching (`.CLJ`, `.Clj`, etc.) 77 | - Babashka shebang detection (`#!/usr/bin/env bb`, `#!/usr/bin/bb`) 78 | 79 | - **Documentation improvements** 80 | - Updated README and CLAUDE.md to reflect optional parinfer-rust 81 | - Added GEMINI.md for project overview and instructions 82 | - Consolidated hook examples using global settings 83 | - Better installation instructions with fewer requirements 84 | 85 | ### Removed 86 | - **Unused nREPL functions** - Cleaned up high-level convenience functions 87 | - Removed `->uuid` function (unused) 88 | - Removed edit validator and validation tracking (overly complex) 89 | - Simplified codebase by removing unnecessary abstractions 90 | - Better focus on core functionality 91 | 92 | ### Fixed 93 | - **nREPL session management** - More robust session handling 94 | - Better session data management with improved validation 95 | - Cleaner filesystem usage with session-scoped temp files 96 | - Proper cleanup of session directories 97 | 98 | ## [0.1.1] - 2025-11-11 99 | 100 | ### Summary 101 | 102 | This release improves the developer experience with Claude Code through enhanced documentation and better nREPL evaluation workflow. The most significant improvements are the new clojure-eval skill that provides streamlined REPL interactions and improved documentation with heredoc examples for better code evaluation patterns. 103 | 104 | ### Added 105 | - **Claude Code skill for clojure-eval** - New skill provides streamlined nREPL evaluation workflow in Claude Code with automatic port discovery and session management 106 | - **Stdin support for clj-nrepl-eval** - Evaluate code directly from stdin for easier piping and scripting workflows 107 | - **Delimiter repair test for edge cases** - Added test coverage for unusual delimiter patterns 108 | 109 | ### Changed 110 | - **Improved skill documentation** - Enhanced clojure-eval skill instructions with heredoc examples showing best practices for code evaluation 111 | - **Timeout handling for nREPL** - All nREPL evaluations now use consistent timeout and interrupt handling for better reliability 112 | - **Documentation cleanup** - Cleaned up CLAUDE.md file for better clarity 113 | 114 | ## [0.1.0] - 2025-11-10 115 | 116 | ### Summary 117 | 118 | This release introduces edit validation metrics, enhanced nREPL connection discovery, and improved statistics tracking. The most significant improvements are in understanding how well the delimiter repair and cljfmt formatting are working through comprehensive validation metrics and success rate tracking. 119 | 120 | ### Added 121 | - **Edit validation metrics** - Track validation of completed edits to understand delimiter repair effectiveness 122 | - New event types: `:edit-validated-ok`, `:edit-validated-error`, `:edit-validated-fixed`, `:edit-validated-fix-failed` 123 | - Tracks whether PostToolUse hook successfully validates and fixes edited files 124 | - Enables measurement of end-to-end edit quality and repair success rate 125 | - Stats summary includes edit validation breakdown and success metrics 126 | 127 | - **Connection discovery for nREPL** - `--connected-ports` flag lists active nREPL connections 128 | - Displays all available nREPL servers with session information 129 | - Helps users discover which ports they've previously connected to 130 | - Makes `--port` requirement clearer by providing easy discovery mechanism 131 | - Scans nREPL session files in session-scoped temp directory 132 | 133 | - **nREPL testing utilities** - Added `deps.edn` for connection testing 134 | - Provides reproducible nREPL server setup for development and testing 135 | - Documents port conventions (7890, 7891, 7892, etc.) 136 | - Simplifies manual testing of nREPL evaluation features 137 | 138 | ### Changed 139 | - **Required --port flag** - Made `--port` explicit and required for all nREPL operations 140 | - Removed fallback to NREPL_PORT env var and .nrepl-port file 141 | - Prevents confusion about which server is being used 142 | - Clearer, more predictable behavior 143 | - Use `--connected-ports` to discover available servers 144 | 145 | - **Enhanced stats tracking** - Improved cljfmt metrics with success/failure distinction 146 | - Track `:cljfmt-fixed` (formatting applied successfully) separately from `:cljfmt-check-error` (formatting failed) 147 | - More accurate success rates for formatting operations 148 | - Stats summary shows cljfmt fix success rate 149 | 150 | - **Improved stats summary** - Refactored to focus on delimiter repair validation utility 151 | - Clearer breakdown of Pre vs Post hook validation events 152 | - Edit validation metrics highlighted as key quality indicators 153 | - Better organization with validation-focused sections 154 | - More actionable insights about delimiter repair effectiveness 155 | 156 | - **CLI option parsing** - Fixed `--stats` flag handling to properly support custom file paths 157 | - Parse CLI options before processing hook input 158 | - `--stats-file` now correctly accepts custom paths 159 | 160 | ### Fixed 161 | - **Parser generalization** - Removed `file-path` parameter from `validate-stored-connection` 162 | - Parser no longer requires file path context 163 | - Cleaner API for connection validation 164 | - Fixes unnecessary coupling between validation and file operations 165 | 166 | - **clj-kondo linting** - Fixed all linting warnings 167 | - Cleaner codebase with zero linting issues 168 | - Improved code quality and maintainability 169 | 170 | - **Test fixes** - Corrected test suite for recent API changes 171 | - All tests passing 172 | - Better test coverage for new features 173 | 174 | [0.2.1]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.2.1 175 | [0.2.0]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.2.0 176 | [0.1.1]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.1.1 177 | [0.1.0]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.1.0 178 | 179 | ## [0.0.4-alpha] - 2025-11-09 180 | 181 | ### Summary 182 | 183 | This release simplifies session identification and improves the stats tracking system. 184 | 185 | ### Changed 186 | - **Session identification simplified** - Removed Bash hook and CML_CLAUDE_CODE_SESSION_ID functionality 187 | - Now relies exclusively on GPID (grandparent process ID) approach 188 | - Removed PreToolUse Bash hook that prepended session ID to commands 189 | - Removed all CML_CLAUDE_CODE_SESSION_ID environment variable references 190 | - Updated tmp.clj to use only GPID for session identification 191 | - Deleted scripts/echo-session-id.sh utility 192 | - Updated configuration examples to remove Bash from matchers 193 | - GPID provides stable session identification using Claude Code process hierarchy 194 | 195 | - **Improved GPID-based session identification** 196 | - Use grandparent PID instead of parent PID for stable session IDs 197 | - GPID remains constant across multiple bb invocations within same Claude session 198 | - Fixes nREPL session persistence by ensuring consistent session file paths 199 | - Benefits: persistent namespaces, variables, and REPL state across evaluations 200 | 201 | - **Backup path refactoring** 202 | - Replace path-preserving backups with hash-based structure 203 | - Use SHA-256 for stronger collision resistance 204 | - Implement 2-level directory sharding (h0h1/h2h3/) to prevent filesystem issues 205 | - Format: {backups-dir}/{shard1}/{shard2}/{hash}--{sanitized-filename} 206 | - Simplify session-root path structure to clojure-mcp-light/{session}-proj-{hash} 207 | 208 | - **Stats script improvements** 209 | - Fixed misleading "Hook-level events" terminology 210 | - Now correctly shows "Delimiter events" (events with :hook-event field) 211 | - Added separate "Cljfmt events" count 212 | - More accurate breakdown of event types 213 | 214 | - **Documentation updates** 215 | - Updated CLAUDE.md with current log file path (.clojure-mcp-light-hooks.log) 216 | - Note that log file is configurable via --log-file flag 217 | 218 | [0.0.4-alpha]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.0.4-alpha 219 | 220 | ## [0.0.3-alpha] - 2025-11-09 221 | 222 | This version represents a major improvement in robustness and developer experience. 223 | 224 | ### Summary 225 | 226 | * **Fixed hook response protocol** - Hooks now return `nil` for normal operations instead of explicit `permissionDecision: allow` responses. The previous approach was bypassing Claude Code's normal permission dialogs, causing the UI to not properly prompt users for confirmation. 227 | 228 | * **Robust nREPL session persistence** - Session management now properly handles multiple concurrent Claude Code sessions running in the same directory using session-scoped temporary files with fallback strategies (env var → PPID-based → global). 229 | 230 | * **Automatic cleanup via SessionEnd hook** - Session persistence requires temporary file storage that must be cleaned up. The new SessionEnd hook automatically removes session directories when Claude Code sessions terminate, preventing accumulation of stale temporary files. 231 | 232 | * **cljfmt support** - The `--cljfmt` CLI option enables automatic code formatting. Claude frequently indents code incorrectly by one space, and cljfmt quickly fixes these issues. Well-formatted code is essential for parinfer's indent mode to work correctly, making this option highly recommended. 233 | 234 | * **Debugging support** - The `--log-level` and `--log-file` CLI options provide configurable logging. Without proper logging, developing and troubleshooting clojure-mcp-light is extremely difficult. 235 | 236 | * **Statistics tracking** - The `--stats` flag enables global tracking of delimiter events. The `scripts/stats-summary.bb` tool provides comprehensive analysis of fix rates, error patterns, and code quality metrics. 237 | 238 | ### Added 239 | - **Statistics tracking system** - Track delimiter events to analyze LLM code quality 240 | - `--stats` CLI flag enables event logging to `~/.clojure-mcp-light/stats.log` 241 | - Event types: `:delimiter-error`, `:delimiter-fixed`, `:delimiter-fix-failed`, `:delimiter-ok` 242 | - Stats include timestamps, hook events, and file paths 243 | - `scripts/stats-summary.bb` - Comprehensive analysis tool for stats logs 244 | - Low-level parse error tracking and false positive filtering 245 | - Cljfmt efficiency tracking (already-formatted vs needed-formatting vs check-errors) 246 | 247 | - **Unified tmp namespace** - Session-scoped temporary file management 248 | - Centralized temporary file paths with automatic cleanup 249 | - Editor session detection with fallback strategies (env var → PPID-based → global) 250 | - Deterministic paths based on user, hostname, session ID, and project SHA 251 | - Per-project isolation prevents conflicts across multiple projects 252 | - Functions: `session-root`, `editor-scope-id`, `cleanup-session!`, `get-possible-session-ids` 253 | 254 | - **SessionEnd cleanup hook** - Automatic temp file cleanup 255 | - Removes session directories when Claude Code sessions terminate 256 | - Attempts cleanup for all possible session IDs (env-based and PPID-based) 257 | - Recursive deletion with detailed logging (attempted, deleted, errors, skipped) 258 | - Never blocks SessionEnd events, even on errors 259 | 260 | - **Enhanced CLI options** 261 | - `--log-level LEVEL` - Explicit log level control (trace, debug, info, warn, error, fatal, report) 262 | - `--log-file PATH` - Custom log file path (default: `./.clojure-mcp-light-hooks.log`) 263 | - `--cljfmt` - Enable automatic code formatting with cljfmt after write/edit operations 264 | 265 | - **Comprehensive testing documentation** in CLAUDE.md 266 | - Manual hook testing instructions 267 | - Claude Code integration testing guide 268 | - Troubleshooting section for common issues 269 | 270 | ### Changed 271 | - **Logging system** - Replaced custom logging with Timbre 272 | - Structured logging with timestamps, namespaces, and line numbers 273 | - Configurable appenders and log levels 274 | - Conditional ns-filter for targeted logging 275 | - Disabled by default to avoid breaking hook protocol 276 | 277 | - **Hook system improvements** 278 | - Refactored hook response format to minimize unnecessary output 279 | - Updated hook tests to match new response format 280 | - Extracted PPID session ID logic into dedicated function 281 | - Flattened tmp directory structure to single session-project level 282 | 283 | - **CLI handling** - Refactored into dedicated `handle-cli-args` function 284 | - Cleaner separation of concerns 285 | - Better error handling and help messages 286 | - Uses `tools.cli` for argument parsing 287 | 288 | - **File organization** - Migrated to unified tmp namespace 289 | - `hook.clj` now uses tmp namespace for backups 290 | - `nrepl_eval.clj` now uses tmp namespace for per-target sessions 291 | - Consistent session-scoped file management across all components 292 | 293 | ### Removed 294 | - **-c short flag** for `--cljfmt` option (prevented conflicts with potential future flags) 295 | 296 | [0.0.3-alpha]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.0.3-alpha 297 | 298 | ## [0.0.2-alpha] - 2025-11-08 299 | 300 | ### Added 301 | - **Enhanced ClojureScript support** - Learning to use edamame to detect delimiter errors across the widest possible set of Clojure/ClojureScript files 302 | - Added `:features #{:clj :cljs :cljr :default}` to enable platform-specific reader features 303 | - Explicit readers for common ClojureScript/EDN tagged literals: 304 | - `#js` - JavaScript object literals 305 | - `#jsx` - JSX literals 306 | - `#queue` - Queue data structures 307 | - `#date` - Date literals 308 | - Changed `:auto-resolve` to use `name` function for better compatibility 309 | 310 | - **scripts/test-parse-all.bb** - Testing utility for delimiter detection 311 | - Recursively finds and parses all Clojure files in a directory 312 | - Reports unknown tags with suggestions for adding readers 313 | - Helps validate edamame configuration across real codebases 314 | - Stops on first error with detailed reporting 315 | 316 | - **Dynamic var for error handling** - `*signal-on-bad-parse*` (defaults to `true`) 317 | - Triggers parinfer on unknown tag errors as a safety net 318 | - Allows users to opt out via binding if needed 319 | - More defensive approach: better to attempt repair than skip 320 | 321 | - **Expanded test coverage** 322 | - 30 tests (up from 27) with 165 assertions (up from 129) 323 | - New test suites for ClojureScript features: 324 | - `clojurescript-tagged-literals-test` - All supported tagged literals 325 | - `clojurescript-features-test` - Namespaced keywords and `::keys` destructuring 326 | - `mixed-clj-cljs-features-test` - Cross-platform code with reader conditionals 327 | - Tests validate both delimiter detection and proper parsing 328 | 329 | ### Changed 330 | - Updated `bb.edn` to use cognitect test-runner instead of manual test loading 331 | - Cleaner test execution 332 | - Better output formatting 333 | - Standard Clojure tooling approach 334 | 335 | ### Removed 336 | - **Legacy standalone .bb scripts** - Removed `clj-paren-repair-hook.bb` and `clojure-nrepl-eval.bb` 337 | - Now use `bb -m clojure-mcp-light.hook` and `bb -m clojure-mcp-light.nrepl-eval` instead 338 | - bbin installation uses namespace entrypoints from `bb.edn` 339 | - Eliminates 597 lines of duplicate code 340 | - Simpler maintenance with single source of truth 341 | 342 | [0.0.2-alpha]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.0.2-alpha 343 | 344 | ## [0.0.1-alpha] - 2025-11-08 345 | 346 | ### Added 347 | - **clj-paren-repair-claude-hook** - Claude Code hook for automatic Clojure delimiter fixing 348 | - Detects delimiter errors using edamame parser 349 | - Auto-fixes with parinfer-rust 350 | - PreToolUse hooks for Write/Edit/Bash operations 351 | - PostToolUse hooks for Edit operations with backup/restore 352 | - Cross-platform backup path handling 353 | - Session-specific backup isolation 354 | 355 | - **clj-nrepl-eval** - nREPL evaluation tool 356 | - Direct bencode protocol implementation for nREPL communication 357 | - Automatic delimiter repair before evaluation 358 | - Timeout and interrupt handling for long-running evaluations 359 | - Persistent session support with Claude Code session-id based tmp-file with `./.nrepl-session` file as fallback 360 | - `--reset-session` flag for session management 361 | - Port detection: CLI flag > NREPL_PORT env > .nrepl-port file 362 | - Formatted output with dividers 363 | 364 | - **Slash commands** for Claude Code 365 | - `/start-nrepl` - Start nREPL server in background 366 | - `/clojure-nrepl` - Information about REPL evaluation 367 | 368 | - **Installation support** 369 | - bbin package manager integration 370 | - Proper namespace structure (clojure-mcp-light.*) 371 | 372 | - **Documentation** 373 | - Comprehensive README.md 374 | - CLAUDE.md project documentation for Claude Code 375 | - Example settings and configuration files 376 | - EPL-2.0 license 377 | 378 | ### Changed 379 | - Logging disabled by default for hook operations 380 | - Error handling: applies parinfer on all errors for maximum robustness 381 | 382 | [0.0.1-alpha]: https://github.com/bhauman/clojure-mcp-light/releases/tag/v0.0.1-alpha 383 | --------------------------------------------------------------------------------