summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/hexai-mcp-server/main.go17
-rw-r--r--internal/appconfig/config.go33
-rw-r--r--internal/hexaimcp/run.go75
-rw-r--r--internal/hexaimcp/run_test.go11
-rw-r--r--internal/mcp/handlers_test.go2
-rw-r--r--internal/mcp/server.go53
-rw-r--r--internal/mcp/server_test.go12
-rw-r--r--internal/slashcommands/converter.go113
-rw-r--r--internal/slashcommands/converter_test.go273
-rw-r--r--internal/slashcommands/syncer.go175
-rw-r--r--internal/slashcommands/syncer_test.go369
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))
+ }
+}