1package server
2
3import (
4 _ "embed"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "text/template"
11
12 "shelley.exe.dev/skills"
13)
14
15//go:embed system_prompt.txt
16var systemPromptTemplate string
17
18//go:embed subagent_system_prompt.txt
19var subagentSystemPromptTemplate string
20
21// SystemPromptData contains all the data needed to render the system prompt template
22type SystemPromptData struct {
23 WorkingDirectory string
24 GitInfo *GitInfo
25 Codebase *CodebaseInfo
26 IsExeDev bool
27 IsSudoAvailable bool
28 Hostname string // For exe.dev, the public hostname (e.g., "vmname.exe.xyz")
29 ShelleyDBPath string // Path to the shelley database
30 SkillsXML string // XML block for available skills
31}
32
33// DBPath is the path to the shelley database, set at startup
34var DBPath string
35
36type GitInfo struct {
37 Root string
38}
39
40type CodebaseInfo struct {
41 InjectFiles []string
42 InjectFileContents map[string]string
43 GuidanceFiles []string
44}
45
46// GenerateSystemPrompt generates the system prompt using the embedded template.
47// If workingDir is empty, it uses the current working directory.
48func GenerateSystemPrompt(workingDir string) (string, error) {
49 data, err := collectSystemData(workingDir)
50 if err != nil {
51 return "", fmt.Errorf("failed to collect system data: %w", err)
52 }
53
54 tmpl, err := template.New("system_prompt").Parse(systemPromptTemplate)
55 if err != nil {
56 return "", fmt.Errorf("failed to parse template: %w", err)
57 }
58
59 var buf strings.Builder
60 err = tmpl.Execute(&buf, data)
61 if err != nil {
62 return "", fmt.Errorf("failed to execute template: %w", err)
63 }
64
65 return buf.String(), nil
66}
67
68func collectSystemData(workingDir string) (*SystemPromptData, error) {
69 wd := workingDir
70 if wd == "" {
71 var err error
72 wd, err = os.Getwd()
73 if err != nil {
74 return nil, fmt.Errorf("failed to get working directory: %w", err)
75 }
76 }
77
78 data := &SystemPromptData{
79 WorkingDirectory: wd,
80 }
81
82 // Try to collect git info
83 gitInfo, err := collectGitInfo()
84 if err == nil {
85 data.GitInfo = gitInfo
86 }
87
88 // Collect codebase info
89 codebaseInfo, err := collectCodebaseInfo(wd, gitInfo)
90 if err == nil {
91 data.Codebase = codebaseInfo
92 }
93
94 // Check if running on exe.dev
95 data.IsExeDev = isExeDev()
96
97 // Check sudo availability
98 data.IsSudoAvailable = isSudoAvailable()
99
100 // Get hostname for exe.dev
101 if data.IsExeDev {
102 if hostname, err := os.Hostname(); err == nil {
103 // If hostname doesn't contain dots, add .exe.xyz suffix
104 if !strings.Contains(hostname, ".") {
105 hostname = hostname + ".exe.xyz"
106 }
107 data.Hostname = hostname
108 }
109 }
110
111 // Set shelley database path if it was configured
112 if DBPath != "" {
113 // Convert to absolute path if relative
114 if !filepath.IsAbs(DBPath) {
115 if absPath, err := filepath.Abs(DBPath); err == nil {
116 data.ShelleyDBPath = absPath
117 } else {
118 data.ShelleyDBPath = DBPath
119 }
120 } else {
121 data.ShelleyDBPath = DBPath
122 }
123 }
124
125 // Discover and load skills
126 var gitRoot string
127 if gitInfo != nil {
128 gitRoot = gitInfo.Root
129 }
130 data.SkillsXML = collectSkills(wd, gitRoot)
131
132 return data, nil
133}
134
135func collectGitInfo() (*GitInfo, error) {
136 // Find git root
137 rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
138 rootOutput, err := rootCmd.Output()
139 if err != nil {
140 return nil, err
141 }
142 root := strings.TrimSpace(string(rootOutput))
143
144 return &GitInfo{
145 Root: root,
146 }, nil
147}
148
149func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
150 info := &CodebaseInfo{
151 InjectFiles: []string{},
152 InjectFileContents: make(map[string]string),
153 GuidanceFiles: []string{},
154 }
155
156 // Track seen files to avoid duplicates on case-insensitive file systems
157 seenFiles := make(map[string]bool)
158
159 // Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
160 if home, err := os.UserHomeDir(); err == nil {
161 // Prefer ~/.config/shelley/AGENTS.md (XDG convention)
162 configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
163 if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
164 info.InjectFiles = append(info.InjectFiles, configAgentsFile)
165 info.InjectFileContents[configAgentsFile] = string(content)
166 seenFiles[strings.ToLower(configAgentsFile)] = true
167 }
168 // Also check legacy ~/.shelley/AGENTS.md location
169 shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
170 if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
171 lowerPath := strings.ToLower(shelleyAgentsFile)
172 if !seenFiles[lowerPath] {
173 info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
174 info.InjectFileContents[shelleyAgentsFile] = string(content)
175 seenFiles[lowerPath] = true
176 }
177 }
178 }
179
180 // Determine the root directory to search
181 searchRoot := wd
182 if gitInfo != nil {
183 searchRoot = gitInfo.Root
184 }
185
186 // Find root-level guidance files (case-insensitive)
187 rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
188 for _, file := range rootGuidanceFiles {
189 lowerPath := strings.ToLower(file)
190 if seenFiles[lowerPath] {
191 continue
192 }
193 seenFiles[lowerPath] = true
194
195 content, err := os.ReadFile(file)
196 if err == nil && len(content) > 0 {
197 info.InjectFiles = append(info.InjectFiles, file)
198 info.InjectFileContents[file] = string(content)
199 }
200 }
201
202 // If working directory is different from root, also check working directory
203 if wd != searchRoot {
204 wdGuidanceFiles := findGuidanceFilesInDir(wd)
205 for _, file := range wdGuidanceFiles {
206 lowerPath := strings.ToLower(file)
207 if seenFiles[lowerPath] {
208 continue
209 }
210 seenFiles[lowerPath] = true
211
212 content, err := os.ReadFile(file)
213 if err == nil && len(content) > 0 {
214 info.InjectFiles = append(info.InjectFiles, file)
215 info.InjectFileContents[file] = string(content)
216 }
217 }
218 }
219
220 // Find all guidance files recursively for the directory listing
221 allGuidanceFiles := findAllGuidanceFiles(searchRoot)
222 info.GuidanceFiles = allGuidanceFiles
223
224 return info, nil
225}
226
227func findGuidanceFilesInDir(dir string) []string {
228 // Read directory entries to handle case-insensitive file systems
229 entries, err := os.ReadDir(dir)
230 if err != nil {
231 return nil
232 }
233
234 guidanceNames := map[string]bool{
235 "agent.md": true,
236 "agents.md": true,
237 "claude.md": true,
238 "dear_llm.md": true,
239 "readme.md": true,
240 }
241
242 var found []string
243 seen := make(map[string]bool)
244
245 for _, entry := range entries {
246 if entry.IsDir() {
247 continue
248 }
249 lowerName := strings.ToLower(entry.Name())
250 if guidanceNames[lowerName] && !seen[lowerName] {
251 seen[lowerName] = true
252 found = append(found, filepath.Join(dir, entry.Name()))
253 }
254 }
255 return found
256}
257
258func findAllGuidanceFiles(root string) []string {
259 guidanceNames := map[string]bool{
260 "agent.md": true,
261 "agents.md": true,
262 "claude.md": true,
263 "dear_llm.md": true,
264 }
265
266 var found []string
267 seen := make(map[string]bool)
268
269 filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
270 if err != nil {
271 return nil // Continue on errors
272 }
273 if info.IsDir() {
274 // Skip hidden directories and common ignore patterns
275 if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
276 return filepath.SkipDir
277 }
278 return nil
279 }
280 lowerName := strings.ToLower(info.Name())
281 if guidanceNames[lowerName] {
282 lowerPath := strings.ToLower(path)
283 if !seen[lowerPath] {
284 seen[lowerPath] = true
285 found = append(found, path)
286 }
287 }
288 return nil
289 })
290 return found
291}
292
293func isExeDev() bool {
294 _, err := os.Stat("/exe.dev")
295 return err == nil
296}
297
298// collectSkills discovers skills from default directories and project tree.
299func collectSkills(workingDir, gitRoot string) string {
300 // Start with default directories (user-level skills)
301 dirs := skills.DefaultDirs()
302
303 // Discover user-level skills from configured directories
304 foundSkills := skills.Discover(dirs)
305
306 // Also discover skills anywhere in the project tree
307 treeSkills := skills.DiscoverInTree(workingDir, gitRoot)
308
309 // Merge, avoiding duplicates by path
310 seen := make(map[string]bool)
311 for _, s := range foundSkills {
312 seen[s.Path] = true
313 }
314 for _, s := range treeSkills {
315 if !seen[s.Path] {
316 foundSkills = append(foundSkills, s)
317 seen[s.Path] = true
318 }
319 }
320
321 // Generate XML
322 return skills.ToPromptXML(foundSkills)
323}
324
325func isSudoAvailable() bool {
326 cmd := exec.Command("sudo", "-n", "id")
327 _, err := cmd.CombinedOutput()
328 return err == nil
329}
330
331// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
332type SubagentSystemPromptData struct {
333 WorkingDirectory string
334 GitInfo *GitInfo
335}
336
337// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
338func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
339 wd := workingDir
340 if wd == "" {
341 var err error
342 wd, err = os.Getwd()
343 if err != nil {
344 return "", fmt.Errorf("failed to get working directory: %w", err)
345 }
346 }
347
348 data := &SubagentSystemPromptData{
349 WorkingDirectory: wd,
350 }
351
352 // Try to collect git info
353 gitInfo, err := collectGitInfo()
354 if err == nil {
355 data.GitInfo = gitInfo
356 }
357
358 tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
359 if err != nil {
360 return "", fmt.Errorf("failed to parse subagent template: %w", err)
361 }
362
363 var buf strings.Builder
364 err = tmpl.Execute(&buf, data)
365 if err != nil {
366 return "", fmt.Errorf("failed to execute subagent template: %w", err)
367 }
368
369 return buf.String(), nil
370}