From cfd02d2874992f7e293d5098bd328a495825a8d4 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 12 Feb 2026 09:32:26 +0200 Subject: feat: add automatic MCP prompt to slash command syncing Adds optional syncing of MCP prompts to Markdown slash command files for AI agents that don't yet support MCP prompts (e.g., Cursor IDE). Features: - Syncs prompts on create/update/delete operations - Configurable via TOML config, environment vars, or CLI flags - Backfill support with --sync-all flag - Thread-safe atomic file writes - Non-fatal sync failures (logged but don't break operations) - Comprehensive test coverage (81.1% total) Configuration: - Config: [mcp] slashcommand_sync = true, slashcommand_dir = "~/.cursor/commands" - Env: HEXAI_MCP_SLASHCOMMAND_SYNC, HEXAI_MCP_SLASHCOMMAND_DIR - CLI: --slashcommand-sync, --slashcommand-dir, --sync-all Fixes config merging bug where project config would reset global MCP settings. Co-Authored-By: Claude Sonnet 4.5 --- internal/slashcommands/syncer_test.go | 369 ++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 internal/slashcommands/syncer_test.go (limited to 'internal/slashcommands/syncer_test.go') diff --git a/internal/slashcommands/syncer_test.go b/internal/slashcommands/syncer_test.go new file mode 100644 index 0000000..7ae13dd --- /dev/null +++ b/internal/slashcommands/syncer_test.go @@ -0,0 +1,369 @@ +package slashcommands + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/promptstore" +) + +func TestNewSyncer_Disabled(t *testing.T) { + cfg := appconfig.App{ + MCPSlashCommandSync: false, + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() with disabled sync failed: %v", err) + } + + if syncer.enabled { + t.Error("NewSyncer() should create disabled syncer when MCPSlashCommandSync is false") + } +} + +func TestNewSyncer_NoDirectory(t *testing.T) { + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: "", + } + + _, err := NewSyncer(cfg) + if err == nil { + t.Error("NewSyncer() should fail when directory is not configured") + } +} + +func TestNewSyncer_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "test-commands") + + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: testDir, + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() failed: %v", err) + } + + if !syncer.enabled { + t.Error("NewSyncer() should create enabled syncer") + } + + // Verify directory was created + if _, err := os.Stat(testDir); os.IsNotExist(err) { + t.Error("NewSyncer() should create commands directory") + } +} + +func TestNewSyncer_ExpandsHomeDirectory(t *testing.T) { + tmpDir := t.TempDir() + home := os.Getenv("HOME") + + // Set temporary HOME for test + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", home) + + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: "~/test-commands", + } + + syncer, err := NewSyncer(cfg) + if err != nil { + t.Fatalf("NewSyncer() failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, "test-commands") + if syncer.commandsDir != expectedDir { + t.Errorf("NewSyncer() commandsDir = %q, want %q", syncer.commandsDir, expectedDir) + } +} + +func TestSync_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + prompt := &promptstore.Prompt{Name: "test"} + err := syncer.Sync(prompt, OpCreate) + if err != nil { + t.Errorf("Sync() with disabled syncer should not error, got: %v", err) + } +} + +func TestSync_Create(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + prompt := &promptstore.Prompt{ + Name: "test-prompt", + Title: "Test Prompt", + Description: "A test prompt", + Arguments: []promptstore.PromptArgument{ + {Name: "arg1", Description: "First argument", Required: true}, + }, + Messages: []promptstore.PromptMessage{ + {Role: "user", Content: promptstore.MessageContent{Type: "text", Text: "Hello {{arg1}}"}}, + }, + Tags: []string{"test", "example"}, + Created: time.Now(), + Updated: time.Now(), + } + + err := syncer.Sync(prompt, OpCreate) + if err != nil { + t.Fatalf("Sync() failed: %v", err) + } + + // Verify file was created + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read synced file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "# Test Prompt") { + t.Error("Synced file should contain prompt title") + } + if !strings.Contains(contentStr, "A test prompt") { + t.Error("Synced file should contain description") + } + if !strings.Contains(contentStr, "Hello {{arg1}}") { + t.Error("Synced file should contain message template") + } + if !strings.Contains(contentStr, "test, example") { + t.Error("Synced file should contain tags") + } +} + +func TestSync_Update(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create initial prompt + prompt := &promptstore.Prompt{ + Name: "test-prompt", + Title: "Original Title", + Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Original"}}}, + } + + if err := syncer.Sync(prompt, OpCreate); err != nil { + t.Fatalf("Initial Sync() failed: %v", err) + } + + // Update prompt + prompt.Title = "Updated Title" + prompt.Messages[0].Content.Text = "Updated" + + if err := syncer.Sync(prompt, OpUpdate); err != nil { + t.Fatalf("Update Sync() failed: %v", err) + } + + // Verify file was updated + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "Updated Title") { + t.Error("Updated file should contain new title") + } + if strings.Contains(contentStr, "Original Title") { + t.Error("Updated file should not contain old title") + } +} + +func TestDelete_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + err := syncer.Delete("test") + if err != nil { + t.Errorf("Delete() with disabled syncer should not error, got: %v", err) + } +} + +func TestDelete_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create a test file + filename := filepath.Join(tmpDir, "hexai-test-prompt.md") + if err := os.WriteFile(filename, []byte("test content"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Delete it + if err := syncer.Delete("test-prompt"); err != nil { + t.Fatalf("Delete() failed: %v", err) + } + + // Verify file was deleted + if _, err := os.Stat(filename); !os.IsNotExist(err) { + t.Error("Delete() should remove the file") + } +} + +func TestDelete_NonExistentFile(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Delete non-existent file (should be idempotent) + err := syncer.Delete("non-existent") + if err != nil { + t.Errorf("Delete() of non-existent file should not error, got: %v", err) + } +} + +func TestSyncAll(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Create a mock store with test prompts + storeDir := t.TempDir() + store, err := promptstore.NewJSONLStore(storeDir) + if err != nil { + t.Fatalf("Failed to create test store: %v", err) + } + + // Add test prompts + prompts := []*promptstore.Prompt{ + {Name: "prompt1", Title: "Prompt 1", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 1"}}}}, + {Name: "prompt2", Title: "Prompt 2", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 2"}}}}, + {Name: "prompt3", Title: "Prompt 3", Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test 3"}}}}, + } + + for _, p := range prompts { + if err := store.Create(p); err != nil { + t.Fatalf("Failed to create test prompt: %v", err) + } + } + + // Sync all + if err := syncer.SyncAll(store); err != nil { + t.Fatalf("SyncAll() failed: %v", err) + } + + // Verify all files were created + for _, p := range prompts { + filename := filepath.Join(tmpDir, MakeFilename(p.Name)) + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Errorf("SyncAll() should create file for prompt %q", p.Name) + } + } +} + +func TestSyncAll_Disabled(t *testing.T) { + syncer := &Syncer{enabled: false} + + err := syncer.SyncAll(nil) + if err == nil { + t.Error("SyncAll() with disabled syncer should return error") + } +} + +func TestAtomicWrite(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + filename := filepath.Join(tmpDir, "test-atomic.md") + content := []byte("test content") + + err := syncer.atomicWrite(filename, content) + if err != nil { + t.Fatalf("atomicWrite() failed: %v", err) + } + + // Verify file was created with correct content + actual, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(actual) != string(content) { + t.Errorf("atomicWrite() content = %q, want %q", actual, content) + } + + // Verify no temp files left behind + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read directory: %v", err) + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".tmp-") { + t.Errorf("atomicWrite() left temp file: %s", entry.Name()) + } + } +} + +func TestConcurrentSync(t *testing.T) { + tmpDir := t.TempDir() + + syncer := &Syncer{ + commandsDir: tmpDir, + enabled: true, + } + + // Sync multiple prompts concurrently + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + prompt := &promptstore.Prompt{ + Name: "prompt-" + string(rune('0'+n)), + Title: "Concurrent Test", + Messages: []promptstore.PromptMessage{{Role: "user", Content: promptstore.MessageContent{Text: "Test"}}}, + } + if err := syncer.Sync(prompt, OpCreate); err != nil { + t.Errorf("Concurrent Sync() failed: %v", err) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify all files were created + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read directory: %v", err) + } + + if len(entries) != 10 { + t.Errorf("Concurrent sync created %d files, want 10", len(entries)) + } +} -- cgit v1.2.3