diff --git a/.gitignore b/.gitignore index 5a03b1d..989d0e6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ data/ test_data/ qdrant_data/ barou/ -.agent/ # Binaries /cmd/agent-runner/agent-runner @@ -59,3 +58,8 @@ temp/ # Scripts scripts/ .ragcode/ +.agent/skills/ + +# Debug logs +startup_debug.txt +windsurf_debug_log.txt diff --git a/cmd/install/main.go b/cmd/install/main.go index cf2a838..fdf2356 100644 --- a/cmd/install/main.go +++ b/cmd/install/main.go @@ -511,9 +511,9 @@ func installBinary() { } var binDir string if runtime.GOOS == "windows" { - binDir = filepath.Join(home, ".local", "share", "ragcode", "bin") + binDir = filepath.Join(home, installDirName, "bin") } else { - binDir = filepath.Join(home, ".local", "share", "ragcode", "bin") + binDir = filepath.Join(home, installDirName, "bin") } if err := os.MkdirAll(binDir, 0755); err != nil { fail(fmt.Sprintf("Could not create bin directory: %v", err)) @@ -588,10 +588,13 @@ func copyFile(src, dst string) error { return err } } - defer destFile.Close() + _, copyErr := io.Copy(destFile, sourceFile) + closeErr := destFile.Close() - _, err = io.Copy(destFile, sourceFile) - return err + if copyErr != nil { + return copyErr + } + return closeErr } // downloadAndExtractBinary fetches the release archive and extracts the binary. @@ -661,11 +664,17 @@ func downloadAndExtractBinary(dest string) bool { warn(fmt.Sprintf("Could not create destination file: %v", err)) return false } - defer outFile.Close() cmd.Stdout = outFile - if err := cmd.Run(); err != nil { - warn(fmt.Sprintf("Failed to extract binary: %v", err)) + runErr := cmd.Run() + closeErr := outFile.Close() + + if runErr != nil { + warn(fmt.Sprintf("Failed to extract binary: %v", runErr)) + return false + } + if closeErr != nil { + warn(fmt.Sprintf("Failed to finalise binary file: %v", closeErr)) return false } @@ -707,10 +716,15 @@ func addToPath(binDir string) { warn(fmt.Sprintf("Could not update shell config: %v", err)) return } - defer f.Close() - if _, err := f.WriteString(fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", binDir)); err != nil { warn(fmt.Sprintf("Could not write to shell config: %v", err)) + if cerr := f.Close(); cerr != nil { + warn(fmt.Sprintf("Could not finalise shell config after write failure: %v", cerr)) + } + return + } + if err := f.Close(); err != nil { + warn(fmt.Sprintf("Could not finalise shell config: %v", err)) } else { success(fmt.Sprintf("Added to %s (restart shell to apply)", shellConfig)) } diff --git a/cmd/rag-code-mcp/main.go b/cmd/rag-code-mcp/main.go index 1c3d7c5..f1dca3f 100644 --- a/cmd/rag-code-mcp/main.go +++ b/cmd/rag-code-mcp/main.go @@ -12,6 +12,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -364,16 +365,6 @@ workspace: } func main() { - // AGGRESSIVE STARTUP DEBUG - f, _ := os.Create("/tmp/ragcode-startup.txt") - cwd, _ := os.Getwd() - exe, _ := os.Executable() - fmt.Fprintf(f, "Time: %s\n", time.Now()) - fmt.Fprintf(f, "Exe: %s\n", exe) - fmt.Fprintf(f, "CWD: %s\n", cwd) - fmt.Fprintf(f, "Args: %v\n", os.Args) - f.Close() - // Define flags configPath := flag.String("config", "config.yaml", "Path to configuration file") ollamaBaseURLFlag := flag.String("ollama-base-url", "", "Ollama base URL (overrides config/env)") @@ -437,7 +428,7 @@ func main() { // Handle update flag if *updateFlag { fmt.Println("Checking for updates...") - info, err := updater.CheckForUpdates(Version) + info, err := updater.CheckForUpdates(context.Background(), Version, true) if err != nil { log.Fatalf("Failed to check for updates: %v", err) } @@ -447,8 +438,26 @@ func main() { } fmt.Printf("Found new version: %s\nDownloading...\n", info.LatestVersion) - tempFile := filepath.Join(os.TempDir(), "ragcode_update.tar.gz") - if err := info.DownloadAndVerify(tempFile); err != nil { + + // Determine extension from asset URL + ext := ".tar.gz" + if strings.HasSuffix(info.AssetURL, ".zip") { + ext = ".zip" + } + // Create a unique temporary file securely + tmp, err := os.CreateTemp("", "ragcode_update_*"+ext) + if err != nil { + log.Fatalf("Failed to create temporary file for update: %v", err) + } + tempFile := tmp.Name() + // We only need the path; close the file descriptor + if err := tmp.Close(); err != nil { + log.Fatalf("Failed to close temporary file for update: %v", err) + } + // Ensure the temporary file is removed after applying the update + defer os.Remove(tempFile) + + if err := info.DownloadAndVerify(context.Background(), tempFile); err != nil { log.Fatalf("Update failed: %v", err) } @@ -477,12 +486,7 @@ func main() { } // Background update check - go func() { - info, err := updater.CheckForUpdates(Version) - if err == nil && info != nil { - logger.Info("🌟 New version available: %s. Run 'rag-code-mcp --update' to upgrade.", info.LatestVersion) - } - }() + triggerBackgroundUpdateCheck() // Apply logging settings from config unless env vars already override them applyLoggingConfig(cfg.Logging) @@ -643,6 +647,11 @@ func main() { indexWorkspaceTool := tools.NewIndexWorkspaceTool(workspaceManager) + listSkillsTool := tools.NewListSkillsTool() + installSkillTool := tools.NewInstallSkillTool(workspaceManager) + checkUpdateTool := tools.NewCheckUpdateTool(Version) + applyUpdateTool := tools.NewApplyUpdateTool(Version) + // Example: use typed ToolHandlerFor for search_code registerSearchCodeToolTyped(server, searchTool, cfg) @@ -655,6 +664,10 @@ func main() { registerAgentTool(server, searchDocsTool, cfg) registerAgentTool(server, hybridTool, cfg) registerAgentTool(server, indexWorkspaceTool, cfg) + registerAgentTool(server, listSkillsTool, cfg) + registerAgentTool(server, installSkillTool, cfg) + registerAgentTool(server, checkUpdateTool, cfg) + registerAgentTool(server, applyUpdateTool, cfg) if err := registerFileResources(server); err != nil { log.Fatalf("Failed to register resources: %v", err) @@ -708,6 +721,9 @@ func registerSearchCodeToolTyped(server *mcp.Server, tool *tools.SearchLocalInde logger.Info("✅ Tool '%s' completed in %v", tool.Name(), duration) + // Trigger background update check (non-blocking) + triggerBackgroundUpdateCheck() + return nil, SearchCodeOutput{Results: result}, nil }) } @@ -751,6 +767,9 @@ func registerAgentTool(server *mcp.Server, tool MCPTool, cfg *config.Config) { logger.Info("✅ Tool '%s' completed in %v", tool.Name(), duration) + // Trigger background update check (non-blocking) + triggerBackgroundUpdateCheck() + return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: result}, @@ -1097,6 +1116,54 @@ func getToolSchema(toolName string) map[string]interface{} { "required": []string{"query"}, } + case "list_skills": + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + + case "install_skill": + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "skill_id": map[string]interface{}{ + "type": "string", + "description": "The ID of the skill to install or uninstall", + }, + "active": map[string]interface{}{ + "type": "boolean", + "description": "True to install the skill, false to uninstall it", + }, + "file_path": map[string]interface{}{ + "type": "string", + "description": "Optional: file path to help detect workspace context", + }, + }, + "required": []string{"skill_id", "active"}, + } + + case "check_update": + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "force": map[string]interface{}{ + "type": "boolean", + "description": "Force check ignoring cache (default: false)", + }, + }, + } + + case "apply_update": + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "force": map[string]interface{}{ + "type": "boolean", + "description": "Force update even if version matches (default: false)", + }, + }, + } + default: return map[string]interface{}{ "type": "object", @@ -1252,6 +1319,7 @@ func ensureIDERules(cfg *config.Config, filePath string) { - Always provide 'file_path' to tools to ensure they detect the correct project context. - Use 'hybrid_search' if looking for exact variable names or error messages. - If the tool says "workspace not indexed", use 'index_workspace' once. +- **Skills System**: Use 'list_skills' to see available AI behaviors and 'install_skill' to enable them in this workspace (e.g., 'ragcode-priority', 'ragcode-update'). ` // 3. Define target rule files @@ -1287,3 +1355,27 @@ func ensureIDERules(cfg *config.Config, filePath string) { } } } + +var ( + lastUpdateCheck time.Time + lastUpdateCheckMutex sync.Mutex +) + +func triggerBackgroundUpdateCheck() { + lastUpdateCheckMutex.Lock() + defer lastUpdateCheckMutex.Unlock() + + // Only check if more than 1 hour passed since last check in THIS session + // to avoid spamming go-routines, while updater.CheckForUpdates handles the 24h logic + if time.Since(lastUpdateCheck) < 1*time.Hour { + return + } + lastUpdateCheck = time.Now() + + go func() { + info, err := updater.CheckForUpdates(context.Background(), Version, false) + if err == nil && info != nil { + logger.Info("🌟 New version available: %s. Run 'rag-code-mcp --update' or use the 'apply_update' tool to upgrade.", info.LatestVersion) + } + }() +} diff --git a/internal/ragcode/indexer.go b/internal/ragcode/indexer.go index c9fd8f3..4875501 100644 --- a/internal/ragcode/indexer.go +++ b/internal/ragcode/indexer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "hash/fnv" + "log" "path/filepath" "strings" @@ -13,6 +14,57 @@ import ( "github.com/doITmagic/rag-code-mcp/internal/memory" ) +// maxEmbedChars is the maximum number of Unicode characters sent to the embedding +// model. Common models (e.g. nomic-embed-text) have an 8 192-token context window +// (~4 chars/token → ~32 768 chars). We use 30 000 to give ~6% headroom and stay +// compatible with smaller-window models. +const maxEmbedChars = 30_000 + +// buildEmbedText constructs the text to embed for a CodeChunk, then truncates it +// to maxChars (rune-safe, UTF-8 correct) to avoid exceeding the model's context +// window. Metadata (docstring, signature) is always preserved in full; only Code +// is truncated when the total exceeds maxChars. +// Returns (text, wasTruncated). +func buildEmbedText(ch codetypes.CodeChunk, maxChars int) (string, bool) { + meta := strings.TrimSpace(strings.Join(filterNonEmpty([]string{ + ch.Docstring, + ch.Signature, + }), "\n\n")) + + var full string + if ch.Code != "" { + if meta != "" { + full = meta + "\n\n" + ch.Code + } else { + full = ch.Code + } + } else { + full = meta + } + + runes := []rune(full) + if len(runes) <= maxChars { + return full, false + } + + // Truncate only the Code portion — keep metadata intact. + metaWithSep := meta + if meta != "" && ch.Code != "" { + metaWithSep = meta + "\n\n" + } + metaRunes := []rune(metaWithSep) + remaining := maxChars - len(metaRunes) + if remaining < 0 { + remaining = 0 + } + codeRunes := []rune(ch.Code) + if remaining > len(codeRunes) { + remaining = len(codeRunes) + } + truncated := metaWithSep + string(codeRunes[:remaining]) + return truncated, true +} + // Indexer indexes CodeChunks into LongTermMemory using an embedding Provider. type Indexer struct { analyzer codetypes.PathAnalyzer @@ -34,14 +86,17 @@ func (i *Indexer) IndexPaths(ctx context.Context, paths []string, sourceTag stri indexed := 0 for _, ch := range chunks { - text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{ - ch.Docstring, - ch.Signature, - ch.Code, - }), "\n\n")) + text, wasTruncated := buildEmbedText(ch, maxEmbedChars) + text = strings.TrimSpace(text) if text == "" { continue } + if wasTruncated { + // Log a warning so users know which symbols were partially indexed. + // The full code is still accessible via rag_read_file_context. + log.Printf("[WARN] embed text truncated for %s (%s:%d-%d) — content exceeds model context window", + ch.Name, filepath.Base(ch.FilePath), ch.StartLine, ch.EndLine) + } emb, err := i.embedder.Embed(ctx, text) if err != nil { diff --git a/internal/ragcode/indexer_embed_test.go b/internal/ragcode/indexer_embed_test.go new file mode 100644 index 0000000..9edfcf8 --- /dev/null +++ b/internal/ragcode/indexer_embed_test.go @@ -0,0 +1,113 @@ +package ragcode + +import ( + "strings" + "testing" + + "github.com/doITmagic/rag-code-mcp/internal/codetypes" +) + +func TestBuildEmbedText_NoTruncation(t *testing.T) { + ch := codetypes.CodeChunk{ + Package: "mypkg", + Name: "MyFunc", + Signature: "func MyFunc() string", + Code: "return \"hello\"", + Docstring: "MyFunc returns hello.", + FilePath: "foo.go", + } + text, truncated := buildEmbedText(ch, maxEmbedChars) + if truncated { + t.Fatal("expected no truncation for small chunk") + } + if !strings.Contains(text, ch.Docstring) { + t.Errorf("embed text missing docstring") + } + if !strings.Contains(text, ch.Signature) { + t.Errorf("embed text missing signature") + } + if !strings.Contains(text, ch.Code) { + t.Errorf("embed text missing code") + } +} + +func TestBuildEmbedText_TruncatesCode(t *testing.T) { + bigCode := strings.Repeat("x", 40_000) + ch := codetypes.CodeChunk{ + Package: "mypkg", + Name: "BigFunc", + Signature: "func BigFunc()", + Code: bigCode, + FilePath: "big.go", + } + limit := 30_000 + text, truncated := buildEmbedText(ch, limit) + if !truncated { + t.Fatal("expected truncation for large code body") + } + runes := []rune(text) + if len(runes) > limit { + t.Errorf("truncated text has %d runes, want <= %d", len(runes), limit) + } + // Signature must survive truncation + if !strings.Contains(text, "func BigFunc()") { + t.Errorf("embed text missing signature after truncation") + } +} + +func TestBuildEmbedText_WithDocstring_TruncatesCode(t *testing.T) { + bigCode := strings.Repeat("y", 40_000) + ch := codetypes.CodeChunk{ + Signature: "func Fn()", + Code: bigCode, + Docstring: "This is a docstring.", + FilePath: "fn.go", + } + limit := 30_000 + text, truncated := buildEmbedText(ch, limit) + if !truncated { + t.Fatal("expected truncation") + } + if !strings.Contains(text, "This is a docstring.") { + t.Errorf("docstring missing after truncation") + } + if len([]rune(text)) > limit { + t.Errorf("text exceeds limit after truncation") + } +} + +func TestBuildEmbedText_ExactlyAtLimit(t *testing.T) { + limit := 100 + // meta = "func Fn()\n\n" → 11 chars + meta := "func Fn()\n\n" + codeLen := limit - len([]rune(meta)) + ch := codetypes.CodeChunk{ + Signature: "func Fn()", + Code: strings.Repeat("a", codeLen), + FilePath: "fn.go", + } + text, truncated := buildEmbedText(ch, limit) + if truncated { + t.Fatalf("expected no truncation at exact boundary, got truncated; len=%d", len([]rune(text))) + } + _ = text +} + +func TestBuildEmbedText_EmptyCode(t *testing.T) { + ch := codetypes.CodeChunk{ + Signature: "func Empty()", + Code: "", + Docstring: "Empty function.", + FilePath: "empty.go", + } + text, truncated := buildEmbedText(ch, maxEmbedChars) + if truncated { + t.Fatal("expected no truncation for empty code") + } + if !strings.Contains(text, "Empty function.") { + t.Errorf("docstring missing") + } + if !strings.Contains(text, "func Empty()") { + t.Errorf("signature missing") + } +} diff --git a/internal/skills/embedded/debugging-guide/SKILL.md b/internal/skills/embedded/debugging-guide/SKILL.md new file mode 100644 index 0000000..6b5b88c --- /dev/null +++ b/internal/skills/embedded/debugging-guide/SKILL.md @@ -0,0 +1,29 @@ +--- +name: debugging-guide +description: How to use ragcode tools for fast root-cause analysis and debugging +--- + +# 🐞 Skill: Debugging with Ragcode + +This skill defines a workflow for using ragcode tools to fix bugs efficiently. + +--- + +## 🔍 The 3-Step Debug Workflow + +### Step 1: Locate the Symptom +- Search for the exact error message using `hybrid_search`. +- If no error message, search semantically for the failing feature using `search_code`. + +### Step 2: Contextualize the Flow +- Find where the failing function is called using `find_implementations`. +- Look at the surrounding logic with `get_code_context` (fetch at least 10 lines before and after). + +### Step 3: Analyze Data Models +- Use `find_type_definition` to check if a struct field might be missing or wrongly typed. +- Use `list_package_exports` to see if there are utility functions already solving the problem. + +--- + +## 💡 Troubleshooting Pro-Tip: +If you are lost, search for "logger" or "Error" in the package to see how other parts of the system handle failures. diff --git a/internal/skills/embedded/go-best-practices/SKILL.md b/internal/skills/embedded/go-best-practices/SKILL.md new file mode 100644 index 0000000..12e8679 --- /dev/null +++ b/internal/skills/embedded/go-best-practices/SKILL.md @@ -0,0 +1,52 @@ +--- +name: go-best-practices +description: Go development patterns, project structure, and idiomatic practices +--- + +# 🐹 Skill: Go Best Practices + +This skill guides the AI in writing high-quality, idiomatic Go code and understanding Go project structures. + +--- + +## 🔗 External References (Source of Truth) +- [Google Go Style Guide](https://google.github.io/styleguide/go/) +- [Effective Go](https://go.dev/doc/effective_go) +- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md) + +--- + +## 🏗️ Project Structure +Most Go projects in this ecosystem follow the Standard Go Project Layout: +- `/cmd`: Main applications/binaries. +- `/internal`: Private packages (not importable by other projects). +- `/pkg`: Public packages (rarely used here, prefer `internal`). +- `/api`: API definitions. + +## ✅ Implementation Patterns + +### 1. Error Handling +- **Check immediately**: Never ignore errors. +- **Wrap errors**: Use `fmt.Errorf("context: %w", err)` to provide trace details. +- **Sentinel Errors**: Define custom errors in the package for specific conditions. + +### 2. Interfaces +- **Small is better**: Prefer `io.Reader` over large monolithic interfaces. +- **Consumer defines**: Define interfaces where they are USED, not where they are implemented. + +### 3. Concurrency +- **Use Channels**: For communication/orchestration. +- **Mutex**: For simple state protection. +- **Context**: Always propagate `context.Context` for cancellation and timeouts. + +### 4. Testing +- **Table-Driven Tests**: Use anonymous structs and `t.Run` for multiple test cases. +- **Package name**: Use `package_test` for external testing (test only the public API). +- **Mocking**: Use interfaces to mock dependencies. Avoid complex mocking frameworks if simple stubs work. +- **Helpers**: Use `t.Helper()` for repetitive assertions. + +--- + +## 🔧 Tools for Go Project Analysis: +- `mcp_ragcode_find_type_definition`: Essential for understanding structs and interfaces. +- `mcp_ragcode_list_package_exports`: Quick overview of a package's public API. diff --git a/internal/skills/embedded/php-laravel/SKILL.md b/internal/skills/embedded/php-laravel/SKILL.md new file mode 100644 index 0000000..884e189 --- /dev/null +++ b/internal/skills/embedded/php-laravel/SKILL.md @@ -0,0 +1,44 @@ +--- +name: php-laravel +description: PHP and Laravel specific patterns, Service Providers, and Eloquent practices +--- + +# 🐘 Skill: PHP & Laravel Patterns + +Guidelines for working with PHP projects, specifically those using the Laravel framework. + +--- + +## 🔗 External References (Source of Truth) +- [Laravel Official Documentation](https://laravel.com/docs) +- [PHP-FIG PEPs (Standards)](https://www.php-fig.org/psr/) +- [Spatie's Guidelines](https://guidelines.spatie.be/) + +--- + +## 🏰 Laravel Architecture + +### 1. Service Providers +- All core logic should be registered in Service Providers. +- Check `app/Providers` for dependency injection setup. + +### 2. Eloquent Models +- Models belong in `app/Models`. +- Use Scopes for reusable query logic. +- Always check for Relationships (hasMany, belongsTo). + +### 3. Controllers & Requests +- Keep controllers thin. Move validation to `FormRequest` classes. +- Move complex logic to `Services` or `Actions`. + +### 4. Testing +- **Pest OR PHPUnit**: Use Pest for a modern API or PHPUnit for classic suites. +- **Feature Tests**: Test behavior from the user's perspective (Routes + DB). +- **Unit Tests**: For pure logic outside the Laravel container. +- **RefreshDatabase**: Use traits to keep tests isolated. + +--- + +## 🔧 Tool Usage: +- `mcp_ragcode_hybrid_search`: Great for finding specific Eloquent model names or Route definitions. +- `mcp_ragcode_list_package_exports`: Use for listing methods in a Laravel Class. diff --git a/internal/skills/embedded/python-best-practices/SKILL.md b/internal/skills/embedded/python-best-practices/SKILL.md new file mode 100644 index 0000000..0384e1d --- /dev/null +++ b/internal/skills/embedded/python-best-practices/SKILL.md @@ -0,0 +1,44 @@ +--- +name: python-best-practices +description: Python patterns, type hinting, and project standards +--- + +# 🐍 Skill: Python Best Practices + +This skill provides directions for writing robust and maintainable Python code. + +--- + +## 🔗 External References (Source of Truth) +- [PEP 8 - Style Guide for Python](https://peps.python.org/pep-0008/) +- [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) +- [Real Python Best Practices](https://realpython.com/python-best-practices/) + +--- + +## 💎 Clean Code Patterns + +### 1. Type Hinting +- Always use `typing` module for function signatures. +- Prefer `list[str]` (Python 3.9+) or `List[str]`. + +### 2. Async usage +- Use `asyncio` for I/O bound tasks. +- Ensure `await` is used correctly with async functions. + +### 3. Docstrings +- Use Google or NumPy style docstrings for classes and public methods. + +### 4. Testing +- **pytest**: Use `pytest` for its powerful fixture system. +- **Hypothesis**: Use for property-based testing if needed. +- **Mocking**: Use `unittest.mock` to isolate components. +- **Coverage**: Aim for high coverage in business logic. + +## 📦 Dependency Management +- Look for `pyproject.toml`, `requirements.txt`, or `setup.py`. + +--- + +## 🔧 Search Tips for Python: +- Use `mcp_ragcode_search_code` with queries like "Python class definition" or "decorator implementation". diff --git a/internal/skills/embedded/ragcode-priority/SKILL.md b/internal/skills/embedded/ragcode-priority/SKILL.md new file mode 100644 index 0000000..1c0f80c --- /dev/null +++ b/internal/skills/embedded/ragcode-priority/SKILL.md @@ -0,0 +1,137 @@ +--- +name: ragcode-priority +description: Prioritize ragcode MCP tools for all code searches in this project +--- + +# 🎯 Skill: Ragcode MCP Priority Usage + +This skill defines **MANDATORY** rules for searching code in the **rag-code-mcp** project. + +--- + +## ✅ MANDATORY RULES + +### 1. Search Hierarchy + +When searching code, ALWAYS follow this order: + +| Priority | Tool | When to Use | +|----------|------|-------------| +| 🥇 **First** | `mcp_ragcode_search_code` | Exploration, understanding concepts, unfamiliar code | +| 🥈 **Second** | `mcp_ragcode_hybrid_search` | Exact match: function names, errors, string literals | +| 🥉 **Third** | `mcp_ragcode_get_function_details` | Complete code of a known function | +| 4️⃣ | `mcp_ragcode_find_type_definition` | Complete struct/interface definition | +| 5️⃣ | `mcp_ragcode_find_implementations` | Where a function is called/implemented | +| 6️⃣ | `mcp_ragcode_list_package_exports` | What a package exports | + +### 2. "Search First" Rule + +**ALWAYS** start with a ragcode search before assuming code structure! + +``` +❌ WRONG: "I know it's in internal/ragcode, I'll open the file directly" +✅ CORRECT: search_code → find exact location → open file +``` + +### 3. Standard Workflow + +``` +Step 1: mcp_ragcode_search_code (understand context) + ↓ +Step 2: mcp_ragcode_get_function_details OR find_type_definition (details) + ↓ +Step 3: view_file (only for specific lines if needed) +``` + +--- + +## ⛔ FORBIDDEN + +### Do NOT use these tools for code search: + +| Tool | Why Forbidden | Exception | +|------|---------------|-----------| +| `grep_search` | No semantic context | ONLY for JavaScript/TypeScript (not indexed yet) | +| `find_by_name` | For files, not content | OK for finding files, not code | +| `view_file` randomly | Waste of time without context | ONLY after ragcode search | + +### Forbidden behaviors: + +1. ❌ **DO NOT assume** code structure without searching +2. ❌ **DO NOT make multiple grep searches** when one ragcode search suffices +3. ❌ **DO NOT navigate manually** through directories to find code +4. ❌ **DO NOT open random files** hoping to find what you need + +--- + +## 🌐 Language Support + +### ✅ Full support (use ONLY ragcode): +- **Go** - complete analyzer, all tools work +- **Python** - complete analyzer +- **PHP/Laravel** - complete analyzer with Laravel support +- **HTML** - basic analyzer + +### ⚠️ Limited support (fallback allowed): +- **JavaScript/TypeScript** - indexing in development + - Try `search_code` FIRST + - If not found, use `grep_search` as backup + +--- + +## 🔧 Recommended Parameters + +### search_code +``` +query: +file_path: /home/razvan/go/src/github.com/doITmagic/rag-code-mcp # optional, for context +limit: 5 # sufficient for most searches +``` + +### hybrid_search +``` +query: +limit: 5 +``` + +### get_function_details +``` +function_name: +package: +``` + +--- + +## 📖 Why ragcode? + +| Aspect | grep_search | ragcode | +|--------|-------------|---------| +| Understands context | ❌ | ✅ | +| Semantic search | ❌ | ✅ | +| Finds similar functions | ❌ | ✅ | +| Returns complete code | ❌ | ✅ | +| Multi-language support | Limited | ✅ | + +**Concrete example:** +- Search "user authentication" with grep: finds only literal string "authentication" +- Search with ragcode: finds login functions, token validation, auth middleware + +--- + +## ⚡ Quick Reference + +``` +🔍 Exploration → search_code +🎯 Exact match → hybrid_search +📝 Function code → get_function_details +🏗️ Type definition → find_type_definition +🔗 Usages → find_implementations +📦 Package contents → list_package_exports +📄 Specific context → get_code_context (with file + lines) +``` + +--- + +## 📚 Examples + +See [examples/search_patterns.md](examples/search_patterns.md) for project-specific examples. diff --git a/internal/skills/embedded/ragcode-priority/examples/search_patterns.md b/internal/skills/embedded/ragcode-priority/examples/search_patterns.md new file mode 100644 index 0000000..85689bb --- /dev/null +++ b/internal/skills/embedded/ragcode-priority/examples/search_patterns.md @@ -0,0 +1,184 @@ +# 📚 Ragcode Search Patterns for rag-code-mcp + +This file contains practical examples for searching the rag-code-mcp codebase. + +--- + +## 🔍 Finding Analyzers + +### Find all language analyzers +``` +mcp_ragcode_search_code + query: "language analyzer implementation CodeAnalyzer" + limit: 10 +``` + +### Find specific language analyzer +``` +# Go analyzer +mcp_ragcode_search_code query: "Go AST parsing analyzer" + +# Python analyzer +mcp_ragcode_search_code query: "Python code analyzer module parsing" + +# PHP/Laravel analyzer +mcp_ragcode_search_code query: "PHP Laravel adapter analyzer" +``` + +### Get analyzer implementation details +``` +mcp_ragcode_get_function_details + function_name: "NewCodeAnalyzer" + package: "golang" +``` + +--- + +## 🏗️ Understanding Types + +### Find Symbol structure +``` +mcp_ragcode_find_type_definition type_name: "Symbol" +``` + +### Find PathAnalyzer interface +``` +mcp_ragcode_find_type_definition type_name: "PathAnalyzer" +``` + +### Find all types in a package +``` +mcp_ragcode_list_package_exports + package: "codetypes" + symbol_type: "type" +``` + +--- + +## 🔗 Finding Usages + +### Where is IndexWorkspace called? +``` +mcp_ragcode_find_implementations symbol_name: "IndexWorkspace" +``` + +### Who implements PathAnalyzer? +``` +mcp_ragcode_find_implementations symbol_name: "PathAnalyzer" +``` + +### Where is Symbol used? +``` +mcp_ragcode_find_implementations symbol_name: "Symbol" +``` + +--- + +## 🎯 Exact Matches + +### Find specific function by name +``` +mcp_ragcode_hybrid_search query: "func IndexWorkspace" +``` + +### Find error handling +``` +mcp_ragcode_hybrid_search query: "return nil, fmt.Errorf" +``` + +### Find specific constant or variable +``` +mcp_ragcode_hybrid_search query: "LanguageGo" +``` + +--- + +## 📦 Exploring Packages + +### What does the ragcode package export? +``` +mcp_ragcode_list_package_exports package: "ragcode" +``` + +### What functions are in workspace package? +``` +mcp_ragcode_list_package_exports + package: "workspace" + symbol_type: "function" +``` + +### What types are in codetypes package? +``` +mcp_ragcode_list_package_exports + package: "codetypes" + symbol_type: "type" +``` + +--- + +## 🔄 Common Workflows + +### Workflow 1: Understanding a feature +``` +# Step 1: Semantic search for the concept +mcp_ragcode_search_code query: "language detection workspace" + +# Step 2: Get the main function details +mcp_ragcode_get_function_details function_name: "DetectLanguages" + +# Step 3: Find where it's used +mcp_ragcode_find_implementations symbol_name: "DetectLanguages" +``` + +### Workflow 2: Debugging an issue +``` +# Step 1: Search for related code +mcp_ragcode_search_code query: "indexing error handling workspace" + +# Step 2: Find the exact error message +mcp_ragcode_hybrid_search query: "workspace not indexed" + +# Step 3: Get context around the error +mcp_ragcode_get_code_context + file_path: "" + start_line: + end_line: +``` + +### Workflow 3: Adding a new feature +``` +# Step 1: Find similar implementations +mcp_ragcode_search_code query: "analyzer implementation for language" + +# Step 2: Understand the interface +mcp_ragcode_find_type_definition type_name: "PathAnalyzer" + +# Step 3: See existing implementations +mcp_ragcode_find_implementations symbol_name: "PathAnalyzer" +``` + +--- + +## ⚠️ JavaScript/TypeScript Fallback + +When searching JS/TS code (not yet fully indexed): + +``` +# Try ragcode first +mcp_ragcode_search_code query: "javascript parser" + +# If no results, fallback to grep +grep_search + SearchPath: "/home/razvan/go/src/github.com/doITmagic/rag-code-mcp" + Query: "javascript" + Includes: ["*.go"] +``` + +--- + +## 📝 Tips + +1. **Be semantic**: Use descriptive queries like "authentication middleware" not just "auth" +2. **Start broad**: Begin with `search_code`, narrow down with specific tools +3. **Use packages**: When function names are ambiguous, specify the package +4. **Check implementations**: Always use `find_implementations` to understand impact before refactoring diff --git a/internal/skills/embedded/ragcode-update/SKILL.md b/internal/skills/embedded/ragcode-update/SKILL.md new file mode 100644 index 0000000..1702f9c --- /dev/null +++ b/internal/skills/embedded/ragcode-update/SKILL.md @@ -0,0 +1,54 @@ +--- +name: ragcode-update +description: Guide for managing ragcode updates, health checks, and AI skills +--- + +# 🛠️ Skill: Ragcode Maintenance & Updates + +This skill instructs the AI on how to keep the `ragcode-mcp` server up to date and healthy. + +--- + +## 🔄 Update Workflow + +### 1. Detection +When you see a notification in the logs like: +`🌟 New version available: X.Y.Z. Run 'apply_update' to upgrade.` + +**You MUST:** +1. Inform the user that a new version is available. +2. Briefly explain that the update includes potential bug fixes or new features. +3. Ask for explicit permission: *"Would you like me to apply the update for you now?"* + +### 2. Manual Check +If the user asks "is there any update?", use the `check_update` tool with `force: true`. + +### 3. Applying Update +When the user says "Yes" or "Apply update": +1. Call `mcp_ragcode_apply_update`. +2. Inform the user that the installation was successful. +3. Remind the user: *"The server needs a restart (or IDE reload) to activate the new version."* + +--- + +## 🧩 Skill Management + +When the user wants to enhance your capabilities: +1. Use `list_skills` to see what is bundled in the binary. +2. Explain what each skill does based on its description. +3. Use `install_skill(skill_id, active=true)` to enable it. + +--- + +## 🏥 Health Checks +If the system feels slow or tools fail consistently: +1. Suggest running the health check (CLI flag `--health`). +2. Check if the workspace is indexed. If not, suggest `index_workspace`. + +--- + +## ⚡ Quick Summary of Tools: +- `check_update`: Check GitHub for new releases. +- `apply_update`: Download and install the latest binary. +- `list_skills`: See available AI behaviors. +- `install_skill`: Toggle a specific behavior on/off. diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000..b30d37a --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,144 @@ +package skills + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +var skillIDRegex = regexp.MustCompile(`^[a-z0-9-]+$`) + +//go:embed embedded/* +var embeddedSkills embed.FS + +// SkillInfo holds the metadata of an embedded skill +type SkillInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ListAvailableSkills scans the embedded filesystem for skills and extracts metadata from SKILL.md +func ListAvailableSkills() ([]SkillInfo, error) { + var available []SkillInfo + + entries, err := embeddedSkills.ReadDir("embedded") + if err != nil { + return nil, fmt.Errorf("failed to read embedded skills: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillID := entry.Name() + info, err := GetSkillMetadata(skillID) + if err != nil { + // Skip skills with invalid metadata but log or handle as needed + continue + } + available = append(available, info) + } + + return available, nil +} + +// GetSkillMetadata extracts metadata from the SKILL.md of a specific skill +func GetSkillMetadata(skillID string) (SkillInfo, error) { + filePath := fmt.Sprintf("embedded/%s/SKILL.md", skillID) + content, err := embeddedSkills.ReadFile(filePath) + if err != nil { + return SkillInfo{}, fmt.Errorf("failed to read SKILL.md for %s: %w", skillID, err) + } + + // Basic parsing of YAML frontmatter + parts := strings.Split(string(content), "---") + if len(parts) < 3 { + return SkillInfo{}, fmt.Errorf("invalid frontmatter in SKILL.md for %s", skillID) + } + + var metadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + } + + if err := yaml.Unmarshal([]byte(parts[1]), &metadata); err != nil { + return SkillInfo{}, fmt.Errorf("failed to parse metadata for %s: %w", skillID, err) + } + + return SkillInfo{ + ID: skillID, + Name: metadata.Name, + Description: metadata.Description, + }, nil +} + +// validateSkillID ensures the skill ID is safe and matches a strict allowlist +func validateSkillID(skillID string) error { + if !skillIDRegex.MatchString(skillID) { + return fmt.Errorf("invalid skill ID: must be lowercase alphanumeric with hyphens (e.g. 'my-skill')") + } + return nil +} + +// InstallSkill copies all files of an embedded skill to the destination directory +func InstallSkill(skillID string, workspaceRoot string) error { + if err := validateSkillID(skillID); err != nil { + return err + } + + destDir := filepath.Join(workspaceRoot, ".agent", "skills", skillID) + // Additional safety check: ensure destDir is within workspaceRoot + // Note: This might be redundant with validateSkillID but good for defense in depth + // However, workspaceRoot might be relative or absolute, so we rely on skillID validation primarily. + + srcDir := "embedded/" + skillID + + // Verify the skill exists in embedded FS before trying to walk + if _, err := embeddedSkills.ReadDir(srcDir); err != nil { + return fmt.Errorf("skill '%s' not found in embedded library", skillID) + } + + return fs.WalkDir(embeddedSkills, srcDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Calculate relative path from skill root + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Join with destination directory + targetPath := filepath.Join(destDir, relPath) + + if d.IsDir() { + return os.MkdirAll(targetPath, 0755) + } + + // Read from embed and write to disk + content, err := embeddedSkills.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(targetPath, content, 0644) + }) +} + +// UninstallSkill removes a skill from the workspace +func UninstallSkill(skillID string, workspaceRoot string) error { + if err := validateSkillID(skillID); err != nil { + return err + } + destDir := filepath.Join(workspaceRoot, ".agent", "skills", skillID) + return os.RemoveAll(destDir) +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000..c89c18c --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,79 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +func TestListAvailableSkills(t *testing.T) { + available, err := ListAvailableSkills() + if err != nil { + t.Fatalf("ListAvailableSkills failed: %v", err) + } + + if len(available) == 0 { + t.Error("Expected at least one available skill, got none") + } + + // Verify we found our priority skill + foundPriority := false + for _, skill := range available { + if skill.ID == "ragcode-priority" { + foundPriority = true + if skill.Name == "" || skill.Description == "" { + t.Errorf("Skill %s has empty name or description", skill.ID) + } + } + } + + if !foundPriority { + t.Error("Skill 'ragcode-priority' not found in available skills") + } +} + +func TestInstallAndUninstallSkill(t *testing.T) { + tempDir := t.TempDir() + + skillID := "ragcode-priority" + + // Test Installation + err := InstallSkill(skillID, tempDir) + if err != nil { + t.Fatalf("InstallSkill failed: %v", err) + } + + // Verify file existence + skillFile := filepath.Join(tempDir, ".agent", "skills", skillID, "SKILL.md") + if _, err := os.Stat(skillFile); os.IsNotExist(err) { + t.Errorf("Skill file %s was not created", skillFile) + } + + // Test Uninstallation + err = UninstallSkill(skillID, tempDir) + if err != nil { + t.Fatalf("UninstallSkill failed: %v", err) + } + + // Verify file removal + if _, err := os.Stat(filepath.Dir(skillFile)); !os.IsNotExist(err) { + t.Errorf("Skill directory %s still exists after uninstallation", filepath.Dir(skillFile)) + } +} + +func TestInstallSkillPathTraversal(t *testing.T) { + tempDir := t.TempDir() + + evilIDs := []string{ + "../foo", + "foo/bar", + "/etc/passwd", + `foo\bar`, + } + + for _, id := range evilIDs { + if err := InstallSkill(id, tempDir); err == nil { + t.Errorf("InstallSkill should have failed for ID '%s'", id) + } + } +} diff --git a/internal/tools/install_skill.go b/internal/tools/install_skill.go new file mode 100644 index 0000000..ba78fc4 --- /dev/null +++ b/internal/tools/install_skill.go @@ -0,0 +1,74 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/doITmagic/rag-code-mcp/internal/skills" + "github.com/doITmagic/rag-code-mcp/internal/workspace" +) + +type InstallSkillTool struct { + workspaceManager *workspace.Manager +} + +func NewInstallSkillTool(workspaceManager *workspace.Manager) *InstallSkillTool { + return &InstallSkillTool{ + workspaceManager: workspaceManager, + } +} + +func (t *InstallSkillTool) Name() string { + return "install_skill" +} + +func (t *InstallSkillTool) Description() string { + return `Installs or uninstalls an AI skill to/from the current workspace. +Param 'skill_id' is the unique identifier of the skill (e.g., 'go-best-practices', 'ragcode-priority'). +Param 'active' (bool) determines if it should be installed (true) or removed (false). +Param 'file_path' (string) is HIGHLY RECOMMENDED to help detect the correct workspace root. + +EXAMPLES: +- mcp_ragcode_install_skill(skill_id="go-best-practices", active=true, file_path="/path/to/project/go.mod") +- mcp_ragcode_install_skill(skill_id="ragcode-priority", active=true, file_path="/path/to/project/README.md")` +} + +func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + active, ok := args["active"].(bool) + if !ok { + return "", fmt.Errorf("parameter 'active' (boolean) is required") + } + + skillID, ok := args["skill_id"].(string) + if !ok || skillID == "" { + return "", fmt.Errorf("parameter 'skill_id' is required") + } + + // Detect workspace to know where to install the skill + workspaceInfo, err := t.workspaceManager.DetectWorkspace(args) + if err != nil { + return "", fmt.Errorf("could not detect workspace for skill installation: %w\n\n"+ + "TIP: If automatic detection fails, please provide an explicit 'file_path' or 'workspace_root' "+ + "to a directory in your project containing workspace markers (like .git, go.mod, package.json).", err) + } + + workspaceRoot := workspaceInfo.Root + + if active { + err = skills.InstallSkill(skillID, workspaceRoot) + if err != nil { + return "", fmt.Errorf("failed to install skill %s: %w", skillID, err) + } + return fmt.Sprintf("✅ Skill '%s' has been successfully installed in %s/.agent/skills/%s", skillID, workspaceRoot, skillID), nil + } else { + err = skills.UninstallSkill(skillID, workspaceRoot) + if err != nil { + return "", fmt.Errorf("failed to uninstall skill %s: %w", skillID, err) + } + return fmt.Sprintf("🗑️ Skill '%s' has been removed from %s", skillID, workspaceRoot), nil + } +} + +func (t *InstallSkillTool) SetWorkspaceManager(m *workspace.Manager) { + t.workspaceManager = m +} diff --git a/internal/tools/list_skills.go b/internal/tools/list_skills.go new file mode 100644 index 0000000..54911cc --- /dev/null +++ b/internal/tools/list_skills.go @@ -0,0 +1,41 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/doITmagic/rag-code-mcp/internal/skills" +) + +type ListSkillsTool struct{} + +func NewListSkillsTool() *ListSkillsTool { + return &ListSkillsTool{} +} + +func (t *ListSkillsTool) Name() string { + return "list_skills" +} + +func (t *ListSkillsTool) Description() string { + return "Lists all available AI skills bundled within the ragcode binary. These skills can be installed to help the AI better understand the project using ragcode tools." +} + +func (t *ListSkillsTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + available, err := skills.ListAvailableSkills() + if err != nil { + return "", fmt.Errorf("failed to list skills: %w", err) + } + + if len(available) == 0 { + return "No skills found in the binary.", nil + } + + data, err := json.MarshalIndent(available, "", " ") + if err != nil { + return "", fmt.Errorf("failed to format skills list: %w", err) + } + + return string(data), nil +} diff --git a/internal/tools/updates.go b/internal/tools/updates.go new file mode 100644 index 0000000..dbe0b7e --- /dev/null +++ b/internal/tools/updates.go @@ -0,0 +1,96 @@ +package tools + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/doITmagic/rag-code-mcp/internal/updater" +) + +type CheckUpdateTool struct { + version string +} + +func NewCheckUpdateTool(version string) *CheckUpdateTool { + return &CheckUpdateTool{version: version} +} + +func (t *CheckUpdateTool) Name() string { return "check_update" } +func (t *CheckUpdateTool) Description() string { + return "Checks for available ragcode-mcp updates on GitHub and reports if a newer version is available." +} + +func (t *CheckUpdateTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + force := false + if f, ok := args["force"].(bool); ok { + force = f + } + info, err := updater.CheckForUpdates(ctx, t.version, force) + if err != nil { + return "", fmt.Errorf("failed to check for updates: %w", err) + } + + if info == nil { + return fmt.Sprintf("✅ You are using the latest version (%s).", t.version), nil + } + + return fmt.Sprintf("🌟 New version available: %s\nRun 'apply_update' to upgrade.", info.LatestVersion), nil +} + +type ApplyUpdateTool struct { + version string +} + +func NewApplyUpdateTool(version string) *ApplyUpdateTool { + return &ApplyUpdateTool{version: version} +} + +func (t *ApplyUpdateTool) Name() string { return "apply_update" } +func (t *ApplyUpdateTool) Description() string { + return "Downloads and installs the latest version of ragcode-mcp. The server will need to be restarted after completion." +} + +func (t *ApplyUpdateTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + force := true // default to true for apply_update if not specified + if f, ok := args["force"].(bool); ok { + force = f + } + + info, err := updater.CheckForUpdates(ctx, t.version, force) + if err != nil { + return "", err + } + if info == nil { + return "✅ You are already on the latest version.", nil + } + + // Create a unique temporary file securely + // Use pattern with extension based on asset type + ext := ".tar.gz" + if strings.HasSuffix(info.AssetURL, ".zip") { + ext = ".zip" + } + + tempFile, err := os.CreateTemp("", "ragcode_update_*"+ext) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tempPath := tempFile.Name() + if err := tempFile.Close(); err != nil { // Close immediately, DownloadAndVerify re-opens/overwrites it + os.Remove(tempPath) + return "", fmt.Errorf("failed to close temp file: %w", err) + } + defer os.Remove(tempPath) + + if err := info.DownloadAndVerify(ctx, tempPath); err != nil { + return "", fmt.Errorf("failed to download update: %w", err) + } + + if err := updater.ApplyUpdate(tempPath); err != nil { + return "", fmt.Errorf("failed to install update: %w", err) + } + + return fmt.Sprintf("✅ Successfully updated to %s! Please restart your IDE or MCP command to use the new version.", info.LatestVersion), nil +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 6a8ddff..5f98b61 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -1,7 +1,9 @@ package updater import ( + "archive/zip" "bufio" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -13,10 +15,127 @@ import ( "path/filepath" "runtime" "strings" + "sync" + "time" "github.com/Masterminds/semver/v3" ) +type UpdateCache struct { + LastCheck time.Time `json:"last_check"` + LatestVersion string `json:"latest_version"` + UpdateDetails *UpdateInfo `json:"update_details,omitempty"` +} + +var ( + cacheFile = "update_cache.json" + cacheMutex sync.Mutex +) + +func getCachePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get user config dir: %w", err) + } + + appConfigDir := filepath.Join(configDir, "rag-code-mcp") + if err := os.MkdirAll(appConfigDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config dir: %w", err) + } + + return filepath.Join(appConfigDir, cacheFile), nil +} + +func GetCachedUpdate() (*UpdateCache, error) { + cacheMutex.Lock() + defer cacheMutex.Unlock() + + path, err := getCachePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cache UpdateCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + return &cache, nil +} + +func SaveUpdateCache(info *UpdateInfo) error { + cacheMutex.Lock() + defer cacheMutex.Unlock() + + cache := UpdateCache{ + LastCheck: time.Now(), + } + if info != nil { + cache.LatestVersion = info.LatestVersion + cache.UpdateDetails = info + } + + data, err := json.Marshal(cache) + if err != nil { + return err + } + + path, err := getCachePath() + if err != nil { + return err + } + + // Write to a temporary file in the same directory and atomically replace + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, "update_cache_*.tmp") + if err != nil { + return err + } + tmpName := tmpFile.Name() + + // Ensure cleanup if we fail before rename + success := false + defer func() { + if !success { + tmpFile.Close() + os.Remove(tmpName) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + return err + } + if err := tmpFile.Sync(); err != nil { + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + + // On success, rename the temp file to the final path (atomic on same filesystem). + // On Windows, os.Rename may not reliably replace an existing file, so remove it first. + if runtime.GOOS == "windows" { + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to remove existing cache file: %w", err) + } + } + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + + // Set restrictive permissions; treat as best-effort if it fails (notably on some filesystems or Windows) + if err := os.Chmod(path, 0600); err != nil { + fmt.Fprintf(os.Stderr, "[WARN] Failed to set restrictive permissions on cache: %v\n", err) + } + + success = true + return nil +} + const ( GitHubOwner = "doITmagic" GitHubRepo = "rag-code-mcp" @@ -30,18 +149,52 @@ type UpdateInfo struct { } // CheckForUpdates queries GitHub for the latest release and compares it with the current version. -func CheckForUpdates(currentVersion string) (*UpdateInfo, error) { +// If force is false, it returns cached results if available and less than 24 hours old. +func CheckForUpdates(ctx context.Context, currentVersion string, force bool) (*UpdateInfo, error) { if currentVersion == "" || currentVersion == "dev" { return nil, nil // Skip checks for dev versions } + if !force { + cache, err := GetCachedUpdate() + if err == nil && cache != nil { + // Check if cache is fresh (24h) + if time.Since(cache.LastCheck) < 24*time.Hour { + // Still need to compare versions because currentVersion might have changed + curr, errCurr := semver.NewVersion(currentVersion) + if cache.UpdateDetails != nil { + latest, errLatest := semver.NewVersion(cache.UpdateDetails.LatestVersion) + if errCurr == nil && errLatest == nil { + if latest.GreaterThan(curr) { + return cache.UpdateDetails, nil + } + // Already on latest (or newer) version according to cache + return nil, nil + } + // If semver parsing fails for current or cached version, treat as cache miss + // and fall through to the network-based check below. + } else if errCurr == nil { + // Fresh cache with no update details means no update was available last check + return nil, nil + } + } + } + } + curr, err := semver.NewVersion(currentVersion) if err != nil { return nil, fmt.Errorf("invalid current version %q: %w", currentVersion, err) } url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", GitHubOwner, GitHubRepo) - resp, err := http.Get(url) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch latest release: %w", err) } @@ -68,16 +221,15 @@ func CheckForUpdates(currentVersion string) (*UpdateInfo, error) { return nil, fmt.Errorf("invalid latest version %q: %w", release.TagName, err) } - if !latest.GreaterThan(curr) { - return nil, nil // No update needed - } - + // Always record the latest version/tag so callers can cache this information + // even when no update is required. Additional update details (asset URLs, + // checksums) are only populated when a newer version is actually available. info := &UpdateInfo{ LatestVersion: latest.String(), Tag: release.TagName, } - // Match asset for current platform + // Always populate asset and checksum URLs if found in release archiveName := fmt.Sprintf("rag-code-mcp_%s_%s", runtime.GOOS, runtime.GOARCH) if runtime.GOOS == "windows" { archiveName += ".zip" @@ -94,17 +246,30 @@ func CheckForUpdates(currentVersion string) (*UpdateInfo, error) { } } - if info.AssetURL == "" { - return nil, fmt.Errorf("no asset found for platform %s/%s", runtime.GOOS, runtime.GOARCH) + updateAvailable := false + if latest.GreaterThan(curr) { + if info.AssetURL == "" { + return nil, fmt.Errorf("no asset found for platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + updateAvailable = true } - return info, nil + // Always save cache after successful network call, even if update info is nil (meaning we are on latest) + if err := SaveUpdateCache(info); err != nil { + // Log error but don't fail the update check itself + fmt.Fprintf(os.Stderr, "[WARN] Failed to save update cache: %v\n", err) + } + + if updateAvailable { + return info, nil + } + return nil, nil } // DownloadAndVerify downloads the archive and checks its integrity. -func (info *UpdateInfo) DownloadAndVerify(destPath string) error { +func (info *UpdateInfo) DownloadAndVerify(ctx context.Context, destPath string) error { // 1. Download archive - if err := downloadFile(info.AssetURL, destPath); err != nil { + if err := downloadFile(ctx, info.AssetURL, destPath); err != nil { return fmt.Errorf("failed to download asset: %w", err) } @@ -113,7 +278,13 @@ func (info *UpdateInfo) DownloadAndVerify(destPath string) error { return fmt.Errorf("no checksum URL available") } - resp, err := http.Get(info.ChecksumURL) + req, err := http.NewRequestWithContext(ctx, "GET", info.ChecksumURL, nil) + if err != nil { + return fmt.Errorf("failed to create checksum request: %w", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to download checksums: %w", err) } @@ -182,9 +353,9 @@ func ApplyUpdate(archivePath string) error { return fmt.Errorf("failed to extract tar.gz: %w", err) } } else if strings.HasSuffix(archivePath, ".zip") { - // Basic unzip command for windows/linux if available - // Ideally use archive/zip in Go for better cross-platform - return fmt.Errorf("zip extraction not yet implemented in updater") + if err := unzip(archivePath, tempDir); err != nil { + return fmt.Errorf("failed to extract zip: %w", err) + } } newBinPath := filepath.Join(tempDir, binaryName) @@ -197,6 +368,14 @@ func ApplyUpdate(archivePath string) error { // but it's safer to move the old one to a .old suffix and move the new one in. oldBinPath := self + ".old" if err := os.Rename(self, oldBinPath); err != nil { + if runtime.GOOS == "windows" { + // Fallback: write as .new and tell user + newBinPermanent := self + ".new" + if err := moveFile(newBinPath, newBinPermanent); err != nil { + return fmt.Errorf("failed to write new binary: %w", err) + } + return fmt.Errorf("could not replace running binary on Windows: %w. New version saved to %s. Please close the server and rename it manually.", err, filepath.Base(newBinPermanent)) + } return fmt.Errorf("failed to move current binary to %s: %w", oldBinPath, err) } @@ -230,8 +409,15 @@ func moveFile(src, dst string) error { return os.WriteFile(dst, input, 0755) } -func downloadFile(url, dest string) error { - resp, err := http.Get(url) +func downloadFile(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + + // Longer timeout for binary download + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) if err != nil { return err } @@ -245,10 +431,14 @@ func downloadFile(url, dest string) error { if err != nil { return err } - defer out.Close() - _, err = io.Copy(out, resp.Body) - return err + _, copyErr := io.Copy(out, resp.Body) + closeErr := out.Close() + + if copyErr != nil { + return copyErr + } + return closeErr } func calculateSHA256(path string) (string, error) { @@ -270,3 +460,73 @@ func getAssetName(url string) string { parts := strings.Split(url, "/") return parts[len(parts)-1] } + +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + // Normalize ZIP entry name to prevent ZipSlip, including backslash separators + cleanName := strings.ReplaceAll(f.Name, "\\", "/") + cleanName = filepath.Clean(cleanName) + + cleanDest := filepath.Clean(dest) + fpath := filepath.Join(cleanDest, cleanName) + + // Check for ZipSlip (Directory traversal) using filepath.Rel + rel, err := filepath.Rel(cleanDest, fpath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", f.Name) + } + + // Security: Reject non-regular files (symlinks, devices, etc.) + info := f.FileInfo() + if !info.Mode().IsRegular() && !info.IsDir() { + continue + } + + if info.IsDir() { + if err := os.MkdirAll(fpath, 0755); err != nil { + return err + } + continue + } + + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return err + } + + // Use fixed mode 0644 for extracted files for better security consistency + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + if cerr := outFile.Close(); cerr != nil { + return fmt.Errorf("failed to open zip entry: %w; additionally failed to close destination file: %v", err, cerr) + } + return err + } + + _, copyErr := io.Copy(outFile, rc) + + closeErr := outFile.Close() + rcErr := rc.Close() + + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if rcErr != nil { + return rcErr + } + } + return nil +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..7e92de8 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,63 @@ +package updater + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestGetCachePath(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("AppData", tempDir) + t.Setenv("HOME", tempDir) + + path, err := getCachePath() + if err != nil { + t.Fatalf("getCachePath failed: %v", err) + } + + if path == "" { + t.Error("Expected non-empty path") + } + + dir := filepath.Dir(path) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("Cache directory does not exist: %v", err) + } +} + +func TestSaveAndGetUpdateCache(t *testing.T) { + tempDir := t.TempDir() + + // Isolate user config dir to the temp directory + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("AppData", tempDir) + t.Setenv("HOME", tempDir) // Fallback for some systems + + info := &UpdateInfo{ + LatestVersion: "v1.2.3", + Tag: "v1.2.3", + AssetURL: "http://example.com/asset", + ChecksumURL: "http://example.com/checksum", + } + + err := SaveUpdateCache(info) + if err != nil { + t.Fatalf("SaveUpdateCache failed: %v", err) + } + + cached, err := GetCachedUpdate() + if err != nil { + t.Fatalf("GetCachedUpdate failed: %v", err) + } + + if cached.LatestVersion != "v1.2.3" { + t.Errorf("Expected version v1.2.3, got %s", cached.LatestVersion) + } + + if time.Since(cached.LastCheck) > time.Minute { + t.Error("LastCheck is too old") + } +} diff --git a/internal/workspace/state.go b/internal/workspace/state.go index 149720d..6672c9a 100644 --- a/internal/workspace/state.go +++ b/internal/workspace/state.go @@ -55,8 +55,8 @@ func LoadState(path string) (*WorkspaceState, error) { // SaveState saves workspace state to disk func (s *WorkspaceState) Save(path string) error { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { @@ -67,10 +67,15 @@ func (s *WorkspaceState) Save(path string) error { if err != nil { return err } - defer f.Close() s.LastIndexed = time.Now() - return json.NewEncoder(f).Encode(s) + encodeErr := json.NewEncoder(f).Encode(s) + closeErr := f.Close() + + if encodeErr != nil { + return encodeErr + } + return closeErr } // UpdateFile updates the state for a file