Merge branch 'main' into chore/modernize

Tai Groot created

Change summary

CRUSH.md                               | 11 ++++++++
README.md                              | 22 ++++++++++++++++
internal/config/config.go              |  6 ++--
internal/llm/tools/bash.go             |  4 +-
internal/llm/tools/bash_test.go        |  1 
internal/llm/tools/shell/shell.go      | 38 ++++++++++++++--------------
internal/llm/tools/shell/shell_test.go | 36 +++++++++++++-------------
7 files changed, 75 insertions(+), 43 deletions(-)

Detailed changes

CRUSH.md 🔗

@@ -1,6 +1,7 @@
 # Crush Development Guide
 
 ## Build/Test/Lint Commands
+
 - **Build**: `go build .` or `go run .`
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
 - **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
@@ -8,6 +9,7 @@
 - **Dev**: `task dev` (runs with profiling enabled)
 
 ## Code Style Guidelines
+
 - **Imports**: Use goimports formatting, group stdlib, external, internal packages
 - **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
 - **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
@@ -21,3 +23,12 @@
 - **JSON tags**: Use snake_case for JSON field names
 - **File permissions**: Use octal notation (0o755, 0o644) for file permissions
 - **Comments**: End comments in periods unless comments are at the end of the line.
+
+## Formatting
+
+- ALWAYS format any Go code you write.
+  - First, try `goftumpt -w .`.
+  - If `gofumpt` is not available, use `goimports`.
+  - If `goimports` is not available, use `gofmt`.
+  - You can also use `task fmt` to run `gofumpt -w .` on the entire project,
+    as long as `gofumpt` is on the `PATH`.

README.md 🔗

@@ -10,6 +10,28 @@
 
 Crush is a tool for building software with AI.
 
+## Getting Started
+
+For now, the quickest way to get started is to set an environment variable for
+your preferred provider. Note that you can switch between providers mid-
+sessions, so you're welcome to set environment variables for multiple
+providers.
+
+| Environment Variable       | Provider                                           |
+| -------------------------- | -------------------------------------------------- |
+| `ANTHROPIC_API_KEY`        | Anthropic                                          |
+| `OPENAI_API_KEY`           | OpenAI                                             |
+| `GEMINI_API_KEY`           | Google Gemini                                      |
+| `VERTEXAI_PROJECT`         | Google Cloud VertexAI (Gemini)                     |
+| `VERTEXAI_LOCATION`        | Google Cloud VertexAI (Gemini)                     |
+| `GROQ_API_KEY`             | Groq                                               |
+| `AWS_ACCESS_KEY_ID`        | AWS Bedrock (Claude)                               |
+| `AWS_SECRET_ACCESS_KEY`    | AWS Bedrock (Claude)                               |
+| `AWS_REGION`               | AWS Bedrock (Claude)                               |
+| `AZURE_OPENAI_ENDPOINT`    | Azure OpenAI models                                |
+| `AZURE_OPENAI_API_KEY`     | Azure OpenAI models (optional when using Entra ID) |
+| `AZURE_OPENAI_API_VERSION` | Azure OpenAI models                                |
+
 ## License
 
 [MIT](https://github.com/charmbracelet/crush/raw/main/LICENSE)

internal/config/config.go 🔗

@@ -203,17 +203,17 @@ func Load(workingDir string, debug bool) (*Config, error) {
 func configureViper() {
 	viper.SetConfigName(fmt.Sprintf(".%s", appName))
 	viper.SetConfigType("json")
-	
+
 	// Unix-style paths
 	viper.AddConfigPath("$HOME")
 	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
 	viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
-	
+
 	// Windows-style paths
 	viper.AddConfigPath(fmt.Sprintf("$USERPROFILE"))
 	viper.AddConfigPath(fmt.Sprintf("$APPDATA/%s", appName))
 	viper.AddConfigPath(fmt.Sprintf("$LOCALAPPDATA/%s", appName))
-	
+
 	viper.SetEnvPrefix(strings.ToUpper(appName))
 	viper.AutomaticEnv()
 }

internal/llm/tools/bash.go 🔗

@@ -52,7 +52,7 @@ func getSafeReadOnlyCommands() []string {
 	baseCommands := []string{
 		// Cross-platform commands
 		"echo", "hostname", "whoami",
-		
+
 		// Git commands (cross-platform)
 		"git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
 		"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 {
 		return 0
 	}
 	return len(strings.Split(s, "\n"))
-}
+}

internal/llm/tools/shell/shell.go 🔗

@@ -1,7 +1,7 @@
 // Package shell provides cross-platform shell execution capabilities.
-// 
+//
 // WINDOWS COMPATIBILITY:
-// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and 
+// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
 // native Windows shell support (cmd.exe/PowerShell) for optimal compatibility:
 // - On Windows: Uses native cmd.exe or PowerShell for Windows-specific commands
 // - 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
 
 	// Determine which shell to use based on platform and command
 	shellType := s.determineShellType(command)
-	
+
 	switch shellType {
 	case ShellTypeCmd:
 		return s.execWindows(ctx, command, "cmd")
@@ -110,19 +110,19 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
 	}
 
 	firstCmd := strings.ToLower(parts[0])
-	
+
 	// Check if it's a Windows-specific command
 	if windowsNativeCommands[firstCmd] {
 		return ShellTypeCmd
 	}
-	
+
 	// Check for PowerShell-specific syntax
-	if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") || 
-	   strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
-	   strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
+	if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
+		strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
+		strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
 		return ShellTypePowerShell
 	}
-	
+
 	// Default to POSIX emulation for cross-platform compatibility
 	return ShellTypePOSIX
 }
