on main help sign in
Merge branch 'main' into chore/modernize
67d5af1Tai Groot
CRUSH.md+11
@@ -1,6 +1,7 @@
1# Crush Development Guide
1# Crush Development Guide
2
2
3## Build/Test/Lint Commands
3## Build/Test/Lint Commands
4+
4- **Build**: `go build .` or `go run .`
5- **Build**: `go build .` or `go run .`
5- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
7- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
@@ -8,6 +9,7 @@
8- **Dev**: `task dev` (runs with profiling enabled)
9- **Dev**: `task dev` (runs with profiling enabled)
9
10
10## Code Style Guidelines
11## Code Style Guidelines
12+
11- **Imports**: Use goimports formatting, group stdlib, external, internal packages
13- **Imports**: Use goimports formatting, group stdlib, external, internal packages
12- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
14- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
13- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
15- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
@@ -21,3 +23,12 @@
21- **JSON tags**: Use snake_case for JSON field names
23- **JSON tags**: Use snake_case for JSON field names
22- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
24- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
23- **Comments**: End comments in periods unless comments are at the end of the line.
25- **Comments**: End comments in periods unless comments are at the end of the line.
26+
27+## Formatting
28+
29+- ALWAYS format any Go code you write.
30+ - First, try `goftumpt -w .`.
31+ - If `gofumpt` is not available, use `goimports`.
32+ - If `goimports` is not available, use `gofmt`.
33+ - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
34+ as long as `gofumpt` is on the `PATH`.
README.md+22
@@ -10,6 +10,28 @@
10
10
11Crush is a tool for building software with AI.
11Crush is a tool for building software with AI.
12
12
13+## Getting Started
14+
15+For now, the quickest way to get started is to set an environment variable for
16+your preferred provider. Note that you can switch between providers mid-
17+sessions, so you're welcome to set environment variables for multiple
18+providers.
19+
20+| Environment Variable | Provider |
21+| -------------------------- | -------------------------------------------------- |
22+| `ANTHROPIC_API_KEY` | Anthropic |
23+| `OPENAI_API_KEY` | OpenAI |
24+| `GEMINI_API_KEY` | Google Gemini |
25+| `VERTEXAI_PROJECT` | Google Cloud VertexAI (Gemini) |
26+| `VERTEXAI_LOCATION` | Google Cloud VertexAI (Gemini) |
27+| `GROQ_API_KEY` | Groq |
28+| `AWS_ACCESS_KEY_ID` | AWS Bedrock (Claude) |
29+| `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (Claude) |
30+| `AWS_REGION` | AWS Bedrock (Claude) |
31+| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI models |
32+| `AZURE_OPENAI_API_KEY` | Azure OpenAI models (optional when using Entra ID) |
33+| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models |
34+
13## License
35## License
14
36
15[MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
37[MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)
internal/config/config.go+3−3
@@ -203,17 +203,17 @@ func Load(workingDir string, debug bool) (*Config, error) {
203func configureViper() {
203func configureViper() {
204 viper.SetConfigName(fmt.Sprintf(".%s", appName))
204 viper.SetConfigName(fmt.Sprintf(".%s", appName))
205 viper.SetConfigType("json")
205 viper.SetConfigType("json")
206−
206+
207 // Unix-style paths
207 // Unix-style paths
208 viper.AddConfigPath("$HOME")
208 viper.AddConfigPath("$HOME")
209 viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
209 viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
210 viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
210 viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
211−
211+
212 // Windows-style paths
212 // Windows-style paths
213 viper.AddConfigPath(fmt.Sprintf("$USERPROFILE"))
213 viper.AddConfigPath(fmt.Sprintf("$USERPROFILE"))
214 viper.AddConfigPath(fmt.Sprintf("$APPDATA/%s", appName))
214 viper.AddConfigPath(fmt.Sprintf("$APPDATA/%s", appName))
215 viper.AddConfigPath(fmt.Sprintf("$LOCALAPPDATA/%s", appName))
215 viper.AddConfigPath(fmt.Sprintf("$LOCALAPPDATA/%s", appName))
216−
216+
217 viper.SetEnvPrefix(strings.ToUpper(appName))
217 viper.SetEnvPrefix(strings.ToUpper(appName))
218 viper.AutomaticEnv()
218 viper.AutomaticEnv()
219}
219}
internal/llm/tools/bash.go+2−2
@@ -52,7 +52,7 @@ func getSafeReadOnlyCommands() []string {
52 baseCommands := []string{
52 baseCommands := []string{
53 // Cross-platform commands
53 // Cross-platform commands
54 "echo", "hostname", "whoami",
54 "echo", "hostname", "whoami",
55−
55+
56 // Git commands (cross-platform)
56 // Git commands (cross-platform)
57 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
57 "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
58 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
58 "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
@@ -395,4 +395,4 @@ func countLines(s string) int {
395 return 0
395 return 0
396 }
396 }
397 return len(strings.Split(s, "\n"))
397 return len(strings.Split(s, "\n"))
398−}
398+}
internal/llm/tools/bash_test.go−1
@@ -94,4 +94,3 @@ func TestPlatformSpecificSafeCommands(t *testing.T) {
94 }
94 }
95 }
95 }
96}
96}
97−
internal/llm/tools/shell/shell.go+19−19
@@ -1,7 +1,7 @@
1// Package shell provides cross-platform shell execution capabilities.
1// Package shell provides cross-platform shell execution capabilities.
2−//
2+//
3// WINDOWS COMPATIBILITY:
3// WINDOWS COMPATIBILITY:
4−// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
4+// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
5// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility:
5// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility:
6// - On Windows: Uses native cmd.exe or PowerShell for Windows-specific commands
6// - On Windows: Uses native cmd.exe or PowerShell for Windows-specific commands
7// - Cross-platform: Falls back to POSIX emulation for Unix-style commands
7// - Cross-platform: Falls back to POSIX emulation for Unix-style commands
@@ -86,7 +86,7 @@ func (s *PersistentShell) Exec(ctx context.Context, command string) (string, str
86
86
87 // Determine which shell to use based on platform and command
87 // Determine which shell to use based on platform and command
88 shellType := s.determineShellType(command)
88 shellType := s.determineShellType(command)
89−
89+
90 switch shellType {
90 switch shellType {
91 case ShellTypeCmd:
91 case ShellTypeCmd:
92 return s.execWindows(ctx, command, "cmd")
92 return s.execWindows(ctx, command, "cmd")
@@ -110,19 +110,19 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
110 }
110 }
111
111
112 firstCmd := strings.ToLower(parts[0])
112 firstCmd := strings.ToLower(parts[0])
113−
113+
114 // Check if it's a Windows-specific command
114 // Check if it's a Windows-specific command
115 if windowsNativeCommands[firstCmd] {
115 if windowsNativeCommands[firstCmd] {
116 return ShellTypeCmd
116 return ShellTypeCmd
117 }
117 }
118−
118+
119 // Check for PowerShell-specific syntax
119 // Check for PowerShell-specific syntax
120− if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
120+ if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
121− strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
121+ strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
122− strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
122+ strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
123 return ShellTypePowerShell
123 return ShellTypePowerShell
124 }
124 }
125−
125+
126 // Default to POSIX emulation for cross-platform compatibility
126 // Default to POSIX emulation for cross-platform compatibility
127 return ShellTypePOSIX
127 return ShellTypePOSIX
128}
128}
@@ -130,12 +130,12 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
130// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
130// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
131func (s *PersistentShell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
131func (s *PersistentShell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
132 var cmd *exec.Cmd
132 var cmd *exec.Cmd
133−
133+
134 // Handle directory changes specially to maintain persistent shell behavior
134 // Handle directory changes specially to maintain persistent shell behavior
135 if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
135 if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
136 return s.handleWindowsCD(command)
136 return s.handleWindowsCD(command)
137 }
137 }
138−
138+
139 switch shell {
139 switch shell {
140 case "cmd":
140 case "cmd":
141 // Use cmd.exe for Windows commands
141 // Use cmd.exe for Windows commands
@@ -150,16 +150,16 @@ func (s *PersistentShell) execWindows(ctx context.Context, command string, shell
150 default:
150 default:
151 return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
151 return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
152 }
152 }
153−
153+
154 // Set environment variables
154 // Set environment variables
155 cmd.Env = s.env
155 cmd.Env = s.env
156−
156+
157 var stdout, stderr bytes.Buffer
157 var stdout, stderr bytes.Buffer
158 cmd.Stdout = &stdout
158 cmd.Stdout = &stdout
159 cmd.Stderr = &stderr
159 cmd.Stderr = &stderr
160−
160+
161 err := cmd.Run()
161 err := cmd.Run()
162−
162+
163 logging.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
163 logging.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
164 return stdout.String(), stderr.String(), err
164 return stdout.String(), stderr.String(), err
165}
165}
@@ -171,9 +171,9 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
171 if len(parts) < 2 {
171 if len(parts) < 2 {
172 return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
172 return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
173 }
173 }
174−
174+
175 targetDir := parts[1]
175 targetDir := parts[1]
176−
176+
177 // Handle relative paths
177 // Handle relative paths
178 if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
178 if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
179 // Relative path - resolve against current directory
179 // Relative path - resolve against current directory
@@ -193,12 +193,12 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
193 // Absolute path
193 // Absolute path
194 s.cwd = targetDir
194 s.cwd = targetDir
195 }
195 }
196−
196+
197 // Verify the directory exists
197 // Verify the directory exists
198 if _, err := os.Stat(s.cwd); err != nil {
198 if _, err := os.Stat(s.cwd); err != nil {
199 return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
199 return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
200 }
200 }
201−
201+
202 return "", "", nil
202 return "", "", nil
203}
203}
204
204
internal/llm/tools/shell/shell_test.go+18−18
@@ -87,10 +87,10 @@ func TestRunContinuity(t *testing.T) {
87
87
88func TestShellTypeDetection(t *testing.T) {
88func TestShellTypeDetection(t *testing.T) {
89 shell := &PersistentShell{}
89 shell := &PersistentShell{}
90−
90+
91 tests := []struct {
91 tests := []struct {
92− command string
92+ command string
93− expected ShellType
93+ expected ShellType
94 windowsOnly bool
94 windowsOnly bool
95 }{
95 }{
96 // Windows-specific commands
96 // Windows-specific commands
@@ -100,14 +100,14 @@ func TestShellTypeDetection(t *testing.T) {
100 {"del file.txt", ShellTypeCmd, true},
100 {"del file.txt", ShellTypeCmd, true},
101 {"md newdir", ShellTypeCmd, true},
101 {"md newdir", ShellTypeCmd, true},
102 {"tasklist", ShellTypeCmd, true},
102 {"tasklist", ShellTypeCmd, true},
103−
103+
104 // PowerShell commands
104 // PowerShell commands
105 {"Get-Process", ShellTypePowerShell, true},
105 {"Get-Process", ShellTypePowerShell, true},
106 {"Get-ChildItem", ShellTypePowerShell, true},
106 {"Get-ChildItem", ShellTypePowerShell, true},
107 {"Set-Location C:\\", ShellTypePowerShell, true},
107 {"Set-Location C:\\", ShellTypePowerShell, true},
108 {"Get-Content file.txt | Where-Object {$_ -match 'pattern'}", ShellTypePowerShell, true},
108 {"Get-Content file.txt | Where-Object {$_ -match 'pattern'}", ShellTypePowerShell, true},
109 {"$files = Get-ChildItem", ShellTypePowerShell, true},
109 {"$files = Get-ChildItem", ShellTypePowerShell, true},
110−
110+
111 // Unix/cross-platform commands
111 // Unix/cross-platform commands
112 {"ls -la", ShellTypePOSIX, false},
112 {"ls -la", ShellTypePOSIX, false},
113 {"cat file.txt", ShellTypePOSIX, false},
113 {"cat file.txt", ShellTypePOSIX, false},
@@ -116,11 +116,11 @@ func TestShellTypeDetection(t *testing.T) {
116 {"git status", ShellTypePOSIX, false},
116 {"git status", ShellTypePOSIX, false},
117 {"go build", ShellTypePOSIX, false},
117 {"go build", ShellTypePOSIX, false},
118 }
118 }
119−
119+
120 for _, test := range tests {
120 for _, test := range tests {
121 t.Run(test.command, func(t *testing.T) {
121 t.Run(test.command, func(t *testing.T) {
122 result := shell.determineShellType(test.command)
122 result := shell.determineShellType(test.command)
123−
123+
124 if test.windowsOnly && runtime.GOOS != "windows" {
124 if test.windowsOnly && runtime.GOOS != "windows" {
125 // On non-Windows systems, everything should use POSIX
125 // On non-Windows systems, everything should use POSIX
126 if result != ShellTypePOSIX {
126 if result != ShellTypePOSIX {
@@ -140,12 +140,12 @@ func TestWindowsCDHandling(t *testing.T) {
140 if runtime.GOOS != "windows" {
140 if runtime.GOOS != "windows" {
141 t.Skip("Windows CD handling test only runs on Windows")
141 t.Skip("Windows CD handling test only runs on Windows")
142 }
142 }
143−
143+
144 shell := &PersistentShell{
144 shell := &PersistentShell{
145 cwd: "C:\\Users",
145 cwd: "C:\\Users",
146 env: []string{},
146 env: []string{},
147 }
147 }
148−
148+
149 tests := []struct {
149 tests := []struct {
150 command string
150 command string
151 expectedCwd string
151 expectedCwd string
@@ -156,12 +156,12 @@ func TestWindowsCDHandling(t *testing.T) {
156 {"cd C:\\Windows", "C:\\Windows", false},
156 {"cd C:\\Windows", "C:\\Windows", false},
157 {"cd", "", true}, // Missing argument
157 {"cd", "", true}, // Missing argument
158 }
158 }
159−
159+
160 for _, test := range tests {
160 for _, test := range tests {
161 t.Run(test.command, func(t *testing.T) {
161 t.Run(test.command, func(t *testing.T) {
162 originalCwd := shell.cwd
162 originalCwd := shell.cwd
163 stdout, stderr, err := shell.handleWindowsCD(test.command)
163 stdout, stderr, err := shell.handleWindowsCD(test.command)
164−
164+
165 if test.shouldError {
165 if test.shouldError {
166 if err == nil {
166 if err == nil {
167 t.Errorf("Command %q should have failed", test.command)
167 t.Errorf("Command %q should have failed", test.command)
@@ -174,7 +174,7 @@ func TestWindowsCDHandling(t *testing.T) {
174 t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
174 t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
175 }
175 }
176 }
176 }
177−
177+
178 // Reset for next test
178 // Reset for next test
179 shell.cwd = originalCwd
179 shell.cwd = originalCwd
180 _ = stdout
180 _ = stdout
@@ -187,17 +187,17 @@ func TestCrossPlatformExecution(t *testing.T) {
187 shell := newPersistentShell(".")
187 shell := newPersistentShell(".")
188 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
188 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
189 defer cancel()
189 defer cancel()
190−
190+
191 // Test a simple command that should work on all platforms
191 // Test a simple command that should work on all platforms
192 stdout, stderr, err := shell.Exec(ctx, "echo hello")
192 stdout, stderr, err := shell.Exec(ctx, "echo hello")
193 if err != nil {
193 if err != nil {
194 t.Fatalf("Echo command failed: %v, stderr: %s", err, stderr)
194 t.Fatalf("Echo command failed: %v, stderr: %s", err, stderr)
195 }
195 }
196−
196+
197 if stdout == "" {
197 if stdout == "" {
198 t.Error("Echo command produced no output")
198 t.Error("Echo command produced no output")
199 }
199 }
200−
200+
201 // The output should contain "hello" regardless of platform
201 // The output should contain "hello" regardless of platform
202 if !strings.Contains(strings.ToLower(stdout), "hello") {
202 if !strings.Contains(strings.ToLower(stdout), "hello") {
203 t.Errorf("Echo output should contain 'hello', got: %q", stdout)
203 t.Errorf("Echo output should contain 'hello', got: %q", stdout)
@@ -208,17 +208,17 @@ func TestWindowsNativeCommands(t *testing.T) {
208 if runtime.GOOS != "windows" {
208 if runtime.GOOS != "windows" {
209 t.Skip("Windows native command test only runs on Windows")
209 t.Skip("Windows native command test only runs on Windows")
210 }
210 }
211−
211+
212 shell := newPersistentShell(".")
212 shell := newPersistentShell(".")
213 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
213 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
214 defer cancel()
214 defer cancel()
215−
215+
216 // Test Windows dir command
216 // Test Windows dir command
217 stdout, stderr, err := shell.Exec(ctx, "dir")
217 stdout, stderr, err := shell.Exec(ctx, "dir")
218 if err != nil {
218 if err != nil {
219 t.Fatalf("Dir command failed: %v, stderr: %s", err, stderr)
219 t.Fatalf("Dir command failed: %v, stderr: %s", err, stderr)
220 }
220 }
221−
221+
222 if stdout == "" {
222 if stdout == "" {
223 t.Error("Dir command produced no output")
223 t.Error("Dir command produced no output")
224 }
224 }