on main help sign in
Merge pull request #140 from charmbracelet/feature/custom-context-paths

allow for custom contextFiles outside of workingDir path

commit426674bef08263d410c8d269dbba645e66e45950 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 11 Jul 2025, 14:02 (1 year ago) parent3cbafd3b6a97d2
3 files changed+163−7
internal/llm/agent/agent.go+2−2 internal/llm/prompt/prompt.go+48−5 internal/llm/prompt/prompt_test.go+113
internal/llm/agent/agent.go+2−2
@@ -149,7 +149,7 @@ func NewAgent(
149149 }
150150 opts := []provider.ProviderClientOption{
151151 provider.WithModel(agentCfg.Model),
152− provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
152+ provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID, config.Get().Options.ContextPaths...)),
153153 }
154154 agentProvider, err := provider.NewProvider(*providerCfg, opts...)
155155 if err != nil {
@@ -827,7 +827,7 @@ func (a *agent) UpdateModel() error {
827827
828828 opts := []provider.ProviderClientOption{
829829 provider.WithModel(a.agentCfg.Model),
830− provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID)),
830+ provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID, cfg.Options.ContextPaths...)),
831831 }
832832
833833 newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
internal/llm/prompt/prompt.go+48−5
@@ -5,6 +5,9 @@ import (
55 "path/filepath"
66 "strings"
77 "sync"
8+
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/env"
811)
912
1013type PromptID string
@@ -21,7 +24,7 @@ func GetPrompt(promptID PromptID, provider string, contextPaths ...string) strin
2124 basePrompt := ""
2225 switch promptID {
2326 case PromptCoder:
24− basePrompt = CoderPrompt(provider)
27+ basePrompt = CoderPrompt(provider, contextPaths...)
2528 case PromptTitle:
2629 basePrompt = TitlePrompt()
2730 case PromptTask:
@@ -38,6 +41,32 @@ func getContextFromPaths(workingDir string, contextPaths []string) string {
3841 return processContextPaths(workingDir, contextPaths)
3942}
4043
44+// expandPath expands ~ and environment variables in file paths
45+func expandPath(path string) string {
46+ // Handle tilde expansion
47+ if strings.HasPrefix(path, "~/") {
48+ homeDir, err := os.UserHomeDir()
49+ if err == nil {
50+ path = filepath.Join(homeDir, path[2:])
51+ }
52+ } else if path == "~" {
53+ homeDir, err := os.UserHomeDir()
54+ if err == nil {
55+ path = homeDir
56+ }
57+ }
58+
59+ // Handle environment variable expansion using the same pattern as config
60+ if strings.HasPrefix(path, "$") {
61+ resolver := config.NewEnvironmentVariableResolver(env.New())
62+ if expanded, err := resolver.ResolveValue(path); err == nil {
63+ path = expanded
64+ }
65+ }
66+
67+ return path
68+}
69+
4170func processContextPaths(workDir string, paths []string) string {
4271 var (
4372 wg sync.WaitGroup
@@ -53,8 +82,23 @@ func processContextPaths(workDir string, paths []string) string {
5382 go func(p string) {
5483 defer wg.Done()
5584
56− if strings.HasSuffix(p, "/") {
57− filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error {
85+ // Expand ~ and environment variables before processing
86+ p = expandPath(p)
87+
88+ // Use absolute path if provided, otherwise join with workDir
89+ fullPath := p
90+ if !filepath.IsAbs(p) {
91+ fullPath = filepath.Join(workDir, p)
92+ }
93+
94+ // Check if the path is a directory using os.Stat
95+ info, err := os.Stat(fullPath)
96+ if err != nil {
97+ return // Skip if path doesn't exist or can't be accessed
98+ }
99+
100+ if info.IsDir() {
101+ filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
58102 if err != nil {
59103 return err
60104 }
@@ -78,8 +122,7 @@ func processContextPaths(workDir string, paths []string) string {
78122 return nil
79123 })
80124 } else {
81− fullPath := filepath.Join(workDir, p)
82−
125+ // It's a file, process it directly
83126 // Check if we've already processed this file (case-insensitive)
84127 lowerPath := strings.ToLower(fullPath)
85128
internal/llm/prompt/prompt_test.gonew file+113
@@ -0,0 +1,113 @@
1+package prompt
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestExpandPath(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ expected func() string
15+ }{
16+ {
17+ name: "regular path unchanged",
18+ input: "/absolute/path",
19+ expected: func() string {
20+ return "/absolute/path"
21+ },
22+ },
23+ {
24+ name: "tilde expansion",
25+ input: "~/documents",
26+ expected: func() string {
27+ home, _ := os.UserHomeDir()
28+ return filepath.Join(home, "documents")
29+ },
30+ },
31+ {
32+ name: "tilde only",
33+ input: "~",
34+ expected: func() string {
35+ home, _ := os.UserHomeDir()
36+ return home
37+ },
38+ },
39+ {
40+ name: "environment variable expansion",
41+ input: "$HOME",
42+ expected: func() string {
43+ return os.Getenv("HOME")
44+ },
45+ },
46+ {
47+ name: "relative path unchanged",
48+ input: "relative/path",
49+ expected: func() string {
50+ return "relative/path"
51+ },
52+ },
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := expandPath(tt.input)
58+ expected := tt.expected()
59+
60+ // Skip test if environment variable is not set
61+ if strings.HasPrefix(tt.input, "$") && expected == "" {
62+ t.Skip("Environment variable not set")
63+ }
64+
65+ if result != expected {
66+ t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, expected)
67+ }
68+ })
69+ }
70+}
71+
72+func TestProcessContextPaths(t *testing.T) {
73+ // Create a temporary directory and file for testing
74+ tmpDir := t.TempDir()
75+ testFile := filepath.Join(tmpDir, "test.txt")
76+ testContent := "test content"
77+
78+ err := os.WriteFile(testFile, []byte(testContent), 0o644)
79+ if err != nil {
80+ t.Fatalf("Failed to create test file: %v", err)
81+ }
82+
83+ // Test with absolute path to file
84+ result := processContextPaths("", []string{testFile})
85+ expected := "# From:" + testFile + "\n" + testContent
86+
87+ if result != expected {
88+ t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected)
89+ }
90+
91+ // Test with directory path (should process all files in directory)
92+ result = processContextPaths("", []string{tmpDir})
93+ if !strings.Contains(result, testContent) {
94+ t.Errorf("processContextPaths with directory path failed to include file content")
95+ }
96+
97+ // Test with tilde expansion (if we can create a file in home directory)
98+ tmpDir = t.TempDir()
99+ t.Setenv("HOME", tmpDir)
100+ homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
101+ err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
102+ if err == nil {
103+ defer os.Remove(homeTestFile) // Clean up
104+
105+ tildeFile := "~/crush_test_file.txt"
106+ result = processContextPaths("", []string{tildeFile})
107+ expected = "# From:" + homeTestFile + "\n" + testContent
108+
109+ if result != expected {
110+ t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected)
111+ }
112+ }
113+}