diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-12 09:32:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-12 09:32:26 +0200 |
| commit | cfd02d2874992f7e293d5098bd328a495825a8d4 (patch) | |
| tree | bb241a61ce35c717c16539ab5d4413264514168d | |
| parent | 0cd9db181218eaf0fb1ec1cddcd83035d984e94c (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | cmd/hexai-mcp-server/main.go | 17 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 33 | ||||
| -rw-r--r-- | internal/hexaimcp/run.go | 75 | ||||
| -rw-r--r-- | internal/hexaimcp/run_test.go | 11 | ||||
| -rw-r--r-- | internal/mcp/handlers_test.go | 2 | ||||
| -rw-r--r-- | internal/mcp/server.go | 53 | ||||
| -rw-r--r-- | internal/mcp/server_test.go | 12 | ||||
| -rw-r--r-- | internal/slashcommands/converter.go | 113 | ||||
| -rw-r--r-- | internal/slashcommands/converter_test.go | 273 | ||||
| -rw-r--r-- | internal/slashcommands/syncer.go | 175 | ||||
| -rw-r--r-- | internal/slashcommands/syncer_test.go | 369 |
11 files changed, 1115 insertions, 18 deletions
diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go index 65335f7..acdb1d0 100644 --- a/cmd/hexai-mcp-server/main.go +++ b/cmd/hexai-mcp-server/main.go @@ -17,6 +17,9 @@ func main() { logPath := flag.String("log", defaultLog, "path to log file (optional)") configPath := flag.String("config", "", "path to config file (optional)") promptsDir := flag.String("prompts-dir", "", "path to prompts directory (optional)") + slashCommandSync := flag.Bool("slashcommand-sync", false, "enable slash command sync") + slashCommandDir := flag.String("slashcommand-dir", "", "directory for slash command files") + syncAll := flag.Bool("sync-all", false, "backfill all existing prompts and exit") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() @@ -29,6 +32,20 @@ func main() { if *promptsDir != "" { os.Setenv("HEXAI_MCP_PROMPTS_DIR", *promptsDir) } + if *slashCommandSync { + os.Setenv("HEXAI_MCP_SLASHCOMMAND_SYNC", "true") + } + if *slashCommandDir != "" { + os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", *slashCommandDir) + } + + // Handle backfill operation + if *syncAll { + if err := hexaimcp.RunBackfill(*logPath, *configPath); err != nil { + log.Fatalf("backfill error: %v", err) + } + return + } if err := hexaimcp.Run(*logPath, *configPath, os.Stdin, os.Stdout, os.Stderr); err != nil { log.Fatalf("server error: %v", err) diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 8ae9597..490ed4e 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -126,7 +126,9 @@ type App struct { TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` // MCP: Model Context Protocol server settings - MCPPromptsDir string `json:"-" toml:"-"` // Directory for prompt storage + MCPPromptsDir string `json:"-" toml:"-"` // Directory for prompt storage + MCPSlashCommandSync bool `json:"-" toml:"-"` // Enable slash command sync + MCPSlashCommandDir string `json:"-" toml:"-"` // Directory for slash command files } // CustomAction describes a user-defined code action. @@ -383,7 +385,9 @@ type sectionTmuxEditAgent struct { // sectionMCP configures the MCP server settings. type sectionMCP struct { - PromptsDir string `toml:"prompts_dir"` + PromptsDir string `toml:"prompts_dir"` + SlashCommandSync bool `toml:"slashcommand_sync"` + SlashCommandDir string `toml:"slashcommand_dir"` } type sectionOpenAI struct { @@ -719,6 +723,12 @@ func (fc *fileConfig) toApp() App { if strings.TrimSpace(fc.MCP.PromptsDir) != "" { out.MCPPromptsDir = strings.TrimSpace(fc.MCP.PromptsDir) } + if fc.MCP.SlashCommandSync { + out.MCPSlashCommandSync = fc.MCP.SlashCommandSync + } + if strings.TrimSpace(fc.MCP.SlashCommandDir) != "" { + out.MCPSlashCommandDir = strings.TrimSpace(fc.MCP.SlashCommandDir) + } return out } @@ -1058,6 +1068,16 @@ func (a *App) mergeBasics(other *App) { if other.IgnoreLSPNotify != nil { a.IgnoreLSPNotify = other.IgnoreLSPNotify } + // MCP settings + if s := strings.TrimSpace(other.MCPPromptsDir); s != "" { + a.MCPPromptsDir = s + } + if other.MCPSlashCommandSync { + a.MCPSlashCommandSync = other.MCPSlashCommandSync + } + if s := strings.TrimSpace(other.MCPSlashCommandDir); s != "" { + a.MCPSlashCommandDir = s + } } // mergeSurfaceModels copies per-surface model and temperature overrides. @@ -1599,6 +1619,15 @@ func loadFromEnv(logger *log.Logger) *App { out.MCPPromptsDir = s any = true } + if s := getenv("HEXAI_MCP_SLASHCOMMAND_SYNC"); s != "" { + b := s == "true" || s == "1" + out.MCPSlashCommandSync = b + any = true + } + if s := getenv("HEXAI_MCP_SLASHCOMMAND_DIR"); s != "" { + out.MCPSlashCommandDir = s + any = true + } if !any { return nil diff --git a/internal/hexaimcp/run.go b/internal/hexaimcp/run.go index 6b28a2a..5f687f1 100644 --- a/internal/hexaimcp/run.go +++ b/internal/hexaimcp/run.go @@ -12,6 +12,7 @@ import ( "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/mcp" "codeberg.org/snonux/hexai/internal/promptstore" + "codeberg.org/snonux/hexai/internal/slashcommands" ) // ServerRunner interface allows dependency injection for testing. @@ -25,11 +26,12 @@ type ServerFactory func( w io.Writer, logger *log.Logger, store promptstore.PromptStore, + syncer *slashcommands.Syncer, ) ServerRunner // defaultServerFactory is the production server factory. -func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner { - return mcp.NewServer(r, w, logger, store) +func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner { + return mcp.NewServer(r, w, logger, store, syncer) } // Run starts the MCP server with the given configuration. @@ -76,8 +78,14 @@ func RunWithFactory( return fmt.Errorf("cannot create prompt store: %w", err) } + // Create slash command syncer (optional) + syncer, err := createSyncer(cfg, logger) + if err != nil { + return fmt.Errorf("cannot create syncer: %w", err) + } + // Create and run server - server := factory(stdin, stdout, logger, store) + server := factory(stdin, stdout, logger, store, syncer) if err := server.Run(); err != nil { return fmt.Errorf("server error: %w", err) } @@ -156,3 +164,64 @@ func expandPath(path string) (string, error) { return filepath.Abs(path) } + +// createSyncer creates a slash command syncer from config. +// Returns nil syncer if sync is disabled. +func createSyncer(cfg appconfig.App, logger *log.Logger) (*slashcommands.Syncer, error) { + syncer, err := slashcommands.NewSyncer(cfg) + if err != nil { + return nil, err + } + + if syncer != nil && cfg.MCPSlashCommandSync { + logger.Printf("slash command sync enabled: %s", cfg.MCPSlashCommandDir) + } + + return syncer, nil +} + +// RunBackfill performs a one-time sync of all prompts and exits. +func RunBackfill(logPath, configPath string) error { + logger, err := setupLogger(logPath) + if err != nil { + return fmt.Errorf("cannot setup logger: %w", err) + } + defer func() { + if f, ok := logger.Writer().(*os.File); ok && f != os.Stderr { + f.Close() + } + }() + + logger.Printf("hexai-mcp-server backfill starting") + + cfg := loadConfig(logger, configPath) + + // Force enable sync for backfill + if cfg.MCPSlashCommandDir == "" { + return fmt.Errorf("commands directory not configured (use --slashcommand-dir)") + } + cfg.MCPSlashCommandSync = true + + syncer, err := createSyncer(cfg, logger) + if err != nil { + return fmt.Errorf("cannot create syncer: %w", err) + } + + promptsDir, err := getPromptsDir(cfg) + if err != nil { + return fmt.Errorf("cannot determine prompts directory: %w", err) + } + + store, err := promptstore.NewJSONLStore(promptsDir) + if err != nil { + return fmt.Errorf("cannot create prompt store: %w", err) + } + + logger.Printf("starting backfill sync...") + if err := syncer.SyncAll(store); err != nil { + return fmt.Errorf("backfill failed: %w", err) + } + + logger.Printf("backfill complete") + return nil +} diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go index 2adf678..794fa1f 100644 --- a/internal/hexaimcp/run_test.go +++ b/internal/hexaimcp/run_test.go @@ -15,6 +15,7 @@ import ( "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/mcp" "codeberg.org/snonux/hexai/internal/promptstore" + "codeberg.org/snonux/hexai/internal/slashcommands" ) // mockServerRunner implements ServerRunner for testing @@ -34,8 +35,8 @@ func TestFullProtocolFlow(t *testing.T) { tmpDir := t.TempDir() // Create test server factory - serverFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner { - return mcp.NewServer(r, w, logger, store) + serverFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner { + return mcp.NewServer(r, w, logger, store, syncer) } // Setup I/O pipes @@ -271,7 +272,7 @@ func TestDefaultServerFactory(t *testing.T) { t.Fatalf("NewJSONLStore() error = %v", err) } - server := defaultServerFactory(inBuf, outBuf, logger, store) + server := defaultServerFactory(inBuf, outBuf, logger, store, nil) if server == nil { t.Fatal("defaultServerFactory() returned nil") } @@ -282,7 +283,7 @@ func TestRun(t *testing.T) { logPath := filepath.Join(tmpDir, "test.log") // Create a mock server factory that returns immediately - mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner { + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner { return &mockServerRunner{ runFunc: func() error { return nil // Exit immediately @@ -315,7 +316,7 @@ func TestRunWithFactory_ServerError(t *testing.T) { logPath := filepath.Join(tmpDir, "test.log") // Create a mock server factory that returns an error - mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner { + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner { return &mockServerRunner{ runFunc: func() error { return fmt.Errorf("mock server error") diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go index 1c74f98..2a4f821 100644 --- a/internal/mcp/handlers_test.go +++ b/internal/mcp/handlers_test.go @@ -871,7 +871,7 @@ func TestServer_Run_InvalidJSON(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) // Write invalid JSON msg := []byte(`{invalid json}`) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 58de01d..83f75e8 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -13,6 +13,7 @@ import ( "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/promptstore" + "codeberg.org/snonux/hexai/internal/slashcommands" ) // Server implements an MCP server over stdio using JSON-RPC 2.0. @@ -23,6 +24,7 @@ type Server struct { outMu sync.Mutex logger *log.Logger store promptstore.PromptStore + syncer *slashcommands.Syncer initialized bool mu sync.RWMutex @@ -32,12 +34,13 @@ type Server struct { // NewServer creates a new MCP server with the given store and I/O streams. // The store provides access to prompts; logger is used for debugging. -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) *Server { +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) *Server { s := &Server{ in: bufio.NewReader(r), out: w, logger: logger, store: store, + syncer: syncer, } // Initialize dispatch table @@ -354,6 +357,14 @@ func (s *Server) handlePromptsCreate(req Request) { } s.logger.Printf("created prompt: %s", params.Name) + + // Sync to slash commands if enabled + if s.syncer != nil { + if err := s.syncer.Sync(prompt, slashcommands.OpCreate); err != nil { + s.logger.Printf("slash command sync failed: %v", err) + } + } + s.sendResponse(req.ID, result) // Notify clients that the prompt list has changed @@ -453,6 +464,14 @@ func (s *Server) handlePromptsUpdate(req Request) { } s.logger.Printf("updated prompt: %s", params.Name) + + // Sync to slash commands if enabled + if s.syncer != nil { + if err := s.syncer.Sync(existing, slashcommands.OpUpdate); err != nil { + s.logger.Printf("slash command sync failed: %v", err) + } + } + s.sendResponse(req.ID, result) // Notify clients that the prompt list has changed @@ -531,6 +550,14 @@ func (s *Server) handlePromptsDelete(req Request) { } s.logger.Printf("deleted prompt: %s", params.Name) + + // Delete slash command file if enabled + if s.syncer != nil { + if err := s.syncer.Delete(params.Name); err != nil { + s.logger.Printf("slash command sync delete failed: %v", err) + } + } + s.sendResponse(req.ID, result) // Notify clients that the prompt list has changed @@ -712,6 +739,14 @@ func (s *Server) callCreatePromptTool(id any, args map[string]interface{}) { } s.logger.Printf("created prompt via tool: %s", params.Name) + + // Sync to slash commands if enabled + if s.syncer != nil { + if err := s.syncer.Sync(prompt, slashcommands.OpCreate); err != nil { + s.logger.Printf("slash command sync failed: %v", err) + } + } + s.sendToolSuccess(id, fmt.Sprintf("Successfully created prompt: %s", params.Name)) // Notify clients that the prompt list has changed @@ -751,6 +786,14 @@ func (s *Server) callUpdatePromptTool(id any, args map[string]interface{}) { } s.logger.Printf("updated prompt via tool: %s", params.Name) + + // Sync to slash commands if enabled + if s.syncer != nil { + if err := s.syncer.Sync(existing, slashcommands.OpUpdate); err != nil { + s.logger.Printf("slash command sync failed: %v", err) + } + } + s.sendToolSuccess(id, fmt.Sprintf("Successfully updated prompt: %s", params.Name)) // Notify clients that the prompt list has changed @@ -774,6 +817,14 @@ func (s *Server) callDeletePromptTool(id any, args map[string]interface{}) { } s.logger.Printf("deleted prompt via tool: %s", name) + + // Delete slash command file if enabled + if s.syncer != nil { + if err := s.syncer.Delete(name); err != nil { + s.logger.Printf("slash command sync delete failed: %v", err) + } + } + s.sendToolSuccess(id, fmt.Sprintf("Successfully deleted prompt: %s", name)) // Notify clients that the prompt list has changed diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 4b43f51..8e3d7b5 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -54,7 +54,7 @@ func createTestServer(t *testing.T, store promptstore.PromptStore) (*Server, *by inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - return NewServer(inBuf, outBuf, logger, store), inBuf, outBuf + return NewServer(inBuf, outBuf, logger, store, nil), inBuf, outBuf } // sendRequest writes a JSON-RPC request as newline-delimited JSON (MCP stdio protocol). @@ -395,7 +395,7 @@ func TestServer_Run(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) err := server.Run() if err != nil { @@ -408,7 +408,7 @@ func TestServer_Run(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) // Send initialize request req := Request{ @@ -465,7 +465,7 @@ func TestServer_ReadMessage(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) // Write a newline-delimited JSON message (MCP stdio protocol) msg := `{"jsonrpc":"2.0","id":1,"method":"test"}` @@ -487,7 +487,7 @@ func TestServer_ReadMessage(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) // Write empty lines followed by a valid message msg := `{"jsonrpc":"2.0","id":1,"method":"test"}` @@ -508,7 +508,7 @@ func TestServer_ReadMessage(t *testing.T) { inBuf := &bytes.Buffer{} outBuf := &bytes.Buffer{} logger := log.New(io.Discard, "", 0) - server := NewServer(inBuf, outBuf, logger, store) + server := NewServer(inBuf, outBuf, logger, store, nil) _, err := server.readMessage() if err != io.EOF { diff --git a/internal/slashcommands/converter.go b/internal/slashcommands/converter.go new file mode 100644 index 0000000..87c77c8 --- /dev/null +++ b/internal/slashcommands/converter.go @@ -0,0 +1,113 @@ +// Summary: Converts MCP prompts to generic slash command Markdown format. +package slashcommands + +import ( + "fmt" + "regexp" + "strings" + + "codeberg.org/snonux/hexai/internal/promptstore" +) + +// ConvertPromptToMarkdown converts an MCP prompt to slash command Markdown. +// Returns a formatted Markdown string suitable for writing to a .md file. +func ConvertPromptToMarkdown(prompt *promptstore.Prompt) string { + var sb strings.Builder + + // Title + sb.WriteString("# ") + sb.WriteString(prompt.Title) + sb.WriteString("\n\n") + + // Description + if prompt.Description != "" { + sb.WriteString(prompt.Description) + sb.WriteString("\n\n") + } + + // Arguments section + if len(prompt.Arguments) > 0 { + sb.WriteString(formatArguments(prompt.Arguments)) + sb.WriteString("\n") + } + + // Template section (first user message) + if len(prompt.Messages) > 0 { + sb.WriteString("## Template\n\n") + sb.WriteString(formatMessages(prompt.Messages)) + sb.WriteString("\n\n") + } + + // Tags section + if len(prompt.Tags) > 0 { + sb.WriteString("## Tags\n\n") + sb.WriteString(strings.Join(prompt.Tags, ", ")) + sb.WriteString("\n\n") + } + + // Footer + sb.WriteString("---\n") + sb.WriteString("*Generated from MCP prompt: ") + sb.WriteString(prompt.Name) + sb.WriteString("*\n") + + return sb.String() +} + +// formatArguments creates the Usage section with argument documentation. +// Returns formatted Markdown listing all arguments with their properties. +func formatArguments(args []promptstore.PromptArgument) string { + var sb strings.Builder + + sb.WriteString("## Usage\n\n") + sb.WriteString("This prompt template accepts the following arguments:\n\n") + + for _, arg := range args { + // Format: - **arg_name** (required/optional): Description + sb.WriteString("- **") + sb.WriteString(arg.Name) + sb.WriteString("** (") + if arg.Required { + sb.WriteString("required") + } else { + sb.WriteString("optional") + } + sb.WriteString("): ") + sb.WriteString(arg.Description) + sb.WriteString("\n") + } + + return sb.String() +} + +// formatMessages extracts the first user message for the Template section. +// Shows users what the prompt template looks like with {{placeholders}}. +func formatMessages(messages []promptstore.PromptMessage) string { + // Find first user message + for _, msg := range messages { + if msg.Role == "user" && msg.Content.Text != "" { + return msg.Content.Text + } + } + return "(No template content)" +} + +// sanitizeFilename ensures prompt name is valid for filename. +// Replaces non-alphanumeric characters with hyphens. +func sanitizeFilename(name string) string { + // Replace any character that's not alphanumeric or underscore with hyphen + re := regexp.MustCompile(`[^a-zA-Z0-9_]+`) + sanitized := re.ReplaceAllString(name, "-") + + // Remove leading/trailing hyphens + sanitized = strings.Trim(sanitized, "-") + + // Convert to lowercase for consistency + return strings.ToLower(sanitized) +} + +// MakeFilename creates a slash command filename with hexai- prefix. +// Returns: hexai-{sanitized_name}.md +func MakeFilename(promptName string) string { + return fmt.Sprintf("hexai-%s.md", sanitizeFilename(promptName)) +} diff --git a/internal/slashcommands/converter_test.go b/internal/slashcommands/converter_test.go new file mode 100644 index 0000000..1614dc0 --- /dev/null +++ b/internal/slashcommands/converter_test.go @@ -0,0 +1,273 @@ +package slashcommands + +import ( + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/promptstore" +) + +func TestConvertPromptToMarkdown_MinimalPrompt(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "minimal", + Title: "Minimal Prompt", + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "# Minimal Prompt") { + t.Error("Markdown should contain title") + } + if !strings.Contains(result, "*Generated from MCP prompt: minimal*") { + t.Error("Markdown should contain footer with prompt name") + } +} + +func TestConvertPromptToMarkdown_WithDescription(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "test", + Title: "Test Prompt", + Description: "This is a test prompt description.", + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "This is a test prompt description.") { + t.Error("Markdown should contain description") + } +} + +func TestConvertPromptToMarkdown_WithArguments(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "args-test", + Title: "Arguments Test", + Arguments: []promptstore.PromptArgument{ + {Name: "required_arg", Description: "A required argument", Required: true}, + {Name: "optional_arg", Description: "An optional argument", Required: false}, + }, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Usage") { + t.Error("Markdown should contain Usage section") + } + if !strings.Contains(result, "**required_arg** (required)") { + t.Error("Markdown should mark required arguments") + } + if !strings.Contains(result, "**optional_arg** (optional)") { + t.Error("Markdown should mark optional arguments") + } + if !strings.Contains(result, "A required argument") { + t.Error("Markdown should contain argument descriptions") + } +} + +func TestConvertPromptToMarkdown_WithMessages(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "msg-test", + Title: "Messages Test", + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "This is the template with {{placeholder}}", + }, + }, + { + Role: "assistant", + Content: promptstore.MessageContent{ + Type: "text", + Text: "This should be ignored", + }, + }, + }, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Template") { + t.Error("Markdown should contain Template section") + } + if !strings.Contains(result, "This is the template with {{placeholder}}") { + t.Error("Markdown should contain user message content") + } + if strings.Contains(result, "This should be ignored") { + t.Error("Markdown should only include first user message in template") + } +} + +func TestConvertPromptToMarkdown_WithTags(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "tags-test", + Title: "Tags Test", + Tags: []string{"coding", "review", "refactor"}, + } + + result := ConvertPromptToMarkdown(prompt) + + if !strings.Contains(result, "## Tags") { + t.Error("Markdown should contain Tags section") + } + if !strings.Contains(result, "coding, review, refactor") { + t.Error("Markdown should contain all tags as comma-separated list") + } +} + +func TestConvertPromptToMarkdown_FullPrompt(t *testing.T) { + prompt := &promptstore.Prompt{ + Name: "full-example", + Title: "Full Example Prompt", + Description: "A complete example with all fields.", + Arguments: []promptstore.PromptArgument{ + {Name: "input", Description: "Input text", Required: true}, + {Name: "format", Description: "Output format", Required: false}, + }, + Messages: []promptstore.PromptMessage{ + { + Role: "user", + Content: promptstore.MessageContent{ + Type: "text", + Text: "Process {{input}} with format {{format}}", + }, + }, + }, + Tags: []string{"example", "test"}, + } + + result := ConvertPromptToMarkdown(prompt) + + // Verify all sections are present in order + sections := []string{ + "# Full Example Prompt", + "A complete example with all fields.", + "## Usage", + "**input** (required)", + "**format** (optional)", + "## Template", + "Process {{input}} with format {{format}}", + "## Tags", + "example, test", + "---", + "*Generated from MCP prompt: full-example*", + } + + for _, section := range sections { + if !strings.Contains(result, section) { + t.Errorf("Markdown missing section: %q", section) + } + } +} + +func TestFormatArguments(t *testing.T) { + args := []promptstore.PromptArgument{ + {Name: "arg1", Description: "First arg", Required: true}, + {Name: "arg2", Description: "Second arg", Required: false}, + } + + result := formatArguments(args) + + if !strings.Contains(result, "## Usage") { + t.Error("formatArguments should include Usage header") + } + if !strings.Contains(result, "**arg1** (required): First arg") { + t.Error("formatArguments should format required arguments correctly") + } + if !strings.Contains(result, "**arg2** (optional): Second arg") { + t.Error("formatArguments should format optional arguments correctly") + } +} + +func TestFormatMessages_FirstUserMessage(t *testing.T) { + messages := []promptstore.PromptMessage{ + {Role: "assistant", Content: promptstore.MessageContent{Text: "Should skip"}}, + {Role: "user", Content: promptstore.MessageContent{Text: "First user message"}}, + {Role: "user", Content: promptstore.MessageContent{Text: "Second user message"}}, + } + + result := formatMessages(messages) + + if result != "First user message" { + t.Errorf("formatMessages() = %q, want %q", result, "First user message") + } +} + +func TestFormatMessages_NoUserMessage(t *testing.T) { + messages := []promptstore.PromptMessage{ + {Role: "assistant", Content: promptstore.MessageContent{Text: "Only assistant"}}, + } + + result := formatMessages(messages) + + if result != "(No template content)" { + t.Errorf("formatMessages() should return placeholder when no user message, got %q", result) + } +} + +func TestFormatMessages_EmptyMessages(t *testing.T) { + messages := []promptstore.PromptMessage{} + + result := formatMessages(messages) + + if result != "(No template content)" { + t.Errorf("formatMessages() should return placeholder for empty messages, got %q", result) + } +} + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple-name", "simple-name"}, + {"Name With Spaces", "name-with-spaces"}, + {"special!@#$chars", "special-chars"}, + {"Multiple___Underscores", "multiple___underscores"}, + {"dots.and.dashes", "dots-and-dashes"}, + {"--leading-trailing--", "leading-trailing"}, + {"UPPERCASE", "uppercase"}, + {"mixed_Case-123", "mixed_case-123"}, + {"unicode-café", "unicode-caf"}, + } + + for _, tt := range tests { + result := sanitizeFilename(tt.input) + if result != tt.expected { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestMakeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "hexai-simple.md"}, + {"with spaces", "hexai-with-spaces.md"}, + {"Special_123", "hexai-special_123.md"}, + {"--trim--", "hexai-trim.md"}, + } + + for _, tt := range tests { + result := MakeFilename(tt.input) + if result != tt.expected { + t.Errorf("MakeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestMakeFilename_AlwaysHasPrefix(t *testing.T) { + result := MakeFilename("test") + if !strings.HasPrefix(result, "hexai-") { + t.Error("MakeFilename should always add hexai- prefix") + } +} + +func TestMakeFilename_AlwaysHasExtension(t *testing.T) { + result := MakeFilename("test") + if !strings.HasSuffix(result, ".md") { + t.Error("MakeFilename should always add .md extension") + } +} 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) + } < |