@@ -130,12 +130,12 @@ func (s *PersistentShell) determineShellType(command string) ShellType {
 // execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
 func (s *PersistentShell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
 	var cmd *exec.Cmd
-	
+
 	// Handle directory changes specially to maintain persistent shell behavior
 	if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
 		return s.handleWindowsCD(command)
 	}
-	
+
 	switch shell {
 	case "cmd":
 		// Use cmd.exe for Windows commands
@@ -150,16 +150,16 @@ func (s *PersistentShell) execWindows(ctx context.Context, command string, shell
 	default:
 		return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
 	}
-	
+
 	// Set environment variables
 	cmd.Env = s.env
-	
+
 	var stdout, stderr bytes.Buffer
 	cmd.Stdout = &stdout
 	cmd.Stderr = &stderr
-	
+
 	err := cmd.Run()
-	
+
 	logging.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
 	return stdout.String(), stderr.String(), err
 }
@@ -171,9 +171,9 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
 	if len(parts) < 2 {
 		return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
 	}
-	
+
 	targetDir := parts[1]
-	
+
 	// Handle relative paths
 	if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
 		// Relative path - resolve against current directory
@@ -193,12 +193,12 @@ func (s *PersistentShell) handleWindowsCD(command string) (string, string, error
 		// Absolute path
 		s.cwd = targetDir
 	}
-	
+
 	// Verify the directory exists
 	if _, err := os.Stat(s.cwd); err != nil {
 		return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
 	}
-	
+
 	return "", "", nil
 }
 

internal/llm/tools/shell/shell_test.go 🔗

@@ -87,10 +87,10 @@ func TestRunContinuity(t *testing.T) {
 
 func TestShellTypeDetection(t *testing.T) {
 	shell := &PersistentShell{}
-	
+
 	tests := []struct {
-		command    string
-		expected   ShellType
+		command     string
+		expected    ShellType
 		windowsOnly bool
 	}{
 		// Windows-specific commands
@@ -100,14 +100,14 @@ func TestShellTypeDetection(t *testing.T) {
 		{"del file.txt", ShellTypeCmd, true},
 		{"md newdir", ShellTypeCmd, true},
 		{"tasklist", ShellTypeCmd, true},
-		
+
 		// PowerShell commands
 		{"Get-Process", ShellTypePowerShell, true},
 		{"Get-ChildItem", ShellTypePowerShell, true},
 		{"Set-Location C:\\", ShellTypePowerShell, true},
 		{"Get-Content file.txt | Where-Object {$_ -match 'pattern'}", ShellTypePowerShell, true},
 		{"$files = Get-ChildItem", ShellTypePowerShell, true},
-		
+
 		// Unix/cross-platform commands
 		{"ls -la", ShellTypePOSIX, false},
 		{"cat file.txt", ShellTypePOSIX, false},
@@ -116,11 +116,11 @@ func TestShellTypeDetection(t *testing.T) {
 		{"git status", ShellTypePOSIX, false},
 		{"go build", ShellTypePOSIX, false},
 	}
-	
+
 	for _, test := range tests {
 		t.Run(test.command, func(t *testing.T) {
 			result := shell.determineShellType(test.command)
-			
+
 			if test.windowsOnly && runtime.GOOS != "windows" {
 				// On non-Windows systems, everything should use POSIX
 				if result != ShellTypePOSIX {
@@ -140,12 +140,12 @@ func TestWindowsCDHandling(t *testing.T) {
 	if runtime.GOOS != "windows" {
 		t.Skip("Windows CD handling test only runs on Windows")
 	}
-	
+
 	shell := &PersistentShell{
 		cwd: "C:\\Users",
 		env: []string{},
 	}
-	
+
 	tests := []struct {
 		command     string
 		expectedCwd string
@@ -156,12 +156,12 @@ func TestWindowsCDHandling(t *testing.T) {
 		{"cd C:\\Windows", "C:\\Windows", false},
 		{"cd", "", true}, // Missing argument
 	}
-	
+
 	for _, test := range tests {
 		t.Run(test.command, func(t *testing.T) {
 			originalCwd := shell.cwd
 			stdout, stderr, err := shell.handleWindowsCD(test.command)
-			
+
 			if test.shouldError {
 				if err == nil {
 					t.Errorf("Command %q should have failed", test.command)
@@ -174,7 +174,7 @@ func TestWindowsCDHandling(t *testing.T) {
 					t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.cwd)
 				}
 			}
-			
+
 			// Reset for next test
 			shell.cwd = originalCwd
 			_ = stdout
@@ -187,17 +187,17 @@ func TestCrossPlatformExecution(t *testing.T) {
 	shell := newPersistentShell(".")
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
-	
+
 	// Test a simple command that should work on all platforms
 	stdout, stderr, err := shell.Exec(ctx, "echo hello")
 	if err != nil {
 		t.Fatalf("Echo command failed: %v, stderr: %s", err, stderr)
 	}
-	
+
 	if stdout == "" {
 		t.Error("Echo command produced no output")
 	}
-	
+
 	// The output should contain "hello" regardless of platform
 	if !strings.Contains(strings.ToLower(stdout), "hello") {
 		t.Errorf("Echo output should contain 'hello', got: %q", stdout)
@@ -208,17 +208,17 @@ func TestWindowsNativeCommands(t *testing.T) {
 	if runtime.GOOS != "windows" {
 		t.Skip("Windows native command test only runs on Windows")
 	}
-	
+
 	shell := newPersistentShell(".")
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
-	
+
 	// Test Windows dir command
 	stdout, stderr, err := shell.Exec(ctx, "dir")
 	if err != nil {
 		t.Fatalf("Dir command failed: %v, stderr: %s", err, stderr)
 	}
-	
+
 	if stdout == "" {
 		t.Error("Dir command produced no output")
 	}