diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..05f45f30 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,29 @@ +{ + "name": "lumen", + "owner": { + "name": "Aeneas Rekkas", + "email": "aeneas@ory.com" + }, + "metadata": { + "description": "Lumen — local semantic code search for AI coding agents", + "version": "0.2.0" + }, + "plugins": [ + { + "name": "lumen", + "source": "./", + "description": "Precise local semantic code search via MCP. Indexes your codebase with Go AST parsing, embeds with Ollama or LM Studio, and exposes vector search to Claude through an MCP server — no cloud, no npm.", + "version": "0.2.0", + "author": { + "name": "Aeneas Rekkas", + "url": "https://github.com/aeneasr" + }, + "homepage": "https://github.com/aeneasr/lumen", + "repository": "https://github.com/aeneasr/lumen", + "license": "Apache-2.0", + "keywords": ["semantic-search", "code-index", "mcp", "embeddings", "ollama", "rag"], + "category": "developer-tools", + "tags": ["code-search", "mcp", "ai", "local"] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..7ed40b24 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "lumen", + "description": "Local semantic code search for AI coding agents", + "author": { + "name": "Aeneas Rekkas", + "url": "https://github.com/aeneasr" + }, + "homepage": "https://github.com/aeneasr/lumen", + "repository": "https://github.com/aeneasr/lumen", + "license": "Apache-2.0", + "keywords": ["semantic-search", "code-index", "mcp", "embeddings", "ollama", "rag"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb5de240..663e22a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,4 @@ jobs: OLLAMA_HOST: http://localhost:11434 LUMEN_EMBED_MODEL: all-minilm LUMEN_EMBED_DIMS: '384' + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..22a8b090 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Test + run: make test + + - name: Vet + run: go vet -tags=fts5 ./... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: false + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --build-tags=fts5 + + release: + name: Release + runs-on: ubuntu-latest + container: + image: oryd/xgoreleaser:1.26.0-2.14.1 + needs: [test, lint] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Release + run: goreleaser release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9d806b61..139c969b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -# Binary -lumen +# Build output +bin/ +dist/ +./lumen # IDE .idea/ @@ -7,4 +9,4 @@ lumen *.swp # OS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..ba16bb72 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,89 @@ +version: 2 +project_name: lumen + +before: + hooks: + - apt-get update -qq + - apt-get install -y libsqlite3-dev + - cp /usr/include/sqlite3.h /usr/x86_64-w64-mingw32/include/sqlite3.h + +builds: + - id: macos + main: . + binary: lumen + flags: + - -tags=fts5 + ldflags: + - -s -w + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + goos: + - darwin + goarch: + - amd64 + - arm64 + + - id: linux-amd64 + main: . + binary: lumen + flags: + - -tags=fts5 + ldflags: + - -s -w + env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + + - id: linux-arm64 + main: . + binary: lumen + flags: + - -tags=fts5 + ldflags: + - -s -w + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + goos: + - linux + goarch: + - arm64 + + - id: windows + main: . + binary: lumen + flags: + - -tags=fts5 + ldflags: + - -s -w + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ + goos: + - windows + goarch: + - amd64 + +archives: + - id: default + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" + +checksum: + name_template: checksums.txt + +release: + github: + owner: aeneasr + name: lumen diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..3cd64c9a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "lumen": { + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/run.sh", + "args": ["stdio"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index defa0c3e..8a1d512f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,14 @@ # CLAUDE.md — lumen +Lumen is a code search and indexing tool designed for integration with the Claude Code plugin system. It provides fast, semantic search capabilities over codebases by leveraging vector embeddings +and a Merkle tree structure to efficiently detect changes and minimize re-indexing. + +This repository is structured as a claude plugin available via the claude marketplace. + ## Go Standards - **Version**: Go 1.26+ -- **Build**: `CGO_ENABLED=1 go build -o lumen .` (sqlite-vec requires CGO) +- **Build**: `CGO_ENABLED=1 go build -o bin/lumen-- .` (sqlite-vec requires CGO) - **Format**: `gofmt` (enforced in CI) - **Lint**: `golangci-lint run` (zero issues, see `.golangci.yml`) - **Vet**: `go vet ./...` (external dependency warnings OK) @@ -58,25 +63,57 @@ See `Makefile` for all commands: ```bash -make build # Build binary (CGO_ENABLED=1) +make build # Build binary to bin/ (CGO_ENABLED=1) make test # Run unit + integration tests make e2e # Run E2E tests (requires Ollama/LM Studio) make lint # Run golangci-lint make vet # Run go vet make format # Format code & markdown make tidy # Update go.mod -make clean # Remove binary -make install # Install binary +make clean # Remove bin/ and dist/ +make plugin-dev # Build + print plugin-dir usage +``` + +## Plugin Development + +```bash +make build +claude --plugin-dir . ``` +This loads lumen as a Claude Code plugin directly from the repo. The plugin system handles MCP registration, hooks, and skills declaratively via: +- `.claude-plugin/plugin.json` — plugin manifest +- `.mcp.json` — MCP server config +- `hooks/hooks.json` — SessionStart + PreToolUse hooks +- `skills/` — `/lumen:doctor` and `/lumen:reindex` skills + +## Environment Variables + +| Variable | Default | Description | +| ------------------------- | -------------------- | ------------------------------------------ | +| `LUMEN_BACKEND` | `ollama` | Embedding backend (`ollama` or `lmstudio`) | +| `LUMEN_EMBED_MODEL` | see note ¹ | Embedding model (must be in registry) | +| `OLLAMA_HOST` | `localhost:11434` | Ollama server URL | +| `LM_STUDIO_HOST` | `localhost:1234` | LM Studio server URL | +| `LUMEN_MAX_CHUNK_TOKENS` | `512` | Max tokens per chunk before splitting | + +¹ `ordis/jina-embeddings-v2-base-code` (Ollama), `nomic-ai/nomic-embed-code-GGUF` (LM Studio) + ## Project Structure ``` . ├── main.go # 3-line entrypoint +├── .claude-plugin/ # Plugin manifest +├── .mcp.json # MCP server config +├── hooks/ # Hook declarations +├── skills/ # Skill definitions +├── scripts/ # Platform wrappers (run.sh, run.bat) ├── cmd/ │ ├── root.go # Cobra root command │ ├── stdio.go # MCP server +│ ├── hook.go # Hook handlers +│ ├── purge.go # Index data cleanup │ └── index.go # CLI indexing ├── internal/ │ ├── config/ # Config loading & paths @@ -96,4 +133,12 @@ make install # Install binary - **Chunk splitting at line boundaries**: Oversized chunks split at `LUMEN_MAX_CHUNK_TOKENS` (512 default) - **32-batch embedding**: Balance memory vs. API round-trips - **Cosine distance KNN**: Normalized for semantic similarity +- **Plugin system**: Declarative hooks/MCP/skills via `.claude-plugin/`, replacing manual install/uninstall + +## Claude Integration Notes + +When planning any work related to claude code plugin, marketplace, hooks, ensuring tool use, and other areas around the claude +integration you MUST base your thinking on the following AUTHORATIVE reference docs: +- Marketplace Plugin: https://code.claude.com/docs/en/plugin-marketplaces#marketplace-schema +- Plugin Reference: https://code.claude.com/docs/en/plugins-reference diff --git a/Makefile b/Makefile index ee5e95c7..0def7fd1 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,21 @@ -BINARY := lumen GO := go GOTAGS := fts5 GOFLAGS := -tags=$(GOTAGS) -.PHONY: build test e2e lint vet tidy clean format +XGORELEASER_IMAGE := oryd/xgoreleaser:1.26.0-2.14.1 + +.PHONY: build build-local test e2e lint vet tidy clean format plugin-dev build: - CGO_ENABLED=1 $(GO) build $(GOFLAGS) -o $(BINARY) . + docker run --platform linux/amd64 --mount type=bind,source="$$(pwd)",target=/project \ + $(XGORELEASER_IMAGE) --snapshot --clean + +build-local: + CGO_ENABLED=1 $(GO) build $(GOFLAGS) -o bin/lumen . test: CGO_ENABLED=1 $(GO) test $(GOFLAGS) ./... -install: - CGO_ENABLED=1 $(GO) install $(GOFLAGS) ./... - e2e: CGO_ENABLED=1 $(GO) test -tags=$(GOTAGS),e2e -timeout=20m -v -count=1 ./... @@ -27,8 +29,11 @@ tidy: $(GO) mod tidy clean: - rm -f $(BINARY) + rm -rf bin/ dist/ format: goimports -w . npx --yes prettier --write "**/*.{json,md,mdx,yaml,yml}" + +plugin-dev: build-local + @echo "Run: claude --plugin-dir ." diff --git a/README.md b/README.md index df7abf37..75df4e5b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Lumen — semantic search for code agents +# Lumen: semantic search for code agents [![CI](https://github.com/aeneasr/lumen/actions/workflows/ci.yml/badge.svg)](https://github.com/aeneasr/lumen/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/aeneasr/lumen)](https://goreportcard.com/report/github.com/aeneasr/lumen) @@ -10,14 +10,14 @@ cloud, no external database. Just open-source embedding models (Ollama or LM Studio), SQLite + sqlite-vec, and your CPU. Works on any developer machine because of Golang. -Lumen makes Claude Code **2.1–2.3× faster** and **63–81% cheaper**, +Lumen lights up complex code bases and makes Claude Code **2.1-2.3x faster** and **63-81% cheaper**, with reproducible [benchmarks](docs/BENCHMARKS.md) while **always** retaining or exceeding answer quality over the baseline. | | With lumen | Baseline (no MCP) | | ---------------------------- | --------------------------- | --------------------------- | -| Task completion | **2.1–2.3× faster** | baseline | -| API cost | **63–81% cheaper** | baseline | +| Task completion | **2.1-2.3x faster** | baseline | +| API cost | **63-81% cheaper** | baseline | | Answer quality (blind judge) | **5/5 wins** | 0/5 wins | ## Supported Languages @@ -26,9 +26,9 @@ Supports **12 language families** with semantic chunking: | Language | Parser | Extensions | Status | | ---------------- | ----------- | ----------------------------------------- |-------------------------------------| -| Go | Native AST | `.go` | Optimized: 3.8× faster, 90% cheaper | -| Python | tree-sitter | `.py` | Tested: 1.8× faster, 72% cheaper | -| TypeScript / TSX | tree-sitter | `.ts`, `.tsx` | Tested: 1.4× faster, 48% cheaper | +| Go | Native AST | `.go` | Optimized: 3.8x faster, 90% cheaper | +| Python | tree-sitter | `.py` | Tested: 1.8x faster, 72% cheaper | +| TypeScript / TSX | tree-sitter | `.ts`, `.tsx` | Tested: 1.4x faster, 48% cheaper | | JavaScript / JSX | tree-sitter | `.js`, `.jsx`, `.mjs` | Supported | | Rust | tree-sitter | `.rs` | Supported | | Ruby | tree-sitter | `.rb` | Supported | @@ -63,51 +63,50 @@ for in natural language and gets back precise file paths and line ranges. 1. [Ollama](https://ollama.com/) or [LM Studio](https://lmstudio.ai/download) installed and running -2. [Go](https://go.dev/) 1.26+ +2. Pull the default embedding model: `ollama pull ordis/jina-embeddings-v2-base-code` -```bash -# Install the binary -CGO_ENABLED=1 go install github.com/aeneasr/lumen@latest - -# Pull the default embedding model (recommended) -ollama pull ordis/jina-embeddings-v2-base-code +### As a Claude Code plugin -# Interactive setup — detects services, picks a model, registers MCP, and -# configures Claude Code for optimal semantic search usage -lumen install +```bash +# Install via the Claude Code marketplace +claude marketplace add github:aeneasr/lumen + +# From source (development) +git clone https://github.com/aeneasr/lumen.git +cd lumen +make build +claude --plugin-dir . ``` -> `CGO_ENABLED=1` is required — sqlite-vec compiles from C source. +The binary is downloaded automatically from the [latest GitHub release](https://github.com/aeneasr/lumen/releases) +on first use — no npm, no manual install step. -That's it. `lumen install` handles everything: +The plugin system handles everything automatically: +- **MCP server** registration (`.mcp.json`) +- **SessionStart hook** that directs the agent to prefer semantic search +- **PreToolUse hook** that intercepts natural language Grep/Glob patterns +- **Skills**: `/lumen:doctor` for health checks, `/lumen:reindex` for forced re-indexing -1. **Service detection** — finds running Ollama / LM Studio instances -2. **Model selection** — interactive picker with recommended defaults -3. **MCP registration** — registers with Claude Code (and Codex, if available) -4. **Rules file** — writes a code search directive to `~/.claude/rules/` -5. **SessionStart hook** — injects a high-priority directive into every - conversation so the agent consistently uses semantic search first +### Environment variables -Claude Code will now have access to `semantic_search` and `index_status` tools. -On the first search against a project, it auto-indexes the codebase. +| Variable | Default | Description | +| ------------------- | -------------------------------------- | ---------------------------------- | +| `LUMEN_BACKEND` | `ollama` | Backend: `ollama` or `lmstudio` | +| `LUMEN_EMBED_MODEL` | `ordis/jina-embeddings-v2-base-code` | Embedding model | +| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL | +| `LM_STUDIO_HOST` | `http://localhost:1234` | LM Studio server URL | -### Install flags +### Purge index data -| Flag | Description | -| -------------- | -------------------------------------------- | -| `--model` | Skip interactive model selection | -| `--dry-run` | Print actions without executing them | -| `--no-mcp` | Skip MCP registration | -| `--no-rules` | Skip rules file | -| `--no-hooks` | Skip SessionStart hook registration | - -### Uninstall +To remove all lumen index databases: ```bash -lumen uninstall # removes MCP, rules, and hook -lumen uninstall --purge-data # also removes all index data +lumen purge ``` +This deletes `~/.local/share/lumen/`. Indexes are rebuilt automatically on the +next search. + ## CLI The `lumen index` command lets you pre-index a project from the terminal. @@ -148,7 +147,7 @@ Search indexed code using natural language. Auto-indexes if the index is stale. | `min_score` | float | no | Minimum score threshold (-1 to 1). Default 0.5. Use -1 to return all results. | | `force_reindex` | boolean | no | Force full re-index before searching | -Returns file paths, symbol names, line ranges, and similarity scores (0–1). +Returns file paths, symbol names, line ranges, and similarity scores (0-1). ### `index_status` @@ -217,7 +216,7 @@ Where `` is derived from the absolute project path and embedding model name. No files are added to your repo, no `.gitignore` modifications needed. You can safely delete the entire `lumen` directory to clear all indexes, -or delete specific subdirectories to clear indexes for specific projects/models. +or use `lumen purge` to do it automatically. ## Benchmarks @@ -230,8 +229,8 @@ Key results (Ollama, jina-embeddings-v2-base-code): | Model | Speedup | Cost Savings | Quality | | ---------- | ---------------- | ------------------ | ------------- | -| Sonnet 4.6 | **2.2× faster** | **63% cheaper** | 5/5 MCP wins | -| Opus 4.6 | **2.1× faster** | **81% cheaper** | 5/5 MCP wins | +| Sonnet 4.6 | **2.2x faster** | **63% cheaper** | 5/5 MCP wins | +| Opus 4.6 | **2.1x faster** | **81% cheaper** | 5/5 MCP wins | Results hold across LM Studio (nomic-embed-code) and across Go, Python, and TypeScript in extended multi-model benchmarks. @@ -243,7 +242,7 @@ reproduce instructions. ## Building from source ```bash -CGO_ENABLED=1 go build -o lumen . +make build # outputs bin/lumen-- ``` ## Contributing diff --git a/cmd/hook.go b/cmd/hook.go index 36a50d9d..46dc0cc9 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -19,13 +19,19 @@ import ( "fmt" "os" "path/filepath" + "strings" + "unicode" "github.com/spf13/cobra" ) +// NOTE: Hooks are now declared in hooks/hooks.json (plugin system). +// The hook subcommands remain as the execution targets for those declarations. + func init() { rootCmd.AddCommand(hookCmd) hookCmd.AddCommand(hookSessionStartCmd) + hookCmd.AddCommand(hookPreToolUseCmd) } var hookCmd = &cobra.Command{ @@ -91,3 +97,110 @@ func generateHookContent(mcpName string) string { "If semantic search is unavailable, Grep/Glob are acceptable fallbacks.\n" + "" } + +// --- PreToolUse hook --- + +var hookPreToolUseCmd = &cobra.Command{ + Use: "pre-tool-use [mcp-name]", + Short: "Intercept Grep/Glob calls and suggest semantic search when appropriate", + Args: cobra.MaximumNArgs(1), + RunE: runHookPreToolUse, +} + +// preToolUseInput is the JSON structure Claude Code sends to PreToolUse hooks. +type preToolUseInput struct { + ToolName string `json:"tool_name"` + Input map[string]any `json:"tool_input"` +} + +// preToolUseOutput is the JSON structure Claude Code expects from a PreToolUse hook. +type preToolUseOutput struct { + Decision string `json:"decision"` + Reason string `json:"reason,omitempty"` +} + +func runHookPreToolUse(_ *cobra.Command, args []string) error { + mcpName := filepath.Base(os.Args[0]) + if len(args) > 0 { + mcpName = args[0] + } + + var input preToolUseInput + if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil { + // If we can't parse input, approve silently to avoid blocking. + return json.NewEncoder(os.Stdout).Encode(preToolUseOutput{Decision: "approve"}) + } + + decision := evaluateToolCall(input, mcpName) + + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + return enc.Encode(decision) +} + +// evaluateToolCall determines whether a Grep/Glob call should be intercepted +// with a suggestion to use semantic search instead. +func evaluateToolCall(input preToolUseInput, mcpName string) preToolUseOutput { + switch input.ToolName { + case "Grep", "Glob": + pattern := extractPattern(input) + if pattern != "" && looksLikeNaturalLanguage(pattern) { + toolRef := "mcp__" + mcpName + "__semantic_search" + return preToolUseOutput{ + Decision: "suggest", + Reason: fmt.Sprintf( + "This pattern looks like a natural language query. "+ + "Consider using %s instead for better results. "+ + "Grep/Glob work best with exact literal strings, "+ + "while semantic search understands concepts and intent.", + toolRef, + ), + } + } + } + return preToolUseOutput{Decision: "approve"} +} + +// extractPattern pulls the search pattern from a Grep or Glob tool input. +func extractPattern(input preToolUseInput) string { + if p, ok := input.Input["pattern"].(string); ok { + return p + } + if p, ok := input.Input["query"].(string); ok { + return p + } + return "" +} + +// looksLikeNaturalLanguage returns true if a pattern appears to be a natural +// language query rather than an exact string or regex pattern. Heuristics: +// - Contains spaces (multi-word) +// - No regex metacharacters +// - Longer than 40 characters +// - Predominantly alphabetic characters +func looksLikeNaturalLanguage(pattern string) bool { + if !strings.Contains(pattern, " ") { + return false + } + if len(pattern) <= 40 { + return false + } + // Regex metacharacters indicate an intentional pattern. + if strings.ContainsAny(pattern, `.*+?^${}()|[]\`) { + return false + } + // Check that the majority of non-space characters are letters. + var letters, total int + for _, r := range pattern { + if !unicode.IsSpace(r) { + total++ + if unicode.IsLetter(r) { + letters++ + } + } + } + if total == 0 { + return false + } + return float64(letters)/float64(total) > 0.7 +} diff --git a/cmd/hook_test.go b/cmd/hook_test.go index c668a074..9b1b3afe 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -48,6 +48,114 @@ func TestGenerateHookContent(t *testing.T) { } } +func TestEvaluateToolCall_GrepNaturalLanguage(t *testing.T) { + input := preToolUseInput{ + ToolName: "Grep", + Input: map[string]any{"pattern": "how does the authentication middleware handle token refresh in this codebase"}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "suggest" { + t.Errorf("expected suggest for natural language pattern, got %q", result.Decision) + } + if !strings.Contains(result.Reason, "mcp__lumen__semantic_search") { + t.Error("reason should reference semantic_search tool") + } +} + +func TestEvaluateToolCall_GrepExactString(t *testing.T) { + input := preToolUseInput{ + ToolName: "Grep", + Input: map[string]any{"pattern": "handleSemanticSearch"}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "approve" { + t.Errorf("expected approve for exact string pattern, got %q", result.Decision) + } +} + +func TestEvaluateToolCall_GrepRegex(t *testing.T) { + input := preToolUseInput{ + ToolName: "Grep", + Input: map[string]any{"pattern": `func\s+\w+Search.*context\.Context`}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "approve" { + t.Errorf("expected approve for regex pattern, got %q", result.Decision) + } +} + +func TestEvaluateToolCall_GlobApproved(t *testing.T) { + input := preToolUseInput{ + ToolName: "Glob", + Input: map[string]any{"pattern": "**/*.go"}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "approve" { + t.Errorf("expected approve for glob pattern, got %q", result.Decision) + } +} + +func TestEvaluateToolCall_OtherToolApproved(t *testing.T) { + input := preToolUseInput{ + ToolName: "Read", + Input: map[string]any{"path": "/some/file.go"}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "approve" { + t.Errorf("expected approve for non-Grep/Glob tool, got %q", result.Decision) + } +} + +func TestEvaluateToolCall_ShortPattern(t *testing.T) { + input := preToolUseInput{ + ToolName: "Grep", + Input: map[string]any{"pattern": "find this function"}, + } + result := evaluateToolCall(input, "lumen") + if result.Decision != "approve" { + t.Errorf("expected approve for short pattern (<=40 chars), got %q", result.Decision) + } +} + +func TestLooksLikeNaturalLanguage(t *testing.T) { + cases := []struct { + pattern string + want bool + }{ + {"handleSemanticSearch", false}, // no spaces + {"find this", false}, // too short + {"how does the authentication system work in this project", true}, + {`func\s+\w+`, false}, // regex + {"**/*.go", false}, // glob + {"where is the database connection pool configured and initialized", true}, + {"1234567890 1234567890 1234567890 1234567890 12345", false}, // mostly digits + } + + for _, tc := range cases { + t.Run(tc.pattern, func(t *testing.T) { + got := looksLikeNaturalLanguage(tc.pattern) + if got != tc.want { + t.Errorf("looksLikeNaturalLanguage(%q) = %v, want %v", tc.pattern, got, tc.want) + } + }) + } +} + +func TestPreToolUseOutputJSON(t *testing.T) { + out := preToolUseOutput{Decision: "suggest", Reason: "try semantic search"} + data, err := json.Marshal(out) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if parsed["decision"] != "suggest" { + t.Errorf("expected decision=suggest, got %v", parsed["decision"]) + } +} + func TestHookOutputJSON(t *testing.T) { content := generateHookContent("lumen") out := hookOutput{ diff --git a/cmd/install.go b/cmd/install.go deleted file mode 100644 index 544f0ac7..00000000 --- a/cmd/install.go +++ /dev/null @@ -1,750 +0,0 @@ -// Copyright 2026 Aeneas Rekkas -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "os/exec" - "path/filepath" - "slices" - "strconv" - "strings" - "time" - - "github.com/aeneasr/lumen/internal/config" - "github.com/aeneasr/lumen/internal/embedder" - "github.com/spf13/cobra" -) - -func init() { - installCmd.Flags().StringP("model", "m", "", "skip interactive model selection, use this model") - installCmd.Flags().Bool("dry-run", false, "print actions without executing them") - installCmd.Flags().Bool("no-mcp", false, "skip MCP registration, only write the rules file") - installCmd.Flags().Bool("no-rules", false, "skip rules file update, only register MCP") - installCmd.Flags().Bool("no-hooks", false, "skip SessionStart hook registration") - rootCmd.AddCommand(installCmd) -} - -var installCmd = &cobra.Command{ - Use: "install", - Short: "Install lumen MCP server and configure code search directives", - Args: cobra.NoArgs, - RunE: runInstall, -} - -func runInstall(cmd *cobra.Command, args []string) error { - mcpName := filepath.Base(os.Args[0]) - modelFlag, _ := cmd.Flags().GetString("model") - dryRun, _ := cmd.Flags().GetBool("dry-run") - noMCP, _ := cmd.Flags().GetBool("no-mcp") - noRules, _ := cmd.Flags().GetBool("no-rules") - noHooks, _ := cmd.Flags().GetBool("no-hooks") - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Phase 1: Service detection - backend, host, err := detectAndSelectService(ctx) - if err != nil { - return err - } - - // Phase 2: Model selection - selectedModel, err := selectModel(ctx, backend, host, modelFlag) - if err != nil { - return err - } - - // Phase 3: MCP registration - if !noMCP { - if err := registerMCP(mcpName, backend, selectedModel, dryRun); err != nil { - return err - } - } - - // Phase 4: rules file upsert - if !noRules { - if err := upsertRules(mcpName, dryRun); err != nil { - return err - } - } - - // Phase 5: SessionStart hook registration - if !noHooks { - if err := upsertHook(mcpName, dryRun); err != nil { - return err - } - } - - return nil -} - -// --- Phase 1: Service detection --- - -func detectServices(ctx context.Context) (ollamaOK, lmstudioOK bool) { - ollamaHost := config.EnvOrDefault("OLLAMA_HOST", "http://localhost:11434") - lmstudioHost := config.EnvOrDefault("LM_STUDIO_HOST", "http://localhost:1234") - - type result struct { - name string - ok bool - } - - ch := make(chan result, 2) - go func() { ch <- result{"ollama", probeService(ctx, ollamaHost+"/api/tags")} }() - go func() { ch <- result{"lmstudio", probeService(ctx, lmstudioHost+"/v1/models")} }() - - for range 2 { - r := <-ch - switch r.name { - case "ollama": - ollamaOK = r.ok - case "lmstudio": - lmstudioOK = r.ok - } - } - - return ollamaOK, lmstudioOK -} - -func probeService(ctx context.Context, url string) bool { - probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, url, nil) - if err != nil { - return false - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return false - } - _ = resp.Body.Close() - return resp.StatusCode < 500 -} - -func detectAndSelectService(ctx context.Context) (backend, host string, err error) { - ollamaHost := config.EnvOrDefault("OLLAMA_HOST", "http://localhost:11434") - lmstudioHost := config.EnvOrDefault("LM_STUDIO_HOST", "http://localhost:1234") - - fmt.Fprintln(os.Stderr, "Detecting embedding services...") - - ollamaOK, lmstudioOK := detectServices(ctx) - - printServiceStatus("Ollama", ollamaHost, ollamaOK) - printServiceStatus("LM Studio", lmstudioHost, lmstudioOK) - - switch { - case !ollamaOK && !lmstudioOK: - return "", "", fmt.Errorf( - "no embedding service detected\n" + - " Install Ollama: https://ollama.com\n" + - " Install LM Studio: https://lmstudio.ai", - ) - case ollamaOK && !lmstudioOK: - return config.BackendOllama, ollamaHost, nil - case !ollamaOK && lmstudioOK: - return config.BackendLMStudio, lmstudioHost, nil - default: - // Both available: prompt - return promptServiceSelection(ollamaHost, lmstudioHost) - } -} - -func printServiceStatus(name, host string, ok bool) { - trimHost := strings.TrimPrefix(strings.TrimPrefix(host, "http://"), "https://") - if ok { - fmt.Fprintf(os.Stderr, " \u2713 %-12s (%s)\n", name, trimHost) - } else { - fmt.Fprintf(os.Stderr, " \u2717 %-12s (%s \u2014 not running)\n", name, trimHost) - } -} - -func promptServiceSelection(ollamaHost, lmstudioHost string) (backend, host string, err error) { - if !stdinIsTTY() { - return "", "", fmt.Errorf("stdin is not a terminal — use OLLAMA_HOST or LM_STUDIO_HOST env vars to disambiguate") - } - - fmt.Fprintln(os.Stderr, "\nBoth services are available. Which backend should be used?") - fmt.Fprintln(os.Stderr, " 1. Ollama") - fmt.Fprintln(os.Stderr, " 2. LM Studio") - fmt.Fprint(os.Stderr, "Pick a service [1]: ") - - line, err := readLine() - if err != nil { - return "", "", fmt.Errorf("read input: %w", err) - } - - line = strings.TrimSpace(line) - if line == "" || line == "1" { - return config.BackendOllama, ollamaHost, nil - } - if line == "2" { - return config.BackendLMStudio, lmstudioHost, nil - } - return "", "", fmt.Errorf("invalid selection %q: enter 1 or 2", line) -} - -// --- Phase 2: Model selection --- - -type ollamaTagsResponse struct { - Models []struct { - Name string `json:"name"` - } `json:"models"` -} - -type lmstudioModelsResponse struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` -} - -func fetchJSON[T any](ctx context.Context, url string) (T, error) { - var zero T - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return zero, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return zero, err - } - defer func() { _ = resp.Body.Close() }() - - var data T - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return zero, err - } - return data, nil -} - -func fetchOllamaModels(ctx context.Context, host string) ([]string, error) { - data, err := fetchJSON[ollamaTagsResponse](ctx, host+"/api/tags") - if err != nil { - return nil, err - } - names := make([]string, len(data.Models)) - for i, m := range data.Models { - names[i] = m.Name - } - return names, nil -} - -func fetchLMStudioModels(ctx context.Context, host string) ([]string, error) { - data, err := fetchJSON[lmstudioModelsResponse](ctx, host+"/v1/models") - if err != nil { - return nil, err - } - ids := make([]string, len(data.Data)) - for i, m := range data.Data { - ids[i] = m.ID - } - return ids, nil -} - -// modelEntry holds a supported model and whether it is locally available. -type modelEntry struct { - Name string - Spec embedder.ModelSpec - Available bool -} - -func selectModel(ctx context.Context, backend, host, modelFlag string) (string, error) { - // Fetch locally available models from the service. - var available []string - var err error - if backend == config.BackendOllama { - available, err = fetchOllamaModels(ctx, host) - } else { - available, err = fetchLMStudioModels(ctx, host) - } - if err != nil { - return "", fmt.Errorf("list models: %w", err) - } - - if modelFlag != "" { - if _, known := lookupModelSpec(modelFlag); !known { - fmt.Fprintf(os.Stderr, "Warning: model %q is not a supported model and may not work correctly\n", modelFlag) - } - if !isModelAvailable(modelFlag, available) { - fmt.Fprintf(os.Stderr, "Warning: model %q not found locally — you may need to pull it first\n", modelFlag) - } - return modelFlag, nil - } - - return promptModelSelection(available, backend) -} - -// isModelAvailable checks if a model name (or its alias/canonical form) is in -// the available list. -func isModelAvailable(name string, available []string) bool { - canonical := canonicalModelName(name) - for _, a := range available { - if a == name || canonicalModelName(a) == canonical { - return true - } - } - return false -} - -// supportedModelsForBackend returns all KnownModels entries that match the -// given backend, annotated with local availability. -func supportedModelsForBackend(backend string, available []string) []modelEntry { - defaultModel := embedder.DefaultOllamaModel - if backend == config.BackendLMStudio { - defaultModel = embedder.DefaultLMStudioModel - } - - var entries []modelEntry - for name, spec := range embedder.KnownModels { - if spec.Backend != "" && spec.Backend != backend { - continue - } - entries = append(entries, modelEntry{ - Name: name, - Spec: spec, - Available: isModelAvailable(name, available), - }) - } - - // Sort: default first, then available before not-available, then alphabetically. - slices.SortFunc(entries, func(a, b modelEntry) int { - aDefault := modelMatchesDefault(a.Name, defaultModel) - bDefault := modelMatchesDefault(b.Name, defaultModel) - if aDefault != bDefault { - if aDefault { - return -1 - } - return 1 - } - if a.Available != b.Available { - if a.Available { - return -1 - } - return 1 - } - return strings.Compare(a.Name, b.Name) - }) - - return entries -} - -func promptModelSelection(available []string, backend string) (string, error) { - if !stdinIsTTY() { - return "", fmt.Errorf("stdin is not a terminal — use --model to specify a model non-interactively") - } - - entries := supportedModelsForBackend(backend, available) - if len(entries) == 0 { - return "", fmt.Errorf("no supported models for backend %q", backend) - } - - defaultModel := embedder.DefaultOllamaModel - if backend == config.BackendLMStudio { - defaultModel = embedder.DefaultLMStudioModel - } - - backendLabel := "Ollama" - pullCmd := "ollama pull" - if backend == config.BackendLMStudio { - backendLabel = "LM Studio" - pullCmd = "lms get" - } - - fmt.Fprintf(os.Stderr, "\nSupported models (%s):\n", backendLabel) - for i, e := range entries { - status := "\u2713 ready" - if !e.Available { - status = "\u2717 needs pull" - } - recommended := "" - if modelMatchesDefault(e.Name, defaultModel) { - recommended = " [recommended]" - } - fmt.Fprintf(os.Stderr, " %d. %-40s %4d dims %5d ctx %-13s%s\n", - i+1, e.Name, e.Spec.Dims, e.Spec.CtxLength, status, recommended) - } - - fmt.Fprint(os.Stderr, "\nPick a model [1]: ") - - line, err := readLine() - if err != nil { - return "", fmt.Errorf("read input: %w", err) - } - - line = strings.TrimSpace(line) - idx := 0 - if line == "" { - idx = 0 - } else if n, err := strconv.Atoi(line); err == nil { - if n < 1 || n > len(entries) { - return "", fmt.Errorf("invalid selection %d: enter 1-%d", n, len(entries)) - } - idx = n - 1 - } else { - // Try model name directly. - found := false - for i, e := range entries { - if e.Name == line { - idx = i - found = true - break - } - } - if !found { - return "", fmt.Errorf("invalid selection %q", line) - } - } - - selected := entries[idx] - if !selected.Available { - fmt.Fprintf(os.Stderr, "\nModel %q is not available locally.\n", selected.Name) - fmt.Fprintf(os.Stderr, "Pull it with: %s %s\n", pullCmd, selected.Name) - return "", fmt.Errorf("model %q not available — pull it first", selected.Name) - } - - return selected.Name, nil -} - -// --- Phase 3: MCP registration --- - -func registerMCP(mcpName, backend, model string, dryRun bool) error { - binaryPath, err := os.Executable() - if err != nil { - return fmt.Errorf("resolve binary path: %w", err) - } - - fmt.Fprintln(os.Stderr, "\nRegistering MCP server...") - - claudeErr := registerClaudeCode(mcpName, binaryPath, backend, model, dryRun) - codexErr := registerCodex(mcpName, binaryPath, backend, model, dryRun) - - if claudeErr != nil && !isNotFound(claudeErr) { - fmt.Fprintf(os.Stderr, " Warning: claude registration failed: %v\n", claudeErr) - } - if codexErr != nil && !isNotFound(codexErr) { - fmt.Fprintf(os.Stderr, " Warning: codex registration failed: %v\n", codexErr) - } - - return nil -} - -func registerClaudeCode(mcpName, binaryPath, backend, model string, dryRun bool) error { - if _, err := exec.LookPath("claude"); err != nil { - fmt.Fprintf(os.Stderr, " ! Claude Code (claude not in PATH — skipping)\n") - return err - } - - // Remove existing entry first (ignore errors — may not exist). - if !dryRun { - _ = exec.Command("claude", "mcp", "remove", "--scope", "user", mcpName).Run() - } - - addArgs := []string{ - "mcp", "add", - "--scope", "user", - "-eLUMEN_BACKEND=" + backend, - "-eLUMEN_EMBED_MODEL=" + model, - mcpName, binaryPath, "--", "stdio", - } - - cmdStr := "claude " + strings.Join(addArgs, " ") - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] %s\n", cmdStr) - return nil - } - - out, err := exec.Command("claude", addArgs...).CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %s", cmdStr, strings.TrimSpace(string(out))) - } - fmt.Fprintf(os.Stderr, " \u2713 Claude Code (%s)\n", cmdStr) - return nil -} - -func registerCodex(mcpName, binaryPath, backend, model string, dryRun bool) error { - if _, err := exec.LookPath("codex"); err != nil { - // Codex not in PATH: skip silently - return err - } - - // Remove existing entry first (ignore errors — may not exist). - if !dryRun { - _ = exec.Command("codex", "mcp", "remove", mcpName).Run() - } - - addArgs := []string{ - "mcp", "add", - "--env", "LUMEN_BACKEND=" + backend, - "--env", "LUMEN_EMBED_MODEL=" + model, - mcpName, binaryPath, "stdio", - } - - cmdStr := "codex " + strings.Join(addArgs, " ") - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] %s\n", cmdStr) - return nil - } - - out, err := exec.Command("codex", addArgs...).CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %s", cmdStr, strings.TrimSpace(string(out))) - } - fmt.Fprintf(os.Stderr, " \u2713 Codex (%s)\n", cmdStr) - return nil -} - -func isNotFound(err error) bool { - return errors.Is(err, exec.ErrNotFound) -} - -// --- Phase 4: rules file upsert --- - -func upsertRules(mcpName string, dryRun bool) error { - targetFile := rulesFilePath(mcpName) - - fmt.Fprintf(os.Stderr, "\nWriting rules file...\n") - - content := generateSnippet(mcpName) - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] Would write rules to %s (mcp-name: %s)\n", targetFile, mcpName) - return nil - } - - if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { - return fmt.Errorf("create directory: %w", err) - } - if err := os.WriteFile(targetFile, []byte(content+"\n"), 0o644); err != nil { - return fmt.Errorf("write file: %w", err) - } - fmt.Fprintf(os.Stderr, " \u2713 Wrote rules to %s (mcp-name: %s)\n", targetFile, mcpName) - return nil -} - -// rulesFilePath returns ~/.claude/rules/{mcpName}.md. -func rulesFilePath(mcpName string) string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".claude", "rules", mcpName+".md") -} - -// generateSnippet returns the code search directive for the given MCP server name. -func generateSnippet(mcpName string) string { - toolRef := "`mcp__" + mcpName + "__semantic_search`" - return "# Code Search\n\n" + - "ALWAYS use " + toolRef + " as the FIRST tool for code discovery and exploration.\n" + - "Do NOT default to Grep, Glob, or Read for search tasks — only use them for exact literal string lookups.\n\n" + - "Before using Grep, Glob, Find, or Read for any search, stop and ask:\n\n" + - "> \"Do I already know the exact literal string I'm searching for?\"\n\n" + - "- **No** — understanding how something works, finding where something is implemented, exploring\n" + - " unfamiliar code → use " + toolRef + "\n" + - "- **Yes** — a specific function name, import path, variable name, or error message → Grep/Glob is acceptable\n\n" + - "If semantic search is unavailable, Grep/Glob are acceptable fallbacks." -} - -// canonicalModelName resolves a model name to its canonical form by stripping -// the ":latest" tag and checking the alias map. -func canonicalModelName(name string) string { - stripped := strings.TrimSuffix(name, ":latest") - if canonical, ok := embedder.ModelAliases[stripped]; ok { - return canonical - } - return stripped -} - -// modelMatchesDefault reports whether a model name matches a default, ignoring -// the ":latest" tag that Ollama appends and resolving known aliases. -func modelMatchesDefault(model, defaultModel string) bool { - return canonicalModelName(model) == defaultModel -} - -// lookupModelSpec looks up a model in the KnownModels registry, falling back -// to a lookup with the ":latest" tag stripped and alias resolution. -func lookupModelSpec(name string) (embedder.ModelSpec, bool) { - if spec, ok := embedder.KnownModels[name]; ok { - return spec, true - } - spec, ok := embedder.KnownModels[canonicalModelName(name)] - return spec, ok -} - -// --- Phase 5: SessionStart hook registration --- - -// upsertHook registers a SessionStart hook in ~/.claude/settings.json that -// injects EXTREMELY_IMPORTANT-wrapped directives into every conversation. -func upsertHook(mcpName string, dryRun bool) error { - binaryPath, err := os.Executable() - if err != nil { - return fmt.Errorf("resolve binary path: %w", err) - } - - settingsPath := claudeSettingsPath() - - fmt.Fprintln(os.Stderr, "\nRegistering SessionStart hook...") - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] Would register SessionStart hook in %s\n", settingsPath) - return nil - } - - settings, err := readSettings(settingsPath) - if err != nil { - return err - } - - addSessionStartHook(settings, binaryPath, mcpName) - - if err := writeSettings(settingsPath, settings); err != nil { - return err - } - - fmt.Fprintf(os.Stderr, " ✓ Registered SessionStart hook in %s\n", settingsPath) - return nil -} - -// claudeSettingsPath returns ~/.claude/settings.json. -func claudeSettingsPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".claude", "settings.json") -} - -// readSettings reads and parses ~/.claude/settings.json, returning an empty -// map if the file does not exist. -func readSettings(path string) (map[string]any, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return map[string]any{}, nil - } - return nil, fmt.Errorf("read settings: %w", err) - } - var settings map[string]any - if err := json.Unmarshal(data, &settings); err != nil { - return nil, fmt.Errorf("parse settings: %w", err) - } - return settings, nil -} - -// writeSettings marshals settings with indentation and writes to path, -// creating parent directories if needed. -func writeSettings(path string, settings map[string]any) error { - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return fmt.Errorf("marshal settings: %w", err) - } - data = append(data, '\n') - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create settings directory: %w", err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("write settings: %w", err) - } - return nil -} - -// addSessionStartHook merges a lumen SessionStart hook entry into the settings -// map, replacing any existing hook whose command references the same binary. -func addSessionStartHook(settings map[string]any, binaryPath, mcpName string) { - hookCommand := binaryPath + " hook session-start " + mcpName - - hookEntry := map[string]any{ - "type": "command", - "command": hookCommand, - } - - matcherEntry := map[string]any{ - "matcher": "startup|resume|clear|compact", - "hooks": []any{hookEntry}, - } - - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - hooks = map[string]any{} - settings["hooks"] = hooks - } - - sessionStartHooks, ok := hooks["SessionStart"].([]any) - if !ok { - sessionStartHooks = []any{} - } - - // Remove any existing lumen hooks (matching mcpName or binary path in command). - filtered := make([]any, 0, len(sessionStartHooks)) - for _, entry := range sessionStartHooks { - if !hookEntryMatchesMCPName(entry, mcpName) && !hookEntryMatchesBinary(entry, binaryPath) { - filtered = append(filtered, entry) - } - } - - hooks["SessionStart"] = append(filtered, matcherEntry) -} - -// hookEntryMatchesBinary returns true if a hook entry's command contains the -// given binary path. -func hookEntryMatchesBinary(entry any, binaryPath string) bool { - m, ok := entry.(map[string]any) - if !ok { - return false - } - hooksList, ok := m["hooks"].([]any) - if !ok { - return false - } - for _, h := range hooksList { - hm, ok := h.(map[string]any) - if !ok { - continue - } - cmd, ok := hm["command"].(string) - if ok && strings.Contains(cmd, binaryPath) { - return true - } - } - return false -} - -// --- Helpers --- - -func stdinIsTTY() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return (fi.Mode() & os.ModeCharDevice) != 0 -} - -func readLine() (string, error) { - scanner := bufio.NewScanner(os.Stdin) - if scanner.Scan() { - return scanner.Text(), nil - } - if err := scanner.Err(); err != nil { - return "", err - } - return "", nil -} diff --git a/cmd/install_test.go b/cmd/install_test.go deleted file mode 100644 index de77c151..00000000 --- a/cmd/install_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright 2026 Aeneas Rekkas -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// --- addSessionStartHook tests --- - -func TestAddSessionStartHook_EmptySettings(t *testing.T) { - settings := map[string]any{} - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - t.Fatal("expected hooks key in settings") - } - sessionStart, ok := hooks["SessionStart"].([]any) - if !ok || len(sessionStart) != 1 { - t.Fatalf("expected 1 SessionStart hook, got %v", hooks["SessionStart"]) - } - - entry := sessionStart[0].(map[string]any) - if entry["matcher"] != "startup|resume|clear|compact" { - t.Errorf("unexpected matcher: %v", entry["matcher"]) - } - hooksList := entry["hooks"].([]any) - if len(hooksList) != 1 { - t.Fatalf("expected 1 hook entry, got %d", len(hooksList)) - } - cmd := hooksList[0].(map[string]any)["command"].(string) - if !strings.Contains(cmd, "/usr/local/bin/lumen") { - t.Errorf("command should contain binary path, got: %s", cmd) - } - if !strings.Contains(cmd, "hook session-start lumen") { - t.Errorf("command should contain 'hook session-start lumen', got: %s", cmd) - } -} - -func TestAddSessionStartHook_ReplacesExisting(t *testing.T) { - settings := map[string]any{} - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - - hooks := settings["hooks"].(map[string]any) - sessionStart := hooks["SessionStart"].([]any) - if len(sessionStart) != 1 { - t.Errorf("expected 1 SessionStart hook after re-add, got %d", len(sessionStart)) - } -} - -func TestAddSessionStartHook_PreservesOtherHooks(t *testing.T) { - settings := map[string]any{ - "hooks": map[string]any{ - "SessionStart": []any{ - map[string]any{ - "matcher": "startup", - "hooks": []any{ - map[string]any{"type": "command", "command": "/other/tool hook"}, - }, - }, - }, - }, - } - - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - - hooks := settings["hooks"].(map[string]any) - sessionStart := hooks["SessionStart"].([]any) - if len(sessionStart) != 2 { - t.Errorf("expected 2 SessionStart hooks (other + lumen), got %d", len(sessionStart)) - } -} - -// --- readSettings / writeSettings round-trip --- - -func TestReadWriteSettings_RoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - - settings := map[string]any{"foo": "bar"} - if err := writeSettings(path, settings); err != nil { - t.Fatal(err) - } - - got, err := readSettings(path) - if err != nil { - t.Fatal(err) - } - if got["foo"] != "bar" { - t.Errorf("expected foo=bar, got %v", got["foo"]) - } - - data, _ := os.ReadFile(path) - var parsed map[string]any - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("written file is not valid JSON: %v", err) - } -} - -func TestReadSettings_MissingFile(t *testing.T) { - got, err := readSettings("/nonexistent/path/settings.json") - if err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Errorf("expected empty map for missing file, got %v", got) - } -} - -// --- generateSnippet tests --- - -func TestGenerateSnippet(t *testing.T) { - cases := []struct { - name string - wantRef string - }{ - {"lumen", "mcp__lumen__semantic_search"}, - {"my-custom-server", "mcp__my-custom-server__semantic_search"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - snippet := generateSnippet(tc.name) - if !strings.Contains(snippet, tc.wantRef) { - t.Errorf("expected %q in snippet, got: %s", tc.wantRef, snippet) - } - if !strings.HasPrefix(snippet, "# Code Search") { - t.Error("snippet should start with '# Code Search'") - } - }) - } -} - -// --- rulesFilePath tests --- - -func TestRulesFilePath_Default(t *testing.T) { - got := rulesFilePath("lumen") - if !strings.HasSuffix(got, filepath.Join(".claude", "rules", "lumen.md")) { - t.Errorf("expected default rules path, got %q", got) - } -} - -func TestRulesFilePath_CustomName(t *testing.T) { - got := rulesFilePath("my-server") - if !strings.HasSuffix(got, filepath.Join(".claude", "rules", "my-server.md")) { - t.Errorf("expected default rules path with custom name, got %q", got) - } -} diff --git a/cmd/purge.go b/cmd/purge.go new file mode 100644 index 00000000..26dd14cd --- /dev/null +++ b/cmd/purge.go @@ -0,0 +1,58 @@ +// Copyright 2026 Aeneas Rekkas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/aeneasr/lumen/internal/config" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(purgeCmd) +} + +var purgeCmd = &cobra.Command{ + Use: "purge", + Short: "Remove all lumen index data", + Long: "Deletes all lumen index databases from ~/.local/share/lumen/. This is irreversible — indexes will be rebuilt on the next search.", + Args: cobra.NoArgs, + RunE: runPurge, +} + +func runPurge(_ *cobra.Command, _ []string) error { + dataDir := filepath.Join(config.XDGDataDir(), "lumen") + + info, err := os.Stat(dataDir) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "No index data found — nothing to purge.") + return nil + } + return fmt.Errorf("stat data directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", dataDir) + } + + if err := os.RemoveAll(dataDir); err != nil { + return fmt.Errorf("remove index data: %w", err) + } + fmt.Fprintf(os.Stderr, "Removed all index data (%s)\n", dataDir) + return nil +} diff --git a/cmd/stdio.go b/cmd/stdio.go index 4f617b90..1e0bdafb 100644 --- a/cmd/stdio.go +++ b/cmd/stdio.go @@ -19,11 +19,13 @@ import ( "cmp" "context" "fmt" + "net/http" "os" "path/filepath" "slices" "strings" "sync" + "time" "github.com/aeneasr/lumen/internal/config" "github.com/aeneasr/lumen/internal/embedder" @@ -52,6 +54,8 @@ type SemanticSearchInput struct { Limit int `json:"limit,omitempty" jsonschema:"Max results to return, default 20"` MinScore *float64 `json:"min_score,omitempty" jsonschema:"Minimum score threshold (-1 to 1). Results below this score are excluded. Default 0.5. Use -1 to return all results."` ForceReindex bool `json:"force_reindex,omitempty" jsonschema:"Force full re-index before searching"` + Summary bool `json:"summary,omitempty" jsonschema:"When true, return only file path, symbol, kind, line range, and score — no code content. Useful for location-only queries."` + MaxLines int `json:"max_lines,omitempty" jsonschema:"Truncate each code snippet to this many lines. Default: unlimited."` } // SearchResultItem represents a single search result returned to the caller. @@ -88,6 +92,18 @@ type IndexStatusOutput struct { Stale bool `json:"stale"` } +// HealthCheckInput defines the parameters for the health_check tool. +type HealthCheckInput struct{} + +// HealthCheckOutput is the structured output of the health_check tool. +type HealthCheckOutput struct { + Backend string `json:"backend"` + Host string `json:"host"` + Model string `json:"model"` + Reachable bool `json:"reachable"` + Message string `json:"message"` +} + // --- indexerCache --- // indexerCache manages one *index.Indexer per project path, creating them @@ -172,8 +188,18 @@ func (ic *indexerCache) handleSemanticSearch(ctx context.Context, req *mcp.CallT } out.Results = make([]SearchResultItem, len(results)) - snippets := extractSnippets(input.Path, results) + var snippets []string + if !input.Summary { + snippets = extractSnippets(input.Path, results) + } for i, r := range results { + var content string + if snippets != nil { + content = snippets[i] + if input.MaxLines > 0 && content != "" { + content = truncateLines(content, input.MaxLines) + } + } out.Results[i] = SearchResultItem{ FilePath: r.FilePath, Symbol: r.Symbol, @@ -181,7 +207,7 @@ func (ic *indexerCache) handleSemanticSearch(ctx context.Context, req *mcp.CallT StartLine: r.StartLine, EndLine: r.EndLine, Score: float32(1.0 - r.Distance), - Content: snippets[i], + Content: content, } } @@ -301,6 +327,52 @@ func (ic *indexerCache) handleIndexStatus(_ context.Context, _ *mcp.CallToolRequ }, nil, nil } +// handleHealthCheck pings the configured embedding service and reports status. +func (ic *indexerCache) handleHealthCheck(ctx context.Context, _ *mcp.CallToolRequest, _ HealthCheckInput) (*mcp.CallToolResult, any, error) { + host := ic.cfg.OllamaHost + probeURL := host + "/api/tags" + if ic.cfg.Backend == config.BackendLMStudio { + host = ic.cfg.LMStudioHost + probeURL = host + "/v1/models" + } + + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, probeURL, nil) + if err != nil { + return healthResult(ic.cfg.Backend, host, ic.cfg.Model, false, + fmt.Sprintf("failed to create request: %v", err)), nil, nil + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return healthResult(ic.cfg.Backend, host, ic.cfg.Model, false, + fmt.Sprintf("service unreachable: %v", err)), nil, nil + } + _ = resp.Body.Close() + + if resp.StatusCode >= 500 { + return healthResult(ic.cfg.Backend, host, ic.cfg.Model, false, + fmt.Sprintf("service returned HTTP %d", resp.StatusCode)), nil, nil + } + + return healthResult(ic.cfg.Backend, host, ic.cfg.Model, true, "service is healthy"), nil, nil +} + +func healthResult(backend, host, model string, reachable bool, message string) *mcp.CallToolResult { + status := "OK" + if !reachable { + status = "ERROR" + } + text := fmt.Sprintf("Backend: %s\nHost: %s\nModel: %s\nStatus: %s\nMessage: %s", + backend, host, model, status, message) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + IsError: !reachable, + } +} + func extractSnippets(projectPath string, results []store.SearchResult) []string { snippets := make([]string, len(results)) filesByPath := groupResultsByFile(results) @@ -353,6 +425,15 @@ func extractForFile(snippets []string, lines []string, refs []resultRef) { } } +// truncateLines returns at most maxLines lines from a string. +func truncateLines(s string, maxLines int) string { + lines := strings.SplitN(s, "\n", maxLines+1) + if len(lines) <= maxLines { + return s + } + return strings.Join(lines[:maxLines], "\n") +} + func normalizeLineRange(startLine, endLine, totalLines int) (int, int) { start := max(startLine-1, 0) end := min(endLine, totalLines) @@ -507,6 +588,17 @@ Auto-indexes if the index is stale or empty. Tip: If a search returns no results, retry with a lower min_score (e.g. 0.0 or -1) before trying a completely different query.`, }, indexers.handleSemanticSearch) + mcp.AddTool(server, &mcp.Tool{ + Name: "health_check", + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + Title: "Embedding Service Health Check", + }, + Description: `Check if the configured embedding service (Ollama or LM Studio) is reachable and healthy. + +Reports backend type, host, model name, and connection status. Use this to diagnose embedding failures or verify service availability.`, + }, indexers.handleHealthCheck) + mcp.AddTool(server, &mcp.Tool{ Name: "index_status", Annotations: &mcp.ToolAnnotations{ diff --git a/cmd/uninstall.go b/cmd/uninstall.go deleted file mode 100644 index 09dd1fd3..00000000 --- a/cmd/uninstall.go +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright 2026 Aeneas Rekkas -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/aeneasr/lumen/internal/config" - "github.com/spf13/cobra" -) - -func init() { - uninstallCmd.Flags().Bool("dry-run", false, "print actions without executing them") - uninstallCmd.Flags().Bool("no-mcp", false, "skip MCP removal") - uninstallCmd.Flags().Bool("no-rules", false, "skip rules file removal") - uninstallCmd.Flags().Bool("no-hooks", false, "skip SessionStart hook removal") - uninstallCmd.Flags().Bool("purge-data", false, "remove ALL lumen index data (~/.local/share/lumen/)") - rootCmd.AddCommand(uninstallCmd) -} - -var uninstallCmd = &cobra.Command{ - Use: "uninstall", - Short: "Remove lumen MCP server registration, code search directives, and optionally index data", - Args: cobra.NoArgs, - RunE: runUninstall, -} - -func runUninstall(cmd *cobra.Command, args []string) error { - mcpName := filepath.Base(os.Args[0]) - dryRun, _ := cmd.Flags().GetBool("dry-run") - noMCP, _ := cmd.Flags().GetBool("no-mcp") - noRules, _ := cmd.Flags().GetBool("no-rules") - noHooks, _ := cmd.Flags().GetBool("no-hooks") - purgeData, _ := cmd.Flags().GetBool("purge-data") - - // Phase 1: Remove MCP registration - if !noMCP { - if err := removeMCP(mcpName, dryRun); err != nil { - return err - } - } - - // Phase 2: Remove rules file - if !noRules { - if err := removeRulesFile(mcpName, dryRun); err != nil { - return err - } - } - - // Phase 3: Remove SessionStart hook - if !noHooks { - if err := removeHook(mcpName, dryRun); err != nil { - return err - } - } - - // Phase 4: Purge index data - if purgeData { - if err := purgeIndexData(dryRun); err != nil { - return err - } - } - - return nil -} - -// --- Phase 1: Remove MCP registration --- - -func removeMCP(mcpName string, dryRun bool) error { - fmt.Fprintln(os.Stderr, "Removing MCP server registration...") - - claudeErr := unregisterClaudeCode(mcpName, dryRun) - codexErr := unregisterCodex(mcpName, dryRun) - - if claudeErr != nil && !isNotFound(claudeErr) { - fmt.Fprintf(os.Stderr, " Warning: claude removal failed: %v\n", claudeErr) - } - if codexErr != nil && !isNotFound(codexErr) { - fmt.Fprintf(os.Stderr, " Warning: codex removal failed: %v\n", codexErr) - } - - return nil -} - -func unregisterClaudeCode(mcpName string, dryRun bool) error { - if _, err := exec.LookPath("claude"); err != nil { - fmt.Fprintf(os.Stderr, " ! Claude Code (claude not in PATH — skipping)\n") - return err - } - - args := []string{"mcp", "remove", mcpName} - cmdStr := "claude " + strings.Join(args, " ") - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] %s\n", cmdStr) - return nil - } - - out, err := exec.Command("claude", args...).CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %s", cmdStr, strings.TrimSpace(string(out))) - } - fmt.Fprintf(os.Stderr, " ✓ Claude Code (%s)\n", cmdStr) - return nil -} - -func unregisterCodex(mcpName string, dryRun bool) error { - if _, err := exec.LookPath("codex"); err != nil { - // Codex not in PATH: skip silently - return err - } - - args := []string{"mcp", "remove", mcpName} - cmdStr := "codex " + strings.Join(args, " ") - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] %s\n", cmdStr) - return nil - } - - out, err := exec.Command("codex", args...).CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %s", cmdStr, strings.TrimSpace(string(out))) - } - fmt.Fprintf(os.Stderr, " ✓ Codex (%s)\n", cmdStr) - return nil -} - -// --- Phase 2: Remove rules file --- - -func removeRulesFile(mcpName string, dryRun bool) error { - targetFile := rulesFilePath(mcpName) - - fmt.Fprintln(os.Stderr, "\nRemoving rules file...") - - if !fileExists(targetFile) { - fmt.Fprintf(os.Stderr, " %s does not exist — nothing to remove.\n", targetFile) - return nil - } - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] Would delete %s\n", targetFile) - return nil - } - - if err := os.Remove(targetFile); err != nil { - return fmt.Errorf("remove rules file: %w", err) - } - fmt.Fprintf(os.Stderr, " ✓ Deleted %s\n", targetFile) - return nil -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// --- Phase 3: Remove SessionStart hook --- - -func removeHook(mcpName string, dryRun bool) error { - binaryPath, err := os.Executable() - if err != nil { - return fmt.Errorf("resolve binary path: %w", err) - } - - settingsPath := claudeSettingsPath() - - fmt.Fprintln(os.Stderr, "\nRemoving SessionStart hook...") - - if !fileExists(settingsPath) { - fmt.Fprintf(os.Stderr, " %s does not exist — nothing to remove.\n", settingsPath) - return nil - } - - if dryRun { - fmt.Fprintf(os.Stderr, " [dry-run] Would remove SessionStart hook from %s\n", settingsPath) - return nil - } - - settings, err := readSettings(settingsPath) - if err != nil { - return err - } - - if removeSessionStartHook(settings, binaryPath, mcpName) { - if err := writeSettings(settingsPath, settings); err != nil { - return err - } - fmt.Fprintf(os.Stderr, " ✓ Removed SessionStart hook from %s\n", settingsPath) - } else { - fmt.Fprintf(os.Stderr, " No lumen SessionStart hook found — nothing to remove.\n") - } - - return nil -} - -// removeSessionStartHook removes any SessionStart hook entries whose command -// references the given binary path or mcpName. Returns true if any were removed. -func removeSessionStartHook(settings map[string]any, binaryPath, mcpName string) bool { - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - return false - } - - sessionStartHooks, ok := hooks["SessionStart"].([]any) - if !ok { - return false - } - - filtered := make([]any, 0, len(sessionStartHooks)) - for _, entry := range sessionStartHooks { - if !hookEntryMatchesBinary(entry, binaryPath) && !hookEntryMatchesMCPName(entry, mcpName) { - filtered = append(filtered, entry) - } - } - - if len(filtered) == len(sessionStartHooks) { - return false - } - - if len(filtered) == 0 { - delete(hooks, "SessionStart") - } else { - hooks["SessionStart"] = filtered - } - - // Clean up empty hooks map. - if len(hooks) == 0 { - delete(settings, "hooks") - } - - return true -} - -// hookEntryMatchesMCPName returns true if a hook entry's command contains the -// given MCP name in a "hook session-start " pattern. -func hookEntryMatchesMCPName(entry any, mcpName string) bool { - m, ok := entry.(map[string]any) - if !ok { - return false - } - hooksList, ok := m["hooks"].([]any) - if !ok { - return false - } - for _, h := range hooksList { - hm, ok := h.(map[string]any) - if !ok { - continue - } - cmd, ok := hm["command"].(string) - if ok && strings.Contains(cmd, "hook session-start "+mcpName) { - return true - } - } - return false -} - -// --- Phase 4: Purge index data --- - -func purgeIndexData(dryRun bool) error { - dataDir := filepath.Join(config.XDGDataDir(), "lumen") - - if !fileExists(dataDir) { - fmt.Fprintln(os.Stderr, "\nNo index data found — nothing to purge.") - return nil - } - - if dryRun { - fmt.Fprintf(os.Stderr, "\n [dry-run] Would remove %s\n", dataDir) - return nil - } - - if err := os.RemoveAll(dataDir); err != nil { - return fmt.Errorf("remove index data: %w", err) - } - fmt.Fprintf(os.Stderr, "\n ✓ Removed index data (%s)\n", dataDir) - return nil -} diff --git a/cmd/uninstall_test.go b/cmd/uninstall_test.go deleted file mode 100644 index 69ae4c75..00000000 --- a/cmd/uninstall_test.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2026 Aeneas Rekkas -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "path/filepath" - "testing" -) - -func TestRulesFilePath_UninstallDefault(t *testing.T) { - got := rulesFilePath("my-server") - want := filepath.Join(".claude", "rules", "my-server.md") - if len(got) < len(want) || got[len(got)-len(want):] != want { - t.Errorf("expected path ending in %q, got %q", want, got) - } -} - -// --- removeSessionStartHook tests --- - -func TestRemoveSessionStartHook_MatchesByBinaryPath(t *testing.T) { - settings := map[string]any{} - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - - removed := removeSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - if !removed { - t.Error("expected hook to be removed") - } - - // Hooks map should be cleaned up entirely. - if _, ok := settings["hooks"]; ok { - t.Error("expected hooks key to be removed when empty") - } -} - -func TestRemoveSessionStartHook_MatchesByMCPName(t *testing.T) { - settings := map[string]any{ - "hooks": map[string]any{ - "SessionStart": []any{ - map[string]any{ - "matcher": "startup|resume|clear|compact", - "hooks": []any{ - map[string]any{"type": "command", "command": "/different/path hook session-start my-name"}, - }, - }, - }, - }, - } - - removed := removeSessionStartHook(settings, "/not/matching/path", "my-name") - if !removed { - t.Error("expected hook to be removed by MCP name match") - } -} - -func TestRemoveSessionStartHook_PreservesOthers(t *testing.T) { - settings := map[string]any{ - "hooks": map[string]any{ - "SessionStart": []any{ - map[string]any{ - "matcher": "startup", - "hooks": []any{ - map[string]any{"type": "command", "command": "/other/tool hook"}, - }, - }, - }, - }, - } - - addSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - - removed := removeSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - if !removed { - t.Error("expected lumen hook to be removed") - } - - hooks := settings["hooks"].(map[string]any) - sessionStart := hooks["SessionStart"].([]any) - if len(sessionStart) != 1 { - t.Errorf("expected 1 remaining hook, got %d", len(sessionStart)) - } -} - -func TestRemoveSessionStartHook_NoMatch(t *testing.T) { - settings := map[string]any{ - "hooks": map[string]any{ - "SessionStart": []any{ - map[string]any{ - "matcher": "startup", - "hooks": []any{ - map[string]any{"type": "command", "command": "/other/tool hook"}, - }, - }, - }, - }, - } - - removed := removeSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - if removed { - t.Error("expected no removal when nothing matches") - } -} - -func TestRemoveSessionStartHook_EmptySettings(t *testing.T) { - settings := map[string]any{} - removed := removeSessionStartHook(settings, "/usr/local/bin/lumen", "lumen") - if removed { - t.Error("expected no removal from empty settings") - } -} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 00000000..83c827bd --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,22 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/run.sh hook session-start lumen" + }] + } + ], + "PreToolUse": [ + { + "matcher": "Grep|Glob", + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/run.sh hook pre-tool-use lumen" + }] + } + ] + } +} diff --git a/scripts/run.bat b/scripts/run.bat new file mode 100644 index 00000000..03fe0a5d --- /dev/null +++ b/scripts/run.bat @@ -0,0 +1,62 @@ +@echo off +setlocal enabledelayedexpansion + +:: Determine plugin root: prefer env var set by Claude Code plugin system, +:: fall back to deriving from script location. +if defined CLAUDE_PLUGIN_ROOT ( + set "PLUGIN_ROOT=%CLAUDE_PLUGIN_ROOT%" +) else ( + set "PLUGIN_ROOT=%~dp0.." +) + +:: Architecture detection +set "ARCH=amd64" +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "ARCH=arm64" + +:: Environment defaults +if not defined LUMEN_BACKEND set "LUMEN_BACKEND=ollama" +if not defined LUMEN_EMBED_MODEL set "LUMEN_EMBED_MODEL=ordis/jina-embeddings-v2-base-code" + +:: Binary path +set "BINARY=%PLUGIN_ROOT%\bin\lumen-windows-%ARCH%.exe" + +:: Download on first run if binary is missing +if not exist "%BINARY%" ( + set "REPO=aeneasr/lumen" + + if not defined LUMEN_VERSION ( + for /f "tokens=*" %%i in ('curl -sfL "https://api.github.com/repos/!REPO!/releases/latest" ^| findstr "tag_name"') do ( + for /f "tokens=2 delims=:" %%j in ("%%i") do ( + set "VERSION=%%~j" + set "VERSION=!VERSION: =!" + set "VERSION=!VERSION:,=!" + set "VERSION=!VERSION:"=!" + ) + ) + ) else ( + set "VERSION=%LUMEN_VERSION%" + ) + + if "!VERSION!"=="" ( + echo Error: could not determine latest lumen version >&2 + exit /b 1 + ) + + set "ASSET=lumen-!VERSION:~1!-windows-!ARCH!.zip" + set "URL=https://github.com/!REPO!/releases/download/!VERSION!/!ASSET!" + + echo Downloading lumen !VERSION! for windows/!ARCH!... >&2 + if not exist "%PLUGIN_ROOT%\bin" mkdir "%PLUGIN_ROOT%\bin" + + set "TMP=%TEMP%\lumen-download" + mkdir "!TMP!" 2>nul + + curl -sfL "!URL!" -o "!TMP!\archive.zip" + tar -xf "!TMP!\archive.zip" -C "!TMP!" + move "!TMP!\lumen.exe" "%BINARY%" >nul + rmdir /s /q "!TMP!" + + echo Installed lumen to %BINARY% >&2 +) + +"%BINARY%" %* diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 00000000..dfa8a77d --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Determine plugin root: prefer env var set by Claude Code plugin system, +# fall back to deriving from script location (local dev / direct invocation). +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +# Platform detection +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; +esac + +# Environment defaults +export LUMEN_BACKEND="${LUMEN_BACKEND:-ollama}" +export LUMEN_EMBED_MODEL="${LUMEN_EMBED_MODEL:-ordis/jina-embeddings-v2-base-code}" + +# Find binary: check bin/ first, then goreleaser dist/ output, then download +BINARY="" +for candidate in \ + "${PLUGIN_ROOT}/bin/lumen" \ + "${PLUGIN_ROOT}/bin/lumen-${OS}-${ARCH}" \ + "${PLUGIN_ROOT}/dist/lumen_${OS}_${ARCH}"*/lumen; do + if [ -x "$candidate" ]; then + BINARY="$candidate" + break + fi +done + +# Download on first run if no binary found +if [ -z "$BINARY" ]; then + BINARY="${PLUGIN_ROOT}/bin/lumen-${OS}-${ARCH}" + VERSION="${LUMEN_VERSION:-latest}" + REPO="aeneasr/lumen" + + if [ "$VERSION" = "latest" ]; then + VERSION="$(curl -sfL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')" + fi + + if [ -z "$VERSION" ]; then + echo "Error: could not determine latest lumen version" >&2 + exit 1 + fi + + ASSET="lumen-${VERSION#v}-${OS}-${ARCH}.tar.gz" + URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}" + + echo "Downloading lumen ${VERSION} for ${OS}/${ARCH}..." >&2 + mkdir -p "$(dirname "$BINARY")" + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + + curl -sfL "$URL" -o "${TMP}/archive.tar.gz" + tar -xzf "${TMP}/archive.tar.gz" -C "$TMP" + mv "${TMP}/lumen" "$BINARY" + chmod +x "$BINARY" + echo "Installed lumen to ${BINARY}" >&2 +fi + +exec "$BINARY" "$@" diff --git a/skills/doctor/SKILL.md b/skills/doctor/SKILL.md new file mode 100644 index 00000000..5f6cf4cc --- /dev/null +++ b/skills/doctor/SKILL.md @@ -0,0 +1,12 @@ +# Lumen Doctor + +Run a health check on the Lumen semantic search setup for the current project. + +## Steps + +1. Call mcp__lumen__health_check to verify the embedding service is reachable +2. Call mcp__lumen__index_status with path set to the current working directory to check index freshness +3. Report a summary: + - Embedding service: status, backend, host, model + - Index: total files, indexed files, chunks, stale or fresh, last indexed time + - If any issues found, suggest remediation (e.g. "reinstall the lumen plugin") diff --git a/skills/reindex/SKILL.md b/skills/reindex/SKILL.md new file mode 100644 index 00000000..f857570a --- /dev/null +++ b/skills/reindex/SKILL.md @@ -0,0 +1,12 @@ +# Lumen Reindex + +Force a full re-index of the current project's codebase. + +## Steps + +1. Call mcp__lumen__semantic_search with: + - path: the current working directory + - query: "index status" (a simple query to trigger the search) + - force_reindex: true + - summary: true +2. Report how many files were indexed