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.go | 175 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 internal/slashcommands/syncer.go (limited to 'internal/slashcommands/syncer.go') diff --git a/internal/slashcommands/syncer.go b/internal/slashcommands/syncer.go new file mode 100644 index 0000000..91dbf50 --- /dev/null +++ b/internal/slashcommands/syncer.go @@ -0,0 +1,175 @@ +// Summary: File syncer for exporting MCP prompts to slash command files. +package slashcommands + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/promptstore" +) + +// Operation represents the type of sync operation. +type Operation int + +const ( + OpCreate Operation = iota + OpUpdate + OpDelete +) + +// Syncer manages syncing MCP prompts to slash command files. +// Thread-safe with mutex protection for concurrent operations. +type Syncer struct { + commandsDir string + enabled bool + mu sync.Mutex +} + +// NewSyncer creates a new syncer and validates the commands directory. +// Returns error if directory cannot be created or is not writable. +func NewSyncer(cfg appconfig.App) (*Syncer, error) { + if !cfg.MCPSlashCommandSync { + return &Syncer{enabled: false}, nil + } + + dir := cfg.MCPSlashCommandDir + if dir == "" { + return nil, fmt.Errorf("commands directory not configured") + } + + // Expand home directory + if len(dir) > 0 && dir[0] == '~' { + home := os.Getenv("HOME") + if home != "" { + dir = filepath.Join(home, dir[1:]) + } + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("cannot create commands directory: %w", err) + } + + return &Syncer{ + commandsDir: dir, + enabled: true, + }, nil +} + +// Sync writes a prompt to disk as hexai-{name}.md. +// Uses atomic write (temp file + rename) for consistency. +func (s *Syncer) Sync(prompt *promptstore.Prompt, op Operation) error { + if !s.enabled { + return nil // Silently skip if disabled + } + + s.mu.Lock() + defer s.mu.Unlock() + + filename := MakeFilename(prompt.Name) + path := filepath.Join(s.commandsDir, filename) + + // Convert prompt to Markdown + markdown := ConvertPromptToMarkdown(prompt) + + // Write atomically: temp file + rename + return s.atomicWrite(path, []byte(markdown)) +} + +// Delete removes hexai-{name}.md file. +// Returns nil if file doesn't exist (idempotent). +func (s *Syncer) Delete(promptName string) error { + if !s.enabled { + return nil // Silently skip if disabled + } + + s.mu.Lock() + defer s.mu.Unlock() + + filename := MakeFilename(promptName) + path := filepath.Join(s.commandsDir, filename) + + // Remove file (ignore if doesn't exist) + err := os.Remove(path) + if os.IsNotExist(err) { + return nil // File already gone, that's fine + } + return err +} + +// SyncAll syncs all prompts from the store to slash commands. +// Used for backfilling existing prompts via --sync-all flag. +func (s *Syncer) SyncAll(store promptstore.PromptStore) error { + if !s.enabled { + return fmt.Errorf("syncer is disabled") + } + + // List all prompts (no pagination, use large limit) + prompts, _, err := store.List("", 1000) + if err != nil { + return fmt.Errorf("cannot list prompts: %w", err) + } + + count := 0 + var errors []error + + for i := range prompts { + if err := s.Sync(&prompts[i], OpCreate); err != nil { + errors = append(errors, fmt.Errorf("%s: %w", prompts[i].Name, err)) + } else { + count++ + } + } + + if len(errors) > 0 { + return fmt.Errorf("synced %d prompts with %d errors: %v", count, len(errors), errors) + } + + return nil +} + +// atomicWrite writes data to a file atomically using temp file + rename. +// This prevents partial writes if interrupted. +func (s *Syncer) atomicWrite(path string, data []byte) error { + // Write to temp file in same directory + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + // Clean up temp file on error + defer func() { + if tmpFile != nil { + tmpFile.Close() + os.Remove(tmpPath) + } + }() + + // Write data + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + + // Sync to disk + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("sync temp file: %w", err) + } + + // Close before rename + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + tmpFile = nil // Prevent deferred close + + // Atomic rename + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + + return nil +} -- cgit v1.2.3