From 807627b66e22ab312350e725957bf0ca8a579b63 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 12 Jan 2026 01:31:08 +0000 Subject: [PATCH 1/4] feat: support custom config file path (#106) - Add -c/--config flag for custom config path - Add CLAWS_CONFIG env var support - Precedence: CLI flag > env var > default - Validate file exists before loading - Autosave writes to specified path --- cmd/claws/main.go | 21 ++++++++ cmd/claws/main_test.go | 22 +++++++++ docs/configuration.md | 24 ++++++++- internal/config/file.go | 40 +++++++++++++++ internal/config/file_test.go | 94 ++++++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 1 deletion(-) diff --git a/cmd/claws/main.go b/cmd/claws/main.go index 56a3a5c..fb24107 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 = 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..a80b695 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -27,7 +27,39 @@ const ( DefaultAIMaxToolCallsPerQuery = 50 ) +var ( + customConfigPath string + configPathMu sync.RWMutex +) + +// 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 { + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("config file: %w", err) + } + configPathMu.Lock() + customConfigPath = path + 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 +68,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..925623b 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -688,6 +688,100 @@ 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 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)) } From a032bfd369e3478876dea461a6c698e948234e54 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 12 Jan 2026 01:41:57 +0000 Subject: [PATCH 2/4] fix: expand tilde in custom config path SetConfigPath now expands ~/path to full home directory path, allowing `claws -c ~/.my-config.yaml` to work as expected. --- internal/config/file.go | 12 ++++++++++++ internal/config/file_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/internal/config/file.go b/internal/config/file.go index a80b695..514dc87 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -32,9 +33,20 @@ var ( configPathMu sync.RWMutex ) +// expandTilde expands ~ to user home directory. +func expandTilde(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + // 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 { + path = expandTilde(path) if _, err := os.Stat(path); err != nil { return fmt.Errorf("config file: %w", err) } diff --git a/internal/config/file_test.go b/internal/config/file_test.go index 925623b..639dc3a 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -739,6 +739,38 @@ func TestSetConfigPath_NonExistent(t *testing.T) { } } +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") From 256665146ce84786404fe34c2581a08160262286 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 12 Jan 2026 01:53:19 +0000 Subject: [PATCH 3/4] fix: improve error handling in SetConfigPath - expandTilde now returns error on UserHomeDir failure - use apperrors.Wrap instead of fmt.Errorf --- internal/config/file.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/config/file.go b/internal/config/file.go index 514dc87..763c8c2 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -12,6 +12,7 @@ import ( "gopkg.in/yaml.v3" + apperrors "github.com/clawscli/claws/internal/errors" "github.com/clawscli/claws/internal/log" ) @@ -34,24 +35,29 @@ var ( ) // expandTilde expands ~ to user home directory. -func expandTilde(path string) string { +func expandTilde(path string) (string, error) { if strings.HasPrefix(path, "~/") { - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, path[2:]) + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("expand ~: %w", err) } + return filepath.Join(home, path[2:]), nil } - return path + 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 { - path = expandTilde(path) - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("config file: %w", err) + 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 = path + customConfigPath = expanded configPathMu.Unlock() return nil } From 65e08b1a2b1bbe46292c1e2e0699a387bc4a72da Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Mon, 12 Jan 2026 02:19:16 +0000 Subject: [PATCH 4/4] fix: trim whitespace from CLAWS_CONFIG env var --- cmd/claws/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/claws/main.go b/cmd/claws/main.go index fb24107..187ee8b 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -29,7 +29,7 @@ func main() { // Set custom config path (CLI flag > env var > default) configPath := opts.configFile if configPath == "" { - configPath = os.Getenv("CLAWS_CONFIG") + configPath = strings.TrimSpace(os.Getenv("CLAWS_CONFIG")) } if configPath != "" { if err := config.SetConfigPath(configPath); err != nil {