1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
// 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))
}
// SyncCreate syncs a newly created prompt.
func (s *Syncer) SyncCreate(prompt *promptstore.Prompt) error {
return s.Sync(prompt, OpCreate)
}
// SyncUpdate syncs an updated prompt.
func (s *Syncer) SyncUpdate(prompt *promptstore.Prompt) error {
return s.Sync(prompt, OpUpdate)
}
// 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
}
|