diff options
| -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) + } + + 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 +} 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)) + } +} |
