on main help sign in
feat(config): load user-level context files
6242e4fAmolith
README.md+27
@@ -402,6 +402,33 @@ down. There is a short grace window right after `POST /v1/workspaces` so a
402client that has created the workspace but not yet opened its event stream
402client that has created the workspace but not yet opened its event stream
403does not get reaped before it can attach.
403does not get reaped before it can attach.
404
404
405+### Global context files
406+
407+Crush automatically includes two files for cross-project instructions.
408+
409+- `~/.config/crush/CRUSH.md`: Crush-specific rules that would confuse other
410+ agentic coding tools. If you only use Crush, this is the only one you need to
411+ edit.
412+- `~/.config/AGENTS.md`: generic instructions that other coding tools might
413+ read. Avoid referring to Crush-specific features or workflows here. You
414+ probably only care about this if you use multiple agentic coding tools and
415+ want to share instructions between them.
416+
417+You can customize these paths using the `global_context_paths` option in your
418+configuration:
419+
420+```jsonc
421+{
422+ "$schema": "https://charm.land/crush.json",
423+ "options": {
424+ "global_context_paths": [
425+ "~/path/to/custom/context/file.md",
426+ "/full/path/to/folder/of/files/" // recursively load all .md files in folder
427+ ]
428+ }
429+}
430+```
431+
405### Ignoring Files
432### Ignoring Files
406
433
407Crush respects `.gitignore` files by default, but you can also create a
434Crush respects `.gitignore` files by default, but you can also create a
internal/agent/common_test.go+1
@@ -152,6 +152,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
152 cfg.Config().Options.SkillsPaths = nil
152 cfg.Config().Options.SkillsPaths = nil
153 cfg.Config().Options.DisabledSkills = []string{"crush-config"}
153 cfg.Config().Options.DisabledSkills = []string{"crush-config"}
154 cfg.Config().Options.ContextPaths = nil
154 cfg.Config().Options.ContextPaths = nil
155+ cfg.Config().Options.GlobalContextPaths = nil
155 cfg.Config().LSP = nil
156 cfg.Config().LSP = nil
156
157
157 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
158 systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
internal/agent/prompt/prompt.go+30−21
@@ -29,16 +29,17 @@ type Prompt struct {
29}
29}
30
30
31type PromptDat struct {
31type PromptDat struct {
32− Provider string
32+ Provider string
33− Model string
33+ Model string
34− Config config.Config
34+ Config config.Config
35− WorkingDir string
35+ WorkingDir string
36− IsGitRepo bool
36+ IsGitRepo bool
37− Platform string
37+ Platform string
38− Date string
38+ Date string
39− GitStatus string
39+ GitStatus string
40− ContextFiles []ContextFile
40+ ContextFiles []ContextFile
41+ GlobalContextFiles []ContextFile
41− AvailSkillXML string
42+ AvailSkillXML string
42}
43}
43
44
44type ContextFile struct {
45type ContextFile struct {
@@ -147,22 +148,27 @@ func expandPath(path string, store *config.ConfigStore) string {
147 return path
148 return path
148}
149}
149
150
150−func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
151+// loadContextFiles loads and deduplicates context files from a list of paths.
151− workingDir := cmp.Or(p.workingDir, store.WorkingDir())
152+func loadContextFiles(paths []string, store *config.ConfigStore) map[string][]ContextFile {
152− platform := cmp.Or(p.platform, runtime.GOOS)
153−
154 files := map[string][]ContextFile{}
153 files := map[string][]ContextFile{}
155−
156− cfg := store.Config()
157− for _, pth := range cfg.Options.ContextPaths {
154+ for _, pth := range paths {
158 expanded := expandPath(pth, store)
155 expanded := expandPath(pth, store)
159 pathKey := strings.ToLower(expanded)
156 pathKey := strings.ToLower(expanded)
160 if _, ok := files[pathKey]; ok {
157 if _, ok := files[pathKey]; ok {
161 continue
158 continue
162 }
159 }
163− content := processContextPath(expanded, store)
160+ files[pathKey] = processContextPath(expanded, store)
164− files[pathKey] = content
165 }
161 }
162+ return files
163+}
164+
165+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
166+ workingDir := cmp.Or(p.workingDir, store.WorkingDir())
167+ platform := cmp.Or(p.platform, runtime.GOOS)
168+
169+ cfg := store.Config()
170+ contextFiles := loadContextFiles(cfg.Options.ContextPaths, store)
171+ globalContextFiles := loadContextFiles(cfg.Options.GlobalContextPaths, store)
166
172
167 // Discover and load skills metadata.
173 // Discover and load skills metadata.
168 var availSkillXML string
174 var availSkillXML string
@@ -217,8 +223,11 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, store *
217 }
223 }
218 }
224 }
219
225
220− for _, contextFiles := range files {
226+ for _, files := range contextFiles {
221− data.ContextFiles = append(data.ContextFiles, contextFiles...)
227+ data.ContextFiles = append(data.ContextFiles, files...)
228+ }
229+ for _, files := range globalContextFiles {
230+ data.GlobalContextFiles = append(data.GlobalContextFiles, files...)
222 }
231 }
223 return data, nil
232 return data, nil
224}
233}
internal/agent/templates/coder.md.tpl+16−2
@@ -396,11 +396,25 @@ If a skill mentions scripts, references, or assets, they live in the same folder
396{{end}}
396{{end}}
397
397
398{{if .ContextFiles}}
398{{if .ContextFiles}}
399−<memory>
399+# Project-Specific Context
400+Make sure to follow the instructions in the context below.
401+<project_context>
400{{range .ContextFiles}}
402{{range .ContextFiles}}
401<file path="{{.Path}}">
403<file path="{{.Path}}">
402{{.Content}}
404{{.Content}}
403</file>
405</file>
404{{end}}
406{{end}}
405−</memory>
407+</project_context>
408+{{end}}
409+{{if .GlobalContextFiles}}
410+
411+# User context
412+The following is personal content added by the user that they'd like you to follow no matter what project you're working in.
413+<user_preferences>
414+{{range .GlobalContextFiles}}
415+<file path="{{.Path}}">
416+{{.Content}}
417+</file>
418+{{end}}
419+</user_preferences>
406{{end}}
420{{end}}
internal/config/config.go+1
@@ -260,6 +260,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
260
260
261type Options struct {
261type Options struct {
262 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
262 ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
263+ GlobalContextPaths []string `json:"global_context_paths,omitempty" jsonschema:"description=Paths to files containing global context information for the AI,default=~/.config/crush/CRUSH.md,default=~/.config/AGENTS.md"`
263 SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
264 SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
264 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
265 TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
265 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
266 Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
internal/config/load.go+11
@@ -417,6 +417,16 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
417 if c.Options.TUI == nil {
417 if c.Options.TUI == nil {
418 c.Options.TUI = &TUIOptions{}
418 c.Options.TUI = &TUIOptions{}
419 }
419 }
420+ if len(c.Options.GlobalContextPaths) == 0 {
421+ crushConfigDir := filepath.Dir(GlobalConfig())
422+ c.Options.GlobalContextPaths = []string{
423+ filepath.Join(crushConfigDir, "CRUSH.md"),
424+ filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
425+ }
426+ }
427+ slices.Sort(c.Options.GlobalContextPaths)
428+ c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
429+
420 if dataDir != "" {
430 if dataDir != "" {
421 c.Options.DataDirectory = dataDir
431 c.Options.DataDirectory = dataDir
422 } else if c.Options.DataDirectory == "" {
432 } else if c.Options.DataDirectory == "" {
@@ -448,6 +458,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
448
458
449 // Add the default context paths if they are not already present
459 // Add the default context paths if they are not already present
450 c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
460 c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
461+
451 slices.Sort(c.Options.ContextPaths)
462 slices.Sort(c.Options.ContextPaths)
452 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
463 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
453
464
schema.json+11
@@ -405,6 +405,17 @@
405 "type": "array",
405 "type": "array",
406 "description": "Paths to files containing context information for the AI"
406 "description": "Paths to files containing context information for the AI"
407 },
407 },
408+ "global_context_paths": {
409+ "items": {
410+ "type": "string"
411+ },
412+ "type": "array",
413+ "description": "Paths to files containing global context information for the AI",
414+ "default": [
415+ "~/.config/crush/CRUSH.md",
416+ "~/.config/AGENTS.md"
417+ ]
418+ },
408 "skills_paths": {
419 "skills_paths": {
409 "items": {
420 "items": {
410 "type": "string",
421 "type": "string",