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(wd)
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(dir string) (*GitInfo, error) {
136 // Find git root
137 rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
138 if dir != "" {
139 rootCmd.Dir = dir
140 }
141 rootOutput, err := rootCmd.Output()
142 if err != nil {
143 return nil, err
144 }
145 root := strings.TrimSpace(string(rootOutput))
146
147 return &GitInfo{
148 Root: root,
149 }, nil
150}
151
152func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
153 info := &CodebaseInfo{
154 InjectFiles: []string{},
155 InjectFileContents: make(map[string]string),
156 GuidanceFiles: []string{},
157 }
158
159 // Track seen files to avoid duplicates on case-insensitive file systems
160 seenFiles := make(map[string]bool)
161
162 // Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
163 if home, err := os.UserHomeDir(); err == nil {
164 // Prefer ~/.config/shelley/AGENTS.md (XDG convention)
165 configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
166 if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
167 info.InjectFiles = append(info.InjectFiles, configAgentsFile)
168 info.InjectFileContents[configAgentsFile] = string(content)
169 seenFiles[strings.ToLower(configAgentsFile)] = true
170 }
171 // Also check legacy ~/.shelley/AGENTS.md location
172 shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
173 if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
174 lowerPath := strings.ToLower(shelleyAgentsFile)
175 if !seenFiles[lowerPath] {
176 info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
177 info.InjectFileContents[shelleyAgentsFile] = string(content)
178 seenFiles[lowerPath] = true
179 }
180 }
181 }
182
183 // Determine the root directory to search
184 searchRoot := wd
185 if gitInfo != nil {
186 searchRoot = gitInfo.Root
187 }
188
189 // Find root-level guidance files (case-insensitive)
190 rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
191 for _, file := range rootGuidanceFiles {
192 lowerPath := strings.ToLower(file)
193 if seenFiles[lowerPath] {
194 continue
195 }
196 seenFiles[lowerPath] = true
197
198 content, err := os.ReadFile(file)
199 if err == nil && len(content) > 0 {
200 info.InjectFiles = append(info.InjectFiles, file)
201 info.InjectFileContents[file] = string(content)
202 }
203 }
204
205 // If working directory is different from root, also check working directory
206 if wd != searchRoot {
207 wdGuidanceFiles := findGuidanceFilesInDir(wd)
208 for _, file := range wdGuidanceFiles {
209 lowerPath := strings.ToLower(file)
210 if seenFiles[lowerPath] {
211 continue
212 }
213 seenFiles[lowerPath] = true
214
215 content, err := os.ReadFile(file)
216 if err == nil && len(content) > 0 {
217 info.InjectFiles = append(info.InjectFiles, file)
218 info.InjectFileContents[file] = string(content)
219 }
220 }
221 }
222
223 // Find all guidance files recursively for the directory listing
224 allGuidanceFiles := findAllGuidanceFiles(searchRoot)
225 info.GuidanceFiles = allGuidanceFiles
226
227 return info, nil
228}
229
230func findGuidanceFilesInDir(dir string) []string {
231 // Read directory entries to handle case-insensitive file systems
232 entries, err := os.ReadDir(dir)
233 if err != nil {
234 return nil
235 }
236
237 guidanceNames := map[string]bool{
238 "agent.md": true,
239 "agents.md": true,
240 "claude.md": true,
241 "dear_llm.md": true,
242 "readme.md": true,
243 }
244
245 var found []string
246 seen := make(map[string]bool)
247
248 for _, entry := range entries {
249 if entry.IsDir() {
250 continue
251 }
252 lowerName := strings.ToLower(entry.Name())
253 if guidanceNames[lowerName] && !seen[lowerName] {
254 seen[lowerName] = true
255 found = append(found, filepath.Join(dir, entry.Name()))
256 }
257 }
258 return found
259}
260
261func findAllGuidanceFiles(root string) []string {
262 guidanceNames := map[string]bool{
263 "agent.md": true,
264 "agents.md": true,
265 "claude.md": true,
266 "dear_llm.md": true,
267 }
268
269 var found []string
270 seen := make(map[string]bool)
271
272 filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
273 if err != nil {
274 return nil // Continue on errors
275 }
276 if info.IsDir() {
277 // Skip hidden directories and common ignore patterns
278 if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
279 return filepath.SkipDir
280 }
281 return nil
282 }
283 lowerName := strings.ToLower(info.Name())
284 if guidanceNames[lowerName] {
285 lowerPath := strings.ToLower(path)
286 if !seen[lowerPath] {
287 seen[lowerPath] = true
288 found = append(found, path)
289 }
290 }
291 return nil
292 })
293 return found
294}
295
296func isExeDev() bool {
297 _, err := os.Stat("/exe.dev")
298 return err == nil
299}
300
301// collectSkills discovers skills from default directories, project .skills dirs,
302// and the project tree.
303func collectSkills(workingDir, gitRoot string) string {
304 // Start with default directories (user-level skills)
305 dirs := skills.DefaultDirs()
306
307 // Add .skills directories found in the project tree
308 dirs = append(dirs, skills.ProjectSkillsDirs(workingDir, gitRoot)...)
309
310 // Discover skills from all directories
311 foundSkills := skills.Discover(dirs)
312
313 // Also discover skills anywhere in the project tree
314 treeSkills := skills.DiscoverInTree(workingDir, gitRoot)
315
316 // Merge, avoiding duplicates by path
317 seen := make(map[string]bool)
318 for _, s := range foundSkills {
319 seen[s.Path] = true
320 }
321 for _, s := range treeSkills {
322 if !seen[s.Path] {
323 foundSkills = append(foundSkills, s)
324 seen[s.Path] = true
325 }
326 }
327
328 // Generate XML
329 return skills.ToPromptXML(foundSkills)
330}
331
332func isSudoAvailable() bool {
333 cmd := exec.Command("sudo", "-n", "id")
334 _, err := cmd.CombinedOutput()
335 return err == nil
336}
337
338// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
339type SubagentSystemPromptData struct {
340 WorkingDirectory string
341 GitInfo *GitInfo
342}
343
344// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
345func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
346 wd := workingDir
347 if wd == "" {
348 var err error
349 wd, err = os.Getwd()
350 if err != nil {
351 return "", fmt.Errorf("failed to get working directory: %w", err)
352 }
353 }
354
355 data := &SubagentSystemPromptData{
356 WorkingDirectory: wd,
357 }
358
359 // Try to collect git info
360 gitInfo, err := collectGitInfo(wd)
361 if err == nil {
362 data.GitInfo = gitInfo
363 }
364
365 tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
366 if err != nil {
367 return "", fmt.Errorf("failed to parse subagent template: %w", err)
368 }
369
370 var buf strings.Builder
371 err = tmpl.Execute(&buf, data)
372 if err != nil {
373 return "", fmt.Errorf("failed to execute subagent template: %w", err)
374 }
375
376 return buf.String(), nil
377}