Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cmd/claws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -110,6 +122,7 @@ type cliOptions struct {
envCreds bool
autosave *bool
logFile string
configFile string
service string
resourceID string
theme string
Expand Down Expand Up @@ -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++
Expand Down Expand Up @@ -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 <path>")
fmt.Println(" Use custom config file instead of ~/.config/claws/config.yaml")
fmt.Println(" -l, --log-file <path>")
fmt.Println(" Enable debug logging to specified file")
fmt.Println(" -t, --theme <name>")
Expand All @@ -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=<path> 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")
}
Expand Down
22 changes: 22 additions & 0 deletions cmd/claws/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
24 changes: 23 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions internal/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions internal/config/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down