diff --git a/cmd/claws/main.go b/cmd/claws/main.go index 56a3a5c..187ee8b 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -26,6 +26,18 @@ func main() { propagateAllProxy() + // Set custom config path (CLI flag > env var > default) + configPath := opts.configFile + if configPath == "" { + configPath = strings.TrimSpace(os.Getenv("CLAWS_CONFIG")) + } + if configPath != "" { + if err := config.SetConfigPath(configPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + fileCfg := config.File() cfg := config.Global() @@ -110,6 +122,7 @@ type cliOptions struct { envCreds bool autosave *bool logFile string + configFile string service string resourceID string theme string @@ -161,6 +174,11 @@ func parseFlagsFromArgs(args []string) cliOptions { i++ opts.logFile = args[i] } + case "-c", "--config": + if i+1 < len(args) { + i++ + opts.configFile = args[i] + } case "-s", "--service": if i+1 < len(args) { i++ @@ -221,6 +239,8 @@ func printUsage() { fmt.Println(" Enable saving region/profile/theme to config file") fmt.Println(" --no-autosave") fmt.Println(" Disable saving region/profile/theme to config file") + fmt.Println(" -c, --config ") + fmt.Println(" Use custom config file instead of ~/.config/claws/config.yaml") fmt.Println(" -l, --log-file ") fmt.Println(" Enable debug logging to specified file") fmt.Println(" -t, --theme ") @@ -242,6 +262,7 @@ func printUsage() { fmt.Println(" claws -r us-east-1,ap-northeast-1 Query multiple regions") fmt.Println() fmt.Println("Environment Variables:") + fmt.Println(" CLAWS_CONFIG= Use custom config file") fmt.Println(" CLAWS_READ_ONLY=1|true Enable read-only mode") fmt.Println(" ALL_PROXY Propagated to HTTP_PROXY/HTTPS_PROXY if not set") } diff --git a/cmd/claws/main_test.go b/cmd/claws/main_test.go index bb130a8..cf3b860 100644 --- a/cmd/claws/main_test.go +++ b/cmd/claws/main_test.go @@ -124,3 +124,25 @@ func TestParseFlags_Combined(t *testing.T) { t.Error("readOnly should be true") } } + +func TestParseFlags_ConfigFile(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + {"short flag", []string{"-c", "/path/to/config.yaml"}, "/path/to/config.yaml"}, + {"long flag", []string{"--config", "/custom/config.yaml"}, "/custom/config.yaml"}, + {"with other flags", []string{"-p", "dev", "-c", "/config.yaml", "-r", "us-east-1"}, "/config.yaml"}, + {"no config", []string{"-p", "dev"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + if opts.configFile != tt.expected { + t.Errorf("configFile = %q, want %q", opts.configFile, tt.expected) + } + }) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 612f924..00dfa6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,29 @@ claws uses your standard AWS configuration: ## Configuration File -Optional settings can be stored in `~/.config/claws/config.yaml`: +Optional settings can be stored in `~/.config/claws/config.yaml`. + +### Custom Config File Path + +Use a custom config file instead of the default: + +```bash +# Via CLI flag +claws -c /path/to/config.yaml +claws --config ~/work/claws-work.yaml + +# Via environment variable +CLAWS_CONFIG=/path/to/config.yaml claws +``` + +**Precedence:** `-c` flag > `CLAWS_CONFIG` env var > default (`~/.config/claws/config.yaml`) + +Use cases: +- Environment-specific configs (work/personal) +- CI/CD with project-specific settings +- Testing with different configurations + +### Config File Format ```yaml timeouts: diff --git a/internal/config/file.go b/internal/config/file.go index bccb5a3..763c8c2 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -6,11 +6,13 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" "gopkg.in/yaml.v3" + apperrors "github.com/clawscli/claws/internal/errors" "github.com/clawscli/claws/internal/log" ) @@ -27,7 +29,55 @@ const ( DefaultAIMaxToolCallsPerQuery = 50 ) +var ( + customConfigPath string + configPathMu sync.RWMutex +) + +// expandTilde expands ~ to user home directory. +func expandTilde(path string) (string, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand ~: %w", err) + } + return filepath.Join(home, path[2:]), nil + } + return path, nil +} + +// SetConfigPath sets custom config file path. Must be called before File(). +// Returns error if file doesn't exist or isn't readable. +func SetConfigPath(path string) error { + expanded, err := expandTilde(path) + if err != nil { + return apperrors.Wrap(err, "config file", "path", path) + } + if _, err := os.Stat(expanded); err != nil { + return apperrors.Wrap(err, "config file", "path", expanded) + } + configPathMu.Lock() + customConfigPath = expanded + configPathMu.Unlock() + return nil +} + +// GetConfigPath returns the current custom config path (empty if using default). +func GetConfigPath() string { + configPathMu.RLock() + defer configPathMu.RUnlock() + return customConfigPath +} + func ConfigDir() (string, error) { + configPathMu.RLock() + custom := customConfigPath + configPathMu.RUnlock() + + if custom != "" { + return filepath.Dir(custom), nil + } + home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get home dir: %w", err) @@ -36,6 +86,14 @@ func ConfigDir() (string, error) { } func ConfigPath() (string, error) { + configPathMu.RLock() + custom := customConfigPath + configPathMu.RUnlock() + + if custom != "" { + return custom, nil + } + dir, err := ConfigDir() if err != nil { return "", err diff --git a/internal/config/file_test.go b/internal/config/file_test.go index b77f068..639dc3a 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -688,6 +688,132 @@ func TestGetAIMaxToolCallsPerQuery(t *testing.T) { } } +func TestSetConfigPath(t *testing.T) { + // Create temp config file + tmpDir := t.TempDir() + customPath := filepath.Join(tmpDir, "custom-config.yaml") + if err := os.WriteFile(customPath, []byte("theme: dracula\n"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Reset custom path after test + defer func() { + configPathMu.Lock() + customConfigPath = "" + configPathMu.Unlock() + }() + + // Test setting valid path + if err := SetConfigPath(customPath); err != nil { + t.Fatalf("SetConfigPath failed: %v", err) + } + + // Verify GetConfigPath returns the custom path + if got := GetConfigPath(); got != customPath { + t.Errorf("GetConfigPath() = %q, want %q", got, customPath) + } + + // Verify ConfigPath returns the custom path + got, err := ConfigPath() + if err != nil { + t.Fatalf("ConfigPath failed: %v", err) + } + if got != customPath { + t.Errorf("ConfigPath() = %q, want %q", got, customPath) + } + + // Verify ConfigDir returns custom path's directory + gotDir, err := ConfigDir() + if err != nil { + t.Fatalf("ConfigDir failed: %v", err) + } + if gotDir != tmpDir { + t.Errorf("ConfigDir() = %q, want %q", gotDir, tmpDir) + } +} + +func TestSetConfigPath_NonExistent(t *testing.T) { + err := SetConfigPath("/nonexistent/path/config.yaml") + if err == nil { + t.Error("SetConfigPath should fail for non-existent file") + } +} + +func TestSetConfigPath_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot get user home dir") + } + + // Create temp file in home dir for test + tmpFile := filepath.Join(home, ".claws-test-config.yaml") + if err := os.WriteFile(tmpFile, []byte("theme: test\n"), 0600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + defer os.Remove(tmpFile) + + // Reset custom path after test + defer func() { + configPathMu.Lock() + customConfigPath = "" + configPathMu.Unlock() + }() + + // Test tilde expansion + if err := SetConfigPath("~/.claws-test-config.yaml"); err != nil { + t.Fatalf("SetConfigPath with tilde failed: %v", err) + } + + // Verify path was expanded + got := GetConfigPath() + if got != tmpFile { + t.Errorf("GetConfigPath() = %q, want %q (expanded)", got, tmpFile) + } +} + +func TestCustomConfigPath_Load(t *testing.T) { + tmpDir := t.TempDir() + customPath := filepath.Join(tmpDir, "my-config.yaml") + configData := `theme: nord +startup: + regions: + - eu-west-1 + profiles: + - custom-profile +` + if err := os.WriteFile(customPath, []byte(configData), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + // Reset custom path after test + defer func() { + configPathMu.Lock() + customConfigPath = "" + configPathMu.Unlock() + }() + + if err := SetConfigPath(customPath); err != nil { + t.Fatalf("SetConfigPath failed: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Theme.Preset != "nord" { + t.Errorf("Theme.Preset = %q, want %q", cfg.Theme.Preset, "nord") + } + + regions, profiles := cfg.GetStartup() + if len(regions) != 1 || regions[0] != "eu-west-1" { + t.Errorf("regions = %v, want [eu-west-1]", regions) + } + if len(profiles) != 1 || profiles[0] != "custom-profile" { + t.Errorf("profiles = %v, want [custom-profile]", profiles) + } +} + func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) }