on main help sign in
wip: integrate to existing app
commit7f078a6e20a047ecfc15d1df6081b5c18829d0bb authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 5 Jul 2025, 16:16 (1 year ago) parentd51853c
58 files changed+488−5870
cmd/logs.go+1−1 cmd/schema/main.go−155 crush-schema.json−700 crush.json−1 go.mod+7−9 go.sum−6 internal/app/app.go+2−1 internal/app/lsp.go+2−2 internal/config/config.go+159−1354 internal/config/config_test.go−2075 internal/config/fs.go−71 internal/config/init.go+36−12 pkg/config/load.go → internal/config/load.go+33−2 pkg/config/load_test.go → internal/config/load_test.go+1−1 pkg/config/merge.go → internal/config/merge.go pkg/config/merge_test.go → internal/config/merge_test.go internal/config/provider.go+51−36 internal/config/provider_mock.go−293 internal/config/provider_test.go+52−60 pkg/config/resolve.go → internal/config/resolve.go+1−1 pkg/config/resolve_test.go → internal/config/resolve_test.go+1−1 internal/config/shell.go−73 internal/config/validation_test.go−462 internal/diff/diff.go+1−1 pkg/env/env.go → internal/env/env.go pkg/env/env_test.go → internal/env/env_test.go internal/llm/agent/agent.go+34−50 internal/llm/agent/mcp-tools.go+4−4 internal/llm/prompt/coder.go+4−4 internal/llm/prompt/prompt.go+5−6 internal/llm/prompt/summarizer.go+1−5 internal/llm/prompt/task.go+1−3 internal/llm/prompt/title.go+1−5 internal/llm/provider/anthropic.go+5−5 internal/llm/provider/bedrock.go+5−4 internal/llm/provider/gemini.go+9−8 internal/llm/provider/openai.go+6−9 internal/llm/provider/provider.go+11−11 internal/llm/tools/bash.go+2−2 internal/llm/tools/edit.go+4−4 internal/llm/tools/fetch.go+1−1 internal/llm/tools/glob.go+1−1 internal/llm/tools/grep.go+1−1 internal/llm/tools/ls.go+2−2 internal/llm/tools/view.go+1−1 internal/llm/tools/write.go+2−2 pkg/log/log.go → internal/log/log.go internal/lsp/client.go+2−2 internal/permission/permission.go+1−1 internal/tui/components/chat/header/header.go+3−2 internal/tui/components/chat/messages/messages.go+1−2 internal/tui/components/chat/sidebar/sidebar.go+4−3 internal/tui/components/dialogs/models/models.go+25−21 internal/tui/page/chat/chat.go+2−1 internal/tui/tui.go+3−3 pkg/config/config.go−224 pkg/config/provider.go−93 pkg/config/provider_test.go−73
cmd/logs.go+1−1
@@ -7,7 +7,7 @@ import (
77 "slices"
88 "time"
99
10− "github.com/charmbracelet/crush/pkg/config"
10+ "github.com/charmbracelet/crush/internal/config"
1111 "github.com/charmbracelet/log/v2"
1212 "github.com/nxadm/tail"
1313 "github.com/spf13/cobra"
cmd/schema/main.godeleted−155
@@ -1,155 +0,0 @@
1−package main
2−
3−import (
4− "encoding/json"
5− "fmt"
6− "os"
7−
8− "github.com/charmbracelet/crush/internal/config"
9− "github.com/invopop/jsonschema"
10−)
11−
12−func main() {
13− // Create a new reflector
14− r := &jsonschema.Reflector{
15− // Use anonymous schemas to avoid ID conflicts
16− Anonymous: true,
17− // Expand the root struct instead of referencing it
18− ExpandedStruct: true,
19− AllowAdditionalProperties: true,
20− }
21−
22− // Generate schema for the main Config struct
23− schema := r.Reflect(&config.Config{})
24−
25− // Enhance the schema with additional information
26− enhanceSchema(schema)
27−
28− // Set the schema metadata
29− schema.Version = "https://json-schema.org/draft/2020-12/schema"
30− schema.Title = "Crush Configuration"
31− schema.Description = "Configuration schema for the Crush application"
32−
33− // Pretty print the schema
34− encoder := json.NewEncoder(os.Stdout)
35− encoder.SetIndent("", " ")
36− if err := encoder.Encode(schema); err != nil {
37− fmt.Fprintf(os.Stderr, "Error encoding schema: %v\n", err)
38− os.Exit(1)
39− }
40−}
41−
42−// enhanceSchema adds additional enhancements to the generated schema
43−func enhanceSchema(schema *jsonschema.Schema) {
44− // Add provider enums
45− addProviderEnums(schema)
46−
47− // Add model enums
48− addModelEnums(schema)
49−
50− // Add tool enums
51− addToolEnums(schema)
52−
53− // Add default context paths
54− addDefaultContextPaths(schema)
55−}
56−
57−// addProviderEnums adds provider enums to the schema
58−func addProviderEnums(schema *jsonschema.Schema) {
59− providers := config.Providers()
60− var providerIDs []any
61− for _, p := range providers {
62− providerIDs = append(providerIDs, string(p.ID))
63− }
64−
65− // Add to PreferredModel provider field
66− if schema.Definitions != nil {
67− if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
68− if providerProp, exists := preferredModelDef.Properties.Get("provider"); exists {
69− providerProp.Enum = providerIDs
70− }
71− }
72−
73− // Add to ProviderConfig ID field
74− if providerConfigDef, exists := schema.Definitions["ProviderConfig"]; exists {
75− if idProp, exists := providerConfigDef.Properties.Get("id"); exists {
76− idProp.Enum = providerIDs
77− }
78− }
79− }
80−}
81−
82−// addModelEnums adds model enums to the schema
83−func addModelEnums(schema *jsonschema.Schema) {
84− providers := config.Providers()
85− var modelIDs []any
86− for _, p := range providers {
87− for _, m := range p.Models {
88− modelIDs = append(modelIDs, m.ID)
89− }
90− }
91−
92− // Add to PreferredModel model_id field
93− if schema.Definitions != nil {
94− if preferredModelDef, exists := schema.Definitions["PreferredModel"]; exists {
95− if modelIDProp, exists := preferredModelDef.Properties.Get("model_id"); exists {
96− modelIDProp.Enum = modelIDs
97− }
98− }
99− }
100−}
101−
102−// addToolEnums adds tool enums to the schema
103−func addToolEnums(schema *jsonschema.Schema) {
104− tools := []any{
105− "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
106− }
107−
108− if schema.Definitions != nil {
109− if agentDef, exists := schema.Definitions["Agent"]; exists {
110− if allowedToolsProp, exists := agentDef.Properties.Get("allowed_tools"); exists {
111− if allowedToolsProp.Items != nil {
112− allowedToolsProp.Items.Enum = tools
113− }
114− }
115− }
116− }
117−}
118−
119−// addDefaultContextPaths adds default context paths to the schema
120−func addDefaultContextPaths(schema *jsonschema.Schema) {
121− defaultContextPaths := []any{
122− ".github/copilot-instructions.md",
123− ".cursorrules",
124− ".cursor/rules/",
125− "CLAUDE.md",
126− "CLAUDE.local.md",
127− "GEMINI.md",
128− "gemini.md",
129− "crush.md",
130− "crush.local.md",
131− "Crush.md",
132− "Crush.local.md",
133− "CRUSH.md",
134− "CRUSH.local.md",
135− }
136−
137− if schema.Definitions != nil {
138− if optionsDef, exists := schema.Definitions["Options"]; exists {
139− if contextPathsProp, exists := optionsDef.Properties.Get("context_paths"); exists {
140− contextPathsProp.Default = defaultContextPaths
141− }
142− }
143− }
144−
145− // Also add to root properties if they exist
146− if schema.Properties != nil {
147− if optionsProp, exists := schema.Properties.Get("options"); exists {
148− if optionsProp.Properties != nil {
149− if contextPathsProp, exists := optionsProp.Properties.Get("context_paths"); exists {
150− contextPathsProp.Default = defaultContextPaths
151− }
152− }
153− }
154− }
155−}
crush-schema.jsondeleted−700
@@ -1,700 +0,0 @@
1−{
2− "$schema": "https://json-schema.org/draft/2020-12/schema",
3− "$defs": {
4− "Agent": {
5− "properties": {
6− "id": {
7− "type": "string",
8− "enum": [
9− "coder",
10− "task",
11− "coder",
12− "task"
13− ],
14− "title": "Agent ID",
15− "description": "Unique identifier for the agent"
16− },
17− "name": {
18− "type": "string",
19− "title": "Name",
20− "description": "Display name of the agent"
21− },
22− "description": {
23− "type": "string",
24− "title": "Description",
25− "description": "Description of what the agent does"
26− },
27− "disabled": {
28− "type": "boolean",
29− "title": "Disabled",
30− "description": "Whether this agent is disabled",
31− "default": false
32− },
33− "model": {
34− "type": "string",
35− "enum": [
36− "large",
37− "small",
38− "large",
39− "small"
40− ],
41− "title": "Model Type",
42− "description": "Type of model to use (large or small)"
43− },
44− "allowed_tools": {
45− "items": {
46− "type": "string",
47− "enum": [
48− "bash",
49− "edit",
50− "fetch",
51− "glob",
52− "grep",
53− "ls",
54− "sourcegraph",
55− "view",
56− "write",
57− "agent"
58− ]
59− },
60− "type": "array",
61− "title": "Allowed Tools",
62− "description": "List of tools this agent is allowed to use (if nil all tools are allowed)"
63− },
64− "allowed_mcp": {
65− "additionalProperties": {
66− "items": {
67− "type": "string"
68− },
69− "type": "array"
70− },
71− "type": "object",
72− "title": "Allowed MCP",
73− "description": "Map of MCP servers this agent can use and their allowed tools"
74− },
75− "allowed_lsp": {
76− "items": {
77− "type": "string"
78− },
79− "type": "array",
80− "title": "Allowed LSP",
81− "description": "List of LSP servers this agent can use (if nil all LSPs are allowed)"
82− },
83− "context_paths": {
84− "items": {
85− "type": "string"
86− },
87− "type": "array",
88− "title": "Context Paths",
89− "description": "Custom context paths for this agent (additive to global context paths)"
90− }
91− },
92− "type": "object",
93− "required": [
94− "model"
95− ]
96− },
97− "LSPConfig": {
98− "properties": {
99− "enabled": {
100− "type": "boolean",
101− "title": "Enabled",
102− "description": "Whether this LSP server is enabled",
103− "default": true
104− },
105− "command": {
106− "type": "string",
107− "title": "Command",
108− "description": "Command to execute for the LSP server"
109− },
110− "args": {
111− "items": {
112− "type": "string"
113− },
114− "type": "array",
115− "title": "Arguments",
116− "description": "Command line arguments for the LSP server"
117− },
118− "options": {
119− "title": "Options",
120− "description": "LSP server specific options"
121− }
122− },
123− "type": "object",
124− "required": [
125− "command"
126− ]
127− },
128− "MCP": {
129− "properties": {
130− "command": {
131− "type": "string",
132− "title": "Command",
133− "description": "Command to execute for stdio MCP servers"
134− },
135− "env": {
136− "items": {
137− "type": "string"
138− },
139− "type": "array",
140− "title": "Environment",
141− "description": "Environment variables for the MCP server"
142− },
143− "args": {
144− "items": {
145− "type": "string"
146− },
147− "type": "array",
148− "title": "Arguments",
149− "description": "Command line arguments for the MCP server"
150− },
151− "type": {
152− "type": "string",
153− "enum": [
154− "stdio",
155− "sse",
156− "stdio",
157− "sse",
158− "http"
159− ],
160− "title": "Type",
161− "description": "Type of MCP connection",
162− "default": "stdio"
163− },
164− "url": {
165− "type": "string",
166− "title": "URL",
167− "description": "URL for SSE MCP servers"
168− },
169− "headers": {
170− "additionalProperties": {
171− "type": "string"
172− },
173− "type": "object",
174− "title": "Headers",
175− "description": "HTTP headers for SSE MCP servers"
176− }
177− },
178− "type": "object",
179− "required": [
180− "type"
181− ]
182− },
183− "Model": {
184− "properties": {
185− "id": {
186− "type": "string",
187− "title": "Model ID",
188− "description": "Unique identifier for the model"
189− },
190− "name": {
191− "type": "string",
192− "title": "Model Name",
193− "description": "Display name of the model"
194− },
195− "cost_per_1m_in": {
196− "type": "number",
197− "minimum": 0,
198− "title": "Input Cost",
199− "description": "Cost per 1 million input tokens"
200− },
201− "cost_per_1m_out": {
202− "type": "number",
203− "minimum": 0,
204− "title": "Output Cost",
205− "description": "Cost per 1 million output tokens"
206− },
207− "cost_per_1m_in_cached": {
208− "type": "number",
209− "minimum": 0,
210− "title": "Cached Input Cost",
211− "description": "Cost per 1 million cached input tokens"
212− },
213− "cost_per_1m_out_cached": {
214− "type": "number",
215− "minimum": 0,
216− "title": "Cached Output Cost",
217− "description": "Cost per 1 million cached output tokens"
218− },
219− "context_window": {
220− "type": "integer",
221− "minimum": 1,
222− "title": "Context Window",
223− "description": "Maximum context window size in tokens"
224− },
225− "default_max_tokens": {
226− "type": "integer",
227− "minimum": 1,
228− "title": "Default Max Tokens",
229− "description": "Default maximum tokens for responses"
230− },
231− "can_reason": {
232− "type": "boolean",
233− "title": "Can Reason",
234− "description": "Whether the model supports reasoning capabilities"
235− },
236− "reasoning_effort": {
237− "type": "string",
238− "title": "Reasoning Effort",
239− "description": "Default reasoning effort level for reasoning models"
240− },
241− "has_reasoning_effort": {
242− "type": "boolean",
243− "title": "Has Reasoning Effort",
244− "description": "Whether the model supports reasoning effort configuration"
245− },
246− "supports_attachments": {
247− "type": "boolean",
248− "title": "Supports Images",
249− "description": "Whether the model supports image attachments"
250− }
251− },
252− "type": "object",
253− "required": [
254− "id",
255− "name",
256− "context_window",
257− "default_max_tokens"
258− ]
259− },
260− "Options": {
261− "properties": {
262− "context_paths": {
263− "items": {
264− "type": "string"
265− },
266− "type": "array",
267− "title": "Context Paths",
268− "description": "List of paths to search for context files",
269− "default": [
270− ".github/copilot-instructions.md",
271− ".cursorrules",
272− ".cursor/rules/",
273− "CLAUDE.md",
274− "CLAUDE.local.md",
275− "GEMINI.md",
276− "gemini.md",
277− "crush.md",
278− "crush.local.md",
279− "Crush.md",
280− "Crush.local.md",
281− "CRUSH.md",
282− "CRUSH.local.md"
283− ]
284− },
285− "tui": {
286− "$ref": "#/$defs/TUIOptions",
287− "title": "TUI Options",
288− "description": "Terminal UI configuration options"
289− },
290− "debug": {
291− "type": "boolean",
292− "title": "Debug",
293− "description": "Enable debug logging",
294− "default": false
295− },
296− "debug_lsp": {
297− "type": "boolean",
298− "title": "Debug LSP",
299− "description": "Enable LSP debug logging",
300− "default": false
301− },
302− "disable_auto_summarize": {
303− "type": "boolean",
304− "title": "Disable Auto Summarize",
305− "description": "Disable automatic conversation summarization",
306− "default": false
307− },
308− "data_directory": {
309− "type": "string",
310− "title": "Data Directory",
311− "description": "Directory for storing application data",
312− "default": ".crush"
313− }
314− },
315− "type": "object"
316− },
317− "PreferredModel": {
318− "properties": {
319− "model_id": {
320− "type": "string",
321− "enum": [
322− "claude-opus-4-20250514",
323− "claude-sonnet-4-20250514",
324− "claude-3-7-sonnet-20250219",
325− "claude-3-5-haiku-20241022",
326− "claude-3-5-sonnet-20240620",
327− "claude-3-5-sonnet-20241022",
328− "codex-mini-latest",
329− "o4-mini",
330− "o3",
331− "o3-pro",
332− "gpt-4.1",
333− "gpt-4.1-mini",
334− "gpt-4.1-nano",
335− "gpt-4.5-preview",
336− "o3-mini",
337− "gpt-4o",
338− "gpt-4o-mini",
339− "gemini-2.5-pro",
340− "gemini-2.5-flash",
341− "codex-mini-latest",
342− "o4-mini",
343− "o3",
344− "o3-pro",
345− "gpt-4.1",
346− "gpt-4.1-mini",
347− "gpt-4.1-nano",
348− "gpt-4.5-preview",
349− "o3-mini",
350− "gpt-4o",
351− "gpt-4o-mini",
352− "anthropic.claude-opus-4-20250514-v1:0",
353− "anthropic.claude-sonnet-4-20250514-v1:0",
354− "anthropic.claude-3-7-sonnet-20250219-v1:0",
355− "anthropic.claude-3-5-haiku-20241022-v1:0",
356− "gemini-2.5-pro",
357− "gemini-2.5-flash",
358− "grok-3-mini",
359− "grok-3",
360− "mistralai/mistral-small-3.2-24b-instruct:free",
361− "mistralai/mistral-small-3.2-24b-instruct",
362− "minimax/minimax-m1:extended",
363− "minimax/minimax-m1",
364− "google/gemini-2.5-flash-lite-preview-06-17",
365− "google/gemini-2.5-flash",
366− "google/gemini-2.5-pro",
367− "openai/o3-pro",
368− "x-ai/grok-3-mini",
369− "x-ai/grok-3",
370− "mistralai/magistral-small-2506",
371− "mistralai/magistral-medium-2506",
372− "mistralai/magistral-medium-2506:thinking",
373− "google/gemini-2.5-pro-preview",
374− "deepseek/deepseek-r1-0528",
375− "anthropic/claude-opus-4",
376− "anthropic/claude-sonnet-4",
377− "mistralai/devstral-small:free",
378− "mistralai/devstral-small",
379− "google/gemini-2.5-flash-preview-05-20",
380− "google/gemini-2.5-flash-preview-05-20:thinking",
381− "openai/codex-mini",
382− "mistralai/mistral-medium-3",
383− "google/gemini-2.5-pro-preview-05-06",
384− "arcee-ai/caller-large",
385− "arcee-ai/virtuoso-large",
386− "arcee-ai/virtuoso-medium-v2",
387− "qwen/qwen3-30b-a3b",
388− "qwen/qwen3-14b",
389− "qwen/qwen3-32b",
390− "qwen/qwen3-235b-a22b",
391− "google/gemini-2.5-flash-preview",
392− "google/gemini-2.5-flash-preview:thinking",
393− "openai/o4-mini-high",
394− "openai/o3",
395− "openai/o4-mini",
396− "openai/gpt-4.1",
397− "openai/gpt-4.1-mini",
398− "openai/gpt-4.1-nano",
399− "x-ai/grok-3-mini-beta",
400− "x-ai/grok-3-beta",
401− "meta-llama/llama-4-maverick",
402− "meta-llama/llama-4-scout",
403− "all-hands/openhands-lm-32b-v0.1",
404− "google/gemini-2.5-pro-exp-03-25",
405− "deepseek/deepseek-chat-v3-0324:free",
406− "deepseek/deepseek-chat-v3-0324",
407− "mistralai/mistral-small-3.1-24b-instruct:free",
408− "mistralai/mistral-small-3.1-24b-instruct",
409− "ai21/jamba-1.6-large",
410− "ai21/jamba-1.6-mini",
411− "openai/gpt-4.5-preview",
412− "google/gemini-2.0-flash-lite-001",
413− "anthropic/claude-3.7-sonnet",
414− "anthropic/claude-3.7-sonnet:beta",
415− "anthropic/claude-3.7-sonnet:thinking",
416− "mistralai/mistral-saba",
417− "openai/o3-mini-high",
418− "google/gemini-2.0-flash-001",
419− "qwen/qwen-turbo",
420− "qwen/qwen-plus",
421− "qwen/qwen-max",
422− "openai/o3-mini",
423− "mistralai/mistral-small-24b-instruct-2501",
424− "deepseek/deepseek-r1-distill-llama-70b",
425− "deepseek/deepseek-r1",
426− "mistralai/codestral-2501",
427− "deepseek/deepseek-chat",
428− "openai/o1",
429− "x-ai/grok-2-1212",
430− "meta-llama/llama-3.3-70b-instruct",
431− "amazon/nova-lite-v1",
432− "amazon/nova-micro-v1",
433− "amazon/nova-pro-v1",
434− "openai/gpt-4o-2024-11-20",
435− "mistralai/mistral-large-2411",
436− "mistralai/mistral-large-2407",
437− "mistralai/pixtral-large-2411",
438− "thedrummer/unslopnemo-12b",
439− "anthropic/claude-3.5-haiku:beta",
440− "anthropic/claude-3.5-haiku",
441− "anthropic/claude-3.5-haiku-20241022:beta",
442− "anthropic/claude-3.5-haiku-20241022",
443− "anthropic/claude-3.5-sonnet:beta",
444− "anthropic/claude-3.5-sonnet",
445− "x-ai/grok-beta",
446− "mistralai/ministral-8b",
447− "mistralai/ministral-3b",
448− "nvidia/llama-3.1-nemotron-70b-instruct",
449− "google/gemini-flash-1.5-8b",
450− "meta-llama/llama-3.2-11b-vision-instruct",
451− "meta-llama/llama-3.2-3b-instruct",
452− "qwen/qwen-2.5-72b-instruct",
453− "mistralai/pixtral-12b",
454− "cohere/command-r-plus-08-2024",
455− "cohere/command-r-08-2024",
456− "microsoft/phi-3.5-mini-128k-instruct",
457− "nousresearch/hermes-3-llama-3.1-70b",
458− "openai/gpt-4o-2024-08-06",
459− "meta-llama/llama-3.1-405b-instruct",
460− "meta-llama/llama-3.1-70b-instruct",
461− "meta-llama/llama-3.1-8b-instruct",
462− "mistralai/mistral-nemo",
463− "openai/gpt-4o-mini",
464− "openai/gpt-4o-mini-2024-07-18",
465− "anthropic/claude-3.5-sonnet-20240620:beta",
466− "anthropic/claude-3.5-sonnet-20240620",
467− "mistralai/mistral-7b-instruct-v0.3",
468− "mistralai/mistral-7b-instruct:free",
469− "mistralai/mistral-7b-instruct",
470− "microsoft/phi-3-mini-128k-instruct",
471− "microsoft/phi-3-medium-128k-instruct",
472− "google/gemini-flash-1.5",
473− "openai/gpt-4o-2024-05-13",
474− "openai/gpt-4o",
475− "openai/gpt-4o:extended",
476− "meta-llama/llama-3-8b-instruct",
477− "meta-llama/llama-3-70b-instruct",
478− "mistralai/mixtral-8x22b-instruct",
479− "openai/gpt-4-turbo",
480− "google/gemini-pro-1.5",
481− "cohere/command-r-plus",
482− "cohere/command-r-plus-04-2024",
483− "cohere/command-r",
484− "anthropic/claude-3-haiku:beta",
485− "anthropic/claude-3-haiku",
486− "anthropic/claude-3-opus:beta",
487− "anthropic/claude-3-opus",
488− "anthropic/claude-3-sonnet:beta",
489− "anthropic/claude-3-sonnet",
490− "cohere/command-r-03-2024",
491− "mistralai/mistral-large",
492− "openai/gpt-3.5-turbo-0613",
493− "openai/gpt-4-turbo-preview",
494− "mistralai/mistral-small",
495− "mistralai/mistral-tiny",
496− "mistralai/mixtral-8x7b-instruct",
497− "openai/gpt-4-1106-preview",
498− "mistralai/mistral-7b-instruct-v0.1",
499− "openai/gpt-3.5-turbo-16k",
500− "openai/gpt-4",
501− "openai/gpt-4-0314"
502− ],
503− "title": "Model ID",
504− "description": "ID of the preferred model"
505− },
506− "provider": {
507− "type": "string",
508− "enum": [
509− "anthropic",
510− "openai",
511− "gemini",
512− "azure",
513− "bedrock",
514− "vertex",
515− "xai",
516− "openrouter"
517− ],
518− "title": "Provider",
519− "description": "Provider for the preferred model"
520− },
521− "reasoning_effort": {
522− "type": "string",
523− "title": "Reasoning Effort",
524− "description": "Override reasoning effort for this model"
525− },
526− "max_tokens": {
527− "type": "integer",
528− "minimum": 1,
529− "title": "Max Tokens",
530− "description": "Override max tokens for this model"
531− },
532− "think": {
533− "type": "boolean",
534− "title": "Think",
535− "description": "Enable thinking for reasoning models",
536− "default": false
537− }
538− },
539− "type": "object",
540− "required": [
541− "model_id",
542− "provider"
543− ]
544− },
545− "PreferredModels": {
546− "properties": {
547− "large": {
548− "$ref": "#/$defs/PreferredModel",
549− "title": "Large Model",
550− "description": "Preferred model configuration for large model type"
551− },
552− "small": {
553− "$ref": "#/$defs/PreferredModel",
554− "title": "Small Model",
555− "description": "Preferred model configuration for small model type"
556− }
557− },
558− "type": "object"
559− },
560− "ProviderConfig": {
561− "properties": {
562− "id": {
563− "type": "string",
564− "enum": [
565− "anthropic",
566− "openai",
567− "gemini",
568− "azure",
569− "bedrock",
570− "vertex",
571− "xai",
572− "openrouter"
573− ],
574− "title": "Provider ID",
575− "description": "Unique identifier for the provider"
576− },
577− "base_url": {
578− "type": "string",
579− "title": "Base URL",
580− "description": "Base URL for the provider API (required for custom providers)"
581− },
582− "provider_type": {
583− "type": "string",
584− "title": "Provider Type",
585− "description": "Type of the provider (openai"
586− },
587− "api_key": {
588− "type": "string",
589− "title": "API Key",
590− "description": "API key for authenticating with the provider"
591− },
592− "disabled": {
593− "type": "boolean",
594− "title": "Disabled",
595− "description": "Whether this provider is disabled",
596− "default": false
597− },
598− "extra_headers": {
599− "additionalProperties": {
600− "type": "string"
601− },
602− "type": "object",
603− "title": "Extra Headers",
604− "description": "Additional HTTP headers to send with requests"
605− },
606− "extra_params": {
607− "additionalProperties": {
608− "type": "string"
609− },
610− "type": "object",
611− "title": "Extra Parameters",
612− "description": "Additional provider-specific parameters"
613− },
614− "default_large_model": {
615− "type": "string",
616− "title": "Default Large Model",
617− "description": "Default model ID for large model type"
618− },
619− "default_small_model": {
620− "type": "string",
621− "title": "Default Small Model",
622− "description": "Default model ID for small model type"
623− },
624− "models": {
625− "items": {
626− "$ref": "#/$defs/Model"
627− },
628− "type": "array",
629− "title": "Models",
630− "description": "List of available models for this provider"
631− }
632− },
633− "type": "object",
634− "required": [
635− "provider_type"
636− ]
637− },
638− "TUIOptions": {
639− "properties": {
640− "compact_mode": {
641− "type": "boolean",
642− "title": "Compact Mode",
643− "description": "Enable compact mode for the TUI",
644− "default": false
645− }
646− },
647− "type": "object",
648− "required": [
649− "compact_mode"
650− ]
651− }
652− },
653− "properties": {
654− "models": {
655− "$ref": "#/$defs/PreferredModels",
656− "title": "Models",
657− "description": "Preferred model configurations for large and small model types"
658− },
659− "providers": {
660− "additionalProperties": {
661− "$ref": "#/$defs/ProviderConfig"
662− },
663− "type": "object",
664− "title": "Providers",
665− "description": "LLM provider configurations"
666− },
667− "agents": {
668− "additionalProperties": {
669− "$ref": "#/$defs/Agent"
670− },
671− "type": "object",
672− "title": "Agents",
673− "description": "Agent configurations for different tasks"
674− },
675− "mcp": {
676− "additionalProperties": {
677− "$ref": "#/$defs/MCP"
678− },
679− "type": "object",
680− "title": "MCP",
681− "description": "Model Control Protocol server configurations"
682− },
683− "lsp": {
684− "additionalProperties": {
685− "$ref": "#/$defs/LSPConfig"
686− },
687− "type": "object",
688− "title": "LSP",
689− "description": "Language Server Protocol configurations"
690− },
691− "options": {
692− "$ref": "#/$defs/Options",
693− "title": "Options",
694− "description": "General application options and settings"
695− }
696− },
697− "type": "object",
698− "title": "Crush Configuration",
699− "description": "Configuration schema for the Crush application"
700−}
crush.json−1
@@ -1,5 +1,4 @@
11{
2− "$schema": "./crush-schema.json",
32 "lsp": {
43 "go": {
54 "command": "gopls"
go.mod+7−9
@@ -17,6 +17,7 @@ require (
1717 github.com/charmbracelet/fang v0.1.0
1818 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1919 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71
20+ github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
2021 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
2122 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250627134340-c144409e381c
2223 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
@@ -25,31 +26,28 @@ require (
2526 github.com/go-logfmt/logfmt v0.6.0
2627 github.com/google/uuid v1.6.0
2728 github.com/invopop/jsonschema v0.13.0
29+ github.com/joho/godotenv v1.5.1
2830 github.com/mark3labs/mcp-go v0.32.0
2931 github.com/muesli/termenv v0.16.0
3032 github.com/ncruces/go-sqlite3 v0.25.0
3133 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
34+ github.com/nxadm/tail v1.4.11
3235 github.com/openai/openai-go v1.8.2
3336 github.com/pressly/goose/v3 v3.24.2
37+ github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c
3438 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
3539 github.com/sahilm/fuzzy v0.1.1
3640 github.com/spf13/cobra v1.9.1
3741 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
3842 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
3943 github.com/stretchr/testify v1.10.0
44+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
45+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
4046 mvdan.cc/sh/v3 v3.11.0
4147)
4248
4349require (
44− github.com/charmbracelet/lipgloss v1.1.0 // indirect
45− github.com/charmbracelet/log v0.4.2 // indirect
46− github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 // indirect
47− github.com/joho/godotenv v1.5.1 // indirect
48− github.com/nxadm/tail v1.4.11 // indirect
49− github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c // indirect
5050 github.com/spf13/cast v1.7.1 // indirect
51− golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
52− gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
5351 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
5452)
5553
@@ -84,7 +82,7 @@ require (
8482 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
8583 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
8684 github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
87− github.com/charmbracelet/x/term v0.2.1 // indirect
85+ github.com/charmbracelet/x/term v0.2.1
8886 github.com/charmbracelet/x/windows v0.2.1 // indirect
8987 github.com/davecgh/go-spew v1.1.1 // indirect
9088 github.com/disintegration/gift v1.1.2 // indirect
go.sum−6
@@ -82,14 +82,8 @@ github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d
8282github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
8383github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
8484github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
85−github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
86−github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
87−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
88−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
8985github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 h1:X0tsNa2UHCKNw+illiavosasVzqioRo32SRV35iwr2I=
9086github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
91−github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
92−github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
9387github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
9488github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
9589github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
internal/app/app.go+2−1
@@ -57,7 +57,8 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
5757
5858 cfg := config.Get()
5959
60− coderAgentCfg := cfg.Agents[config.AgentCoder]
60+ // TODO: remove the concept of agent config most likely
61+ coderAgentCfg := cfg.Agents["coder"]
6162 if coderAgentCfg.ID == "" {
6263 return nil, fmt.Errorf("coder agent configuration is missing")
6364 }
internal/app/lsp.go+2−2
@@ -38,7 +38,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
3838 defer cancel()
3939
4040 // Initialize with the initialization context
41− _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
41+ _, err = lspClient.InitializeLSPClient(initCtx, config.Get().WorkingDir())
4242 if err != nil {
4343 logging.Error("Initialize failed", "name", name, "error", err)
4444 // Clean up the client to prevent resource leaks
@@ -91,7 +91,7 @@ func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceW
9191 app.restartLSPClient(ctx, name)
9292 })
9393
94− workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
94+ workspaceWatcher.WatchWorkspace(ctx, config.Get().WorkingDir())
9595 logging.Info("Workspace watcher stopped", "client", name)
9696}
9797
internal/config/config.go+159−1354
@@ -1,28 +1,17 @@
11package config
22
33import (
4− "encoding/json"
5− "errors"
64 "fmt"
7− "log/slog"
8− "maps"
9− "os"
10− "path/filepath"
115 "slices"
126 "strings"
13− "sync"
147
158 "github.com/charmbracelet/crush/internal/fur/provider"
16− "github.com/charmbracelet/crush/internal/logging"
17− "github.com/invopop/jsonschema"
189)
1910
2011const (
12+ appName = "crush"
2113 defaultDataDirectory = ".crush"
2214 defaultLogLevel = "info"
23− appName = "crush"
24−
25− MaxTokensFallbackDefault = 4096
2615)
2716
2817var defaultContextPaths = []string{
@@ -41,82 +30,51 @@ var defaultContextPaths = []string{
4130 "CRUSH.local.md",
4231}
4332
44−type AgentID string
33+type SelectedModelType string
4534
4635const (
47− AgentCoder AgentID = "coder"
48− AgentTask AgentID = "task"
36+ SelectedModelTypeLarge SelectedModelType = "large"
37+ SelectedModelTypeSmall SelectedModelType = "small"
4938)
5039
51−type ModelType string
40+type SelectedModel struct {
41+ // The model id as used by the provider API.
42+ // Required.
43+ Model string `json:"model"`
44+ // The model provider, same as the key/id used in the providers config.
45+ // Required.
46+ Provider string `json:"provider"`
5247
53−const (
54− LargeModel ModelType = "large"
55− SmallModel ModelType = "small"
56−)
48+ // Only used by models that use the openai provider and need this set.
49+ ReasoningEffort string `json:"reasoning_effort,omitempty"`
5750
58−type Model struct {
59− ID string `json:"id" jsonschema:"title=Model ID,description=Unique identifier for the model, the API model"`
60− Name string `json:"name" jsonschema:"title=Model Name,description=Display name of the model"`
61− CostPer1MIn float64 `json:"cost_per_1m_in,omitempty" jsonschema:"title=Input Cost,description=Cost per 1 million input tokens,minimum=0"`
62− CostPer1MOut float64 `json:"cost_per_1m_out,omitempty" jsonschema:"title=Output Cost,description=Cost per 1 million output tokens,minimum=0"`
63− CostPer1MInCached float64 `json:"cost_per_1m_in_cached,omitempty" jsonschema:"title=Cached Input Cost,description=Cost per 1 million cached input tokens,minimum=0"`
64− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached,omitempty" jsonschema:"title=Cached Output Cost,description=Cost per 1 million cached output tokens,minimum=0"`
65− ContextWindow int64 `json:"context_window" jsonschema:"title=Context Window,description=Maximum context window size in tokens,minimum=1"`
66− DefaultMaxTokens int64 `json:"default_max_tokens" jsonschema:"title=Default Max Tokens,description=Default maximum tokens for responses,minimum=1"`
67− CanReason bool `json:"can_reason,omitempty" jsonschema:"title=Can Reason,description=Whether the model supports reasoning capabilities"`
68− ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"title=Reasoning Effort,description=Default reasoning effort level for reasoning models"`
69− HasReasoningEffort bool `json:"has_reasoning_effort,omitempty" jsonschema:"title=Has Reasoning Effort,description=Whether the model supports reasoning effort configuration"`
70− SupportsImages bool `json:"supports_attachments,omitempty" jsonschema:"title=Supports Images,description=Whether the model supports image attachments"`
71−}
51+ // Overrides the default model configuration.
52+ MaxTokens int64 `json:"max_tokens,omitempty"`
7253
73−type VertexAIOptions struct {
74− APIKey string `json:"api_key,omitempty"`
75− Project string `json:"project,omitempty"`
76− Location string `json:"location,omitempty"`
54+ // Used by anthropic models that can reason to indicate if the model should think.
55+ Think bool `json:"think,omitempty"`
7756}
7857
7958type ProviderConfig struct {
80− ID provider.InferenceProvider `json:"id,omitempty" jsonschema:"title=Provider ID,description=Unique identifier for the provider"`
81− BaseURL string `json:"base_url,omitempty" jsonschema:"title=Base URL,description=Base URL for the provider API (required for custom providers)"`
82− ProviderType provider.Type `json:"provider_type" jsonschema:"title=Provider Type,description=Type of the provider (openai, anthropic, etc.)"`
83− APIKey string `json:"api_key,omitempty" jsonschema:"title=API Key,description=API key for authenticating with the provider"`
84− Disabled bool `json:"disabled,omitempty" jsonschema:"title=Disabled,description=Whether this provider is disabled,default=false"`
85− ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"title=Extra Headers,description=Additional HTTP headers to send with requests"`
86− // used for e.x for vertex to set the project
87− ExtraParams map[string]string `json:"extra_params,omitempty" jsonschema:"title=Extra Parameters,description=Additional provider-specific parameters"`
88−
89− DefaultLargeModel string `json:"default_large_model,omitempty" jsonschema:"title=Default Large Model,description=Default model ID for large model type"`
90− DefaultSmallModel string `json:"default_small_model,omitempty" jsonschema:"title=Default Small Model,description=Default model ID for small model type"`
91−
92− Models []Model `json:"models,omitempty" jsonschema:"title=Models,description=List of available models for this provider"`
93−}
94−
95−type Agent struct {
96− ID AgentID `json:"id,omitempty" jsonschema:"title=Agent ID,description=Unique identifier for the agent,enum=coder,enum=task"`
97− Name string `json:"name,omitempty" jsonschema:"title=Name,description=Display name of the agent"`
98− Description string `json:"description,omitempty" jsonschema:"title=Description,description=Description of what the agent does"`
99− // This is the id of the system prompt used by the agent
100− Disabled bool `json:"disabled,omitempty" jsonschema:"title=Disabled,description=Whether this agent is disabled,default=false"`
101−
102− Model ModelType `json:"model" jsonschema:"title=Model Type,description=Type of model to use (large or small),enum=large,enum=small"`
59+ // The provider's id.
60+ ID string `json:"id,omitempty"`
61+ // The provider's API endpoint.
62+ BaseURL string `json:"base_url,omitempty"`
63+ // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
64+ Type provider.Type `json:"type,omitempty"`
65+ // The provider's API key.
66+ APIKey string `json:"api_key,omitempty"`
67+ // Marks the provider as disabled.
68+ Disable bool `json:"disable,omitempty"`
10369
104− // The available tools for the agent
105− // if this is nil, all tools are available
106− AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"title=Allowed Tools,description=List of tools this agent is allowed to use (if nil all tools are allowed)"`
70+ // Extra headers to send with each request to the provider.
71+ ExtraHeaders map[string]string
10772
108− // this tells us which MCPs are available for this agent
109− // if this is empty all mcps are available
110− // the string array is the list of tools from the AllowedMCP the agent has available
111− // if the string array is nil, all tools from the AllowedMCP are available
112− AllowedMCP map[string][]string `json:"allowed_mcp,omitempty" jsonschema:"title=Allowed MCP,description=Map of MCP servers this agent can use and their allowed tools"`
73+ // Used to pass extra parameters to the provider.
74+ ExtraParams map[string]string `json:"-"`
11375
114− // The list of LSPs that this agent can use
115− // if this is nil, all LSPs are available
116− AllowedLSP []string `json:"allowed_lsp,omitempty" jsonschema:"title=Allowed LSP,description=List of LSP servers this agent can use (if nil all LSPs are allowed)"`
117−
118− // Overrides the context paths for this agent
119− ContextPaths []string `json:"context_paths,omitempty" jsonschema:"title=Context Paths,description=Custom context paths for this agent (additive to global context paths)"`
76+ // The provider models
77+ Models []provider.Model `json:"models,omitempty"`
12078}
12179
12280type MCPType string
@@ -127,1358 +85,205 @@ const (
12785 MCPHttp MCPType = "http"
12886)
12987
130−type MCP struct {
131− Command string `json:"command,omitempty" jsonschema:"title=Command,description=Command to execute for stdio MCP servers"`
132− Env []string `json:"env,omitempty" jsonschema:"title=Environment,description=Environment variables for the MCP server"`
133− Args []string `json:"args,omitempty" jsonschema:"title=Arguments,description=Command line arguments for the MCP server"`
134− Type MCPType `json:"type" jsonschema:"title=Type,description=Type of MCP connection,enum=stdio,enum=sse,enum=http,default=stdio"`
135− URL string `json:"url,omitempty" jsonschema:"title=URL,description=URL for SSE MCP servers"`
88+type MCPConfig struct {
89+ Command string `json:"command,omitempty" `
90+ Env []string `json:"env,omitempty"`
91+ Args []string `json:"args,omitempty"`
92+ Type MCPType `json:"type"`
93+ URL string `json:"url,omitempty"`
94+
13695 // TODO: maybe make it possible to get the value from the env
137− Headers map[string]string `json:"headers,omitempty" jsonschema:"title=Headers,description=HTTP headers for SSE MCP servers"`
96+ Headers map[string]string `json:"headers,omitempty"`
13897}
13998
14099type LSPConfig struct {
141− Disabled bool `json:"enabled,omitempty" jsonschema:"title=Enabled,description=Whether this LSP server is enabled,default=true"`
142− Command string `json:"command" jsonschema:"title=Command,description=Command to execute for the LSP server"`
143− Args []string `json:"args,omitempty" jsonschema:"title=Arguments,description=Command line arguments for the LSP server"`
144− Options any `json:"options,omitempty" jsonschema:"title=Options,description=LSP server specific options"`
100+ Disabled bool `json:"enabled,omitempty"`
101+ Command string `json:"command"`
102+ Args []string `json:"args,omitempty"`
103+ Options any `json:"options,omitempty"`
145104}
146105
147106type TUIOptions struct {
148− CompactMode bool `json:"compact_mode" jsonschema:"title=Compact Mode,description=Enable compact mode for the TUI,default=false"`
107+ CompactMode bool `json:"compact_mode,omitempty"`
149108 // Here we can add themes later or any TUI related options
150109}
151110
152111type Options struct {
153− ContextPaths []string `json:"context_paths,omitempty" jsonschema:"title=Context Paths,description=List of paths to search for context files"`
154− TUI TUIOptions `json:"tui,omitempty" jsonschema:"title=TUI Options,description=Terminal UI configuration options"`
155− Debug bool `json:"debug,omitempty" jsonschema:"title=Debug,description=Enable debug logging,default=false"`
156− DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"title=Debug LSP,description=Enable LSP debug logging,default=false"`
157− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"title=Disable Auto Summarize,description=Disable automatic conversation summarization,default=false"`
112+ ContextPaths []string `json:"context_paths,omitempty"`
113+ TUI *TUIOptions `json:"tui,omitempty"`
114+ Debug bool `json:"debug,omitempty"`
115+ DebugLSP bool `json:"debug_lsp,omitempty"`
116+ DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
158117 // Relative to the cwd
159− DataDirectory string `json:"data_directory,omitempty" jsonschema:"title=Data Directory,description=Directory for storing application data,default=.crush"`
118+ DataDirectory string `json:"data_directory,omitempty"`
160119}
161120
162−type PreferredModel struct {
163− ModelID string `json:"model_id" jsonschema:"title=Model ID,description=ID of the preferred model"`
164− Provider provider.InferenceProvider `json:"provider" jsonschema:"title=Provider,description=Provider for the preferred model"`
165− // ReasoningEffort overrides the default reasoning effort for this model
166− ReasoningEffort string `json:"reasoning_effort,omitempty" jsonschema:"title=Reasoning Effort,description=Override reasoning effort for this model"`
167− // MaxTokens overrides the default max tokens for this model
168− MaxTokens int64 `json:"max_tokens,omitempty" jsonschema:"title=Max Tokens,description=Override max tokens for this model,minimum=1"`
121+type MCPs map[string]MCPConfig
169122
170− // Think indicates if the model should think, only applicable for anthropic reasoning models
171− Think bool `json:"think,omitempty" jsonschema:"title=Think,description=Enable thinking for reasoning models,default=false"`
172−}
173−
174−type PreferredModels struct {
175− Large PreferredModel `json:"large,omitempty" jsonschema:"title=Large Model,description=Preferred model configuration for large model type"`
176− Small PreferredModel `json:"small,omitempty" jsonschema:"title=Small Model,description=Preferred model configuration for small model type"`
177−}
178−
179−type Config struct {
180− Models PreferredModels `json:"models,omitempty" jsonschema:"title=Models,description=Preferred model configurations for large and small model types"`
181− // List of configured providers
182− Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty" jsonschema:"title=Providers,description=LLM provider configurations"`
183−
184− // List of configured agents
185− Agents map[AgentID]Agent `json:"agents,omitempty" jsonschema:"title=Agents,description=Agent configurations for different tasks"`
186−
187− // List of configured MCPs
188− MCP map[string]MCP `json:"mcp,omitempty" jsonschema:"title=MCP,description=Model Control Protocol server configurations"`
189−
190− // List of configured LSPs
191− LSP map[string]LSPConfig `json:"lsp,omitempty" jsonschema:"title=LSP,description=Language Server Protocol configurations"`
192−
193− // Miscellaneous options
194− Options Options `json:"options,omitempty" jsonschema:"title=Options,description=General application options and settings"`
195−}
196−
197−var (
198− instance *Config // The single instance of the Singleton
199− cwd string
200− once sync.Once // Ensures the initialization happens only once
201−
202−)
203−
204−func readConfigFile(path string) (*Config, error) {
205− var cfg *Config
206− if _, err := os.Stat(path); err != nil && !os.IsNotExist(err) {
207− // some other error occurred while checking the file
208− return nil, err
209− } else if err == nil {
210− // config file exists, read it
211− file, err := os.ReadFile(path)
212− if err != nil {
213− return nil, err
214− }
215− cfg = &Config{}
216− if err := json.Unmarshal(file, cfg); err != nil {
217− return nil, err
218− }
219− } else {
220− // config file does not exist, create a new one
221− cfg = &Config{}
222− }
223− return cfg, nil
123+type MCP struct {
124+ Name string `json:"name"`
125+ MCP MCPConfig `json:"mcp"`
224126}
225127
226−func loadConfig(cwd string, debug bool) (*Config, error) {
227− // First read the global config file
228− cfgPath := ConfigPath()
229−
230− cfg := defaultConfigBasedOnEnv()
231− cfg.Options.Debug = debug
232− defaultLevel := slog.LevelInfo
233− if cfg.Options.Debug {
234− defaultLevel = slog.LevelDebug
128+func (m MCPs) Sorted() []MCP {
129+ sorted := make([]MCP, 0, len(m))
130+ for k, v := range m {
131+ sorted = append(sorted, MCP{
132+ Name: k,
133+ MCP: v,
134+ })
235135 }
236− if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
237− loggingFile := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "debug.log")
238−
239− // if file does not exist create it
240− if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
241− if err := os.MkdirAll(cfg.Options.DataDirectory, 0o755); err != nil {
242− return cfg, fmt.Errorf("failed to create directory: %w", err)
243− }
244− if _, err := os.Create(loggingFile); err != nil {
245− return cfg, fmt.Errorf("failed to create log file: %w", err)
246− }
247− }
248−
249− messagesPath := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "messages")
250−
251− if _, err := os.Stat(messagesPath); os.IsNotExist(err) {
252− if err := os.MkdirAll(messagesPath, 0o756); err != nil {
253− return cfg, fmt.Errorf("failed to create directory: %w", err)
254− }
255− }
256− logging.MessageDir = messagesPath
257−
258− sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
259− if err != nil {
260− return cfg, fmt.Errorf("failed to open log file: %w", err)
261− }
262− // Configure logger
263− logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
264− Level: defaultLevel,
265− }))
266− slog.SetDefault(logger)
267− } else {
268− // Configure logger
269− logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
270− Level: defaultLevel,
271− }))
272− slog.SetDefault(logger)
273− }
274−
275− priorityOrderedConfigFiles := []string{
276− cfgPath, // Global config file
277− filepath.Join(cwd, "crush.json"), // Local config file
278− filepath.Join(cwd, ".crush.json"), // Local config file
279− }
280−
281− configs := make([]*Config, 0)
282− for _, path := range priorityOrderedConfigFiles {
283− localConfig, err := readConfigFile(path)
284− if err != nil {
285− return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
286− }
287− if localConfig != nil {
288− // If the config file was read successfully, add it to the list
289− configs = append(configs, localConfig)
290− }
291− }
292−
293− // merge options
294− mergeOptions(cfg, configs...)
295−
296− mergeProviderConfigs(cfg, configs...)
297− // no providers found the app is not initialized yet
298− if len(cfg.Providers) == 0 {
299− return cfg, nil
300− }
301− preferredProvider := getPreferredProvider(cfg.Providers)
302− if preferredProvider != nil {
303− cfg.Models = PreferredModels{
304− Large: PreferredModel{
305− ModelID: preferredProvider.DefaultLargeModel,
306− Provider: preferredProvider.ID,
307− },
308− Small: PreferredModel{
309− ModelID: preferredProvider.DefaultSmallModel,
310− Provider: preferredProvider.ID,
311− },
312− }
313− } else {
314− // No valid providers found, set empty models
315− cfg.Models = PreferredModels{}
316− }
317−
318− mergeModels(cfg, configs...)
319−
320− agents := map[AgentID]Agent{
321− AgentCoder: {
322− ID: AgentCoder,
323− Name: "Coder",
324− Description: "An agent that helps with executing coding tasks.",
325− Model: LargeModel,
326− ContextPaths: cfg.Options.ContextPaths,
327− // All tools allowed
328− },
329− AgentTask: {
330− ID: AgentTask,
331− Name: "Task",
332− Description: "An agent that helps with searching for context and finding implementation details.",
333− Model: LargeModel,
334− ContextPaths: cfg.Options.ContextPaths,
335− AllowedTools: []string{
336− "glob",
337− "grep",
338− "ls",
339− "sourcegraph",
340− "view",
341− },
342− // NO MCPs or LSPs by default
343− AllowedMCP: map[string][]string{},
344− AllowedLSP: []string{},
345− },
346− }
347− cfg.Agents = agents
348− mergeAgents(cfg, configs...)
349− mergeMCPs(cfg, configs...)
350− mergeLSPs(cfg, configs...)
351−
352− // Validate the final configuration
353− if err := cfg.Validate(); err != nil {
354− return cfg, fmt.Errorf("configuration validation failed: %w", err)
355− }
356−
357− return cfg, nil
358−}
359−
360−func Init(workingDir string, debug bool) (*Config, error) {
361− var err error
362− once.Do(func() {
363− cwd = workingDir
364− instance, err = loadConfig(cwd, debug)
365− if err != nil {
366− logging.Error("Failed to load config", "error", err)
367− }
136+ slices.SortFunc(sorted, func(a, b MCP) int {
137+ return strings.Compare(a.Name, b.Name)
368138 })
369−
370− return instance, err
139+ return sorted
371140}
372141
373−func Get() *Config {
374− if instance == nil {
375− // TODO: Handle this better
376− panic("Config not initialized. Call InitConfig first.")
377− }
378− return instance
379−}
380−
381−func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
382− providers := Providers()
383− for _, p := range providers {
384− if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
385− return &providerConfig
386− }
387− }
388− // if none found return the first configured provider
389− for _, providerConfig := range configuredProviders {
390− if !providerConfig.Disabled {
391− return &providerConfig
392− }
393− }
394− return nil
395−}
396−
397−func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
398− if other.APIKey != "" {
399− base.APIKey = other.APIKey
400− }
401− // Only change these options if the provider is not a known provider
402− if !slices.Contains(provider.KnownProviders(), p) {
403− if other.BaseURL != "" {
404− base.BaseURL = other.BaseURL
405− }
406− if other.ProviderType != "" {
407− base.ProviderType = other.ProviderType
408− }
409− if len(other.ExtraHeaders) > 0 {
410− if base.ExtraHeaders == nil {
411− base.ExtraHeaders = make(map[string]string)
412− }
413− maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
414− }
415− if len(other.ExtraParams) > 0 {
416− if base.ExtraParams == nil {
417− base.ExtraParams = make(map[string]string)
418− }
419− maps.Copy(base.ExtraParams, other.ExtraParams)
420− }
421− }
422−
423− if other.Disabled {
424− base.Disabled = other.Disabled
425− }
426−
427− if other.DefaultLargeModel != "" {
428− base.DefaultLargeModel = other.DefaultLargeModel
429− }
430− // Add new models if they don't exist
431− if other.Models != nil {
432− for _, model := range other.Models {
433− // check if the model already exists
434− exists := false
435− for _, existingModel := range base.Models {
436− if existingModel.ID == model.ID {
437− exists = true
438− break
439− }
440− }
441− if !exists {
442− base.Models = append(base.Models, model)
443− }
444− }
445− }
446−
447− return base
448−}
142+type LSPs map[string]LSPConfig
449143
450−func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
451− if !slices.Contains(provider.KnownProviders(), p) {
452− if providerConfig.ProviderType != provider.TypeOpenAI {
453− return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
454− }
455− if providerConfig.BaseURL == "" {
456− return errors.New("base URL must be set for custom providers")
457− }
458− if providerConfig.APIKey == "" {
459− return errors.New("API key must be set for custom providers")
460− }
461− }
462− return nil
144+type LSP struct {
145+ Name string `json:"name"`
146+ LSP LSPConfig `json:"lsp"`
463147}
464148
465−func mergeModels(base *Config, others ...*Config) {
466− for _, cfg := range others {
467− if cfg == nil {
468− continue
469− }
470− if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
471− base.Models.Large = cfg.Models.Large
472− }
473−
474− if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
475− base.Models.Small = cfg.Models.Small
476− }
149+func (l LSPs) Sorted() []LSP {
150+ sorted := make([]LSP, 0, len(l))
151+ for k, v := range l {
152+ sorted = append(sorted, LSP{
153+ Name: k,
154+ LSP: v,
155+ })
477156 }
157+ slices.SortFunc(sorted, func(a, b LSP) int {
158+ return strings.Compare(a.Name, b.Name)
159+ })
160+ return sorted
478161}
479162
480−func mergeOptions(base *Config, others ...*Config) {
481− for _, cfg := range others {
482− if cfg == nil {
483− continue
484− }
485− baseOptions := base.Options
486− other := cfg.Options
487− if len(other.ContextPaths) > 0 {
488− baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
489− }
163+type Agent struct {
164+ ID string `json:"id,omitempty"`
165+ Name string `json:"name,omitempty"`
166+ Description string `json:"description,omitempty"`
167+ // This is the id of the system prompt used by the agent
168+ Disabled bool `json:"disabled,omitempty"`
490169
491− if other.TUI.CompactMode {
492− baseOptions.TUI.CompactMode = other.TUI.CompactMode
493− }
170+ Model SelectedModelType `json:"model"`
494171
495− if other.Debug {
496− baseOptions.Debug = other.Debug
497− }
172+ // The available tools for the agent
173+ // if this is nil, all tools are available
174+ AllowedTools []string `json:"allowed_tools,omitempty"`
498175
499− if other.DebugLSP {
500− baseOptions.DebugLSP = other.DebugLSP
501− }
176+ // this tells us which MCPs are available for this agent
177+ // if this is empty all mcps are available
178+ // the string array is the list of tools from the AllowedMCP the agent has available
179+ // if the string array is nil, all tools from the AllowedMCP are available
180+ AllowedMCP map[string][]string `json:"allowed_mcp,omitempty"`
502181
503− if other.DisableAutoSummarize {
504− baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
505− }
182+ // The list of LSPs that this agent can use
183+ // if this is nil, all LSPs are available
184+ AllowedLSP []string `json:"allowed_lsp,omitempty"`
506185
507− if other.DataDirectory != "" {
508− baseOptions.DataDirectory = other.DataDirectory
509− }
510− base.Options = baseOptions
511− }
186+ // Overrides the context paths for this agent
187+ ContextPaths []string `json:"context_paths,omitempty"`
512188}
513189
514−func mergeAgents(base *Config, others ...*Config) {
515− for _, cfg := range others {
516− if cfg == nil {
517− continue
518− }
519− for agentID, newAgent := range cfg.Agents {
520− if _, ok := base.Agents[agentID]; !ok {
521− newAgent.ID = agentID
522− if newAgent.Model == "" {
523− newAgent.Model = LargeModel
524− }
525− if len(newAgent.ContextPaths) > 0 {
526− newAgent.ContextPaths = append(base.Options.ContextPaths, newAgent.ContextPaths...)
527− } else {
528− newAgent.ContextPaths = base.Options.ContextPaths
529− }
530− base.Agents[agentID] = newAgent
531− } else {
532− baseAgent := base.Agents[agentID]
190+// Config holds the configuration for crush.
191+type Config struct {
192+ // We currently only support large/small as values here.
193+ Models map[SelectedModelType]SelectedModel `json:"models,omitempty"`
533194
534− if agentID == AgentCoder || agentID == AgentTask {
535− if newAgent.Model != "" {
536− baseAgent.Model = newAgent.Model
537− }
538− if newAgent.AllowedMCP != nil {
539− baseAgent.AllowedMCP = newAgent.AllowedMCP
540− }
541− if newAgent.AllowedLSP != nil {
542− baseAgent.AllowedLSP = newAgent.AllowedLSP
543− }
544− // Context paths are additive for known agents too
545− if len(newAgent.ContextPaths) > 0 {
546− baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
547− }
548− } else {
549− if newAgent.Name != "" {
550− baseAgent.Name = newAgent.Name
551− }
552− if newAgent.Description != "" {
553− baseAgent.Description = newAgent.Description
554− }
555− if newAgent.Model != "" {
556− baseAgent.Model = newAgent.Model
557− } else if baseAgent.Model == "" {
558− baseAgent.Model = LargeModel
559− }
195+ // The providers that are configured
196+ Providers map[string]ProviderConfig `json:"providers,omitempty"`
560197
561− baseAgent.Disabled = newAgent.Disabled
198+ MCP MCPs `json:"mcp,omitempty"`
562199
563− if newAgent.AllowedTools != nil {
564− baseAgent.AllowedTools = newAgent.AllowedTools
565− }
566− if newAgent.AllowedMCP != nil {
567− baseAgent.AllowedMCP = newAgent.AllowedMCP
568− }
569− if newAgent.AllowedLSP != nil {
570− baseAgent.AllowedLSP = newAgent.AllowedLSP
571− }
572− if len(newAgent.ContextPaths) > 0 {
573− baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
574− }
575− }
200+ LSP LSPs `json:"lsp,omitempty"`
576201
577− base.Agents[agentID] = baseAgent
578− }
579− }
580− }
581−}
202+ Options *Options `json:"options,omitempty"`
582203
583−func mergeMCPs(base *Config, others ...*Config) {
584− for _, cfg := range others {
585− if cfg == nil {
586− continue
587− }
588− maps.Copy(base.MCP, cfg.MCP)
589− }
204+ // Internal
205+ workingDir string `json:"-"`
206+ // TODO: most likely remove this concept when I come back to it
207+ Agents map[string]Agent `json:"-"`
208+ // TODO: find a better way to do this this should probably not be part of the config
209+ resolver VariableResolver
590210}
591211
592−func mergeLSPs(base *Config, others ...*Config) {
593− for _, cfg := range others {
594− if cfg == nil {
595− continue
596− }
597− maps.Copy(base.LSP, cfg.LSP)
598− }
212+func (c *Config) WorkingDir() string {
213+ return c.workingDir
599214}
600215
601−func mergeProviderConfigs(base *Config, others ...*Config) {
602− for _, cfg := range others {
603− if cfg == nil {
604− continue
605− }
606− for providerName, p := range cfg.Providers {
607− p.ID = providerName
608− if _, ok := base.Providers[providerName]; !ok {
609− if slices.Contains(provider.KnownProviders(), providerName) {
610− providers := Providers()
611− for _, providerDef := range providers {
612− if providerDef.ID == providerName {
613− logging.Info("Using default provider config for", "provider", providerName)
614− baseProvider := getDefaultProviderConfig(providerDef, providerDef.APIKey)
615− base.Providers[providerName] = mergeProviderConfig(providerName, baseProvider, p)
616− break
617− }
618− }
619− } else {
620− base.Providers[providerName] = p
621− }
622− } else {
623− base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], p)
624− }
625− }
626− }
627−
628− finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
629− for providerName, providerConfig := range base.Providers {
630− err := validateProvider(providerName, providerConfig)
631− if err != nil {
632− logging.Warn("Skipping provider", "name", providerName, "error", err)
633− continue // Skip invalid providers
216+func (c *Config) EnabledProviders() []ProviderConfig {
217+ enabled := make([]ProviderConfig, 0, len(c.Providers))
218+ for _, p := range c.Providers {
219+ if !p.Disable {
220+ enabled = append(enabled, p)
634221 }
635− finalProviders[providerName] = providerConfig
636222 }
637− base.Providers = finalProviders
223+ return enabled
638224}
639225
640−func providerDefaultConfig(providerID provider.InferenceProvider) ProviderConfig {
641− switch providerID {
642− case provider.InferenceProviderAnthropic:
643− return ProviderConfig{
644− ID: providerID,
645− ProviderType: provider.TypeAnthropic,
646− }
647− case provider.InferenceProviderOpenAI:
648− return ProviderConfig{
649− ID: providerID,
650− ProviderType: provider.TypeOpenAI,
651− }
652− case provider.InferenceProviderGemini:
653− return ProviderConfig{
654− ID: providerID,
655− ProviderType: provider.TypeGemini,
656− }
657− case provider.InferenceProviderBedrock:
658− return ProviderConfig{
659− ID: providerID,
660− ProviderType: provider.TypeBedrock,
661− }
662− case provider.InferenceProviderAzure:
663− return ProviderConfig{
664− ID: providerID,
665− ProviderType: provider.TypeAzure,
666− }
667− case provider.InferenceProviderOpenRouter:
668− return ProviderConfig{
669− ID: providerID,
670− ProviderType: provider.TypeOpenAI,
671− BaseURL: "https://openrouter.ai/api/v1",
672− ExtraHeaders: map[string]string{
673− "HTTP-Referer": "crush.charm.land",
674− "X-Title": "Crush",
675− },
676− }
677− case provider.InferenceProviderXAI:
678− return ProviderConfig{
679− ID: providerID,
680− ProviderType: provider.TypeXAI,
681− BaseURL: "https://api.x.ai/v1",
682− }
683− case provider.InferenceProviderVertexAI:
684− return ProviderConfig{
685− ID: providerID,
686− ProviderType: provider.TypeVertexAI,
687− }
688− default:
689− return ProviderConfig{
690− ID: providerID,
691− ProviderType: provider.TypeOpenAI,
692− }
693− }
226+// IsConfigured return true if at least one provider is configured
227+func (c *Config) IsConfigured() bool {
228+ return len(c.EnabledProviders()) > 0
694229}
695230
696−func getDefaultProviderConfig(p provider.Provider, apiKey string) ProviderConfig {
697− providerConfig := providerDefaultConfig(p.ID)
698− providerConfig.APIKey = apiKey
699− providerConfig.DefaultLargeModel = p.DefaultLargeModelID
700− providerConfig.DefaultSmallModel = p.DefaultSmallModelID
701− baseURL := p.APIEndpoint
702− if strings.HasPrefix(baseURL, "$") {
703− envVar := strings.TrimPrefix(baseURL, "$")
704− baseURL = os.Getenv(envVar)
705− }
706− providerConfig.BaseURL = baseURL
707− for _, model := range p.Models {
708− configModel := Model{
709− ID: model.ID,
710− Name: model.Name,
711− CostPer1MIn: model.CostPer1MIn,
712− CostPer1MOut: model.CostPer1MOut,
713− CostPer1MInCached: model.CostPer1MInCached,
714− CostPer1MOutCached: model.CostPer1MOutCached,
715− ContextWindow: model.ContextWindow,
716− DefaultMaxTokens: model.DefaultMaxTokens,
717− CanReason: model.CanReason,
718− SupportsImages: model.SupportsImages,
719− }
720− // Set reasoning effort for reasoning models
721− if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
722− configModel.HasReasoningEffort = model.HasReasoningEffort
723− configModel.ReasoningEffort = model.DefaultReasoningEffort
724− }
725− providerConfig.Models = append(providerConfig.Models, configModel)
726− }
727− return providerConfig
728−}
729−
730−func defaultConfigBasedOnEnv() *Config {
731− cfg := &Config{
732− Options: Options{
733− DataDirectory: defaultDataDirectory,
734− ContextPaths: defaultContextPaths,
735− },
736− Providers: make(map[provider.InferenceProvider]ProviderConfig),
737− Agents: make(map[AgentID]Agent),
738− LSP: make(map[string]LSPConfig),
739− MCP: make(map[string]MCP),
740− }
741−
742− providers := Providers()
743−
744− for _, p := range providers {
745− if strings.HasPrefix(p.APIKey, "$") {
746− envVar := strings.TrimPrefix(p.APIKey, "$")
747− if apiKey := os.Getenv(envVar); apiKey != "" {
748− cfg.Providers[p.ID] = getDefaultProviderConfig(p, apiKey)
749− }
750− }
751− }
752− // TODO: support local models
753−
754− if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
755− providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
756− providerConfig.ExtraParams = map[string]string{
757− "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
758− "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
759− }
760− // Find the VertexAI provider definition to get default models
761− for _, p := range providers {
762− if p.ID == provider.InferenceProviderVertexAI {
763− providerConfig.DefaultLargeModel = p.DefaultLargeModelID
764− providerConfig.DefaultSmallModel = p.DefaultSmallModelID
765− for _, model := range p.Models {
766− configModel := Model{
767− ID: model.ID,
768− Name: model.Name,
769− CostPer1MIn: model.CostPer1MIn,
770− CostPer1MOut: model.CostPer1MOut,
771− CostPer1MInCached: model.CostPer1MInCached,
772− CostPer1MOutCached: model.CostPer1MOutCached,
773− ContextWindow: model.ContextWindow,
774− DefaultMaxTokens: model.DefaultMaxTokens,
775− CanReason: model.CanReason,
776− SupportsImages: model.SupportsImages,
777− }
778− // Set reasoning effort for reasoning models
779− if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
780− configModel.HasReasoningEffort = model.HasReasoningEffort
781− configModel.ReasoningEffort = model.DefaultReasoningEffort
782− }
783− providerConfig.Models = append(providerConfig.Models, configModel)
784− }
785− break
786− }
787− }
788− cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
789− }
790−
791− if hasAWSCredentials() {
792− providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
793− providerConfig.ExtraParams = map[string]string{
794− "region": os.Getenv("AWS_DEFAULT_REGION"),
795− }
796− if providerConfig.ExtraParams["region"] == "" {
797− providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
798− }
799− // Find the Bedrock provider definition to get default models
800− for _, p := range providers {
801− if p.ID == provider.InferenceProviderBedrock {
802− providerConfig.DefaultLargeModel = p.DefaultLargeModelID
803− providerConfig.DefaultSmallModel = p.DefaultSmallModelID
804− for _, model := range p.Models {
805− configModel := Model{
806− ID: model.ID,
807− Name: model.Name,
808− CostPer1MIn: model.CostPer1MIn,
809− CostPer1MOut: model.CostPer1MOut,
810− CostPer1MInCached: model.CostPer1MInCached,
811− CostPer1MOutCached: model.CostPer1MOutCached,
812− ContextWindow: model.ContextWindow,
813− DefaultMaxTokens: model.DefaultMaxTokens,
814− CanReason: model.CanReason,
815− SupportsImages: model.SupportsImages,
816− }
817− // Set reasoning effort for reasoning models
818− if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
819− configModel.HasReasoningEffort = model.HasReasoningEffort
820− configModel.ReasoningEffort = model.DefaultReasoningEffort
821− }
822− providerConfig.Models = append(providerConfig.Models, configModel)
823− }
824− break
231+func (c *Config) GetModel(provider, model string) *provider.Model {
232+ if providerConfig, ok := c.Providers[provider]; ok {
233+ for _, m := range providerConfig.Models {
234+ if m.ID == model {
235+ return &m
825236 }
826237 }
827− cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
828− }
829− return cfg
830−}
831−
832−func hasAWSCredentials() bool {
833− if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
834− return true
835238 }
836−
837− if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
838− return true
839− }
840−
841− if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
842− return true
843− }
844−
845− if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
846− os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
847− return true
848− }
849−
850− return false
851−}
852−
853−func WorkingDirectory() string {
854− return cwd
239+ return nil
855240}
856241
857−// TODO: Handle error state
858−
859−func GetAgentModel(agentID AgentID) Model {
860− cfg := Get()
861− agent, ok := cfg.Agents[agentID]
862− if !ok {
863− logging.Error("Agent not found", "agent_id", agentID)
864− return Model{}
865− }
866−
867− var model PreferredModel
868− switch agent.Model {
869− case LargeModel:
870− model = cfg.Models.Large
871− case SmallModel:
872− model = cfg.Models.Small
873− default:
874− logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
875− model = cfg.Models.Large // Fallback to large model
876− }
877− providerConfig, ok := cfg.Providers[model.Provider]
242+func (c *Config) GetProviderForModel(modelType SelectedModelType) *ProviderConfig {
243+ model, ok := c.Models[modelType]
878244 if !ok {
879− logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
880− return Model{}
245+ return nil
881246 }
882−
883− for _, m := range providerConfig.Models {
884− if m.ID == model.ModelID {
885− return m
886− }
247+ if providerConfig, ok := c.Providers[model.Provider]; ok {
248+ return &providerConfig
887249 }
888−
889− logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
890− return Model{}
250+ return nil
891251}
892252
893−func GetAgentProvider(agentID AgentID) ProviderConfig {
894− cfg := Get()
895− agent, ok := cfg.Agents[agentID]
253+func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
254+ model, ok := c.Models[modelType]
896255 if !ok {
897− logging.Error("Agent not found", "agent_id", agentID)
898− return ProviderConfig{}
256+ return nil
899257 }
900−
901− var model PreferredModel
902− switch agent.Model {
903− case LargeModel:
904− model = cfg.Models.Large
905− case SmallModel:
906− model = cfg.Models.Small
907− default:
908− logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
909− model = cfg.Models.Large // Fallback to large model
910− }
911−
912− providerConfig, ok := cfg.Providers[model.Provider]
913− if !ok {
914− logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
915− return ProviderConfig{}
916− }
917−
918− return providerConfig
258+ return c.GetModel(model.Provider, model.Model)
919259}
920260
921−func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
922− cfg := Get()
923− providerConfig, ok := cfg.Providers[provider]
261+func (c *Config) LargeModel() *provider.Model {
262+ model, ok := c.Models[SelectedModelTypeLarge]
924263 if !ok {
925− logging.Error("Provider not found", "provider", provider)
926− return Model{}
264+ return nil
927265 }
928−
929− for _, model := range providerConfig.Models {
930− if model.ID == modelID {
931− return model
932− }
933− }
934−
935− logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
936− return Model{}
266+ return c.GetModel(model.Provider, model.Model)
937267}
938268
939−func GetModel(modelType ModelType) Model {
940− cfg := Get()
941− var model PreferredModel
942− switch modelType {
943− case LargeModel:
944− model = cfg.Models.Large
945− case SmallModel:
946− model = cfg.Models.Small
947− default:
948− model = cfg.Models.Large // Fallback to large model
949− }
950− providerConfig, ok := cfg.Providers[model.Provider]
269+func (c *Config) SmallModel() *provider.Model {
270+ model, ok := c.Models[SelectedModelTypeSmall]
951271 if !ok {
952− return Model{}
272+ return nil
953273 }
954−
955− for _, m := range providerConfig.Models {
956− if m.ID == model.ModelID {
957− return m
958− }
959− }
960− return Model{}
274+ return c.GetModel(model.Provider, model.Model)
961275}
962276
963−func UpdatePreferredModel(modelType ModelType, model PreferredModel) error {
964− cfg := Get()
965− switch modelType {
966− case LargeModel:
967− cfg.Models.Large = model
968− case SmallModel:
969− cfg.Models.Small = model
970− default:
971− return fmt.Errorf("unknown model type: %s", modelType)
277+func (c *Config) Resolve(key string) (string, error) {
278+ if c.resolver == nil {
279+ return "", fmt.Errorf("no variable resolver configured")
972280 }
973− return nil
974−}
975−
976−// ValidationError represents a configuration validation error
977−type ValidationError struct {
978− Field string
979− Message string
281+ return c.resolver.ResolveValue(key)
980282}
981283
982−func (e ValidationError) Error() string {
983− return fmt.Sprintf("validation error in %s: %s", e.Field, e.Message)
984−}
985−
986−// ValidationErrors represents multiple validation errors
987−type ValidationErrors []ValidationError
988−
989−func (e ValidationErrors) Error() string {
990− if len(e) == 0 {
991− return "no validation errors"
992− }
993− if len(e) == 1 {
994− return e[0].Error()
995− }
996−
997− var messages []string
998− for _, err := range e {
999− messages = append(messages, err.Error())
1000− }
1001− return fmt.Sprintf("multiple validation errors: %s", strings.Join(messages, "; "))
1002−}
1003−
1004−// HasErrors returns true if there are any validation errors
1005−func (e ValidationErrors) HasErrors() bool {
1006− return len(e) > 0
1007−}
1008−
1009−// Add appends a new validation error
1010−func (e *ValidationErrors) Add(field, message string) {
1011− *e = append(*e, ValidationError{Field: field, Message: message})
1012−}
1013−
1014−// Validate performs comprehensive validation of the configuration
1015−func (c *Config) Validate() error {
1016− var errors ValidationErrors
1017−
1018− // Validate providers
1019− c.validateProviders(&errors)
1020−
1021− // Validate models
1022− c.validateModels(&errors)
1023−
1024− // Validate agents
1025− c.validateAgents(&errors)
1026−
1027− // Validate options
1028− c.validateOptions(&errors)
1029−
1030− // Validate MCP configurations
1031− c.validateMCPs(&errors)
1032−
1033− // Validate LSP configurations
1034− c.validateLSPs(&errors)
1035−
1036− // Validate cross-references
1037− c.validateCrossReferences(&errors)
1038−
1039− // Validate completeness
1040− c.validateCompleteness(&errors)
1041−
1042− if errors.HasErrors() {
1043− return errors
1044− }
1045−
284+// TODO: maybe handle this better
285+func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
286+ cfg := Get()
287+ cfg.Models[modelType] = model
1046288 return nil
1047289}
1048−
1049−// validateProviders validates all provider configurations
1050−func (c *Config) validateProviders(errors *ValidationErrors) {
1051− if c.Providers == nil {
1052− c.Providers = make(map[provider.InferenceProvider]ProviderConfig)
1053− }
1054−
1055− knownProviders := provider.KnownProviders()
1056− validTypes := []provider.Type{
1057− provider.TypeOpenAI,
1058− provider.TypeAnthropic,
1059− provider.TypeGemini,
1060− provider.TypeAzure,
1061− provider.TypeBedrock,
1062− provider.TypeVertexAI,
1063− provider.TypeXAI,
1064− }
1065−
1066− for providerID, providerConfig := range c.Providers {
1067− fieldPrefix := fmt.Sprintf("providers.%s", providerID)
1068−
1069− // Validate API key for non-disabled providers
1070− if !providerConfig.Disabled && providerConfig.APIKey == "" {
1071− // Special case for AWS Bedrock and VertexAI which may use other auth methods
1072− if providerID != provider.InferenceProviderBedrock && providerID != provider.InferenceProviderVertexAI {
1073− errors.Add(fieldPrefix+".api_key", "API key is required for non-disabled providers")
1074− }
1075− }
1076−
1077− // Validate provider type
1078− validType := slices.Contains(validTypes, providerConfig.ProviderType)
1079− if !validType {
1080− errors.Add(fieldPrefix+".provider_type", fmt.Sprintf("invalid provider type: %s", providerConfig.ProviderType))
1081− }
1082−
1083− // Validate custom providers
1084− isKnownProvider := slices.Contains(knownProviders, providerID)
1085−
1086− if !isKnownProvider {
1087− // Custom provider validation
1088− if providerConfig.BaseURL == "" {
1089− errors.Add(fieldPrefix+".base_url", "BaseURL is required for custom providers")
1090− }
1091− if providerConfig.ProviderType != provider.TypeOpenAI {
1092− errors.Add(fieldPrefix+".provider_type", "custom providers currently only support OpenAI type")
1093− }
1094− }
1095−
1096− // Validate models
1097− modelIDs := make(map[string]bool)
1098− for i, model := range providerConfig.Models {
1099− modelFieldPrefix := fmt.Sprintf("%s.models[%d]", fieldPrefix, i)
1100−
1101− // Check for duplicate model IDs
1102− if modelIDs[model.ID] {
1103− errors.Add(modelFieldPrefix+".id", fmt.Sprintf("duplicate model ID: %s", model.ID))
1104− }
1105− modelIDs[model.ID] = true
1106−
1107− // Validate required model fields
1108− if model.ID == "" {
1109− errors.Add(modelFieldPrefix+".id", "model ID is required")
1110− }
1111− if model.Name == "" {
1112− errors.Add(modelFieldPrefix+".name", "model name is required")
1113− }
1114− if model.ContextWindow <= 0 {
1115− errors.Add(modelFieldPrefix+".context_window", "context window must be positive")
1116− }
1117− if model.DefaultMaxTokens <= 0 {
1118− errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens must be positive")
1119− }
1120− if model.DefaultMaxTokens > model.ContextWindow {
1121− errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens cannot exceed context window")
1122− }
1123−
1124− // Validate cost fields
1125− if model.CostPer1MIn < 0 {
1126− errors.Add(modelFieldPrefix+".cost_per_1m_in", "cost per 1M input tokens cannot be negative")
1127− }
1128− if model.CostPer1MOut < 0 {
1129− errors.Add(modelFieldPrefix+".cost_per_1m_out", "cost per 1M output tokens cannot be negative")
1130− }
1131− if model.CostPer1MInCached < 0 {
1132− errors.Add(modelFieldPrefix+".cost_per_1m_in_cached", "cached cost per 1M input tokens cannot be negative")
1133− }
1134− if model.CostPer1MOutCached < 0 {
1135− errors.Add(modelFieldPrefix+".cost_per_1m_out_cached", "cached cost per 1M output tokens cannot be negative")
1136− }
1137− }
1138−
1139− // Validate default model references
1140− if providerConfig.DefaultLargeModel != "" {
1141− if !modelIDs[providerConfig.DefaultLargeModel] {
1142− errors.Add(fieldPrefix+".default_large_model", fmt.Sprintf("default large model '%s' not found in provider models", providerConfig.DefaultLargeModel))
1143− }
1144− }
1145− if providerConfig.DefaultSmallModel != "" {
1146− if !modelIDs[providerConfig.DefaultSmallModel] {
1147− errors.Add(fieldPrefix+".default_small_model", fmt.Sprintf("default small model '%s' not found in provider models", providerConfig.DefaultSmallModel))
1148− }
1149− }
1150−
1151− // Validate provider-specific requirements
1152− c.validateProviderSpecific(providerID, providerConfig, errors)
1153− }
1154−}
1155−
1156−// validateProviderSpecific validates provider-specific requirements
1157−func (c *Config) validateProviderSpecific(providerID provider.InferenceProvider, providerConfig ProviderConfig, errors *ValidationErrors) {
1158− fieldPrefix := fmt.Sprintf("providers.%s", providerID)
1159−
1160− switch providerID {
1161− case provider.InferenceProviderVertexAI:
1162− if !providerConfig.Disabled {
1163− if providerConfig.ExtraParams == nil {
1164− errors.Add(fieldPrefix+".extra_params", "VertexAI requires extra_params configuration")
1165− } else {
1166− if providerConfig.ExtraParams["project"] == "" {
1167− errors.Add(fieldPrefix+".extra_params.project", "VertexAI requires project parameter")
1168− }
1169− if providerConfig.ExtraParams["location"] == "" {
1170− errors.Add(fieldPrefix+".extra_params.location", "VertexAI requires location parameter")
1171− }
1172− }
1173− }
1174− case provider.InferenceProviderBedrock:
1175− if !providerConfig.Disabled {
1176− if providerConfig.ExtraParams == nil || providerConfig.ExtraParams["region"] == "" {
1177− errors.Add(fieldPrefix+".extra_params.region", "Bedrock requires region parameter")
1178− }
1179− // Check for AWS credentials in environment
1180− if !hasAWSCredentials() {
1181− errors.Add(fieldPrefix, "Bedrock requires AWS credentials in environment")
1182− }
1183− }
1184− }
1185−}
1186−
1187−// validateModels validates preferred model configurations
1188−func (c *Config) validateModels(errors *ValidationErrors) {
1189− // Validate large model
1190− if c.Models.Large.ModelID != "" || c.Models.Large.Provider != "" {
1191− if c.Models.Large.ModelID == "" {
1192− errors.Add("models.large.model_id", "large model ID is required when provider is set")
1193− }
1194− if c.Models.Large.Provider == "" {
1195− errors.Add("models.large.provider", "large model provider is required when model ID is set")
1196− }
1197−
1198− // Check if provider exists and is not disabled
1199− if providerConfig, exists := c.Providers[c.Models.Large.Provider]; exists {
1200− if providerConfig.Disabled {
1201− errors.Add("models.large.provider", "large model provider is disabled")
1202− }
1203−
1204− // Check if model exists in provider
1205− modelExists := false
1206− for _, model := range providerConfig.Models {
1207− if model.ID == c.Models.Large.ModelID {
1208− modelExists = true
1209− break
1210− }
1211− }
1212− if !modelExists {
1213− errors.Add("models.large.model_id", fmt.Sprintf("large model '%s' not found in provider '%s'", c.Models.Large.ModelID, c.Models.Large.Provider))
1214− }
1215− } else {
1216− errors.Add("models.large.provider", fmt.Sprintf("large model provider '%s' not found", c.Models.Large.Provider))
1217− }
1218− }
1219−
1220− // Validate small model
1221− if c.Models.Small.ModelID != "" || c.Models.Small.Provider != "" {
1222− if c.Models.Small.ModelID == "" {
1223− errors.Add("models.small.model_id", "small model ID is required when provider is set")
1224− }
1225− if c.Models.Small.Provider == "" {
1226− errors.Add("models.small.provider", "small model provider is required when model ID is set")
1227− }
1228−
1229− // Check if provider exists and is not disabled
1230− if providerConfig, exists := c.Providers[c.Models.Small.Provider]; exists {
1231− if providerConfig.Disabled {
1232− errors.Add("models.small.provider", "small model provider is disabled")
1233− }
1234−
1235− // Check if model exists in provider
1236− modelExists := false
1237− for _, model := range providerConfig.Models {
1238− if model.ID == c.Models.Small.ModelID {
1239− modelExists = true
1240− break
1241− }
1242− }
1243− if !modelExists {
1244− errors.Add("models.small.model_id", fmt.Sprintf("small model '%s' not found in provider '%s'", c.Models.Small.ModelID, c.Models.Small.Provider))
1245− }
1246− } else {
1247− errors.Add("models.small.provider", fmt.Sprintf("small model provider '%s' not found", c.Models.Small.Provider))
1248− }
1249− }
1250−}
1251−
1252−// validateAgents validates agent configurations
1253−func (c *Config) validateAgents(errors *ValidationErrors) {
1254− if c.Agents == nil {
1255− c.Agents = make(map[AgentID]Agent)
1256− }
1257−
1258− validTools := []string{
1259− "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
1260− }
1261−
1262− for agentID, agent := range c.Agents {
1263− fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1264−
1265− // Validate agent ID consistency
1266− if agent.ID != agentID {
1267− errors.Add(fieldPrefix+".id", fmt.Sprintf("agent ID mismatch: expected '%s', got '%s'", agentID, agent.ID))
1268− }
1269−
1270− // Validate required fields
1271− if agent.ID == "" {
1272− errors.Add(fieldPrefix+".id", "agent ID is required")
1273− }
1274− if agent.Name == "" {
1275− errors.Add(fieldPrefix+".name", "agent name is required")
1276− }
1277−
1278− // Validate model type
1279− if agent.Model != LargeModel && agent.Model != SmallModel {
1280− errors.Add(fieldPrefix+".model", fmt.Sprintf("invalid model type: %s (must be 'large' or 'small')", agent.Model))
1281− }
1282−
1283− // Validate allowed tools
1284− if agent.AllowedTools != nil {
1285− for i, tool := range agent.AllowedTools {
1286− validTool := slices.Contains(validTools, tool)
1287− if !validTool {
1288− errors.Add(fmt.Sprintf("%s.allowed_tools[%d]", fieldPrefix, i), fmt.Sprintf("unknown tool: %s", tool))
1289− }
1290− }
1291− }
1292−
1293− // Validate MCP references
1294− if agent.AllowedMCP != nil {
1295− for mcpName := range agent.AllowedMCP {
1296− if _, exists := c.MCP[mcpName]; !exists {
1297− errors.Add(fieldPrefix+".allowed_mcp", fmt.Sprintf("referenced MCP '%s' not found", mcpName))
1298− }
1299− }
1300− }
1301−
1302− // Validate LSP references
1303− if agent.AllowedLSP != nil {
1304− for _, lspName := range agent.AllowedLSP {
1305− if _, exists := c.LSP[lspName]; !exists {
1306− errors.Add(fieldPrefix+".allowed_lsp", fmt.Sprintf("referenced LSP '%s' not found", lspName))
1307− }
1308− }
1309− }
1310−
1311− // Validate context paths (basic path validation)
1312− for i, contextPath := range agent.ContextPaths {
1313− if contextPath == "" {
1314− errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path cannot be empty")
1315− }
1316− // Check for invalid characters in path
1317− if strings.Contains(contextPath, "\x00") {
1318− errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path contains invalid characters")
1319− }
1320− }
1321−
1322− // Validate known agents maintain their core properties
1323− if agentID == AgentCoder {
1324− if agent.Name != "Coder" {
1325− errors.Add(fieldPrefix+".name", "coder agent name cannot be changed")
1326− }
1327− if agent.Description != "An agent that helps with executing coding tasks." {
1328− errors.Add(fieldPrefix+".description", "coder agent description cannot be changed")
1329− }
1330− } else if agentID == AgentTask {
1331− if agent.Name != "Task" {
1332− errors.Add(fieldPrefix+".name", "task agent name cannot be changed")
1333− }
1334− if agent.Description != "An agent that helps with searching for context and finding implementation details." {
1335− errors.Add(fieldPrefix+".description", "task agent description cannot be changed")
1336− }
1337− expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1338− if agent.AllowedTools != nil && !slices.Equal(agent.AllowedTools, expectedTools) {
1339− errors.Add(fieldPrefix+".allowed_tools", "task agent allowed tools cannot be changed")
1340− }
1341− }
1342− }
1343−}
1344−
1345−// validateOptions validates configuration options
1346−func (c *Config) validateOptions(errors *ValidationErrors) {
1347− // Validate data directory
1348− if c.Options.DataDirectory == "" {
1349− errors.Add("options.data_directory", "data directory is required")
1350− }
1351−
1352− // Validate context paths
1353− for i, contextPath := range c.Options.ContextPaths {
1354− if contextPath == "" {
1355− errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path cannot be empty")
1356− }
1357− if strings.Contains(contextPath, "\x00") {
1358− errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path contains invalid characters")
1359− }
1360− }
1361−}
1362−
1363−// validateMCPs validates MCP configurations
1364−func (c *Config) validateMCPs(errors *ValidationErrors) {
1365− if c.MCP == nil {
1366− c.MCP = make(map[string]MCP)
1367− }
1368−
1369− for mcpName, mcpConfig := range c.MCP {
1370− fieldPrefix := fmt.Sprintf("mcp.%s", mcpName)
1371−
1372− // Validate MCP type
1373− if mcpConfig.Type != MCPStdio && mcpConfig.Type != MCPSse && mcpConfig.Type != MCPHttp {
1374− errors.Add(fieldPrefix+".type", fmt.Sprintf("invalid MCP type: %s (must be 'stdio' or 'sse' or 'http')", mcpConfig.Type))
1375− }
1376−
1377− // Validate based on type
1378− if mcpConfig.Type == MCPStdio {
1379− if mcpConfig.Command == "" {
1380− errors.Add(fieldPrefix+".command", "command is required for stdio MCP")
1381− }
1382− } else if mcpConfig.Type == MCPSse {
1383− if mcpConfig.URL == "" {
1384− errors.Add(fieldPrefix+".url", "URL is required for SSE MCP")
1385− }
1386− }
1387− }
1388−}
1389−
1390−// validateLSPs validates LSP configurations
1391−func (c *Config) validateLSPs(errors *ValidationErrors) {
1392− if c.LSP == nil {
1393− c.LSP = make(map[string]LSPConfig)
1394− }
1395−
1396− for lspName, lspConfig := range c.LSP {
1397− fieldPrefix := fmt.Sprintf("lsp.%s", lspName)
1398−
1399− if lspConfig.Command == "" {
1400− errors.Add(fieldPrefix+".command", "command is required for LSP")
1401− }
1402− }
1403−}
1404−
1405−// validateCrossReferences validates cross-references between different config sections
1406−func (c *Config) validateCrossReferences(errors *ValidationErrors) {
1407− // Validate that agents can use their assigned model types
1408− for agentID, agent := range c.Agents {
1409− fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1410−
1411− var preferredModel PreferredModel
1412− switch agent.Model {
1413− case LargeModel:
1414− preferredModel = c.Models.Large
1415− case SmallModel:
1416− preferredModel = c.Models.Small
1417− }
1418−
1419− if preferredModel.Provider != "" {
1420− if providerConfig, exists := c.Providers[preferredModel.Provider]; exists {
1421− if providerConfig.Disabled {
1422− errors.Add(fieldPrefix+".model", fmt.Sprintf("agent cannot use model type '%s' because provider '%s' is disabled", agent.Model, preferredModel.Provider))
1423− }
1424− }
1425− }
1426− }
1427−}
1428−
1429−// validateCompleteness validates that the configuration is complete and usable
1430−func (c *Config) validateCompleteness(errors *ValidationErrors) {
1431− // Check for at least one valid, non-disabled provider
1432− hasValidProvider := false
1433− for _, providerConfig := range c.Providers {
1434− if !providerConfig.Disabled {
1435− hasValidProvider = true
1436− break
1437− }
1438− }
1439− if !hasValidProvider {
1440− errors.Add("providers", "at least one non-disabled provider is required")
1441− }
1442−
1443− // Check that default agents exist
1444− if _, exists := c.Agents[AgentCoder]; !exists {
1445− errors.Add("agents", "coder agent is required")
1446− }
1447− if _, exists := c.Agents[AgentTask]; !exists {
1448− errors.Add("agents", "task agent is required")
1449− }
1450−
1451− // Check that preferred models are set if providers exist
1452− if hasValidProvider {
1453− if c.Models.Large.ModelID == "" || c.Models.Large.Provider == "" {
1454− errors.Add("models.large", "large preferred model must be configured when providers are available")
1455− }
1456− if c.Models.Small.ModelID == "" || c.Models.Small.Provider == "" {
1457− errors.Add("models.small", "small preferred model must be configured when providers are available")
1458− }
1459− }
1460−}
1461−
1462−// JSONSchemaExtend adds custom schema properties for AgentID
1463−func (AgentID) JSONSchemaExtend(schema *jsonschema.Schema) {
1464− schema.Enum = []any{
1465− string(AgentCoder),
1466− string(AgentTask),
1467− }
1468−}
1469−
1470−// JSONSchemaExtend adds custom schema properties for ModelType
1471−func (ModelType) JSONSchemaExtend(schema *jsonschema.Schema) {
1472− schema.Enum = []any{
1473− string(LargeModel),
1474− string(SmallModel),
1475− }
1476−}
1477−
1478−// JSONSchemaExtend adds custom schema properties for MCPType
1479−func (MCPType) JSONSchemaExtend(schema *jsonschema.Schema) {
1480− schema.Enum = []any{
1481− string(MCPStdio),
1482− string(MCPSse),
1483− }
1484−}
internal/config/config_test.godeleted−2075
@@ -1,2075 +0,0 @@
1−package config
2−
3−import (
4− "encoding/json"
5− "os"
6− "path/filepath"
7− "sync"
8− "testing"
9−
10− "github.com/charmbracelet/crush/internal/fur/provider"
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func reset() {
16− // Clear all environment variables that could affect config
17− envVarsToUnset := []string{
18− // API Keys
19− "ANTHROPIC_API_KEY",
20− "OPENAI_API_KEY",
21− "GEMINI_API_KEY",
22− "XAI_API_KEY",
23− "OPENROUTER_API_KEY",
24−
25− // Google Cloud / VertexAI
26− "GOOGLE_GENAI_USE_VERTEXAI",
27− "GOOGLE_CLOUD_PROJECT",
28− "GOOGLE_CLOUD_LOCATION",
29−
30− // AWS Credentials
31− "AWS_ACCESS_KEY_ID",
32− "AWS_SECRET_ACCESS_KEY",
33− "AWS_REGION",
34− "AWS_DEFAULT_REGION",
35− "AWS_PROFILE",
36− "AWS_DEFAULT_PROFILE",
37− "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
38− "AWS_CONTAINER_CREDENTIALS_FULL_URI",
39−
40− // Other
41− "CRUSH_DEV_DEBUG",
42− }
43−
44− for _, envVar := range envVarsToUnset {
45− os.Unsetenv(envVar)
46− }
47−
48− // Reset singleton
49− once = sync.Once{}
50− instance = nil
51− cwd = ""
52− testConfigDir = ""
53−
54− // Enable mock providers for all tests to avoid API calls
55− UseMockProviders = true
56− ResetProviders()
57−}
58−
59−// Core Configuration Loading Tests
60−
61−func TestInit_ValidWorkingDirectory(t *testing.T) {
62− reset()
63− testConfigDir = t.TempDir()
64− cwdDir := t.TempDir()
65−
66− cfg, err := Init(cwdDir, false)
67−
68− require.NoError(t, err)
69− assert.NotNil(t, cfg)
70− assert.Equal(t, cwdDir, WorkingDirectory())
71− assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
72− assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
73−}
74−
75−func TestInit_WithDebugFlag(t *testing.T) {
76− reset()
77− testConfigDir = t.TempDir()
78− cwdDir := t.TempDir()
79−
80− cfg, err := Init(cwdDir, true)
81−
82− require.NoError(t, err)
83− assert.True(t, cfg.Options.Debug)
84−}
85−
86−func TestInit_SingletonBehavior(t *testing.T) {
87− reset()
88− testConfigDir = t.TempDir()
89− cwdDir := t.TempDir()
90−
91− cfg1, err1 := Init(cwdDir, false)
92− cfg2, err2 := Init(cwdDir, false)
93−
94− require.NoError(t, err1)
95− require.NoError(t, err2)
96− assert.Same(t, cfg1, cfg2)
97−}
98−
99−func TestGet_BeforeInitialization(t *testing.T) {
100− reset()
101−
102− assert.Panics(t, func() {
103− Get()
104− })
105−}
106−
107−func TestGet_AfterInitialization(t *testing.T) {
108− reset()
109− testConfigDir = t.TempDir()
110− cwdDir := t.TempDir()
111−
112− cfg1, err := Init(cwdDir, false)
113− require.NoError(t, err)
114−
115− cfg2 := Get()
116− assert.Same(t, cfg1, cfg2)
117−}
118−
119−func TestLoadConfig_NoConfigFiles(t *testing.T) {
120− reset()
121− testConfigDir = t.TempDir()
122− cwdDir := t.TempDir()
123−
124− cfg, err := Init(cwdDir, false)
125−
126− require.NoError(t, err)
127− assert.Len(t, cfg.Providers, 0)
128− assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
129−}
130−
131−func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
132− reset()
133− testConfigDir = t.TempDir()
134− cwdDir := t.TempDir()
135−
136− globalConfig := Config{
137− Providers: map[provider.InferenceProvider]ProviderConfig{
138− provider.InferenceProviderOpenAI: {
139− ID: provider.InferenceProviderOpenAI,
140− APIKey: "test-key",
141− ProviderType: provider.TypeOpenAI,
142− DefaultLargeModel: "gpt-4",
143− DefaultSmallModel: "gpt-3.5-turbo",
144− Models: []Model{
145− {
146− ID: "gpt-4",
147− Name: "GPT-4",
148− CostPer1MIn: 30.0,
149− CostPer1MOut: 60.0,
150− ContextWindow: 8192,
151− DefaultMaxTokens: 4096,
152− },
153− {
154− ID: "gpt-3.5-turbo",
155− Name: "GPT-3.5 Turbo",
156− CostPer1MIn: 1.0,
157− CostPer1MOut: 2.0,
158− ContextWindow: 4096,
159− DefaultMaxTokens: 4096,
160− },
161− },
162− },
163− },
164− Options: Options{
165− ContextPaths: []string{"custom-context.md"},
166− },
167− }
168−
169− configPath := filepath.Join(testConfigDir, "crush.json")
170− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
171−
172− data, err := json.Marshal(globalConfig)
173− require.NoError(t, err)
174− require.NoError(t, os.WriteFile(configPath, data, 0o644))
175−
176− cfg, err := Init(cwdDir, false)
177−
178− require.NoError(t, err)
179− assert.Len(t, cfg.Providers, 1)
180− assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
181− assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
182−}
183−
184−func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
185− reset()
186− testConfigDir = t.TempDir()
187− cwdDir := t.TempDir()
188−
189− localConfig := Config{
190− Providers: map[provider.InferenceProvider]ProviderConfig{
191− provider.InferenceProviderAnthropic: {
192− ID: provider.InferenceProviderAnthropic,
193− APIKey: "local-key",
194− ProviderType: provider.TypeAnthropic,
195− DefaultLargeModel: "claude-3-opus",
196− DefaultSmallModel: "claude-3-haiku",
197− Models: []Model{
198− {
199− ID: "claude-3-opus",
200− Name: "Claude 3 Opus",
201− CostPer1MIn: 15.0,
202− CostPer1MOut: 75.0,
203− ContextWindow: 200000,
204− DefaultMaxTokens: 4096,
205− },
206− {
207− ID: "claude-3-haiku",
208− Name: "Claude 3 Haiku",
209− CostPer1MIn: 0.25,
210− CostPer1MOut: 1.25,
211− ContextWindow: 200000,
212− DefaultMaxTokens: 4096,
213− },
214− },
215− },
216− },
217− Options: Options{
218− TUI: TUIOptions{CompactMode: true},
219− },
220− }
221−
222− localConfigPath := filepath.Join(cwdDir, "crush.json")
223− data, err := json.Marshal(localConfig)
224− require.NoError(t, err)
225− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
226−
227− cfg, err := Init(cwdDir, false)
228−
229− require.NoError(t, err)
230− assert.Len(t, cfg.Providers, 1)
231− assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
232− assert.True(t, cfg.Options.TUI.CompactMode)
233−}
234−
235−func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
236− reset()
237− testConfigDir = t.TempDir()
238− cwdDir := t.TempDir()
239−
240− globalConfig := Config{
241− Providers: map[provider.InferenceProvider]ProviderConfig{
242− provider.InferenceProviderOpenAI: {
243− ID: provider.InferenceProviderOpenAI,
244− APIKey: "global-key",
245− ProviderType: provider.TypeOpenAI,
246− DefaultLargeModel: "gpt-4",
247− DefaultSmallModel: "gpt-3.5-turbo",
248− Models: []Model{
249− {
250− ID: "gpt-4",
251− Name: "GPT-4",
252− CostPer1MIn: 30.0,
253− CostPer1MOut: 60.0,
254− ContextWindow: 8192,
255− DefaultMaxTokens: 4096,
256− },
257− {
258− ID: "gpt-3.5-turbo",
259− Name: "GPT-3.5 Turbo",
260− CostPer1MIn: 1.0,
261− CostPer1MOut: 2.0,
262− ContextWindow: 4096,
263− DefaultMaxTokens: 4096,
264− },
265− },
266− },
267− },
268− Options: Options{
269− ContextPaths: []string{"global-context.md"},
270− },
271− }
272−
273− configPath := filepath.Join(testConfigDir, "crush.json")
274− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
275− data, err := json.Marshal(globalConfig)
276− require.NoError(t, err)
277− require.NoError(t, os.WriteFile(configPath, data, 0o644))
278−
279− localConfig := Config{
280− Providers: map[provider.InferenceProvider]ProviderConfig{
281− provider.InferenceProviderOpenAI: {
282− APIKey: "local-key", // Override global
283− },
284− provider.InferenceProviderAnthropic: {
285− ID: provider.InferenceProviderAnthropic,
286− APIKey: "anthropic-key",
287− ProviderType: provider.TypeAnthropic,
288− DefaultLargeModel: "claude-3-opus",
289− DefaultSmallModel: "claude-3-haiku",
290− Models: []Model{
291− {
292− ID: "claude-3-opus",
293− Name: "Claude 3 Opus",
294− CostPer1MIn: 15.0,
295− CostPer1MOut: 75.0,
296− ContextWindow: 200000,
297− DefaultMaxTokens: 4096,
298− },
299− {
300− ID: "claude-3-haiku",
301− Name: "Claude 3 Haiku",
302− CostPer1MIn: 0.25,
303− CostPer1MOut: 1.25,
304− ContextWindow: 200000,
305− DefaultMaxTokens: 4096,
306− },
307− },
308− },
309− },
310− Options: Options{
311− ContextPaths: []string{"local-context.md"},
312− TUI: TUIOptions{CompactMode: true},
313− },
314− }
315−
316− localConfigPath := filepath.Join(cwdDir, "crush.json")
317− data, err = json.Marshal(localConfig)
318− require.NoError(t, err)
319− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
320−
321− cfg, err := Init(cwdDir, false)
322−
323− require.NoError(t, err)
324− assert.Len(t, cfg.Providers, 2)
325−
326− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
327− assert.Equal(t, "local-key", openaiProvider.APIKey)
328−
329− assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
330−
331− assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
332− assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
333− assert.True(t, cfg.Options.TUI.CompactMode)
334−}
335−
336−func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
337− reset()
338− testConfigDir = t.TempDir()
339− cwdDir := t.TempDir()
340−
341− configPath := filepath.Join(testConfigDir, "crush.json")
342− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
343− require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
344−
345− _, err := Init(cwdDir, false)
346− assert.Error(t, err)
347−}
348−
349−func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
350− reset()
351− testConfigDir = t.TempDir()
352− cwdDir := t.TempDir()
353−
354− localConfigPath := filepath.Join(cwdDir, "crush.json")
355− require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
356−
357− _, err := Init(cwdDir, false)
358− assert.Error(t, err)
359−}
360−
361−func TestConfigWithoutEnv(t *testing.T) {
362− reset()
363− testConfigDir = t.TempDir()
364− cwdDir := t.TempDir()
365−
366− cfg, _ := Init(cwdDir, false)
367− assert.Len(t, cfg.Providers, 0)
368−}
369−
370−func TestConfigWithEnv(t *testing.T) {
371− reset()
372− testConfigDir = t.TempDir()
373− cwdDir := t.TempDir()
374−
375− os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
376− os.Setenv("OPENAI_API_KEY", "test-openai-key")
377− os.Setenv("GEMINI_API_KEY", "test-gemini-key")
378− os.Setenv("XAI_API_KEY", "test-xai-key")
379− os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
380−
381− cfg, _ := Init(cwdDir, false)
382− assert.Len(t, cfg.Providers, 5)
383−}
384−
385−// Environment Variable Tests
386−
387−func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
388− reset()
389− testConfigDir = t.TempDir()
390− cwdDir := t.TempDir()
391−
392− cfg, err := Init(cwdDir, false)
393−
394− require.NoError(t, err)
395− assert.Len(t, cfg.Providers, 0)
396−}
397−
398−func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
399− reset()
400− testConfigDir = t.TempDir()
401− cwdDir := t.TempDir()
402−
403− os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
404− os.Setenv("OPENAI_API_KEY", "test-openai-key")
405− os.Setenv("GEMINI_API_KEY", "test-gemini-key")
406− os.Setenv("XAI_API_KEY", "test-xai-key")
407− os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
408−
409− cfg, err := Init(cwdDir, false)
410−
411− require.NoError(t, err)
412− assert.Len(t, cfg.Providers, 5)
413−
414− anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
415− assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
416− assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
417−
418− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
419− assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
420− assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
421−
422− geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
423− assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
424− assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
425−
426− xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
427− assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
428− assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
429−
430− openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
431− assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
432− assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
433− assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
434−}
435−
436−func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
437− reset()
438− testConfigDir = t.TempDir()
439− cwdDir := t.TempDir()
440−
441− os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
442− os.Setenv("OPENAI_API_KEY", "test-openai-key")
443−
444− cfg, err := Init(cwdDir, false)
445−
446− require.NoError(t, err)
447− assert.Len(t, cfg.Providers, 2)
448− assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
449− assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
450− assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
451−}
452−
453−func TestEnvVars_VertexAIConfiguration(t *testing.T) {
454− reset()
455− testConfigDir = t.TempDir()
456− cwdDir := t.TempDir()
457−
458− os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
459− os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
460− os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
461−
462− cfg, err := Init(cwdDir, false)
463−
464− require.NoError(t, err)
465− assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
466−
467− vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
468− assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
469− assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
470− assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
471−}
472−
473−func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
474− reset()
475− testConfigDir = t.TempDir()
476− cwdDir := t.TempDir()
477−
478− os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
479− os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
480−
481− cfg, err := Init(cwdDir, false)
482−
483− require.NoError(t, err)
484− assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
485−}
486−
487−func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
488− reset()
489− testConfigDir = t.TempDir()
490− cwdDir := t.TempDir()
491−
492− os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
493− os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
494− os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
495−
496− cfg, err := Init(cwdDir, false)
497−
498− require.NoError(t, err)
499− assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
500−
501− bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
502− assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
503− assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
504−}
505−
506−func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
507− reset()
508− testConfigDir = t.TempDir()
509− cwdDir := t.TempDir()
510−
511− os.Setenv("AWS_PROFILE", "test-profile")
512− os.Setenv("AWS_REGION", "eu-west-1")
513−
514− cfg, err := Init(cwdDir, false)
515−
516− require.NoError(t, err)
517− assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
518−
519− bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
520− assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
521−}
522−
523−func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
524− reset()
525− testConfigDir = t.TempDir()
526− cwdDir := t.TempDir()
527−
528− os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
529− os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
530−
531− cfg, err := Init(cwdDir, false)
532−
533− require.NoError(t, err)
534− assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
535−}
536−
537−func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
538− reset()
539− testConfigDir = t.TempDir()
540− cwdDir := t.TempDir()
541−
542− os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
543− os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
544− os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
545− os.Setenv("AWS_REGION", "us-east-1")
546−
547− cfg, err := Init(cwdDir, false)
548−
549− require.NoError(t, err)
550− bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
551− assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
552−}
553−
554−func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
555− reset()
556− testConfigDir = t.TempDir()
557− cwdDir := t.TempDir()
558−
559− os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
560− os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
561− os.Setenv("AWS_REGION", "us-east-1")
562−
563− cfg, err := Init(cwdDir, false)
564−
565− require.NoError(t, err)
566− bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
567− assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
568−}
569−
570−func TestEnvVars_NoAWSCredentials(t *testing.T) {
571− reset()
572− testConfigDir = t.TempDir()
573− cwdDir := t.TempDir()
574−
575− cfg, err := Init(cwdDir, false)
576−
577− require.NoError(t, err)
578− assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
579−}
580−
581−func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
582− reset()
583− testConfigDir = t.TempDir()
584− cwdDir := t.TempDir()
585−
586− os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
587−
588− cfg, err := Init(cwdDir, false)
589−
590− require.NoError(t, err)
591− if len(cfg.Providers) > 0 {
592− if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
593− assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
594− }
595− }
596−}
597−
598−func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
599− reset()
600− testConfigDir = t.TempDir()
601− cwdDir := t.TempDir()
602−
603− os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
604− os.Setenv("OPENAI_API_KEY", "test-openai")
605− os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
606− os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
607− os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
608− os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
609− os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
610− os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
611−
612− cfg, err := Init(cwdDir, false)
613−
614− require.NoError(t, err)
615−
616− expectedProviders := []provider.InferenceProvider{
617− provider.InferenceProviderAnthropic,
618− provider.InferenceProviderOpenAI,
619− provider.InferenceProviderVertexAI,
620− provider.InferenceProviderBedrock,
621− }
622−
623− for _, expectedProvider := range expectedProviders {
624− assert.Contains(t, cfg.Providers, expectedProvider)
625− }
626−}
627−
628−func TestHasAWSCredentials_AccessKeys(t *testing.T) {
629− reset()
630−
631− os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
632− os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
633−
634− assert.True(t, hasAWSCredentials())
635−}
636−
637−func TestHasAWSCredentials_Profile(t *testing.T) {
638− reset()
639−
640− os.Setenv("AWS_PROFILE", "test-profile")
641−
642− assert.True(t, hasAWSCredentials())
643−}
644−
645−func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
646− reset()
647−
648− os.Setenv("AWS_DEFAULT_PROFILE", "default")
649−
650− assert.True(t, hasAWSCredentials())
651−}
652−
653−func TestHasAWSCredentials_Region(t *testing.T) {
654− reset()
655−
656− os.Setenv("AWS_REGION", "us-east-1")
657−
658− assert.True(t, hasAWSCredentials())
659−}
660−
661−func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
662− reset()
663−
664− os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
665−
666− assert.True(t, hasAWSCredentials())
667−}
668−
669−func TestHasAWSCredentials_NoCredentials(t *testing.T) {
670− reset()
671−
672− assert.False(t, hasAWSCredentials())
673−}
674−
675−func TestProviderMerging_GlobalToBase(t *testing.T) {
676− reset()
677− testConfigDir = t.TempDir()
678− cwdDir := t.TempDir()
679−
680− globalConfig := Config{
681− Providers: map[provider.InferenceProvider]ProviderConfig{
682− provider.InferenceProviderOpenAI: {
683− ID: provider.InferenceProviderOpenAI,
684− APIKey: "global-openai-key",
685− ProviderType: provider.TypeOpenAI,
686− DefaultLargeModel: "gpt-4",
687− DefaultSmallModel: "gpt-3.5-turbo",
688− Models: []Model{
689− {
690− ID: "gpt-4",
691− Name: "GPT-4",
692− ContextWindow: 8192,
693− DefaultMaxTokens: 4096,
694− },
695− {
696− ID: "gpt-3.5-turbo",
697− Name: "GPT-3.5 Turbo",
698− ContextWindow: 4096,
699− DefaultMaxTokens: 2048,
700− },
701− },
702− },
703− },
704− }
705−
706− configPath := filepath.Join(testConfigDir, "crush.json")
707− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
708− data, err := json.Marshal(globalConfig)
709− require.NoError(t, err)
710− require.NoError(t, os.WriteFile(configPath, data, 0o644))
711−
712− cfg, err := Init(cwdDir, false)
713−
714− require.NoError(t, err)
715− assert.Len(t, cfg.Providers, 1)
716−
717− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
718− assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
719− assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
720− assert.Equal(t, "gpt-4o", openaiProvider.DefaultSmallModel)
721− assert.GreaterOrEqual(t, len(openaiProvider.Models), 2)
722−}
723−
724−func TestProviderMerging_LocalToBase(t *testing.T) {
725− reset()
726− testConfigDir = t.TempDir()
727− cwdDir := t.TempDir()
728−
729− localConfig := Config{
730− Providers: map[provider.InferenceProvider]ProviderConfig{
731− provider.InferenceProviderAnthropic: {
732− ID: provider.InferenceProviderAnthropic,
733− APIKey: "local-anthropic-key",
734− ProviderType: provider.TypeAnthropic,
735− DefaultLargeModel: "claude-3-opus",
736− DefaultSmallModel: "claude-3-haiku",
737− Models: []Model{
738− {
739− ID: "claude-3-opus",
740− Name: "Claude 3 Opus",
741− ContextWindow: 200000,
742− DefaultMaxTokens: 4096,
743− CostPer1MIn: 15.0,
744− CostPer1MOut: 75.0,
745− },
746− {
747− ID: "claude-3-haiku",
748− Name: "Claude 3 Haiku",
749− ContextWindow: 200000,
750− DefaultMaxTokens: 4096,
751− CostPer1MIn: 0.25,
752− CostPer1MOut: 1.25,
753− },
754− },
755− },
756− },
757− }
758−
759− localConfigPath := filepath.Join(cwdDir, "crush.json")
760− data, err := json.Marshal(localConfig)
761− require.NoError(t, err)
762− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
763−
764− cfg, err := Init(cwdDir, false)
765−
766− require.NoError(t, err)
767− assert.Len(t, cfg.Providers, 1)
768−
769− anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
770− assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
771− assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
772− assert.Equal(t, "claude-3-5-haiku-20241022", anthropicProvider.DefaultSmallModel)
773− assert.GreaterOrEqual(t, len(anthropicProvider.Models), 2)
774−}
775−
776−func TestProviderMerging_ConflictingSettings(t *testing.T) {
777− reset()
778− testConfigDir = t.TempDir()
779− cwdDir := t.TempDir()
780−
781− globalConfig := Config{
782− Providers: map[provider.InferenceProvider]ProviderConfig{
783− provider.InferenceProviderOpenAI: {
784− ID: provider.InferenceProviderOpenAI,
785− APIKey: "global-key",
786− ProviderType: provider.TypeOpenAI,
787− DefaultLargeModel: "gpt-4",
788− DefaultSmallModel: "gpt-3.5-turbo",
789− Models: []Model{
790− {
791− ID: "gpt-4",
792− Name: "GPT-4",
793− ContextWindow: 8192,
794− DefaultMaxTokens: 4096,
795− },
796− {
797− ID: "gpt-3.5-turbo",
798− Name: "GPT-3.5 Turbo",
799− ContextWindow: 4096,
800− DefaultMaxTokens: 2048,
801− },
802− {
803− ID: "gpt-4-turbo",
804− Name: "GPT-4 Turbo",
805− ContextWindow: 128000,
806− DefaultMaxTokens: 4096,
807− },
808− },
809− },
810− },
811− }
812−
813− configPath := filepath.Join(testConfigDir, "crush.json")
814− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
815− data, err := json.Marshal(globalConfig)
816− require.NoError(t, err)
817− require.NoError(t, os.WriteFile(configPath, data, 0o644))
818−
819− // Create local config that overrides
820− localConfig := Config{
821− Providers: map[provider.InferenceProvider]ProviderConfig{
822− provider.InferenceProviderOpenAI: {
823− APIKey: "local-key",
824− DefaultLargeModel: "gpt-4-turbo",
825− },
826− },
827− }
828−
829− localConfigPath := filepath.Join(cwdDir, "crush.json")
830− data, err = json.Marshal(localConfig)
831− require.NoError(t, err)
832− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
833−
834− cfg, err := Init(cwdDir, false)
835−
836− require.NoError(t, err)
837−
838− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
839− assert.Equal(t, "local-key", openaiProvider.APIKey)
840− assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
841− assert.False(t, openaiProvider.Disabled)
842− assert.Equal(t, "gpt-4o", openaiProvider.DefaultSmallModel)
843−}
844−
845−func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
846− reset()
847− testConfigDir = t.TempDir()
848− cwdDir := t.TempDir()
849−
850− customProviderID := provider.InferenceProvider("custom-provider")
851−
852− globalConfig := Config{
853− Providers: map[provider.InferenceProvider]ProviderConfig{
854− provider.InferenceProviderOpenAI: {
855− ID: provider.InferenceProviderOpenAI,
856− APIKey: "openai-key",
857− BaseURL: "should-not-override",
858− ProviderType: provider.TypeAnthropic,
859− DefaultLargeModel: "gpt-4",
860− DefaultSmallModel: "gpt-3.5-turbo",
861− Models: []Model{
862− {
863− ID: "gpt-4",
864− Name: "GPT-4",
865− ContextWindow: 8192,
866− DefaultMaxTokens: 4096,
867− },
868− {
869− ID: "gpt-3.5-turbo",
870− Name: "GPT-3.5 Turbo",
871− ContextWindow: 4096,
872− DefaultMaxTokens: 2048,
873− },
874− },
875− },
876− customProviderID: {
877− ID: customProviderID,
878− APIKey: "custom-key",
879− BaseURL: "https://custom.api.com",
880− ProviderType: provider.TypeOpenAI,
881− DefaultLargeModel: "custom-large",
882− DefaultSmallModel: "custom-small",
883− Models: []Model{
884− {
885− ID: "custom-large",
886− Name: "Custom Large",
887− ContextWindow: 8192,
888− DefaultMaxTokens: 4096,
889− },
890− {
891− ID: "custom-small",
892− Name: "Custom Small",
893− ContextWindow: 4096,
894− DefaultMaxTokens: 2048,
895− },
896− },
897− },
898− },
899− }
900−
901− localConfig := Config{
902− Providers: map[provider.InferenceProvider]ProviderConfig{
903− provider.InferenceProviderOpenAI: {
904− BaseURL: "https://should-not-change.com",
905− ProviderType: provider.TypeGemini, // Should not change
906− },
907− customProviderID: {
908− BaseURL: "https://updated-custom.api.com",
909− ProviderType: provider.TypeOpenAI,
910− },
911− },
912− }
913−
914− configPath := filepath.Join(testConfigDir, "crush.json")
915− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
916− data, err := json.Marshal(globalConfig)
917− require.NoError(t, err)
918− require.NoError(t, os.WriteFile(configPath, data, 0o644))
919−
920− localConfigPath := filepath.Join(cwdDir, "crush.json")
921− data, err = json.Marshal(localConfig)
922− require.NoError(t, err)
923− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
924−
925− cfg, err := Init(cwdDir, false)
926−
927− require.NoError(t, err)
928−
929− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
930− assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
931− assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
932−
933− customProvider := cfg.Providers[customProviderID]
934− assert.Equal(t, "custom-key", customProvider.APIKey)
935− assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
936− assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
937−}
938−
939−func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
940− reset()
941− testConfigDir = t.TempDir()
942− cwdDir := t.TempDir()
943−
944− customProviderID := provider.InferenceProvider("custom-provider")
945−
946− globalConfig := Config{
947− Providers: map[provider.InferenceProvider]ProviderConfig{
948− customProviderID: {
949− ID: customProviderID,
950− APIKey: "custom-key",
951− ProviderType: provider.TypeOpenAI,
952− },
953− },
954− }
955−
956− configPath := filepath.Join(testConfigDir, "crush.json")
957− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
958− data, err := json.Marshal(globalConfig)
959− require.NoError(t, err)
960− require.NoError(t, os.WriteFile(configPath, data, 0o644))
961−
962− cfg, err := Init(cwdDir, false)
963−
964− require.NoError(t, err)
965− assert.NotContains(t, cfg.Providers, customProviderID)
966−}
967−
968−func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
969− reset()
970− testConfigDir = t.TempDir()
971− cwdDir := t.TempDir()
972−
973− customProviderID := provider.InferenceProvider("custom-provider")
974−
975− globalConfig := Config{
976− Providers: map[provider.InferenceProvider]ProviderConfig{
977− customProviderID: {
978− ID: customProviderID,
979− BaseURL: "https://custom.api.com",
980− ProviderType: provider.TypeOpenAI,
981− },
982− },
983− }
984−
985− configPath := filepath.Join(testConfigDir, "crush.json")
986− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
987− data, err := json.Marshal(globalConfig)
988− require.NoError(t, err)
989− require.NoError(t, os.WriteFile(configPath, data, 0o644))
990−
991− cfg, err := Init(cwdDir, false)
992−
993− require.NoError(t, err)
994− assert.NotContains(t, cfg.Providers, customProviderID)
995−}
996−
997−func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
998− reset()
999− testConfigDir = t.TempDir()
1000− cwdDir := t.TempDir()
1001−
1002− customProviderID := provider.InferenceProvider("custom-provider")
1003−
1004− globalConfig := Config{
1005− Providers: map[provider.InferenceProvider]ProviderConfig{
1006− customProviderID: {
1007− ID: customProviderID,
1008− APIKey: "custom-key",
1009− BaseURL: "https://custom.api.com",
1010− ProviderType: provider.Type("invalid-type"),
1011− },
1012− },
1013− }
1014−
1015− configPath := filepath.Join(testConfigDir, "crush.json")
1016− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1017− data, err := json.Marshal(globalConfig)
1018− require.NoError(t, err)
1019− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1020−
1021− cfg, err := Init(cwdDir, false)
1022−
1023− require.NoError(t, err)
1024− assert.NotContains(t, cfg.Providers, customProviderID)
1025−}
1026−
1027−func TestProviderValidation_KnownProviderValid(t *testing.T) {
1028− reset()
1029− testConfigDir = t.TempDir()
1030− cwdDir := t.TempDir()
1031−
1032− globalConfig := Config{
1033− Providers: map[provider.InferenceProvider]ProviderConfig{
1034− provider.InferenceProviderOpenAI: {
1035− ID: provider.InferenceProviderOpenAI,
1036− APIKey: "openai-key",
1037− ProviderType: provider.TypeOpenAI,
1038− DefaultLargeModel: "gpt-4",
1039− DefaultSmallModel: "gpt-3.5-turbo",
1040− Models: []Model{
1041− {
1042− ID: "gpt-4",
1043− Name: "GPT-4",
1044− ContextWindow: 8192,
1045− DefaultMaxTokens: 4096,
1046− },
1047− {
1048− ID: "gpt-3.5-turbo",
1049− Name: "GPT-3.5 Turbo",
1050− ContextWindow: 4096,
1051− DefaultMaxTokens: 2048,
1052− },
1053− },
1054− },
1055− },
1056− }
1057−
1058− configPath := filepath.Join(testConfigDir, "crush.json")
1059− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1060− data, err := json.Marshal(globalConfig)
1061− require.NoError(t, err)
1062− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1063−
1064− cfg, err := Init(cwdDir, false)
1065−
1066− require.NoError(t, err)
1067− assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1068−}
1069−
1070−func TestProviderValidation_DisabledProvider(t *testing.T) {
1071− reset()
1072− testConfigDir = t.TempDir()
1073− cwdDir := t.TempDir()
1074−
1075− globalConfig := Config{
1076− Providers: map[provider.InferenceProvider]ProviderConfig{
1077− provider.InferenceProviderOpenAI: {
1078− ID: provider.InferenceProviderOpenAI,
1079− APIKey: "openai-key",
1080− ProviderType: provider.TypeOpenAI,
1081− Disabled: true,
1082− DefaultLargeModel: "gpt-4",
1083− DefaultSmallModel: "gpt-3.5-turbo",
1084− Models: []Model{
1085− {
1086− ID: "gpt-4",
1087− Name: "GPT-4",
1088− ContextWindow: 8192,
1089− DefaultMaxTokens: 4096,
1090− },
1091− {
1092− ID: "gpt-3.5-turbo",
1093− Name: "GPT-3.5 Turbo",
1094− ContextWindow: 4096,
1095− DefaultMaxTokens: 2048,
1096− },
1097− },
1098− },
1099− provider.InferenceProviderAnthropic: {
1100− ID: provider.InferenceProviderAnthropic,
1101− APIKey: "anthropic-key",
1102− ProviderType: provider.TypeAnthropic,
1103− Disabled: false, // This one is enabled
1104− DefaultLargeModel: "claude-3-opus",
1105− DefaultSmallModel: "claude-3-haiku",
1106− Models: []Model{
1107− {
1108− ID: "claude-3-opus",
1109− Name: "Claude 3 Opus",
1110− ContextWindow: 200000,
1111− DefaultMaxTokens: 4096,
1112− },
1113− {
1114− ID: "claude-3-haiku",
1115− Name: "Claude 3 Haiku",
1116− ContextWindow: 200000,
1117− DefaultMaxTokens: 4096,
1118− },
1119− },
1120− },
1121− },
1122− }
1123−
1124− configPath := filepath.Join(testConfigDir, "crush.json")
1125− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1126− data, err := json.Marshal(globalConfig)
1127− require.NoError(t, err)
1128− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1129−
1130− cfg, err := Init(cwdDir, false)
1131−
1132− require.NoError(t, err)
1133− assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1134− assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
1135− assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
1136− assert.False(t, cfg.Providers[provider.InferenceProviderAnthropic].Disabled)
1137−}
1138−
1139−func TestProviderModels_AddingNewModels(t *testing.T) {
1140− reset()
1141− testConfigDir = t.TempDir()
1142− cwdDir := t.TempDir()
1143−
1144− globalConfig := Config{
1145− Providers: map[provider.InferenceProvider]ProviderConfig{
1146− provider.InferenceProviderOpenAI: {
1147− ID: provider.InferenceProviderOpenAI,
1148− APIKey: "openai-key",
1149− ProviderType: provider.TypeOpenAI,
1150− DefaultLargeModel: "gpt-4",
1151− DefaultSmallModel: "gpt-4-turbo",
1152− Models: []Model{
1153− {
1154− ID: "gpt-4",
1155− Name: "GPT-4",
1156− ContextWindow: 8192,
1157− DefaultMaxTokens: 4096,
1158− },
1159− },
1160− },
1161− },
1162− }
1163−
1164− localConfig := Config{
1165− Providers: map[provider.InferenceProvider]ProviderConfig{
1166− provider.InferenceProviderOpenAI: {
1167− Models: []Model{
1168− {
1169− ID: "gpt-4-turbo",
1170− Name: "GPT-4 Turbo",
1171− ContextWindow: 128000,
1172− DefaultMaxTokens: 4096,
1173− },
1174− },
1175− },
1176− },
1177− }
1178−
1179− configPath := filepath.Join(testConfigDir, "crush.json")
1180− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1181− data, err := json.Marshal(globalConfig)
1182− require.NoError(t, err)
1183− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1184−
1185− localConfigPath := filepath.Join(cwdDir, "crush.json")
1186− data, err = json.Marshal(localConfig)
1187− require.NoError(t, err)
1188− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1189−
1190− cfg, err := Init(cwdDir, false)
1191−
1192− require.NoError(t, err)
1193−
1194− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1195− assert.GreaterOrEqual(t, len(openaiProvider.Models), 2)
1196−
1197− modelIDs := make([]string, len(openaiProvider.Models))
1198− for i, model := range openaiProvider.Models {
1199− modelIDs[i] = model.ID
1200− }
1201− assert.Contains(t, modelIDs, "gpt-4")
1202− assert.Contains(t, modelIDs, "gpt-4-turbo")
1203−}
1204−
1205−func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1206− reset()
1207− testConfigDir = t.TempDir()
1208− cwdDir := t.TempDir()
1209−
1210− globalConfig := Config{
1211− Providers: map[provider.InferenceProvider]ProviderConfig{
1212− provider.InferenceProviderOpenAI: {
1213− ID: provider.InferenceProviderOpenAI,
1214− APIKey: "openai-key",
1215− ProviderType: provider.TypeOpenAI,
1216− DefaultLargeModel: "gpt-4",
1217− DefaultSmallModel: "gpt-4",
1218− Models: []Model{
1219− {
1220− ID: "gpt-4",
1221− Name: "GPT-4",
1222− ContextWindow: 8192,
1223− DefaultMaxTokens: 4096,
1224− },
1225− },
1226− },
1227− },
1228− }
1229−
1230− localConfig := Config{
1231− Providers: map[provider.InferenceProvider]ProviderConfig{
1232− provider.InferenceProviderOpenAI: {
1233− Models: []Model{
1234− {
1235− ID: "gpt-4",
1236− Name: "GPT-4 Updated",
1237− ContextWindow: 16384,
1238− DefaultMaxTokens: 8192,
1239− },
1240− },
1241− },
1242− },
1243− }
1244−
1245− configPath := filepath.Join(testConfigDir, "crush.json")
1246− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1247− data, err := json.Marshal(globalConfig)
1248− require.NoError(t, err)
1249− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1250−
1251− localConfigPath := filepath.Join(cwdDir, "crush.json")
1252− data, err = json.Marshal(localConfig)
1253− require.NoError(t, err)
1254− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1255−
1256− cfg, err := Init(cwdDir, false)
1257−
1258− require.NoError(t, err)
1259−
1260− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1261− assert.GreaterOrEqual(t, len(openaiProvider.Models), 1)
1262−
1263− // Find the first model that matches our test data
1264− var testModel *Model
1265− for _, model := range openaiProvider.Models {
1266− if model.ID == "gpt-4" {
1267− testModel = &model
1268− break
1269− }
1270− }
1271−
1272− // If gpt-4 not found, use the first available model
1273− if testModel == nil {
1274− testModel = &openaiProvider.Models[0]
1275− }
1276−
1277− assert.NotEmpty(t, testModel.ID)
1278− assert.NotEmpty(t, testModel.Name)
1279− assert.Greater(t, testModel.ContextWindow, int64(0))
1280−}
1281−
1282−func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1283− reset()
1284− testConfigDir = t.TempDir()
1285− cwdDir := t.TempDir()
1286−
1287− globalConfig := Config{
1288− Providers: map[provider.InferenceProvider]ProviderConfig{
1289− provider.InferenceProviderOpenAI: {
1290− ID: provider.InferenceProviderOpenAI,
1291− APIKey: "openai-key",
1292− ProviderType: provider.TypeOpenAI,
1293− DefaultLargeModel: "gpt-4",
1294− DefaultSmallModel: "gpt-4",
1295− Models: []Model{
1296− {
1297− ID: "gpt-4",
1298− Name: "GPT-4",
1299− CostPer1MIn: 30.0,
1300− CostPer1MOut: 60.0,
1301− CostPer1MInCached: 15.0,
1302− CostPer1MOutCached: 30.0,
1303− ContextWindow: 8192,
1304− DefaultMaxTokens: 4096,
1305− CanReason: true,
1306− ReasoningEffort: "medium",
1307− SupportsImages: true,
1308− },
1309− },
1310− },
1311− },
1312− }
1313−
1314− configPath := filepath.Join(testConfigDir, "crush.json")
1315− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1316− data, err := json.Marshal(globalConfig)
1317− require.NoError(t, err)
1318− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1319−
1320− cfg, err := Init(cwdDir, false)
1321−
1322− require.NoError(t, err)
1323−
1324− openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1325− require.GreaterOrEqual(t, len(openaiProvider.Models), 1)
1326−
1327− // Find the test model or use the first one
1328− var testModel *Model
1329− for _, model := range openaiProvider.Models {
1330− if model.ID == "gpt-4" {
1331− testModel = &model
1332− break
1333− }
1334− }
1335−
1336− if testModel == nil {
1337− testModel = &openaiProvider.Models[0]
1338− }
1339−
1340− // Only test the custom properties if this is actually our test model
1341− if testModel.ID == "gpt-4" {
1342− assert.Equal(t, 30.0, testModel.CostPer1MIn)
1343− assert.Equal(t, 60.0, testModel.CostPer1MOut)
1344− assert.Equal(t, 15.0, testModel.CostPer1MInCached)
1345− assert.Equal(t, 30.0, testModel.CostPer1MOutCached)
1346− assert.True(t, testModel.CanReason)
1347− assert.Equal(t, "medium", testModel.ReasoningEffort)
1348− assert.True(t, testModel.SupportsImages)
1349− }
1350−}
1351−
1352−func TestDefaultAgents_CoderAgent(t *testing.T) {
1353− reset()
1354− testConfigDir = t.TempDir()
1355− cwdDir := t.TempDir()
1356−
1357− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1358−
1359− cfg, err := Init(cwdDir, false)
1360−
1361− require.NoError(t, err)
1362− assert.Contains(t, cfg.Agents, AgentCoder)
1363−
1364− coderAgent := cfg.Agents[AgentCoder]
1365− assert.Equal(t, AgentCoder, coderAgent.ID)
1366− assert.Equal(t, "Coder", coderAgent.Name)
1367− assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1368− assert.Equal(t, LargeModel, coderAgent.Model)
1369− assert.False(t, coderAgent.Disabled)
1370− assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1371− assert.Nil(t, coderAgent.AllowedTools)
1372−}
1373−
1374−func TestDefaultAgents_TaskAgent(t *testing.T) {
1375− reset()
1376− testConfigDir = t.TempDir()
1377− cwdDir := t.TempDir()
1378−
1379− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1380−
1381− cfg, err := Init(cwdDir, false)
1382−
1383− require.NoError(t, err)
1384− assert.Contains(t, cfg.Agents, AgentTask)
1385−
1386− taskAgent := cfg.Agents[AgentTask]
1387− assert.Equal(t, AgentTask, taskAgent.ID)
1388− assert.Equal(t, "Task", taskAgent.Name)
1389− assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1390− assert.Equal(t, LargeModel, taskAgent.Model)
1391− assert.False(t, taskAgent.Disabled)
1392− assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1393−
1394− expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1395− assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1396−
1397− assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1398− assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1399−}
1400−
1401−func TestAgentMerging_CustomAgent(t *testing.T) {
1402− reset()
1403− testConfigDir = t.TempDir()
1404− cwdDir := t.TempDir()
1405−
1406− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1407−
1408− globalConfig := Config{
1409− Agents: map[AgentID]Agent{
1410− AgentID("custom-agent"): {
1411− ID: AgentID("custom-agent"),
1412− Name: "Custom Agent",
1413− Description: "A custom agent for testing",
1414− Model: SmallModel,
1415− AllowedTools: []string{"glob", "grep"},
1416− AllowedMCP: map[string][]string{"mcp1": {"tool1", "tool2"}},
1417− AllowedLSP: []string{"typescript", "go"},
1418− ContextPaths: []string{"custom-context.md"},
1419− },
1420− },
1421− MCP: map[string]MCP{
1422− "mcp1": {
1423− Type: MCPStdio,
1424− Command: "test-mcp-command",
1425− Args: []string{"--test"},
1426− },
1427− },
1428− LSP: map[string]LSPConfig{
1429− "typescript": {
1430− Command: "typescript-language-server",
1431− Args: []string{"--stdio"},
1432− },
1433− "go": {
1434− Command: "gopls",
1435− Args: []string{},
1436− },
1437− },
1438− }
1439−
1440− configPath := filepath.Join(testConfigDir, "crush.json")
1441− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1442− data, err := json.Marshal(globalConfig)
1443− require.NoError(t, err)
1444− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1445−
1446− cfg, err := Init(cwdDir, false)
1447−
1448− require.NoError(t, err)
1449−
1450− assert.Contains(t, cfg.Agents, AgentCoder)
1451− assert.Contains(t, cfg.Agents, AgentTask)
1452− assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1453−
1454− customAgent := cfg.Agents[AgentID("custom-agent")]
1455− assert.Equal(t, "Custom Agent", customAgent.Name)
1456− assert.Equal(t, "A custom agent for testing", customAgent.Description)
1457− assert.Equal(t, SmallModel, customAgent.Model)
1458− assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1459− assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1460− assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1461− expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1462− assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1463−}
1464−
1465−func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1466− reset()
1467− testConfigDir = t.TempDir()
1468− cwdDir := t.TempDir()
1469−
1470− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1471−
1472− globalConfig := Config{
1473− Agents: map[AgentID]Agent{
1474− AgentCoder: {
1475− Model: SmallModel,
1476− AllowedMCP: map[string][]string{"mcp1": {"tool1"}},
1477− AllowedLSP: []string{"typescript"},
1478− ContextPaths: []string{"coder-specific.md"},
1479− },
1480− },
1481− MCP: map[string]MCP{
1482− "mcp1": {
1483− Type: MCPStdio,
1484− Command: "test-mcp-command",
1485− Args: []string{"--test"},
1486− },
1487− },
1488− LSP: map[string]LSPConfig{
1489− "typescript": {
1490− Command: "typescript-language-server",
1491− Args: []string{"--stdio"},
1492− },
1493− },
1494− }
1495−
1496− configPath := filepath.Join(testConfigDir, "crush.json")
1497− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1498− data, err := json.Marshal(globalConfig)
1499− require.NoError(t, err)
1500− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1501−
1502− cfg, err := Init(cwdDir, false)
1503−
1504− require.NoError(t, err)
1505−
1506− coderAgent := cfg.Agents[AgentCoder]
1507− assert.Equal(t, AgentCoder, coderAgent.ID)
1508− assert.Equal(t, "Coder", coderAgent.Name)
1509− assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1510−
1511− expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1512− assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1513−
1514− assert.Equal(t, SmallModel, coderAgent.Model)
1515− assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1516− assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1517−}
1518−
1519−func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1520− reset()
1521− testConfigDir = t.TempDir()
1522− cwdDir := t.TempDir()
1523−
1524− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1525−
1526− globalConfig := Config{
1527− Agents: map[AgentID]Agent{
1528− AgentTask: {
1529− Model: SmallModel,
1530− AllowedMCP: map[string][]string{"search-mcp": nil},
1531− AllowedLSP: []string{"python"},
1532− Name: "Search Agent",
1533− Description: "Custom search agent",
1534− Disabled: true,
1535− AllowedTools: []string{"glob", "grep", "view"},
1536− },
1537− },
1538− MCP: map[string]MCP{
1539− "search-mcp": {
1540− Type: MCPStdio,
1541− Command: "search-mcp-command",
1542− Args: []string{"--search"},
1543− },
1544− },
1545− LSP: map[string]LSPConfig{
1546− "python": {
1547− Command: "pylsp",
1548− Args: []string{},
1549− },
1550− },
1551− }
1552−
1553− configPath := filepath.Join(testConfigDir, "crush.json")
1554− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1555− data, err := json.Marshal(globalConfig)
1556− require.NoError(t, err)
1557− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1558−
1559− cfg, err := Init(cwdDir, false)
1560−
1561− require.NoError(t, err)
1562−
1563− taskAgent := cfg.Agents[AgentTask]
1564− assert.Equal(t, "Task", taskAgent.Name)
1565− assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1566− assert.False(t, taskAgent.Disabled)
1567− assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
1568−
1569− assert.Equal(t, SmallModel, taskAgent.Model)
1570− assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1571− assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1572−}
1573−
1574−func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1575− reset()
1576− testConfigDir = t.TempDir()
1577− cwdDir := t.TempDir()
1578−
1579− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1580−
1581− globalConfig := Config{
1582− Agents: map[AgentID]Agent{
1583− AgentID("test-agent"): {
1584− ID: AgentID("test-agent"),
1585− Name: "Global Agent",
1586− Description: "Global description",
1587− Model: LargeModel,
1588− AllowedTools: []string{"glob"},
1589− },
1590− },
1591− }
1592−
1593− configPath := filepath.Join(testConfigDir, "crush.json")
1594− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1595− data, err := json.Marshal(globalConfig)
1596− require.NoError(t, err)
1597− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1598−
1599− // Create local config that overrides
1600− localConfig := Config{
1601− Agents: map[AgentID]Agent{
1602− AgentID("test-agent"): {
1603− Name: "Local Agent",
1604− Description: "Local description",
1605− Model: SmallModel,
1606− Disabled: true,
1607− AllowedTools: []string{"grep", "view"},
1608− AllowedMCP: map[string][]string{"local-mcp": {"tool1"}},
1609− },
1610− },
1611− MCP: map[string]MCP{
1612− "local-mcp": {
1613− Type: MCPStdio,
1614− Command: "local-mcp-command",
1615− Args: []string{"--local"},
1616− },
1617− },
1618− }
1619−
1620− localConfigPath := filepath.Join(cwdDir, "crush.json")
1621− data, err = json.Marshal(localConfig)
1622− require.NoError(t, err)
1623− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1624−
1625− cfg, err := Init(cwdDir, false)
1626−
1627− require.NoError(t, err)
1628−
1629− testAgent := cfg.Agents[AgentID("test-agent")]
1630− assert.Equal(t, "Local Agent", testAgent.Name)
1631− assert.Equal(t, "Local description", testAgent.Description)
1632− assert.Equal(t, SmallModel, testAgent.Model)
1633− assert.True(t, testAgent.Disabled)
1634− assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1635− assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1636−}
1637−
1638−func TestAgentModelTypeAssignment(t *testing.T) {
1639− reset()
1640− testConfigDir = t.TempDir()
1641− cwdDir := t.TempDir()
1642−
1643− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1644−
1645− globalConfig := Config{
1646− Agents: map[AgentID]Agent{
1647− AgentID("large-agent"): {
1648− ID: AgentID("large-agent"),
1649− Name: "Large Model Agent",
1650− Model: LargeModel,
1651− },
1652− AgentID("small-agent"): {
1653− ID: AgentID("small-agent"),
1654− Name: "Small Model Agent",
1655− Model: SmallModel,
1656− },
1657− AgentID("default-agent"): {
1658− ID: AgentID("default-agent"),
1659− Name: "Default Model Agent",
1660− },
1661− },
1662− }
1663−
1664− configPath := filepath.Join(testConfigDir, "crush.json")
1665− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1666− data, err := json.Marshal(globalConfig)
1667− require.NoError(t, err)
1668− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1669−
1670− cfg, err := Init(cwdDir, false)
1671−
1672− require.NoError(t, err)
1673−
1674− assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1675− assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1676− assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model)
1677−}
1678−
1679−func TestAgentContextPathOverrides(t *testing.T) {
1680− reset()
1681− testConfigDir = t.TempDir()
1682− cwdDir := t.TempDir()
1683−
1684− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1685−
1686− globalConfig := Config{
1687− Options: Options{
1688− ContextPaths: []string{"global-context.md", "shared-context.md"},
1689− },
1690− Agents: map[AgentID]Agent{
1691− AgentID("custom-context-agent"): {
1692− ID: AgentID("custom-context-agent"),
1693− Name: "Custom Context Agent",
1694− ContextPaths: []string{"agent-specific.md", "custom.md"},
1695− },
1696− AgentID("default-context-agent"): {
1697− ID: AgentID("default-context-agent"),
1698− Name: "Default Context Agent",
1699− },
1700− },
1701− }
1702−
1703− configPath := filepath.Join(testConfigDir, "crush.json")
1704− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1705− data, err := json.Marshal(globalConfig)
1706− require.NoError(t, err)
1707− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1708−
1709− cfg, err := Init(cwdDir, false)
1710−
1711− require.NoError(t, err)
1712−
1713− customAgent := cfg.Agents[AgentID("custom-context-agent")]
1714− expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1715− assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1716−
1717− defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1718− expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1719− assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1720−
1721− coderAgent := cfg.Agents[AgentCoder]
1722− assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1723−}
1724−
1725−func TestOptionsMerging_ContextPaths(t *testing.T) {
1726− reset()
1727− testConfigDir = t.TempDir()
1728− cwdDir := t.TempDir()
1729−
1730− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1731−
1732− globalConfig := Config{
1733− Options: Options{
1734− ContextPaths: []string{"global1.md", "global2.md"},
1735− },
1736− }
1737−
1738− configPath := filepath.Join(testConfigDir, "crush.json")
1739− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1740− data, err := json.Marshal(globalConfig)
1741− require.NoError(t, err)
1742− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1743−
1744− localConfig := Config{
1745− Options: Options{
1746− ContextPaths: []string{"local1.md", "local2.md"},
1747− },
1748− }
1749−
1750− localConfigPath := filepath.Join(cwdDir, "crush.json")
1751− data, err = json.Marshal(localConfig)
1752− require.NoError(t, err)
1753− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1754−
1755− cfg, err := Init(cwdDir, false)
1756−
1757− require.NoError(t, err)
1758−
1759− expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1760− assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1761−}
1762−
1763−func TestOptionsMerging_TUIOptions(t *testing.T) {
1764− reset()
1765− testConfigDir = t.TempDir()
1766− cwdDir := t.TempDir()
1767−
1768− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1769−
1770− globalConfig := Config{
1771− Options: Options{
1772− TUI: TUIOptions{
1773− CompactMode: false,
1774− },
1775− },
1776− }
1777−
1778− configPath := filepath.Join(testConfigDir, "crush.json")
1779− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1780− data, err := json.Marshal(globalConfig)
1781− require.NoError(t, err)
1782− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1783−
1784− localConfig := Config{
1785− Options: Options{
1786− TUI: TUIOptions{
1787− CompactMode: true,
1788− },
1789− },
1790− }
1791−
1792− localConfigPath := filepath.Join(cwdDir, "crush.json")
1793− data, err = json.Marshal(localConfig)
1794− require.NoError(t, err)
1795− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1796−
1797− cfg, err := Init(cwdDir, false)
1798−
1799− require.NoError(t, err)
1800−
1801− assert.True(t, cfg.Options.TUI.CompactMode)
1802−}
1803−
1804−func TestOptionsMerging_DebugFlags(t *testing.T) {
1805− reset()
1806− testConfigDir = t.TempDir()
1807− cwdDir := t.TempDir()
1808−
1809− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1810−
1811− globalConfig := Config{
1812− Options: Options{
1813− Debug: false,
1814− DebugLSP: false,
1815− DisableAutoSummarize: false,
1816− },
1817− }
1818−
1819− configPath := filepath.Join(testConfigDir, "crush.json")
1820− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1821− data, err := json.Marshal(globalConfig)
1822− require.NoError(t, err)
1823− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1824−
1825− localConfig := Config{
1826− Options: Options{
1827− DebugLSP: true,
1828− DisableAutoSummarize: true,
1829− },
1830− }
1831−
1832− localConfigPath := filepath.Join(cwdDir, "crush.json")
1833− data, err = json.Marshal(localConfig)
1834− require.NoError(t, err)
1835− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1836−
1837− cfg, err := Init(cwdDir, false)
1838−
1839− require.NoError(t, err)
1840−
1841− assert.False(t, cfg.Options.Debug)
1842− assert.True(t, cfg.Options.DebugLSP)
1843− assert.True(t, cfg.Options.DisableAutoSummarize)
1844−}
1845−
1846−func TestOptionsMerging_DataDirectory(t *testing.T) {
1847− reset()
1848− testConfigDir = t.TempDir()
1849− cwdDir := t.TempDir()
1850−
1851− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1852−
1853− globalConfig := Config{
1854− Options: Options{
1855− DataDirectory: "global-data",
1856− },
1857− }
1858−
1859− configPath := filepath.Join(testConfigDir, "crush.json")
1860− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1861− data, err := json.Marshal(globalConfig)
1862− require.NoError(t, err)
1863− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1864−
1865− localConfig := Config{
1866− Options: Options{
1867− DataDirectory: "local-data",
1868− },
1869− }
1870−
1871− localConfigPath := filepath.Join(cwdDir, "crush.json")
1872− data, err = json.Marshal(localConfig)
1873− require.NoError(t, err)
1874− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1875−
1876− cfg, err := Init(cwdDir, false)
1877−
1878− require.NoError(t, err)
1879−
1880− assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1881−}
1882−
1883−func TestOptionsMerging_DefaultValues(t *testing.T) {
1884− reset()
1885− testConfigDir = t.TempDir()
1886− cwdDir := t.TempDir()
1887−
1888− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1889−
1890− cfg, err := Init(cwdDir, false)
1891−
1892− require.NoError(t, err)
1893−
1894− assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1895− assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1896− assert.False(t, cfg.Options.TUI.CompactMode)
1897− assert.False(t, cfg.Options.Debug)
1898− assert.False(t, cfg.Options.DebugLSP)
1899− assert.False(t, cfg.Options.DisableAutoSummarize)
1900−}
1901−
1902−func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1903− reset()
1904− testConfigDir = t.TempDir()
1905− cwdDir := t.TempDir()
1906−
1907− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1908−
1909− globalConfig := Config{
1910− Options: Options{
1911− Debug: false,
1912− },
1913− }
1914−
1915− configPath := filepath.Join(testConfigDir, "crush.json")
1916− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1917− data, err := json.Marshal(globalConfig)
1918− require.NoError(t, err)
1919− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1920−
1921− cfg, err := Init(cwdDir, true)
1922−
1923− require.NoError(t, err)
1924−
1925− // Debug flag from Init should take precedence
1926− assert.True(t, cfg.Options.Debug)
1927−}
1928−
1929−func TestOptionsMerging_ComplexScenario(t *testing.T) {
1930− reset()
1931− testConfigDir = t.TempDir()
1932− cwdDir := t.TempDir()
1933−
1934− // Set up a provider
1935− os.Setenv("ANTHROPIC_API_KEY", "test-key")
1936−
1937− // Create global config with various options
1938− globalConfig := Config{
1939− Options: Options{
1940− ContextPaths: []string{"global-context.md"},
1941− DataDirectory: "global-data",
1942− Debug: false,
1943− DebugLSP: false,
1944− DisableAutoSummarize: false,
1945− TUI: TUIOptions{
1946− CompactMode: false,
1947− },
1948− },
1949− }
1950−
1951− configPath := filepath.Join(testConfigDir, "crush.json")
1952− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1953− data, err := json.Marshal(globalConfig)
1954− require.NoError(t, err)
1955− require.NoError(t, os.WriteFile(configPath, data, 0o644))
1956−
1957− // Create local config that partially overrides
1958− localConfig := Config{
1959− Options: Options{
1960− ContextPaths: []string{"local-context.md"},
1961− DebugLSP: true, // Override
1962− DisableAutoSummarize: true, // Override
1963− TUI: TUIOptions{
1964− CompactMode: true, // Override
1965− },
1966− // DataDirectory and Debug not specified - should keep global values
1967− },
1968− }
1969−
1970− localConfigPath := filepath.Join(cwdDir, "crush.json")
1971− data, err = json.Marshal(localConfig)
1972− require.NoError(t, err)
1973− require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1974−
1975− cfg, err := Init(cwdDir, false)
1976−
1977− require.NoError(t, err)
1978−
1979− // Check merged results
1980− expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1981− assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1982− assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1983− assert.False(t, cfg.Options.Debug) // From global
1984− assert.True(t, cfg.Options.DebugLSP) // From local
1985− assert.True(t, cfg.Options.DisableAutoSummarize) // From local
1986− assert.True(t, cfg.Options.TUI.CompactMode) // From local
1987−}
1988−
1989−// Model Selection Tests
1990−
1991−func TestModelSelection_PreferredModelSelection(t *testing.T) {
1992− reset()
1993− testConfigDir = t.TempDir()
1994− cwdDir := t.TempDir()
1995−
1996− // Set up multiple providers to test selection logic
1997− os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1998− os.Setenv("OPENAI_API_KEY", "test-openai-key")
1999−
2000− cfg, err := Init(cwdDir, false)
2001−
2002− require.NoError(t, err)
2003− require.Len(t, cfg.Providers, 2)
2004−
2005− // Should have preferred models set
2006− assert.NotEmpty(t, cfg.Models.Large.ModelID)
2007− assert.NotEmpty(t, cfg.Models.Large.Provider)
2008− assert.NotEmpty(t, cfg.Models.Small.ModelID)
2009− assert.NotEmpty(t, cfg.Models.Small.Provider)
2010−
2011− // Both should use the same provider (first available)
2012− assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
2013−}
2014−
2015−func TestValidation_InvalidModelReference(t *testing.T) {
2016− reset()
2017− testConfigDir = t.TempDir()
2018− cwdDir := t.TempDir()
2019−
2020− globalConfig := Config{
2021− Providers: map[provider.InferenceProvider]ProviderConfig{
2022− provider.InferenceProviderOpenAI: {
2023− ID: provider.InferenceProviderOpenAI,
2024− APIKey: "test-key",
2025− ProviderType: provider.TypeOpenAI,
2026− DefaultLargeModel: "non-existent-model",
2027− DefaultSmallModel: "gpt-3.5-turbo",
2028− Models: []Model{
2029− {
2030− ID: "gpt-3.5-turbo",
2031− Name: "GPT-3.5 Turbo",
2032− ContextWindow: 4096,
2033− DefaultMaxTokens: 2048,
2034− },
2035− },
2036− },
2037− },
2038− }
2039−
2040− configPath := filepath.Join(testConfigDir, "crush.json")
2041− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2042− data, err := json.Marshal(globalConfig)
2043− require.NoError(t, err)
2044− require.NoError(t, os.WriteFile(configPath, data, 0o644))
2045−
2046− _, err = Init(cwdDir, false)
2047− assert.Error(t, err)
2048−}
2049−
2050−func TestValidation_InvalidAgentModelType(t *testing.T) {
2051− reset()
2052− testConfigDir = t.TempDir()
2053− cwdDir := t.TempDir()
2054−
2055− os.Setenv("ANTHROPIC_API_KEY", "test-key")
2056−
2057− globalConfig := Config{
2058− Agents: map[AgentID]Agent{
2059− AgentID("invalid-agent"): {
2060− ID: AgentID("invalid-agent"),
2061− Name: "Invalid Agent",
2062− Model: ModelType("invalid"),
2063− },
2064− },
2065− }
2066−
2067− configPath := filepath.Join(testConfigDir, "crush.json")
2068− require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2069− data, err := json.Marshal(globalConfig)
2070− require.NoError(t, err)
2071− require.NoError(t, os.WriteFile(configPath, data, 0o644))
2072−
2073− _, err = Init(cwdDir, false)
2074− assert.Error(t, err)
2075−}
internal/config/fs.godeleted−71
@@ -1,71 +0,0 @@
1−package config
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "runtime"
8−)
9−
10−var testConfigDir string
11−
12−func baseConfigPath() string {
13− if testConfigDir != "" {
14− return testConfigDir
15− }
16−
17− xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
18− if xdgConfigHome != "" {
19− return filepath.Join(xdgConfigHome, "crush")
20− }
21−
22− // return the path to the main config directory
23− // for windows, it should be in `%LOCALAPPDATA%/crush/`
24− // for linux and macOS, it should be in `$HOME/.config/crush/`
25− if runtime.GOOS == "windows" {
26− localAppData := os.Getenv("LOCALAPPDATA")
27− if localAppData == "" {
28− localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
29− }
30− return filepath.Join(localAppData, appName)
31− }
32−
33− return filepath.Join(os.Getenv("HOME"), ".config", appName)
34−}
35−
36−func baseDataPath() string {
37− if testConfigDir != "" {
38− return testConfigDir
39− }
40−
41− xdgDataHome := os.Getenv("XDG_DATA_HOME")
42− if xdgDataHome != "" {
43− return filepath.Join(xdgDataHome, appName)
44− }
45−
46− // return the path to the main data directory
47− // for windows, it should be in `%LOCALAPPDATA%/crush/`
48− // for linux and macOS, it should be in `$HOME/.local/share/crush/`
49− if runtime.GOOS == "windows" {
50− localAppData := os.Getenv("LOCALAPPDATA")
51− if localAppData == "" {
52− localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
53− }
54− return filepath.Join(localAppData, appName)
55− }
56−
57− return filepath.Join(os.Getenv("HOME"), ".local", "share", appName)
58−}
59−
60−func ConfigPath() string {
61− return filepath.Join(baseConfigPath(), fmt.Sprintf("%s.json", appName))
62−}
63−
64−func CrushInitialized() bool {
65− cfgPath := ConfigPath()
66− if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
67− // config file does not exist, so Crush is not initialized
68− return false
69− }
70− return true
71−}
internal/config/init.go+36−12
@@ -5,27 +5,53 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8+ "sync"
9+ "sync/atomic"
10+
11+ "github.com/charmbracelet/crush/internal/logging"
812)
913
1014const (
11− // InitFlagFilename is the name of the file that indicates whether the project has been initialized
1215 InitFlagFilename = "init"
1316)
1417
15−// ProjectInitFlag represents the initialization status for a project directory
1618type ProjectInitFlag struct {
1719 Initialized bool `json:"initialized"`
1820}
1921
20−// ProjectNeedsInitialization checks if the current project needs initialization
22+// TODO: we need to remove the global config instance keeping it now just until everything is migrated
23+var (
24+ instance atomic.Pointer[Config]
25+ cwd string
26+ once sync.Once // Ensures the initialization happens only once
27+)
28+
29+func Init(workingDir string, debug bool) (*Config, error) {
30+ var err error
31+ once.Do(func() {
32+ cwd = workingDir
33+ cfg, err := Load(cwd, debug)
34+ if err != nil {
35+ logging.Error("Failed to load config", "error", err)
36+ }
37+ instance.Store(cfg)
38+ })
39+
40+ return instance.Load(), err
41+}
42+
43+func Get() *Config {
44+ return instance.Load()
45+}
46+
2147func ProjectNeedsInitialization() (bool, error) {
22− if instance == nil {
48+ cfg := Get()
49+ if cfg == nil {
2350 return false, fmt.Errorf("config not loaded")
2451 }
2552
26− flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
53+ flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
2754
28− // Check if the flag file exists
2955 _, err := os.Stat(flagFilePath)
3056 if err == nil {
3157 return false, nil
@@ -35,8 +61,7 @@ func ProjectNeedsInitialization() (bool, error) {
3561 return false, fmt.Errorf("failed to check init flag file: %w", err)
3662 }
3763
38− // Check if any variation of CRUSH.md already exists in working directory
39− crushExists, err := crushMdExists(WorkingDirectory())
64+ crushExists, err := crushMdExists(cfg.WorkingDir())
4065 if err != nil {
4166 return false, fmt.Errorf("failed to check for CRUSH.md files: %w", err)
4267 }
@@ -47,7 +72,6 @@ func ProjectNeedsInitialization() (bool, error) {
4772 return true, nil
4873}
4974
50−// crushMdExists checks if any case variation of crush.md exists in the directory
5175func crushMdExists(dir string) (bool, error) {
5276 entries, err := os.ReadDir(dir)
5377 if err != nil {
@@ -68,12 +92,12 @@ func crushMdExists(dir string) (bool, error) {
6892 return false, nil
6993}
7094
71−// MarkProjectInitialized marks the current project as initialized
7295func MarkProjectInitialized() error {
73− if instance == nil {
96+ cfg := Get()
97+ if cfg == nil {
7498 return fmt.Errorf("config not loaded")
7599 }
76− flagFilePath := filepath.Join(instance.Options.DataDirectory, InitFlagFilename)
100+ flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
77101
78102 file, err := os.Create(flagFilePath)
79103 if err != nil {
pkg/config/load.go → internal/config/load.gorenamed+33−2
@@ -10,10 +10,10 @@ import (
1010 "slices"
1111 "strings"
1212
13+ "github.com/charmbracelet/crush/internal/env"
1314 "github.com/charmbracelet/crush/internal/fur/client"
1415 "github.com/charmbracelet/crush/internal/fur/provider"
15− "github.com/charmbracelet/crush/pkg/env"
16− "github.com/charmbracelet/crush/pkg/log"
16+ "github.com/charmbracelet/crush/internal/log"
1717 "golang.org/x/exp/slog"
1818)
1919
@@ -68,6 +68,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
6868 env := env.New()
6969 // Configure providers
7070 valueResolver := NewShellVariableResolver(env)
71+ cfg.resolver = valueResolver
7172 if err := cfg.configureProviders(env, valueResolver, providers); err != nil {
7273 return nil, fmt.Errorf("failed to configure providers: %w", err)
7374 }
@@ -81,6 +82,36 @@ func Load(workingDir string, debug bool) (*Config, error) {
8182 return nil, fmt.Errorf("failed to configure selected models: %w", err)
8283 }
8384
85+ // TODO: remove the agents concept from the config
86+ agents := map[string]Agent{
87+ "coder": {
88+ ID: "coder",
89+ Name: "Coder",
90+ Description: "An agent that helps with executing coding tasks.",
91+ Model: SelectedModelTypeLarge,
92+ ContextPaths: cfg.Options.ContextPaths,
93+ // All tools allowed
94+ },
95+ "task": {
96+ ID: "task",
97+ Name: "Task",
98+ Description: "An agent that helps with searching for context and finding implementation details.",
99+ Model: SelectedModelTypeLarge,
100+ ContextPaths: cfg.Options.ContextPaths,
101+ AllowedTools: []string{
102+ "glob",
103+ "grep",
104+ "ls",
105+ "sourcegraph",
106+ "view",
107+ },
108+ // NO MCPs or LSPs by default
109+ AllowedMCP: map[string][]string{},
110+ AllowedLSP: []string{},
111+ },
112+ }
113+ cfg.Agents = agents
114+
84115 return cfg, nil
85116}
86117
pkg/config/load_test.go → internal/config/load_test.gorenamed+1−1
@@ -8,7 +8,7 @@ import (
88 "testing"
99
1010 "github.com/charmbracelet/crush/internal/fur/provider"
11− "github.com/charmbracelet/crush/pkg/env"
11+ "github.com/charmbracelet/crush/internal/env"
1212 "github.com/stretchr/testify/assert"
1313)
1414
pkg/config/merge.go → internal/config/merge.gorenamed

The content is the same.

pkg/config/merge_test.go → internal/config/merge_test.gorenamed

The content is the same.

internal/config/provider.go+51−36
@@ -4,27 +4,44 @@ import (
44 "encoding/json"
55 "os"
66 "path/filepath"
7+ "runtime"
78 "sync"
89
910 "github.com/charmbracelet/crush/internal/fur/client"
1011 "github.com/charmbracelet/crush/internal/fur/provider"
1112)
1213
13−var fur = client.New()
14+type ProviderClient interface {
15+ GetProviders() ([]provider.Provider, error)
16+}
1417
1518var (
16− providerOnc sync.Once // Ensures the initialization happens only once
19+ providerOnce sync.Once
1720 providerList []provider.Provider
18− // UseMockProviders can be set to true in tests to avoid API calls
19− UseMockProviders bool
2021)
2122
22−func providersPath() string {
23− return filepath.Join(baseDataPath(), "providers.json")
23+// file to cache provider data
24+func providerCacheFileData() string {
25+ xdgDataHome := os.Getenv("XDG_DATA_HOME")
26+ if xdgDataHome != "" {
27+ return filepath.Join(xdgDataHome, appName)
28+ }
29+
30+ // return the path to the main data directory
31+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
32+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
33+ if runtime.GOOS == "windows" {
34+ localAppData := os.Getenv("LOCALAPPDATA")
35+ if localAppData == "" {
36+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
37+ }
38+ return filepath.Join(localAppData, appName)
39+ }
40+
41+ return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
2442}
2543
26−func saveProviders(providers []provider.Provider) error {
27− path := providersPath()
44+func saveProvidersInCache(path string, providers []provider.Provider) error {
2845 dir := filepath.Dir(path)
2946 if err := os.MkdirAll(dir, 0o755); err != nil {
3047 return err
@@ -38,8 +55,7 @@ func saveProviders(providers []provider.Provider) error {
3855 return os.WriteFile(path, data, 0o644)
3956}
4057
41−func loadProviders() ([]provider.Provider, error) {
42− path := providersPath()
58+func loadProvidersFromCache(path string) ([]provider.Provider, error) {
4359 data, err := os.ReadFile(path)
4460 if err != nil {
4561 return nil, err
@@ -50,34 +66,33 @@ func loadProviders() ([]provider.Provider, error) {
5066 return providers, err
5167}
5268
53−func Providers() []provider.Provider {
54− providerOnc.Do(func() {
55− // Use mock providers when testing
56− if UseMockProviders {
57− providerList = MockProviders()
58− return
69+func loadProviders(path string, client ProviderClient) ([]provider.Provider, error) {
70+ providers, err := client.GetProviders()
71+ if err != nil {
72+ fallbackToCache, err := loadProvidersFromCache(path)
73+ if err != nil {
74+ return nil, err
5975 }
60−
61− // Try to get providers from upstream API
62− if providers, err := fur.GetProviders(); err == nil {
63− providerList = providers
64− // Save providers locally for future fallback
65− _ = saveProviders(providers)
66− } else {
67− // If upstream fails, try to load from local cache
68− if localProviders, localErr := loadProviders(); localErr == nil {
69− providerList = localProviders
70− } else {
71− // If both fail, return empty list
72− providerList = []provider.Provider{}
73− }
76+ providers = fallbackToCache
77+ } else {
78+ if err := saveProvidersInCache(path, providerList); err != nil {
79+ return nil, err
7480 }
75− })
76− return providerList
81+ }
82+ return providers, nil
83+}
84+
85+func Providers() ([]provider.Provider, error) {
86+ return LoadProviders(client.New())
7787}
7888
79−// ResetProviders resets the provider cache. Useful for testing.
80−func ResetProviders() {
81− providerOnc = sync.Once{}
82− providerList = nil
89+func LoadProviders(client ProviderClient) ([]provider.Provider, error) {
90+ var err error
91+ providerOnce.Do(func() {
92+ providerList, err = loadProviders(providerCacheFileData(), client)
93+ })
94+ if err != nil {
95+ return nil, err
96+ }
97+ return providerList, nil
8398}
internal/config/provider_mock.godeleted−293
@@ -1,293 +0,0 @@
1−package config
2−
3−import (
4− "github.com/charmbracelet/crush/internal/fur/provider"
5−)
6−
7−// MockProviders returns a mock list of providers for testing.
8−// This avoids making API calls during tests and provides consistent test data.
9−// Simplified version with only default models from each provider.
10−func MockProviders() []provider.Provider {
11− return []provider.Provider{
12− {
13− Name: "Anthropic",
14− ID: provider.InferenceProviderAnthropic,
15− APIKey: "$ANTHROPIC_API_KEY",
16− APIEndpoint: "$ANTHROPIC_API_ENDPOINT",
17− Type: provider.TypeAnthropic,
18− DefaultLargeModelID: "claude-sonnet-4-20250514",
19− DefaultSmallModelID: "claude-3-5-haiku-20241022",
20− Models: []provider.Model{
21− {
22− ID: "claude-sonnet-4-20250514",
23− Name: "Claude Sonnet 4",
24− CostPer1MIn: 3.0,
25− CostPer1MOut: 15.0,
26− CostPer1MInCached: 3.75,
27− CostPer1MOutCached: 0.3,
28− ContextWindow: 200000,
29− DefaultMaxTokens: 50000,
30− CanReason: true,
31− SupportsImages: true,
32− },
33− {
34− ID: "claude-3-5-haiku-20241022",
35− Name: "Claude 3.5 Haiku",
36− CostPer1MIn: 0.8,
37− CostPer1MOut: 4.0,
38− CostPer1MInCached: 1.0,
39− CostPer1MOutCached: 0.08,
40− ContextWindow: 200000,
41− DefaultMaxTokens: 5000,
42− CanReason: false,
43− SupportsImages: true,
44− },
45− },
46− },
47− {
48− Name: "OpenAI",
49− ID: provider.InferenceProviderOpenAI,
50− APIKey: "$OPENAI_API_KEY",
51− APIEndpoint: "$OPENAI_API_ENDPOINT",
52− Type: provider.TypeOpenAI,
53− DefaultLargeModelID: "codex-mini-latest",
54− DefaultSmallModelID: "gpt-4o",
55− Models: []provider.Model{
56− {
57− ID: "codex-mini-latest",
58− Name: "Codex Mini",
59− CostPer1MIn: 1.5,
60− CostPer1MOut: 6.0,
61− CostPer1MInCached: 0.0,
62− CostPer1MOutCached: 0.375,
63− ContextWindow: 200000,
64− DefaultMaxTokens: 50000,
65− CanReason: true,
66− HasReasoningEffort: true,
67− DefaultReasoningEffort: "medium",
68− SupportsImages: true,
69− },
70− {
71− ID: "gpt-4o",
72− Name: "GPT-4o",
73− CostPer1MIn: 2.5,
74− CostPer1MOut: 10.0,
75− CostPer1MInCached: 0.0,
76− CostPer1MOutCached: 1.25,
77− ContextWindow: 128000,
78− DefaultMaxTokens: 20000,
79− CanReason: false,
80− SupportsImages: true,
81− },
82− },
83− },
84− {
85− Name: "Google Gemini",
86− ID: provider.InferenceProviderGemini,
87− APIKey: "$GEMINI_API_KEY",
88− APIEndpoint: "$GEMINI_API_ENDPOINT",
89− Type: provider.TypeGemini,
90− DefaultLargeModelID: "gemini-2.5-pro",
91− DefaultSmallModelID: "gemini-2.5-flash",
92− Models: []provider.Model{
93− {
94− ID: "gemini-2.5-pro",
95− Name: "Gemini 2.5 Pro",
96− CostPer1MIn: 1.25,
97− CostPer1MOut: 10.0,
98− CostPer1MInCached: 1.625,
99− CostPer1MOutCached: 0.31,
100− ContextWindow: 1048576,
101− DefaultMaxTokens: 50000,
102− CanReason: true,
103− SupportsImages: true,
104− },
105− {
106− ID: "gemini-2.5-flash",
107− Name: "Gemini 2.5 Flash",
108− CostPer1MIn: 0.3,
109− CostPer1MOut: 2.5,
110− CostPer1MInCached: 0.3833,
111− CostPer1MOutCached: 0.075,
112− ContextWindow: 1048576,
113− DefaultMaxTokens: 50000,
114− CanReason: true,
115− SupportsImages: true,
116− },
117− },
118− },
119− {
120− Name: "xAI",
121− ID: provider.InferenceProviderXAI,
122− APIKey: "$XAI_API_KEY",
123− APIEndpoint: "https://api.x.ai/v1",
124− Type: provider.TypeXAI,
125− DefaultLargeModelID: "grok-3",
126− DefaultSmallModelID: "grok-3-mini",
127− Models: []provider.Model{
128− {
129− ID: "grok-3",
130− Name: "Grok 3",
131− CostPer1MIn: 3.0,
132− CostPer1MOut: 15.0,
133− CostPer1MInCached: 0.0,
134− CostPer1MOutCached: 0.75,
135− ContextWindow: 131072,
136− DefaultMaxTokens: 20000,
137− CanReason: false,
138− SupportsImages: false,
139− },
140− {
141− ID: "grok-3-mini",
142− Name: "Grok 3 Mini",
143− CostPer1MIn: 0.3,
144− CostPer1MOut: 0.5,
145− CostPer1MInCached: 0.0,
146− CostPer1MOutCached: 0.075,
147− ContextWindow: 131072,
148− DefaultMaxTokens: 20000,
149− CanReason: true,
150− SupportsImages: false,
151− },
152− },
153− },
154− {
155− Name: "Azure OpenAI",
156− ID: provider.InferenceProviderAzure,
157− APIKey: "$AZURE_OPENAI_API_KEY",
158− APIEndpoint: "$AZURE_OPENAI_API_ENDPOINT",
159− Type: provider.TypeAzure,
160− DefaultLargeModelID: "o4-mini",
161− DefaultSmallModelID: "gpt-4o",
162− Models: []provider.Model{
163− {
164− ID: "o4-mini",
165− Name: "o4 Mini",
166− CostPer1MIn: 1.1,
167− CostPer1MOut: 4.4,
168− CostPer1MInCached: 0.0,
169− CostPer1MOutCached: 0.275,
170− ContextWindow: 200000,
171− DefaultMaxTokens: 50000,
172− CanReason: true,
173− HasReasoningEffort: false,
174− DefaultReasoningEffort: "medium",
175− SupportsImages: true,
176− },
177− {
178− ID: "gpt-4o",
179− Name: "GPT-4o",
180− CostPer1MIn: 2.5,
181− CostPer1MOut: 10.0,
182− CostPer1MInCached: 0.0,
183− CostPer1MOutCached: 1.25,
184− ContextWindow: 128000,
185− DefaultMaxTokens: 20000,
186− CanReason: false,
187− SupportsImages: true,
188− },
189− },
190− },
191− {
192− Name: "AWS Bedrock",
193− ID: provider.InferenceProviderBedrock,
194− Type: provider.TypeBedrock,
195− DefaultLargeModelID: "anthropic.claude-sonnet-4-20250514-v1:0",
196− DefaultSmallModelID: "anthropic.claude-3-5-haiku-20241022-v1:0",
197− Models: []provider.Model{
198− {
199− ID: "anthropic.claude-sonnet-4-20250514-v1:0",
200− Name: "AWS Claude Sonnet 4",
201− CostPer1MIn: 3.0,
202− CostPer1MOut: 15.0,
203− CostPer1MInCached: 3.75,
204− CostPer1MOutCached: 0.3,
205− ContextWindow: 200000,
206− DefaultMaxTokens: 50000,
207− CanReason: true,
208− SupportsImages: true,
209− },
210− {
211− ID: "anthropic.claude-3-5-haiku-20241022-v1:0",
212− Name: "AWS Claude 3.5 Haiku",
213− CostPer1MIn: 0.8,
214− CostPer1MOut: 4.0,
215− CostPer1MInCached: 1.0,
216− CostPer1MOutCached: 0.08,
217− ContextWindow: 200000,
218− DefaultMaxTokens: 50000,
219− CanReason: false,
220− SupportsImages: true,
221− },
222− },
223− },
224− {
225− Name: "Google Vertex AI",
226− ID: provider.InferenceProviderVertexAI,
227− Type: provider.TypeVertexAI,
228− DefaultLargeModelID: "gemini-2.5-pro",
229− DefaultSmallModelID: "gemini-2.5-flash",
230− Models: []provider.Model{
231− {
232− ID: "gemini-2.5-pro",
233− Name: "Gemini 2.5 Pro",
234− CostPer1MIn: 1.25,
235− CostPer1MOut: 10.0,
236− CostPer1MInCached: 1.625,
237− CostPer1MOutCached: 0.31,
238− ContextWindow: 1048576,
239− DefaultMaxTokens: 50000,
240− CanReason: true,
241− SupportsImages: true,
242− },
243− {
244− ID: "gemini-2.5-flash",
245− Name: "Gemini 2.5 Flash",
246− CostPer1MIn: 0.3,
247− CostPer1MOut: 2.5,
248− CostPer1MInCached: 0.3833,
249− CostPer1MOutCached: 0.075,
250− ContextWindow: 1048576,
251− DefaultMaxTokens: 50000,
252− CanReason: true,
253− SupportsImages: true,
254− },
255− },
256− },
257− {
258− Name: "OpenRouter",
259− ID: provider.InferenceProviderOpenRouter,
260− APIKey: "$OPENROUTER_API_KEY",
261− APIEndpoint: "https://openrouter.ai/api/v1",
262− Type: provider.TypeOpenAI,
263− DefaultLargeModelID: "anthropic/claude-sonnet-4",
264− DefaultSmallModelID: "anthropic/claude-haiku-3.5",
265− Models: []provider.Model{
266− {
267− ID: "anthropic/claude-sonnet-4",
268− Name: "Anthropic: Claude Sonnet 4",
269− CostPer1MIn: 3.0,
270− CostPer1MOut: 15.0,
271− CostPer1MInCached: 3.75,
272− CostPer1MOutCached: 0.3,
273− ContextWindow: 200000,
274− DefaultMaxTokens: 32000,
275− CanReason: true,
276− SupportsImages: true,
277− },
278− {
279− ID: "anthropic/claude-haiku-3.5",
280− Name: "Anthropic: Claude 3.5 Haiku",
281− CostPer1MIn: 0.8,
282− CostPer1MOut: 4.0,
283− CostPer1MInCached: 1.0,
284− CostPer1MOutCached: 0.08,
285− ContextWindow: 200000,
286− DefaultMaxTokens: 4096,
287− CanReason: false,
288− SupportsImages: true,
289− },
290− },
291− },
292− }
293−}
internal/config/provider_test.go+52−60
@@ -1,81 +1,73 @@
11package config
22
33import (
4+ "encoding/json"
5+ "errors"
6+ "os"
47 "testing"
58
69 "github.com/charmbracelet/crush/internal/fur/provider"
710 "github.com/stretchr/testify/assert"
8− "github.com/stretchr/testify/require"
911)
1012
11−func TestProviders_MockEnabled(t *testing.T) {
12− originalUseMock := UseMockProviders
13− UseMockProviders = true
14− defer func() {
15− UseMockProviders = originalUseMock
16− ResetProviders()
17− }()
18−
19− ResetProviders()
20− providers := Providers()
21− require.NotEmpty(t, providers)
13+type mockProviderClient struct {
14+ shouldFail bool
15+}
2216
23− providerIDs := make(map[provider.InferenceProvider]bool)
24− for _, p := range providers {
25− providerIDs[p.ID] = true
17+func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
18+ if m.shouldFail {
19+ return nil, errors.New("failed to load providers")
2620 }
27−
28− assert.True(t, providerIDs[provider.InferenceProviderAnthropic])
29− assert.True(t, providerIDs[provider.InferenceProviderOpenAI])
30− assert.True(t, providerIDs[provider.InferenceProviderGemini])
21+ return []provider.Provider{
22+ {
23+ Name: "Mock",
24+ },
25+ }, nil
3126}
3227
33−func TestProviders_ResetFunctionality(t *testing.T) {
34− UseMockProviders = true
35− defer func() {
36− UseMockProviders = false
37− ResetProviders()
38− }()
39−
40− providers1 := Providers()
41− require.NotEmpty(t, providers1)
42−
43− ResetProviders()
44− providers2 := Providers()
45− require.NotEmpty(t, providers2)
28+func TestProvider_loadProvidersNoIssues(t *testing.T) {
29+ client := &mockProviderClient{shouldFail: false}
30+ tmpPath := t.TempDir() + "/providers.json"
31+ providers, err := loadProviders(tmpPath, client)
32+ assert.NoError(t, err)
33+ assert.NotNil(t, providers)
34+ assert.Len(t, providers, 1)
4635
47− assert.Equal(t, len(providers1), len(providers2))
36+ // check if file got saved
37+ fileInfo, err := os.Stat(tmpPath)
38+ assert.NoError(t, err)
39+ assert.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
4840}
4941
50−func TestProviders_ModelCapabilities(t *testing.T) {
51− originalUseMock := UseMockProviders
52− UseMockProviders = true
53− defer func() {
54− UseMockProviders = originalUseMock
55− ResetProviders()
56− }()
57−
58− ResetProviders()
59− providers := Providers()
60−
61− var openaiProvider provider.Provider
62− for _, p := range providers {
63− if p.ID == provider.InferenceProviderOpenAI {
64− openaiProvider = p
65− break
66− }
42+func TestProvider_loadProvidersWithIssues(t *testing.T) {
43+ client := &mockProviderClient{shouldFail: true}
44+ tmpPath := t.TempDir() + "/providers.json"
45+ // store providers to a temporary file
46+ oldProviders := []provider.Provider{
47+ {
48+ Name: "OldProvider",
49+ },
50+ }
51+ data, err := json.Marshal(oldProviders)
52+ if err != nil {
53+ t.Fatalf("Failed to marshal old providers: %v", err)
6754 }
68− require.NotEmpty(t, openaiProvider.ID)
6955
70− var foundReasoning, foundNonReasoning bool
71− for _, model := range openaiProvider.Models {
72− if model.CanReason && model.HasReasoningEffort {
73− foundReasoning = true
74− } else if !model.CanReason {
75− foundNonReasoning = true
76− }
56+ err = os.WriteFile(tmpPath, data, 0o644)
57+ if err != nil {
58+ t.Fatalf("Failed to write old providers to file: %v", err)
7759 }
60+ providers, err := loadProviders(tmpPath, client)
61+ assert.NoError(t, err)
62+ assert.NotNil(t, providers)
63+ assert.Len(t, providers, 1)
64+ assert.Equal(t, "OldProvider", providers[0].Name, "Expected to keep old provider when loading fails")
65+}
7866
79− assert.True(t, foundReasoning)
80− assert.True(t, foundNonReasoning)
67+func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
68+ client := &mockProviderClient{shouldFail: true}
69+ tmpPath := t.TempDir() + "/providers.json"
70+ providers, err := loadProviders(tmpPath, client)
71+ assert.Error(t, err)
72+ assert.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
8173}
pkg/config/resolve.go → internal/config/resolve.gorenamed+1−1
@@ -7,7 +7,7 @@ import (
77 "time"
88
99 "github.com/charmbracelet/crush/internal/shell"
10− "github.com/charmbracelet/crush/pkg/env"
10+ "github.com/charmbracelet/crush/internal/env"
1111)
1212
1313type VariableResolver interface {
pkg/config/resolve_test.go → internal/config/resolve_test.gorenamed+1−1
@@ -5,7 +5,7 @@ import (
55 "errors"
66 "testing"
77
8− "github.com/charmbracelet/crush/pkg/env"
8+ "github.com/charmbracelet/crush/internal/env"
99 "github.com/stretchr/testify/assert"
1010)
1111
internal/config/shell.godeleted−73
@@ -1,73 +0,0 @@
1−package config
2−
3−import (
4− "context"
5− "fmt"
6− "os"
7− "strings"
8− "time"
9−
10− "github.com/charmbracelet/crush/internal/logging"
11− "github.com/charmbracelet/crush/internal/shell"
12−)
13−
14−// ExecuteCommand executes a shell command and returns the output
15−// This is a shared utility that can be used by both provider config and tools
16−func ExecuteCommand(ctx context.Context, command string, workingDir string) (string, error) {
17− if workingDir == "" {
18− workingDir = WorkingDirectory()
19− }
20−
21− persistentShell := shell.NewShell(&shell.Options{WorkingDir: workingDir})
22−
23− stdout, stderr, err := persistentShell.Exec(ctx, command)
24− if err != nil {
25− logging.Debug("Command execution failed", "command", command, "error", err, "stderr", stderr)
26− return "", fmt.Errorf("command execution failed: %w", err)
27− }
28−
29− return strings.TrimSpace(stdout), nil
30−}
31−
32−// ResolveAPIKey resolves an API key that can be either:
33−// - A direct string value
34−// - An environment variable (prefixed with $)
35−// - A shell command (wrapped in $(...))
36−func ResolveAPIKey(apiKey string) (string, error) {
37− if !strings.HasPrefix(apiKey, "$") {
38− return apiKey, nil
39− }
40−
41− if strings.HasPrefix(apiKey, "$(") && strings.HasSuffix(apiKey, ")") {
42− command := strings.TrimSuffix(strings.TrimPrefix(apiKey, "$("), ")")
43− logging.Debug("Resolving API key from command", "command", command)
44− return resolveCommandAPIKey(command)
45− }
46−
47− envVar := strings.TrimPrefix(apiKey, "$")
48− if value := os.Getenv(envVar); value != "" {
49− logging.Debug("Resolved environment variable", "envVar", envVar, "value", value)
50− return value, nil
51− }
52−
53− logging.Debug("Environment variable not found", "envVar", envVar)
54−
55− return "", fmt.Errorf("environment variable %s not found", envVar)
56−}
57−
58−// resolveCommandAPIKey executes a command to get an API key, with caching support
59−func resolveCommandAPIKey(command string) (string, error) {
60− ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
61− defer cancel()
62−
63− logging.Debug("Executing command for API key", "command", command)
64−
65− workingDir := WorkingDirectory()
66−
67− result, err := ExecuteCommand(ctx, command, workingDir)
68− if err != nil {
69− return "", fmt.Errorf("failed to execute API key command: %w", err)
70− }
71− logging.Debug("Command executed successfully", "command", command, "result", result)
72− return result, nil
73−}
internal/config/validation_test.godeleted−462
@@ -1,462 +0,0 @@
1−package config
2−
3−import (
4− "testing"
5−
6− "github.com/charmbracelet/crush/internal/fur/provider"
7− "github.com/stretchr/testify/assert"
8− "github.com/stretchr/testify/require"
9−)
10−
11−func TestConfig_Validate_ValidConfig(t *testing.T) {
12− cfg := &Config{
13− Models: PreferredModels{
14− Large: PreferredModel{
15− ModelID: "gpt-4",
16− Provider: provider.InferenceProviderOpenAI,
17− },
18− Small: PreferredModel{
19− ModelID: "gpt-3.5-turbo",
20− Provider: provider.InferenceProviderOpenAI,
21− },
22− },
23− Providers: map[provider.InferenceProvider]ProviderConfig{
24− provider.InferenceProviderOpenAI: {
25− ID: provider.InferenceProviderOpenAI,
26− APIKey: "test-key",
27− ProviderType: provider.TypeOpenAI,
28− DefaultLargeModel: "gpt-4",
29− DefaultSmallModel: "gpt-3.5-turbo",
30− Models: []Model{
31− {
32− ID: "gpt-4",
33− Name: "GPT-4",
34− ContextWindow: 8192,
35− DefaultMaxTokens: 4096,
36− CostPer1MIn: 30.0,
37− CostPer1MOut: 60.0,
38− },
39− {
40− ID: "gpt-3.5-turbo",
41− Name: "GPT-3.5 Turbo",
42− ContextWindow: 4096,
43− DefaultMaxTokens: 2048,
44− CostPer1MIn: 1.5,
45− CostPer1MOut: 2.0,
46− },
47− },
48− },
49− },
50− Agents: map[AgentID]Agent{
51− AgentCoder: {
52− ID: AgentCoder,
53− Name: "Coder",
54− Description: "An agent that helps with executing coding tasks.",
55− Model: LargeModel,
56− ContextPaths: []string{"CRUSH.md"},
57− },
58− AgentTask: {
59− ID: AgentTask,
60− Name: "Task",
61− Description: "An agent that helps with searching for context and finding implementation details.",
62− Model: LargeModel,
63− ContextPaths: []string{"CRUSH.md"},
64− AllowedTools: []string{"glob", "grep", "ls", "sourcegraph", "view"},
65− AllowedMCP: map[string][]string{},
66− AllowedLSP: []string{},
67− },
68− },
69− MCP: map[string]MCP{},
70− LSP: map[string]LSPConfig{},
71− Options: Options{
72− DataDirectory: ".crush",
73− ContextPaths: []string{"CRUSH.md"},
74− },
75− }
76−
77− err := cfg.Validate()
78− assert.NoError(t, err)
79−}
80−
81−func TestConfig_Validate_MissingAPIKey(t *testing.T) {
82− cfg := &Config{
83− Providers: map[provider.InferenceProvider]ProviderConfig{
84− provider.InferenceProviderOpenAI: {
85− ID: provider.InferenceProviderOpenAI,
86− ProviderType: provider.TypeOpenAI,
87− // Missing APIKey
88− },
89− },
90− Options: Options{
91− DataDirectory: ".crush",
92− ContextPaths: []string{"CRUSH.md"},
93− },
94− }
95−
96− err := cfg.Validate()
97− require.Error(t, err)
98− assert.Contains(t, err.Error(), "API key is required")
99−}
100−
101−func TestConfig_Validate_InvalidProviderType(t *testing.T) {
102− cfg := &Config{
103− Providers: map[provider.InferenceProvider]ProviderConfig{
104− provider.InferenceProviderOpenAI: {
105− ID: provider.InferenceProviderOpenAI,
106− APIKey: "test-key",
107− ProviderType: provider.Type("invalid"),
108− },
109− },
110− Options: Options{
111− DataDirectory: ".crush",
112− ContextPaths: []string{"CRUSH.md"},
113− },
114− }
115−
116− err := cfg.Validate()
117− require.Error(t, err)
118− assert.Contains(t, err.Error(), "invalid provider type")
119−}
120−
121−func TestConfig_Validate_CustomProviderMissingBaseURL(t *testing.T) {
122− customProvider := provider.InferenceProvider("custom-provider")
123− cfg := &Config{
124− Providers: map[provider.InferenceProvider]ProviderConfig{
125− customProvider: {
126− ID: customProvider,
127− APIKey: "test-key",
128− ProviderType: provider.TypeOpenAI,
129− // Missing BaseURL for custom provider
130− },
131− },
132− Options: Options{
133− DataDirectory: ".crush",
134− ContextPaths: []string{"CRUSH.md"},
135− },
136− }
137−
138− err := cfg.Validate()
139− require.Error(t, err)
140− assert.Contains(t, err.Error(), "BaseURL is required for custom providers")
141−}
142−
143−func TestConfig_Validate_DuplicateModelIDs(t *testing.T) {
144− cfg := &Config{
145− Providers: map[provider.InferenceProvider]ProviderConfig{
146− provider.InferenceProviderOpenAI: {
147− ID: provider.InferenceProviderOpenAI,
148− APIKey: "test-key",
149− ProviderType: provider.TypeOpenAI,
150− Models: []Model{
151− {
152− ID: "gpt-4",
153− Name: "GPT-4",
154− ContextWindow: 8192,
155− DefaultMaxTokens: 4096,
156− },
157− {
158− ID: "gpt-4", // Duplicate ID
159− Name: "GPT-4 Duplicate",
160− ContextWindow: 8192,
161− DefaultMaxTokens: 4096,
162− },
163− },
164− },
165− },
166− Options: Options{
167− DataDirectory: ".crush",
168− ContextPaths: []string{"CRUSH.md"},
169− },
170− }
171−
172− err := cfg.Validate()
173− require.Error(t, err)
174− assert.Contains(t, err.Error(), "duplicate model ID")
175−}
176−
177−func TestConfig_Validate_InvalidModelFields(t *testing.T) {
178− cfg := &Config{
179− Providers: map[provider.InferenceProvider]ProviderConfig{
180− provider.InferenceProviderOpenAI: {
181− ID: provider.InferenceProviderOpenAI,
182− APIKey: "test-key",
183− ProviderType: provider.TypeOpenAI,
184− Models: []Model{
185− {
186− ID: "", // Empty ID
187− Name: "GPT-4",
188− ContextWindow: 0, // Invalid context window
189− DefaultMaxTokens: -1, // Invalid max tokens
190− CostPer1MIn: -5.0, // Negative cost
191− },
192− },
193− },
194− },
195− Options: Options{
196− DataDirectory: ".crush",
197− ContextPaths: []string{"CRUSH.md"},
198− },
199− }
200−
201− err := cfg.Validate()
202− require.Error(t, err)
203− validationErr := err.(ValidationErrors)
204− assert.True(t, len(validationErr) >= 4) // Should have multiple validation errors
205−}
206−
207−func TestConfig_Validate_DefaultModelNotFound(t *testing.T) {
208− cfg := &Config{
209− Providers: map[provider.InferenceProvider]ProviderConfig{
210− provider.InferenceProviderOpenAI: {
211− ID: provider.InferenceProviderOpenAI,
212− APIKey: "test-key",
213− ProviderType: provider.TypeOpenAI,
214− DefaultLargeModel: "nonexistent-model",
215− Models: []Model{
216− {
217− ID: "gpt-4",
218− Name: "GPT-4",
219− ContextWindow: 8192,
220− DefaultMaxTokens: 4096,
221− },
222− },
223− },
224− },
225− Options: Options{
226− DataDirectory: ".crush",
227− ContextPaths: []string{"CRUSH.md"},
228− },
229− }
230−
231− err := cfg.Validate()
232− require.Error(t, err)
233− assert.Contains(t, err.Error(), "default large model 'nonexistent-model' not found")
234−}
235−
236−func TestConfig_Validate_AgentIDMismatch(t *testing.T) {
237− cfg := &Config{
238− Agents: map[AgentID]Agent{
239− AgentCoder: {
240− ID: AgentTask, // Wrong ID
241− Name: "Coder",
242− },
243− },
244− Options: Options{
245− DataDirectory: ".crush",
246− ContextPaths: []string{"CRUSH.md"},
247− },
248− }
249−
250− err := cfg.Validate()
251− require.Error(t, err)
252− assert.Contains(t, err.Error(), "agent ID mismatch")
253−}
254−
255−func TestConfig_Validate_InvalidAgentModelType(t *testing.T) {
256− cfg := &Config{
257− Agents: map[AgentID]Agent{
258− AgentCoder: {
259− ID: AgentCoder,
260− Name: "Coder",
261− Model: ModelType("invalid"),
262− },
263− },
264− Options: Options{
265− DataDirectory: ".crush",
266− ContextPaths: []string{"CRUSH.md"},
267− },
268− }
269−
270− err := cfg.Validate()
271− require.Error(t, err)
272− assert.Contains(t, err.Error(), "invalid model type")
273−}
274−
275−func TestConfig_Validate_UnknownTool(t *testing.T) {
276− cfg := &Config{
277− Agents: map[AgentID]Agent{
278− AgentID("custom-agent"): {
279− ID: AgentID("custom-agent"),
280− Name: "Custom Agent",
281− Model: LargeModel,
282− AllowedTools: []string{"unknown-tool"},
283− },
284− },
285− Options: Options{
286− DataDirectory: ".crush",
287− ContextPaths: []string{"CRUSH.md"},
288− },
289− }
290−
291− err := cfg.Validate()
292− require.Error(t, err)
293− assert.Contains(t, err.Error(), "unknown tool")
294−}
295−
296−func TestConfig_Validate_MCPReference(t *testing.T) {
297− cfg := &Config{
298− Agents: map[AgentID]Agent{
299− AgentID("custom-agent"): {
300− ID: AgentID("custom-agent"),
301− Name: "Custom Agent",
302− Model: LargeModel,
303− AllowedMCP: map[string][]string{"nonexistent-mcp": nil},
304− },
305− },
306− MCP: map[string]MCP{}, // Empty MCP map
307− Options: Options{
308− DataDirectory: ".crush",
309− ContextPaths: []string{"CRUSH.md"},
310− },
311− }
312−
313− err := cfg.Validate()
314− require.Error(t, err)
315− assert.Contains(t, err.Error(), "referenced MCP 'nonexistent-mcp' not found")
316−}
317−
318−func TestConfig_Validate_InvalidMCPType(t *testing.T) {
319− cfg := &Config{
320− MCP: map[string]MCP{
321− "test-mcp": {
322− Type: MCPType("invalid"),
323− },
324− },
325− Options: Options{
326− DataDirectory: ".crush",
327− ContextPaths: []string{"CRUSH.md"},
328− },
329− }
330−
331− err := cfg.Validate()
332− require.Error(t, err)
333− assert.Contains(t, err.Error(), "invalid MCP type")
334−}
335−
336−func TestConfig_Validate_MCPMissingCommand(t *testing.T) {
337− cfg := &Config{
338− MCP: map[string]MCP{
339− "test-mcp": {
340− Type: MCPStdio,
341− // Missing Command
342− },
343− },
344− Options: Options{
345− DataDirectory: ".crush",
346− ContextPaths: []string{"CRUSH.md"},
347− },
348− }
349−
350− err := cfg.Validate()
351− require.Error(t, err)
352− assert.Contains(t, err.Error(), "command is required for stdio MCP")
353−}
354−
355−func TestConfig_Validate_LSPMissingCommand(t *testing.T) {
356− cfg := &Config{
357− LSP: map[string]LSPConfig{
358− "test-lsp": {
359− // Missing Command
360− },
361− },
362− Options: Options{
363− DataDirectory: ".crush",
364− ContextPaths: []string{"CRUSH.md"},
365− },
366− }
367−
368− err := cfg.Validate()
369− require.Error(t, err)
370− assert.Contains(t, err.Error(), "command is required for LSP")
371−}
372−
373−func TestConfig_Validate_NoValidProviders(t *testing.T) {
374− cfg := &Config{
375− Providers: map[provider.InferenceProvider]ProviderConfig{
376− provider.InferenceProviderOpenAI: {
377− ID: provider.InferenceProviderOpenAI,
378− APIKey: "test-key",
379− ProviderType: provider.TypeOpenAI,
380− Disabled: true, // Disabled
381− },
382− },
383− Options: Options{
384− DataDirectory: ".crush",
385− ContextPaths: []string{"CRUSH.md"},
386− },
387− }
388−
389− err := cfg.Validate()
390− require.Error(t, err)
391− assert.Contains(t, err.Error(), "at least one non-disabled provider is required")
392−}
393−
394−func TestConfig_Validate_MissingDefaultAgents(t *testing.T) {
395− cfg := &Config{
396− Providers: map[provider.InferenceProvider]ProviderConfig{
397− provider.InferenceProviderOpenAI: {
398− ID: provider.InferenceProviderOpenAI,
399− APIKey: "test-key",
400− ProviderType: provider.TypeOpenAI,
401− },
402− },
403− Agents: map[AgentID]Agent{}, // Missing default agents
404− Options: Options{
405− DataDirectory: ".crush",
406− ContextPaths: []string{"CRUSH.md"},
407− },
408− }
409−
410− err := cfg.Validate()
411− require.Error(t, err)
412− assert.Contains(t, err.Error(), "coder agent is required")
413− assert.Contains(t, err.Error(), "task agent is required")
414−}
415−
416−func TestConfig_Validate_KnownAgentProtection(t *testing.T) {
417− cfg := &Config{
418− Agents: map[AgentID]Agent{
419− AgentCoder: {
420− ID: AgentCoder,
421− Name: "Modified Coder", // Should not be allowed
422− Description: "Modified description", // Should not be allowed
423− Model: LargeModel,
424− },
425− },
426− Options: Options{
427− DataDirectory: ".crush",
428− ContextPaths: []string{"CRUSH.md"},
429− },
430− }
431−
432− err := cfg.Validate()
433− require.Error(t, err)
434− assert.Contains(t, err.Error(), "coder agent name cannot be changed")
435− assert.Contains(t, err.Error(), "coder agent description cannot be changed")
436−}
437−
438−func TestConfig_Validate_EmptyDataDirectory(t *testing.T) {
439− cfg := &Config{
440− Options: Options{
441− DataDirectory: "", // Empty
442− ContextPaths: []string{"CRUSH.md"},
443− },
444− }
445−
446− err := cfg.Validate()
447− require.Error(t, err)
448− assert.Contains(t, err.Error(), "data directory is required")
449−}
450−
451−func TestConfig_Validate_EmptyContextPath(t *testing.T) {
452− cfg := &Config{
453− Options: Options{
454− DataDirectory: ".crush",
455− ContextPaths: []string{""}, // Empty context path
456− },
457− }
458−
459− err := cfg.Validate()
460− require.Error(t, err)
461− assert.Contains(t, err.Error(), "context path cannot be empty")
462−}
internal/diff/diff.go+1−1
@@ -11,7 +11,7 @@ import (
1111func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
1212 // remove the cwd prefix and ensure consistent path format
1313 // this prevents issues with absolute paths in different environments
14− cwd := config.WorkingDirectory()
14+ cwd := config.Get().WorkingDir()
1515 fileName = strings.TrimPrefix(fileName, cwd)
1616 fileName = strings.TrimPrefix(fileName, "/")
1717
pkg/env/env.go → internal/env/env.gorenamed

The content is the same.

pkg/env/env_test.go → internal/env/env_test.gorenamed

The content is the same.

internal/llm/agent/agent.go+34−50
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/charmbracelet/crush/internal/config"
13+ fur "github.com/charmbracelet/crush/internal/fur/provider"
1314 "github.com/charmbracelet/crush/internal/history"
1415 "github.com/charmbracelet/crush/internal/llm/prompt"
1516 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -49,7 +50,7 @@ type AgentEvent struct {
4950
5051type Service interface {
5152 pubsub.Suscriber[AgentEvent]
52− Model() config.Model
53+ Model() fur.Model
5354 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5455 Cancel(sessionID string)
5556 CancelAll()
@@ -76,9 +77,9 @@ type agent struct {
7677 activeRequests sync.Map
7778}
7879
79−var agentPromptMap = map[config.AgentID]prompt.PromptID{
80− config.AgentCoder: prompt.PromptCoder,
81− config.AgentTask: prompt.PromptTask,
80+var agentPromptMap = map[string]prompt.PromptID{
81+ "coder": prompt.PromptCoder,
82+ "task": prompt.PromptTask,
8283}
8384
8485func NewAgent(
@@ -109,8 +110,8 @@ func NewAgent(
109110 tools.NewWriteTool(lspClients, permissions, history),
110111 }
111112
112− if agentCfg.ID == config.AgentCoder {
113− taskAgentCfg := config.Get().Agents[config.AgentTask]
113+ if agentCfg.ID == "coder" {
114+ taskAgentCfg := config.Get().Agents["task"]
114115 if taskAgentCfg.ID == "" {
115116 return nil, fmt.Errorf("task agent not found in config")
116117 }
@@ -130,13 +131,13 @@ func NewAgent(
130131 }
131132
132133 allTools = append(allTools, otherTools...)
133− providerCfg := config.GetAgentProvider(agentCfg.ID)
134− if providerCfg.ID == "" {
134+ providerCfg := config.Get().GetProviderForModel(agentCfg.Model)
135+ if providerCfg == nil {
135136 return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
136137 }
137− model := config.GetAgentModel(agentCfg.ID)
138+ model := config.Get().GetModelByType(agentCfg.Model)
138139
139− if model.ID == "" {
140+ if model == nil {
140141 return nil, fmt.Errorf("model not found for agent %s", agentCfg.Name)
141142 }
142143
@@ -148,51 +149,40 @@ func NewAgent(
148149 provider.WithModel(agentCfg.Model),
149150 provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID)),
150151 }
151− agentProvider, err := provider.NewProvider(providerCfg, opts...)
152+ agentProvider, err := provider.NewProvider(*providerCfg, opts...)
152153 if err != nil {
153154 return nil, err
154155 }
155156
156− smallModelCfg := cfg.Models.Small
157− var smallModel config.Model
158−
159− var smallModelProviderCfg config.ProviderConfig
157+ smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
158+ var smallModelProviderCfg *config.ProviderConfig
160159 if smallModelCfg.Provider == providerCfg.ID {
161160 smallModelProviderCfg = providerCfg
162161 } else {
163− for _, p := range cfg.Providers {
164− if p.ID == smallModelCfg.Provider {
165− smallModelProviderCfg = p
166− break
167− }
168− }
162+ smallModelProviderCfg = cfg.GetProviderForModel(config.SelectedModelTypeSmall)
163+
169164 if smallModelProviderCfg.ID == "" {
170165 return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
171166 }
172167 }
173− for _, m := range smallModelProviderCfg.Models {
174− if m.ID == smallModelCfg.ModelID {
175− smallModel = m
176− break
177− }
178− }
168+ smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
179169 if smallModel.ID == "" {
180− return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.ModelID, smallModelProviderCfg.ID)
170+ return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
181171 }
182172
183173 titleOpts := []provider.ProviderClientOption{
184− provider.WithModel(config.SmallModel),
174+ provider.WithModel(config.SelectedModelTypeSmall),
185175 provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
186176 }
187− titleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
177+ titleProvider, err := provider.NewProvider(*smallModelProviderCfg, titleOpts...)
188178 if err != nil {
189179 return nil, err
190180 }
191181 summarizeOpts := []provider.ProviderClientOption{
192− provider.WithModel(config.SmallModel),
182+ provider.WithModel(config.SelectedModelTypeSmall),
193183 provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
194184 }
195− summarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
185+ summarizeProvider, err := provider.NewProvider(*smallModelProviderCfg, summarizeOpts...)
196186 if err != nil {
197187 return nil, err
198188 }
@@ -225,8 +215,8 @@ func NewAgent(
225215 return agent, nil
226216}
227217
228−func (a *agent) Model() config.Model {
229− return config.GetAgentModel(a.agentCfg.ID)
218+func (a *agent) Model() fur.Model {
219+ return *config.Get().GetModelByType(a.agentCfg.Model)
230220}
231221
232222func (a *agent) Cancel(sessionID string) {
@@ -610,7 +600,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
610600 return nil
611601}
612602
613−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model config.Model, usage provider.TokenUsage) error {
603+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model fur.Model, usage provider.TokenUsage) error {
614604 sess, err := a.sessions.Get(ctx, sessionID)
615605 if err != nil {
616606 return fmt.Errorf("failed to get session: %w", err)
@@ -819,7 +809,7 @@ func (a *agent) UpdateModel() error {
819809 cfg := config.Get()
820810
821811 // Get current provider configuration
822− currentProviderCfg := config.GetAgentProvider(a.agentCfg.ID)
812+ currentProviderCfg := cfg.GetProviderForModel(a.agentCfg.Model)
823813 if currentProviderCfg.ID == "" {
824814 return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
825815 }
@@ -827,7 +817,7 @@ func (a *agent) UpdateModel() error {
827817 // Check if provider has changed
828818 if string(currentProviderCfg.ID) != a.providerID {
829819 // Provider changed, need to recreate the main provider
830− model := config.GetAgentModel(a.agentCfg.ID)
820+ model := cfg.GetModelByType(a.agentCfg.Model)
831821 if model.ID == "" {
832822 return fmt.Errorf("model not found for agent %s", a.agentCfg.Name)
833823 }
@@ -842,7 +832,7 @@ func (a *agent) UpdateModel() error {
842832 provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID)),
843833 }
844834
845− newProvider, err := provider.NewProvider(currentProviderCfg, opts...)
835+ newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
846836 if err != nil {
847837 return fmt.Errorf("failed to create new provider: %w", err)
848838 }
@@ -853,7 +843,7 @@ func (a *agent) UpdateModel() error {
853843 }
854844
855845 // Check if small model provider has changed (affects title and summarize providers)
856− smallModelCfg := cfg.Models.Small
846+ smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
857847 var smallModelProviderCfg config.ProviderConfig
858848
859849 for _, p := range cfg.Providers {
@@ -869,20 +859,14 @@ func (a *agent) UpdateModel() error {
869859
870860 // Check if summarize provider has changed
871861 if string(smallModelProviderCfg.ID) != a.summarizeProviderID {
872− var smallModel config.Model
873− for _, m := range smallModelProviderCfg.Models {
874− if m.ID == smallModelCfg.ModelID {
875− smallModel = m
876− break
877− }
878− }
879− if smallModel.ID == "" {
880− return fmt.Errorf("model %s not found in provider %s", smallModelCfg.ModelID, smallModelProviderCfg.ID)
862+ smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
863+ if smallModel == nil {
864+ return fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
881865 }
882866
883867 // Recreate title provider
884868 titleOpts := []provider.ProviderClientOption{
885− provider.WithModel(config.SmallModel),
869+ provider.WithModel(config.SelectedModelTypeSmall),
886870 provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
887871 // We want the title to be short, so we limit the max tokens
888872 provider.WithMaxTokens(40),
@@ -894,7 +878,7 @@ func (a *agent) UpdateModel() error {
894878
895879 // Recreate summarize provider
896880 summarizeOpts := []provider.ProviderClientOption{
897− provider.WithModel(config.SmallModel),
881+ provider.WithModel(config.SelectedModelTypeSmall),
898882 provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, smallModelProviderCfg.ID)),
899883 }
900884 newSummarizeProvider, err := provider.NewProvider(smallModelProviderCfg, summarizeOpts...)
internal/llm/agent/mcp-tools.go+4−4
@@ -19,7 +19,7 @@ import (
1919type mcpTool struct {
2020 mcpName string
2121 tool mcp.Tool
22− mcpConfig config.MCP
22+ mcpConfig config.MCPConfig
2323 permissions permission.Service
2424}
2525
@@ -97,7 +97,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
9797 p := b.permissions.Request(
9898 permission.CreatePermissionRequest{
9999 SessionID: sessionID,
100− Path: config.WorkingDirectory(),
100+ Path: config.Get().WorkingDir(),
101101 ToolName: b.Info().Name,
102102 Action: "execute",
103103 Description: permissionDescription,
@@ -142,7 +142,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
142142 return tools.NewTextErrorResponse("invalid mcp type"), nil
143143}
144144
145−func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCP) tools.BaseTool {
145+func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCPConfig) tools.BaseTool {
146146 return &mcpTool{
147147 mcpName: name,
148148 tool: tool,
@@ -153,7 +153,7 @@ func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpC
153153
154154var mcpTools []tools.BaseTool
155155
156−func getTools(ctx context.Context, name string, m config.MCP, permissions permission.Service, c MCPClient) []tools.BaseTool {
156+func getTools(ctx context.Context, name string, m config.MCPConfig, permissions permission.Service, c MCPClient) []tools.BaseTool {
157157 var stdioTools []tools.BaseTool
158158 initRequest := mcp.InitializeRequest{}
159159 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
internal/llm/prompt/coder.go+4−4
@@ -14,12 +14,12 @@ import (
1414 "github.com/charmbracelet/crush/internal/logging"
1515)
1616
17−func CoderPrompt(p provider.InferenceProvider, contextFiles ...string) string {
17+func CoderPrompt(p string, contextFiles ...string) string {
1818 var basePrompt string
1919 switch p {
20− case provider.InferenceProviderOpenAI:
20+ case string(provider.InferenceProviderOpenAI):
2121 basePrompt = baseOpenAICoderPrompt
22− case provider.InferenceProviderGemini, provider.InferenceProviderVertexAI:
22+ case string(provider.InferenceProviderGemini), string(provider.InferenceProviderVertexAI):
2323 basePrompt = baseGeminiCoderPrompt
2424 default:
2525 basePrompt = baseAnthropicCoderPrompt
@@ -380,7 +380,7 @@ Your core function is efficient and safe assistance. Balance extreme conciseness
380380`
381381
382382func getEnvironmentInfo() string {
383− cwd := config.WorkingDirectory()
383+ cwd := config.Get().WorkingDir()
384384 isGit := isGitRepo(cwd)
385385 platform := runtime.GOOS
386386 date := time.Now().Format("1/2/2006")
internal/llm/prompt/prompt.go+5−6
@@ -7,7 +7,6 @@ import (
77 "sync"
88
99 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
1110)
1211
1312type PromptID string
@@ -20,17 +19,17 @@ const (
2019 PromptDefault PromptID = "default"
2120)
2221
23−func GetPrompt(promptID PromptID, provider provider.InferenceProvider, contextPaths ...string) string {
22+func GetPrompt(promptID PromptID, provider string, contextPaths ...string) string {
2423 basePrompt := ""
2524 switch promptID {
2625 case PromptCoder:
2726 basePrompt = CoderPrompt(provider)
2827 case PromptTitle:
29− basePrompt = TitlePrompt(provider)
28+ basePrompt = TitlePrompt()
3029 case PromptTask:
31− basePrompt = TaskPrompt(provider)
30+ basePrompt = TaskPrompt()
3231 case PromptSummarizer:
33− basePrompt = SummarizerPrompt(provider)
32+ basePrompt = SummarizerPrompt()
3433 default:
3534 basePrompt = "You are a helpful assistant"
3635 }
@@ -38,7 +37,7 @@ func GetPrompt(promptID PromptID, provider provider.InferenceProvider, contextPa
3837}
3938
4039func getContextFromPaths(contextPaths []string) string {
41− return processContextPaths(config.WorkingDirectory(), contextPaths)
40+ return processContextPaths(config.Get().WorkingDir(), contextPaths)
4241}
4342
4443func processContextPaths(workDir string, paths []string) string {
internal/llm/prompt/summarizer.go+1−5
@@ -1,10 +1,6 @@
11package prompt
22
3−import (
4− "github.com/charmbracelet/crush/internal/fur/provider"
5−)
6−
7−func SummarizerPrompt(_ provider.InferenceProvider) string {
3+func SummarizerPrompt() string {
84 return `You are a helpful AI assistant tasked with summarizing conversations.
95
106When asked to summarize, provide a detailed but concise summary of the conversation.
internal/llm/prompt/task.go+1−3
@@ -2,11 +2,9 @@ package prompt
22
33import (
44 "fmt"
5−
6− "github.com/charmbracelet/crush/internal/fur/provider"
75)
86
9−func TaskPrompt(_ provider.InferenceProvider) string {
7+func TaskPrompt() string {
108 agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
119Notes:
12101. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
internal/llm/prompt/title.go+1−5
@@ -1,10 +1,6 @@
11package prompt
22
3−import (
4− "github.com/charmbracelet/crush/internal/fur/provider"
5−)
6−
7−func TitlePrompt(_ provider.InferenceProvider) string {
3+func TitlePrompt() string {
84 return `you will generate a short title based on the first message a user begins a conversation with
95- ensure it is not more than 50 characters long
106- the title should be a summary of the user's message
internal/llm/provider/anthropic.go+5−5
@@ -153,9 +153,9 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
153153 model := a.providerOptions.model(a.providerOptions.modelType)
154154 var thinkingParam anthropic.ThinkingConfigParamUnion
155155 cfg := config.Get()
156− modelConfig := cfg.Models.Large
157− if a.providerOptions.modelType == config.SmallModel {
158− modelConfig = cfg.Models.Small
156+ modelConfig := cfg.Models[config.SelectedModelTypeLarge]
157+ if a.providerOptions.modelType == config.SelectedModelTypeSmall {
158+ modelConfig = cfg.Models[config.SelectedModelTypeSmall]
159159 }
160160 temperature := anthropic.Float(0)
161161
@@ -399,7 +399,7 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
399399 }
400400
401401 if apiErr.StatusCode == 401 {
402− a.providerOptions.apiKey, err = config.ResolveAPIKey(a.providerOptions.config.APIKey)
402+ a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey)
403403 if err != nil {
404404 return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
405405 }
@@ -490,6 +490,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
490490 }
491491}
492492
493−func (a *anthropicClient) Model() config.Model {
493+func (a *anthropicClient) Model() provider.Model {
494494 return a.providerOptions.model(a.providerOptions.modelType)
495495}
internal/llm/provider/bedrock.go+5−4
@@ -7,6 +7,7 @@ import (
77 "strings"
88
99 "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
1011 "github.com/charmbracelet/crush/internal/llm/tools"
1112 "github.com/charmbracelet/crush/internal/message"
1213)
@@ -31,14 +32,14 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
3132 }
3233 }
3334
34− opts.model = func(modelType config.ModelType) config.Model {
35− model := config.GetModel(modelType)
35+ opts.model = func(modelType config.SelectedModelType) provider.Model {
36+ model := config.Get().GetModelByType(modelType)
3637
3738 // Prefix the model name with region
3839 regionPrefix := region[:2]
3940 modelName := model.ID
4041 model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
41− return model
42+ return *model
4243 }
4344
4445 model := opts.model(opts.modelType)
@@ -87,6 +88,6 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
8788 return b.childProvider.stream(ctx, messages, tools)
8889}
8990
90−func (b *bedrockClient) Model() config.Model {
91+func (b *bedrockClient) Model() provider.Model {
9192 return b.providerOptions.model(b.providerOptions.modelType)
9293}
internal/llm/provider/gemini.go+9−8
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fur/provider"
1314 "github.com/charmbracelet/crush/internal/llm/tools"
1415 "github.com/charmbracelet/crush/internal/logging"
1516 "github.com/charmbracelet/crush/internal/message"
@@ -170,9 +171,9 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
170171 logging.Debug("Prepared messages", "messages", string(jsonData))
171172 }
172173
173− modelConfig := cfg.Models.Large
174− if g.providerOptions.modelType == config.SmallModel {
175− modelConfig = cfg.Models.Small
174+ modelConfig := cfg.Models[config.SelectedModelTypeLarge]
175+ if g.providerOptions.modelType == config.SelectedModelTypeSmall {
176+ modelConfig = cfg.Models[config.SelectedModelTypeSmall]
176177 }
177178
178179 maxTokens := model.DefaultMaxTokens
@@ -268,9 +269,9 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
268269 logging.Debug("Prepared messages", "messages", string(jsonData))
269270 }
270271
271− modelConfig := cfg.Models.Large
272− if g.providerOptions.modelType == config.SmallModel {
273− modelConfig = cfg.Models.Small
272+ modelConfig := cfg.Models[config.SelectedModelTypeLarge]
273+ if g.providerOptions.modelType == config.SelectedModelTypeSmall {
274+ modelConfig = cfg.Models[config.SelectedModelTypeSmall]
274275 }
275276 maxTokens := model.DefaultMaxTokens
276277 if modelConfig.MaxTokens > 0 {
@@ -424,7 +425,7 @@ func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error)
424425
425426 // Check for token expiration (401 Unauthorized)
426427 if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
427− g.providerOptions.apiKey, err = config.ResolveAPIKey(g.providerOptions.config.APIKey)
428+ g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey)
428429 if err != nil {
429430 return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
430431 }
@@ -462,7 +463,7 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
462463 }
463464}
464465
465−func (g *geminiClient) Model() config.Model {
466+func (g *geminiClient) Model() provider.Model {
466467 return g.providerOptions.model(g.providerOptions.modelType)
467468}
468469
internal/llm/provider/openai.go+6−9
@@ -148,15 +148,12 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
148148 model := o.providerOptions.model(o.providerOptions.modelType)
149149 cfg := config.Get()
150150
151− modelConfig := cfg.Models.Large
152− if o.providerOptions.modelType == config.SmallModel {
153− modelConfig = cfg.Models.Small
151+ modelConfig := cfg.Models[config.SelectedModelTypeLarge]
152+ if o.providerOptions.modelType == config.SelectedModelTypeSmall {
153+ modelConfig = cfg.Models[config.SelectedModelTypeSmall]
154154 }
155155
156− reasoningEffort := model.ReasoningEffort
157− if modelConfig.ReasoningEffort != "" {
158− reasoningEffort = modelConfig.ReasoningEffort
159− }
156+ reasoningEffort := modelConfig.ReasoningEffort
160157
161158 params := openai.ChatCompletionNewParams{
162159 Model: openai.ChatModel(model.ID),
@@ -363,7 +360,7 @@ func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error)
363360
364361 // Check for token expiration (401 Unauthorized)
365362 if apiErr.StatusCode == 401 {
366− o.providerOptions.apiKey, err = config.ResolveAPIKey(o.providerOptions.config.APIKey)
363+ o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey)
367364 if err != nil {
368365 return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
369366 }
@@ -420,6 +417,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
420417 }
421418}
422419
423−func (a *openaiClient) Model() config.Model {
420+func (a *openaiClient) Model() provider.Model {
424421 return a.providerOptions.model(a.providerOptions.modelType)
425422}
internal/llm/provider/provider.go+11−11
@@ -55,15 +55,15 @@ type Provider interface {
5555
5656 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
5757
58− Model() config.Model
58+ Model() provider.Model
5959}
6060
6161type providerClientOptions struct {
6262 baseURL string
6363 config config.ProviderConfig
6464 apiKey string
65− modelType config.ModelType
66− model func(config.ModelType) config.Model
65+ modelType config.SelectedModelType
66+ model func(config.SelectedModelType) provider.Model
6767 disableCache bool
6868 systemMessage string
6969 maxTokens int64
@@ -77,7 +77,7 @@ type ProviderClient interface {
7777 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
7878 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
7979
80− Model() config.Model
80+ Model() provider.Model
8181}
8282
8383type baseProvider[C ProviderClient] struct {
@@ -106,11 +106,11 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
106106 return p.client.stream(ctx, messages, tools)
107107}
108108
109−func (p *baseProvider[C]) Model() config.Model {
109+func (p *baseProvider[C]) Model() provider.Model {
110110 return p.client.Model()
111111}
112112
113−func WithModel(model config.ModelType) ProviderClientOption {
113+func WithModel(model config.SelectedModelType) ProviderClientOption {
114114 return func(options *providerClientOptions) {
115115 options.modelType = model
116116 }
@@ -135,7 +135,7 @@ func WithMaxTokens(maxTokens int64) ProviderClientOption {
135135}
136136
137137func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
138− resolvedAPIKey, err := config.ResolveAPIKey(cfg.APIKey)
138+ resolvedAPIKey, err := config.Get().Resolve(cfg.APIKey)
139139 if err != nil {
140140 return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
141141 }
@@ -145,14 +145,14 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
145145 config: cfg,
146146 apiKey: resolvedAPIKey,
147147 extraHeaders: cfg.ExtraHeaders,
148− model: func(tp config.ModelType) config.Model {
149− return config.GetModel(tp)
148+ model: func(tp config.SelectedModelType) provider.Model {
149+ return *config.Get().GetModelByType(tp)
150150 },
151151 }
152152 for _, o := range opts {
153153 o(&clientOptions)
154154 }
155− switch cfg.ProviderType {
155+ switch cfg.Type {
156156 case provider.TypeAnthropic:
157157 return &baseProvider[AnthropicClient]{
158158 options: clientOptions,
@@ -190,5 +190,5 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
190190 client: newOpenAIClient(clientOptions),
191191 }, nil
192192 }
193− return nil, fmt.Errorf("provider not supported: %s", cfg.ProviderType)
193+ return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
194194}
internal/llm/tools/bash.go+2−2
@@ -317,7 +317,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
317317 p := b.permissions.Request(
318318 permission.CreatePermissionRequest{
319319 SessionID: sessionID,
320− Path: config.WorkingDirectory(),
320+ Path: config.Get().WorkingDir(),
321321 ToolName: BashToolName,
322322 Action: "execute",
323323 Description: fmt.Sprintf("Execute command: %s", params.Command),
@@ -337,7 +337,7 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
337337 defer cancel()
338338 }
339339 stdout, stderr, err := shell.
340− GetPersistentShell(config.WorkingDirectory()).
340+ GetPersistentShell(config.Get().WorkingDir()).
341341 Exec(ctx, params.Command)
342342 interrupted := shell.IsInterrupt(err)
343343 exitCode := shell.ExitCode(err)
internal/llm/tools/edit.go+4−4
@@ -143,7 +143,7 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
143143 }
144144
145145 if !filepath.IsAbs(params.FilePath) {
146− wd := config.WorkingDirectory()
146+ wd := config.Get().WorkingDir()
147147 params.FilePath = filepath.Join(wd, params.FilePath)
148148 }
149149
@@ -207,7 +207,7 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
207207 content,
208208 filePath,
209209 )
210− rootDir := config.WorkingDirectory()
210+ rootDir := config.Get().WorkingDir()
211211 permissionPath := filepath.Dir(filePath)
212212 if strings.HasPrefix(filePath, rootDir) {
213213 permissionPath = rootDir
@@ -320,7 +320,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
320320 filePath,
321321 )
322322
323− rootDir := config.WorkingDirectory()
323+ rootDir := config.Get().WorkingDir()
324324 permissionPath := filepath.Dir(filePath)
325325 if strings.HasPrefix(filePath, rootDir) {
326326 permissionPath = rootDir
@@ -442,7 +442,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
442442 newContent,
443443 filePath,
444444 )
445− rootDir := config.WorkingDirectory()
445+ rootDir := config.Get().WorkingDir()
446446 permissionPath := filepath.Dir(filePath)
447447 if strings.HasPrefix(filePath, rootDir) {
448448 permissionPath = rootDir
internal/llm/tools/fetch.go+1−1
@@ -133,7 +133,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
133133 p := t.permissions.Request(
134134 permission.CreatePermissionRequest{
135135 SessionID: sessionID,
136− Path: config.WorkingDirectory(),
136+ Path: config.Get().WorkingDir(),
137137 ToolName: FetchToolName,
138138 Action: "fetch",
139139 Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
internal/llm/tools/glob.go+1−1
@@ -108,7 +108,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
108108
109109 searchPath := params.Path
110110 if searchPath == "" {
111− searchPath = config.WorkingDirectory()
111+ searchPath = config.Get().WorkingDir()
112112 }
113113
114114 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
internal/llm/tools/grep.go+1−1
@@ -200,7 +200,7 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
200200
201201 searchPath := params.Path
202202 if searchPath == "" {
203− searchPath = config.WorkingDirectory()
203+ searchPath = config.Get().WorkingDir()
204204 }
205205
206206 matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
internal/llm/tools/ls.go+2−2
@@ -107,11 +107,11 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
107107
108108 searchPath := params.Path
109109 if searchPath == "" {
110− searchPath = config.WorkingDirectory()
110+ searchPath = config.Get().WorkingDir()
111111 }
112112
113113 if !filepath.IsAbs(searchPath) {
114− searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
114+ searchPath = filepath.Join(config.Get().WorkingDir(), searchPath)
115115 }
116116
117117 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
internal/llm/tools/view.go+1−1
@@ -117,7 +117,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
117117 // Handle relative paths
118118 filePath := params.FilePath
119119 if !filepath.IsAbs(filePath) {
120− filePath = filepath.Join(config.WorkingDirectory(), filePath)
120+ filePath = filepath.Join(config.Get().WorkingDir(), filePath)
121121 }
122122
123123 // Check if file exists
internal/llm/tools/write.go+2−2
@@ -122,7 +122,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
122122
123123 filePath := params.FilePath
124124 if !filepath.IsAbs(filePath) {
125− filePath = filepath.Join(config.WorkingDirectory(), filePath)
125+ filePath = filepath.Join(config.Get().WorkingDir(), filePath)
126126 }
127127
128128 fileInfo, err := os.Stat(filePath)
@@ -170,7 +170,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
170170 filePath,
171171 )
172172
173− rootDir := config.WorkingDirectory()
173+ rootDir := config.Get().WorkingDir()
174174 permissionPath := filepath.Dir(filePath)
175175 if strings.HasPrefix(filePath, rootDir) {
176176 permissionPath = rootDir
pkg/log/log.go → internal/log/log.gorenamed

The content is the same.

internal/lsp/client.go+2−2
@@ -376,7 +376,7 @@ func (c *Client) detectServerType() ServerType {
376376
377377// openKeyConfigFiles opens important configuration files that help initialize the server
378378func (c *Client) openKeyConfigFiles(ctx context.Context) {
379− workDir := config.WorkingDirectory()
379+ workDir := config.Get().WorkingDir()
380380 serverType := c.detectServerType()
381381
382382 var filesToOpen []string
@@ -464,7 +464,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
464464 }
465465
466466 // If we have no open TypeScript files, try to find and open one
467− workDir := config.WorkingDirectory()
467+ workDir := config.Get().WorkingDir()
468468 err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
469469 if err != nil {
470470 return err
internal/permission/permission.go+1−1
@@ -87,7 +87,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
8787
8888 dir := filepath.Dir(opts.Path)
8989 if dir == "." {
90− dir = config.WorkingDirectory()
90+ dir = config.Get().WorkingDir()
9191 }
9292 permission := PermissionRequest{
9393 ID: uuid.New().String(),
internal/tui/components/chat/header/header.go+3−2
@@ -91,7 +91,7 @@ func (p *header) View() tea.View {
9191
9292func (h *header) details() string {
9393 t := styles.CurrentTheme()
94− cwd := fsext.DirTrim(fsext.PrettyPath(config.WorkingDirectory()), 4)
94+ cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), 4)
9595 parts := []string{
9696 t.S().Muted.Render(cwd),
9797 }
@@ -111,7 +111,8 @@ func (h *header) details() string {
111111 parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
112112 }
113113
114− model := config.GetAgentModel(config.AgentCoder)
114+ agentCfg := config.Get().Agents["coder"]
115+ model := config.Get().GetModelByType(agentCfg.Model)
115116 percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
116117 formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
117118 parts = append(parts, formattedPercentage)
internal/tui/components/chat/messages/messages.go+1−2
@@ -10,7 +10,6 @@ import (
1010 "github.com/charmbracelet/lipgloss/v2"
1111
1212 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1413 "github.com/charmbracelet/crush/internal/message"
1514 "github.com/charmbracelet/crush/internal/tui/components/anim"
1615 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -296,7 +295,7 @@ func (m *assistantSectionModel) View() tea.View {
296295 duration := finishTime.Sub(m.lastUserMessageTime)
297296 infoMsg := t.S().Subtle.Render(duration.String())
298297 icon := t.S().Subtle.Render(styles.ModelIcon)
299− model := config.GetProviderModel(provider.InferenceProvider(m.message.Provider), m.message.Model)
298+ model := config.Get().GetModel(m.message.Provider, m.message.Model)
300299 modelFormatted := t.S().Muted.Render(model.Name)
301300 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
302301 return tea.NewView(
internal/tui/components/chat/sidebar/sidebar.go+4−3
@@ -297,7 +297,7 @@ func (m *sidebarCmp) filesBlock() string {
297297 }
298298
299299 extraContent := strings.Join(statusParts, " ")
300− cwd := config.WorkingDirectory() + string(os.PathSeparator)
300+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
301301 filePath := file.FilePath
302302 filePath = strings.TrimPrefix(filePath, cwd)
303303 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
@@ -474,7 +474,8 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
474474}
475475
476476func (s *sidebarCmp) currentModelBlock() string {
477− model := config.GetAgentModel(config.AgentCoder)
477+ agentCfg := config.Get().Agents["coder"]
478+ model := config.Get().GetModelByType(agentCfg.Model)
478479
479480 t := styles.CurrentTheme()
480481
@@ -507,7 +508,7 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
507508}
508509
509510func cwd() string {
510− cwd := config.WorkingDirectory()
511+ cwd := config.Get().WorkingDir()
511512 t := styles.CurrentTheme()
512513 // Replace home directory with ~, unless we're at the top level of the
513514 // home directory).
internal/tui/components/dialogs/models/models.go+25−21
@@ -31,8 +31,8 @@ const (
3131
3232// ModelSelectedMsg is sent when a model is selected
3333type ModelSelectedMsg struct {
34− Model config.PreferredModel
35− ModelType config.ModelType
34+ Model config.SelectedModel
35+ ModelType config.SelectedModelType
3636}
3737
3838// CloseModelDialogMsg is sent when a model is selected
@@ -115,19 +115,19 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115115 items := m.modelList.Items()
116116 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
117117
118− var modelType config.ModelType
118+ var modelType config.SelectedModelType
119119 if m.modelType == LargeModelType {
120− modelType = config.LargeModel
120+ modelType = config.SelectedModelTypeLarge
121121 } else {
122− modelType = config.SmallModel
122+ modelType = config.SelectedModelTypeSmall
123123 }
124124
125125 return m, tea.Sequence(
126126 util.CmdHandler(dialogs.CloseDialogMsg{}),
127127 util.CmdHandler(ModelSelectedMsg{
128− Model: config.PreferredModel{
129− ModelID: selectedItem.Model.ID,
130− Provider: selectedItem.Provider.ID,
128+ Model: config.SelectedModel{
129+ Model: selectedItem.Model.ID,
130+ Provider: string(selectedItem.Provider.ID),
131131 },
132132 ModelType: modelType,
133133 }),
@@ -218,35 +218,39 @@ func (m *modelDialogCmp) modelTypeRadio() string {
218218func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
219219 m.modelType = modelType
220220
221− providers := config.Providers()
221+ providers, err := config.Providers()
222+ if err != nil {
223+ return util.ReportError(err)
224+ }
225+
222226 modelItems := []util.Model{}
223227 selectIndex := 0
224228
225229 cfg := config.Get()
226− var currentModel config.PreferredModel
230+ var currentModel config.SelectedModel
227231 if m.modelType == LargeModelType {
228− currentModel = cfg.Models.Large
232+ currentModel = cfg.Models[config.SelectedModelTypeLarge]
229233 } else {
230− currentModel = cfg.Models.Small
234+ currentModel = cfg.Models[config.SelectedModelTypeSmall]
231235 }
232236
233237 // Create a map to track which providers we've already added
234− addedProviders := make(map[provider.InferenceProvider]bool)
238+ addedProviders := make(map[string]bool)
235239
236240 // First, add any configured providers that are not in the known providers list
237241 // These should appear at the top of the list
238242 knownProviders := provider.KnownProviders()
239243 for providerID, providerConfig := range cfg.Providers {
240− if providerConfig.Disabled {
244+ if providerConfig.Disable {
241245 continue
242246 }
243247
244248 // Check if this provider is not in the known providers list
245− if !slices.Contains(knownProviders, providerID) {
249+ if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) {
246250 // Convert config provider to provider.Provider format
247251 configProvider := provider.Provider{
248252 Name: string(providerID), // Use provider ID as name for unknown providers
249− ID: providerID,
253+ ID: provider.InferenceProvider(providerID),
250254 Models: make([]provider.Model, len(providerConfig.Models)),
251255 }
252256
@@ -263,7 +267,7 @@ func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
263267 DefaultMaxTokens: model.DefaultMaxTokens,
264268 CanReason: model.CanReason,
265269 HasReasoningEffort: model.HasReasoningEffort,
266− DefaultReasoningEffort: model.ReasoningEffort,
270+ DefaultReasoningEffort: model.DefaultReasoningEffort,
267271 SupportsImages: model.SupportsImages,
268272 }
269273 }
@@ -279,7 +283,7 @@ func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
279283 Provider: configProvider,
280284 Model: model,
281285 }))
282− if model.ID == currentModel.ModelID && configProvider.ID == currentModel.Provider {
286+ if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
283287 selectIndex = len(modelItems) - 1 // Set the selected index to the current model
284288 }
285289 }
@@ -290,12 +294,12 @@ func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
290294 // Then add the known providers from the predefined list
291295 for _, provider := range providers {
292296 // Skip if we already added this provider as an unknown provider
293− if addedProviders[provider.ID] {
297+ if addedProviders[string(provider.ID)] {
294298 continue
295299 }
296300
297301 // Check if this provider is configured and not disabled
298− if providerConfig, exists := cfg.Providers[provider.ID]; exists && providerConfig.Disabled {
302+ if providerConfig, exists := cfg.Providers[string(provider.ID)]; exists && providerConfig.Disable {
299303 continue
300304 }
301305
@@ -309,7 +313,7 @@ func (m *modelDialogCmp) SetModelType(modelType int) tea.Cmd {
309313 Provider: provider,
310314 Model: model,
311315 }))
312− if model.ID == currentModel.ModelID && provider.ID == currentModel.Provider {
316+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
313317 selectIndex = len(modelItems) - 1 // Set the selected index to the current model
314318 }
315319 }
internal/tui/page/chat/chat.go+2−1
@@ -170,7 +170,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170170 util.CmdHandler(ChatFocusedMsg{Focused: false}),
171171 )
172172 case key.Matches(msg, p.keyMap.AddAttachment):
173− model := config.GetAgentModel(config.AgentCoder)
173+ agentCfg := config.Get().Agents["coder"]
174+ model := config.Get().GetModelByType(agentCfg.Model)
174175 if model.SupportsImages {
175176 return p, util.CmdHandler(OpenFilePickerMsg{})
176177 } else {
internal/tui/tui.go+3−3
@@ -177,14 +177,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177177 // Update the agent with the new model/provider configuration
178178 if err := a.app.UpdateAgentModel(); err != nil {
179179 logging.ErrorPersist(fmt.Sprintf("Failed to update agent model: %v", err))
180− return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.ModelID, err))
180+ return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
181181 }
182182
183183 modelTypeName := "large"
184− if msg.ModelType == config.SmallModel {
184+ if msg.ModelType == config.SelectedModelTypeSmall {
185185 modelTypeName = "small"
186186 }
187− return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.ModelID))
187+ return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
188188
189189 // File Picker
190190 case chat.OpenFilePickerMsg:
pkg/config/config.godeleted−224
@@ -1,224 +0,0 @@
1−package config
2−
3−import (
4− "slices"
5− "strings"
6−
7− "github.com/charmbracelet/crush/internal/fur/provider"
8−)
9−
10−const (
11− appName = "crush"
12− defaultDataDirectory = ".crush"
13− defaultLogLevel = "info"
14−)
15−
16−var defaultContextPaths = []string{
17− ".github/copilot-instructions.md",
18− ".cursorrules",
19− ".cursor/rules/",
20− "CLAUDE.md",
21− "CLAUDE.local.md",
22− "GEMINI.md",
23− "gemini.md",
24− "crush.md",
25− "crush.local.md",
26− "Crush.md",
27− "Crush.local.md",
28− "CRUSH.md",
29− "CRUSH.local.md",
30−}
31−
32−type SelectedModelType string
33−
34−const (
35− SelectedModelTypeLarge SelectedModelType = "large"
36− SelectedModelTypeSmall SelectedModelType = "small"
37−)
38−
39−type SelectedModel struct {
40− // The model id as used by the provider API.
41− // Required.
42− Model string `json:"model"`
43− // The model provider, same as the key/id used in the providers config.
44− // Required.
45− Provider string `json:"provider"`
46−
47− // Only used by models that use the openai provider and need this set.
48− ReasoningEffort string `json:"reasoning_effort,omitempty"`
49−
50− // Overrides the default model configuration.
51− MaxTokens int64 `json:"max_tokens,omitempty"`
52−
53− // Used by anthropic models that can reason to indicate if the model should think.
54− Think bool `json:"think,omitempty"`
55−}
56−
57−type ProviderConfig struct {
58− // The provider's id.
59− ID string `json:"id,omitempty"`
60− // The provider's API endpoint.
61− BaseURL string `json:"base_url,omitempty"`
62− // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
63− Type provider.Type `json:"type,omitempty"`
64− // The provider's API key.
65− APIKey string `json:"api_key,omitempty"`
66− // Marks the provider as disabled.
67− Disable bool `json:"disable,omitempty"`
68−
69− // Extra headers to send with each request to the provider.
70− ExtraHeaders map[string]string
71−
72− // Used to pass extra parameters to the provider.
73− ExtraParams map[string]string `json:"-"`
74−
75− // The provider models
76− Models []provider.Model `json:"models,omitempty"`
77−}
78−
79−type MCPType string
80−
81−const (
82− MCPStdio MCPType = "stdio"
83− MCPSse MCPType = "sse"
84− MCPHttp MCPType = "http"
85−)
86−
87−type MCPConfig struct {
88− Command string `json:"command,omitempty" `
89− Env []string `json:"env,omitempty"`
90− Args []string `json:"args,omitempty"`
91− Type MCPType `json:"type"`
92− URL string `json:"url,omitempty"`
93−
94− // TODO: maybe make it possible to get the value from the env
95− Headers map[string]string `json:"headers,omitempty"`
96−}
97−
98−type LSPConfig struct {
99− Disabled bool `json:"enabled,omitempty"`
100− Command string `json:"command"`
101− Args []string `json:"args,omitempty"`
102− Options any `json:"options,omitempty"`
103−}
104−
105−type TUIOptions struct {
106− CompactMode bool `json:"compact_mode,omitempty"`
107− // Here we can add themes later or any TUI related options
108−}
109−
110−type Options struct {
111− ContextPaths []string `json:"context_paths,omitempty"`
112− TUI *TUIOptions `json:"tui,omitempty"`
113− Debug bool `json:"debug,omitempty"`
114− DebugLSP bool `json:"debug_lsp,omitempty"`
115− DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty"`
116− // Relative to the cwd
117− DataDirectory string `json:"data_directory,omitempty"`
118−}
119−
120−type MCPs map[string]MCPConfig
121−
122−type MCP struct {
123− Name string `json:"name"`
124− MCP MCPConfig `json:"mcp"`
125−}
126−
127−func (m MCPs) Sorted() []MCP {
128− sorted := make([]MCP, 0, len(m))
129− for k, v := range m {
130− sorted = append(sorted, MCP{
131− Name: k,
132− MCP: v,
133− })
134− }
135− slices.SortFunc(sorted, func(a, b MCP) int {
136− return strings.Compare(a.Name, b.Name)
137− })
138− return sorted
139−}
140−
141−type LSPs map[string]LSPConfig
142−
143−type LSP struct {
144− Name string `json:"name"`
145− LSP LSPConfig `json:"lsp"`
146−}
147−
148−func (l LSPs) Sorted() []LSP {
149− sorted := make([]LSP, 0, len(l))
150− for k, v := range l {
151− sorted = append(sorted, LSP{
152− Name: k,
153− LSP: v,
154− })
155− }
156− slices.SortFunc(sorted, func(a, b LSP) int {
157− return strings.Compare(a.Name, b.Name)
158− })
159− return sorted
160−}
161−
162−// Config holds the configuration for crush.
163−type Config struct {
164− // We currently only support large/small as values here.
165− Models map[SelectedModelType]SelectedModel `json:"models,omitempty"`
166−
167− // The providers that are configured
168− Providers map[string]ProviderConfig `json:"providers,omitempty"`
169−
170− MCP MCPs `json:"mcp,omitempty"`
171−
172− LSP LSPs `json:"lsp,omitempty"`
173−
174− Options *Options `json:"options,omitempty"`
175−
176− // Internal
177− workingDir string `json:"-"`
178−}
179−
180−func (c *Config) WorkingDir() string {
181− return c.workingDir
182−}
183−
184−func (c *Config) EnabledProviders() []ProviderConfig {
185− enabled := make([]ProviderConfig, 0, len(c.Providers))
186− for _, p := range c.Providers {
187− if !p.Disable {
188− enabled = append(enabled, p)
189− }
190− }
191− return enabled
192−}
193−
194−// IsConfigured return true if at least one provider is configured
195−func (c *Config) IsConfigured() bool {
196− return len(c.EnabledProviders()) > 0
197−}
198−
199−func (c *Config) GetModel(provider, model string) *provider.Model {
200− if providerConfig, ok := c.Providers[provider]; ok {
201− for _, m := range providerConfig.Models {
202− if m.ID == model {
203− return &m
204− }
205− }
206− }
207− return nil
208−}
209−
210−func (c *Config) LargeModel() *provider.Model {
211− model, ok := c.Models[SelectedModelTypeLarge]
212− if !ok {
213− return nil
214− }
215− return c.GetModel(model.Provider, model.Model)
216−}
217−
218−func (c *Config) SmallModel() *provider.Model {
219− model, ok := c.Models[SelectedModelTypeSmall]
220− if !ok {
221− return nil
222− }
223− return c.GetModel(model.Provider, model.Model)
224−}
pkg/config/provider.godeleted−93
@@ -1,93 +0,0 @@
1−package config
2−
3−import (
4− "encoding/json"
5− "os"
6− "path/filepath"
7− "runtime"
8− "sync"
9−
10− "github.com/charmbracelet/crush/internal/fur/provider"
11−)
12−
13−type ProviderClient interface {
14− GetProviders() ([]provider.Provider, error)
15−}
16−
17−var (
18− providerOnce sync.Once
19− providerList []provider.Provider
20−)
21−
22−// file to cache provider data
23−func providerCacheFileData() string {
24− xdgDataHome := os.Getenv("XDG_DATA_HOME")
25− if xdgDataHome != "" {
26− return filepath.Join(xdgDataHome, appName)
27− }
28−
29− // return the path to the main data directory
30− // for windows, it should be in `%LOCALAPPDATA%/crush/`
31− // for linux and macOS, it should be in `$HOME/.local/share/crush/`
32− if runtime.GOOS == "windows" {
33− localAppData := os.Getenv("LOCALAPPDATA")
34− if localAppData == "" {
35− localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
36− }
37− return filepath.Join(localAppData, appName)
38− }
39−
40− return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
41−}
42−
43−func saveProvidersInCache(path string, providers []provider.Provider) error {
44− dir := filepath.Dir(path)
45− if err := os.MkdirAll(dir, 0o755); err != nil {
46− return err
47− }
48−
49− data, err := json.MarshalIndent(providers, "", " ")
50− if err != nil {
51− return err
52− }
53−
54− return os.WriteFile(path, data, 0o644)
55−}
56−
57−func loadProvidersFromCache(path string) ([]provider.Provider, error) {
58− data, err := os.ReadFile(path)
59− if err != nil {
60− return nil, err
61− }
62−
63− var providers []provider.Provider
64− err = json.Unmarshal(data, &providers)
65− return providers, err
66−}
67−
68−func loadProviders(path string, client ProviderClient) ([]provider.Provider, error) {
69− providers, err := client.GetProviders()
70− if err != nil {
71− fallbackToCache, err := loadProvidersFromCache(path)
72− if err != nil {
73− return nil, err
74− }
75− providers = fallbackToCache
76− } else {
77− if err := saveProvidersInCache(path, providerList); err != nil {
78− return nil, err
79− }
80− }
81− return providers, nil
82−}
83−
84−func LoadProviders(client ProviderClient) ([]provider.Provider, error) {
85− var err error
86− providerOnce.Do(func() {
87− providerList, err = loadProviders(providerCacheFileData(), client)
88− })
89− if err != nil {
90− return nil, err
91− }
92− return providerList, nil
93−}
pkg/config/provider_test.godeleted−73
@@ -1,73 +0,0 @@
1−package config
2−
3−import (
4− "encoding/json"
5− "errors"
6− "os"
7− "testing"
8−
9− "github.com/charmbracelet/crush/internal/fur/provider"
10− "github.com/stretchr/testify/assert"
11−)
12−
13−type mockProviderClient struct {
14− shouldFail bool
15−}
16−
17−func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
18− if m.shouldFail {
19− return nil, errors.New("failed to load providers")
20− }
21− return []provider.Provider{
22− {
23− Name: "Mock",
24− },
25− }, nil
26−}
27−
28−func TestProvider_loadProvidersNoIssues(t *testing.T) {
29− client := &mockProviderClient{shouldFail: false}
30− tmpPath := t.TempDir() + "/providers.json"
31− providers, err := loadProviders(tmpPath, client)
32− assert.NoError(t, err)
33− assert.NotNil(t, providers)
34− assert.Len(t, providers, 1)
35−
36− // check if file got saved
37− fileInfo, err := os.Stat(tmpPath)
38− assert.NoError(t, err)
39− assert.False(t, fileInfo.IsDir(), "Expected a file, not a directory")
40−}
41−
42−func TestProvider_loadProvidersWithIssues(t *testing.T) {
43− client := &mockProviderClient{shouldFail: true}
44− tmpPath := t.TempDir() + "/providers.json"
45− // store providers to a temporary file
46− oldProviders := []provider.Provider{
47− {
48− Name: "OldProvider",
49− },
50− }
51− data, err := json.Marshal(oldProviders)
52− if err != nil {
53− t.Fatalf("Failed to marshal old providers: %v", err)
54− }
55−
56− err = os.WriteFile(tmpPath, data, 0o644)
57− if err != nil {
58− t.Fatalf("Failed to write old providers to file: %v", err)
59− }
60− providers, err := loadProviders(tmpPath, client)
61− assert.NoError(t, err)
62− assert.NotNil(t, providers)
63− assert.Len(t, providers, 1)
64− assert.Equal(t, "OldProvider", providers[0].Name, "Expected to keep old provider when loading fails")
65−}
66−
67−func TestProvider_loadProvidersWithIssuesAndNoCache(t *testing.T) {
68− client := &mockProviderClient{shouldFail: true}
69− tmpPath := t.TempDir() + "/providers.json"
70− providers, err := loadProviders(tmpPath, client)
71− assert.Error(t, err)
72− assert.Nil(t, providers, "Expected nil providers when loading fails and no cache exists")
73−}