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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,36 @@ claws uses your standard AWS configuration:
- `~/.aws/config` - AWS configuration (region, profile)
- Environment variables: `AWS_PROFILE`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, etc.

Configuration is stored in `~/.config/claws/config.yaml` for profile preferences.
### Configuration File

Optional settings can be stored in `~/.config/claws/config.yaml`:

```yaml
timeouts:
aws_init: 10s # AWS initialization timeout (default: 5s)
multi_region_fetch: 60s # Multi-region parallel fetch timeout (default: 30s)
tag_search: 45s # Tag search timeout (default: 30s)
metrics_load: 30s # CloudWatch metrics load timeout (default: 30s)

concurrency:
max_fetches: 100 # Max concurrent API fetches (default: 50)

cloudwatch:
window: 15m # Metrics data window period (default: 15m)

persistence:
enabled: true # Save region/profile on change (default: false)

startup: # Applied on launch if present
profile: production
regions:
- us-east-1
- us-west-2
```

The config file is **not created automatically**. Create it manually if needed.

CLI flags (`-p`, `-r`, `--persist`, `--no-persist`) override config file settings.

For required IAM permissions, see [docs/iam-permissions.md](docs/iam-permissions.md).

Expand Down
59 changes: 42 additions & 17 deletions cmd/claws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ func main() {

propagateAllProxy()

// Apply CLI options to global config
fileCfg := config.File()
cfg := config.Global()

// CLI persistence flags override config file
if opts.persist != nil {
fileCfg.SetPersistenceEnabled(*opts.persist)
}

// Check environment variables (CLI flags take precedence)
if !opts.readOnly {
if v := os.Getenv("CLAWS_READ_ONLY"); v == "1" || v == "true" {
Expand All @@ -45,21 +50,7 @@ func main() {
os.Exit(1)
}

if opts.envCreds {
// Use environment credentials, ignore ~/.aws config
cfg.UseEnvOnly()
} else if opts.profile != "" {
cfg.UseProfile(opts.profile)
// Don't set AWS_PROFILE globally - it interferes with EnvOnly mode
// when switching profiles. SelectionLoadOptions uses WithSharedConfigProfile
// for SDK calls, and BuildSubprocessEnv handles subprocess environment.
}
// else: SDKDefault is the zero value, no action needed
if opts.region != "" {
cfg.SetRegion(opts.region)
// Don't set AWS_REGION globally - SelectionLoadOptions handles SDK calls,
// and BuildSubprocessEnv handles subprocess environment.
}
applyStartupConfig(opts, fileCfg, cfg)

// Enable logging if log file specified
if opts.logFile != "" {
Expand All @@ -86,12 +77,12 @@ func main() {
}
}

// cliOptions holds command line options
type cliOptions struct {
profile string
region string
readOnly bool
envCreds bool
persist *bool // nil = use config, true = enable, false = disable
logFile string
}

Expand All @@ -118,6 +109,12 @@ func parseFlags() cliOptions {
opts.readOnly = true
case "-e", "--env":
opts.envCreds = true
case "--persist":
t := true
opts.persist = &t
case "--no-persist":
f := false
opts.persist = &f
case "-l", "--log-file":
if i+1 < len(args) {
i++
Expand Down Expand Up @@ -158,6 +155,10 @@ func printUsage() {
fmt.Println(" Useful for instance profiles, ECS task roles, Lambda, etc.")
fmt.Println(" -ro, --read-only")
fmt.Println(" Run in read-only mode (disable dangerous actions)")
fmt.Println(" --persist")
fmt.Println(" Enable saving region/profile selection to config file")
fmt.Println(" --no-persist")
fmt.Println(" Disable saving region/profile selection to config file")
fmt.Println(" -l, --log-file <path>")
fmt.Println(" Enable debug logging to specified file")
fmt.Println(" -v, --version")
Expand All @@ -170,6 +171,30 @@ func printUsage() {
fmt.Println(" ALL_PROXY Propagated to HTTP_PROXY/HTTPS_PROXY if not set")
}

// applyStartupConfig applies profile/region config with precedence:
// 1. CLI flags (-p, -r, -e) - highest priority
// 2. Config file startup section
// 3. AWS SDK defaults
func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config.Config) {
startupRegions, startupProfile := fileCfg.GetStartup()

// Apply profile: CLI > startup config
if opts.envCreds {
cfg.UseEnvOnly()
} else if opts.profile != "" {
cfg.UseProfile(opts.profile)
} else if startupProfile != "" {
cfg.UseProfile(startupProfile)
}

// Apply region: CLI > startup config
if opts.region != "" {
cfg.SetRegion(opts.region)
} else if len(startupRegions) > 0 {
cfg.SetRegions(startupRegions)
}
}

// propagateAllProxy copies ALL_PROXY to HTTP_PROXY/HTTPS_PROXY if not set.
// Go's net/http ignores ALL_PROXY, so we propagate it to the standard vars.
func propagateAllProxy() {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ require (
golang.org/x/sync v0.19.0
golang.org/x/term v0.38.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
28 changes: 23 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ import (
"github.com/clawscli/claws/internal/view"
)

// awsInitTimeout is the maximum time to wait for AWS context initialization
const awsInitTimeout = 5 * time.Second

// clearErrorMsg is sent to clear transient errors after a timeout
type clearErrorMsg struct{}

Expand Down Expand Up @@ -113,7 +110,7 @@ func (a *App) Init() tea.Cmd {
// Initialize AWS context in background (region detection, account ID fetch)
// Use timeout to avoid indefinite hang on network issues
initAWSCmd := func() tea.Msg {
ctx, cancel := context.WithTimeout(a.ctx, awsInitTimeout)
ctx, cancel := context.WithTimeout(a.ctx, config.File().AWSInitTimeout())
defer cancel()
err := aws.InitContext(ctx)
return awsContextReadyMsg{err: err}
Expand Down Expand Up @@ -336,6 +333,16 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case navmsg.RegionChangedMsg:
log.Info("regions changed", "regions", msg.Regions)
if config.File().PersistenceEnabled() {
profile := ""
if sel := config.Global().Selection(); sel.IsNamedProfile() {
profile = sel.ProfileName
}
config.File().SetStartup(msg.Regions, profile)
if err := config.File().Save(); err != nil {
log.Warn("failed to persist config", "error", err)
}
}
// Pop views until we find a refreshable one (ResourceBrowser or ServiceBrowser)
for len(a.viewStack) > 0 {
a.currentView = a.viewStack[len(a.viewStack)-1]
Expand All @@ -356,12 +363,23 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case navmsg.ProfilesChangedMsg:
log.Info("profiles changed", "count", len(msg.Selections))
if config.File().PersistenceEnabled() {
profile := ""
if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() {
profile = msg.Selections[0].ProfileName
}
regions := config.Global().Regions()
config.File().SetStartup(regions, profile)
if err := config.File().Save(); err != nil {
log.Warn("failed to persist config", "error", err)
}
}
a.profileRefreshID++
a.profileRefreshing = true
a.profileRefreshError = nil
refreshID := a.profileRefreshID
refreshCmd := func() tea.Msg {
ctx, cancel := context.WithTimeout(a.ctx, awsInitTimeout)
ctx, cancel := context.WithTimeout(a.ctx, config.File().AWSInitTimeout())
defer cancel()
region, accountIDs, err := aws.RefreshContextData(ctx)
return profileRefreshDoneMsg{
Expand Down
8 changes: 2 additions & 6 deletions internal/aws/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import (
appconfig "github.com/clawscli/claws/internal/config"
)

// maxConcurrentProfileFetches limits parallel AWS config loads to prevent
// file descriptor exhaustion and excessive memory usage with many profiles.
const maxConcurrentProfileFetches = 50

// InitContext initializes AWS context by loading config and fetching account ID.
// Updates the global config with region (if not already set) and account ID.
func InitContext(ctx context.Context) error {
Expand All @@ -37,7 +33,7 @@ func InitContext(ctx context.Context) error {

// RefreshContextData re-fetches region and account ID for the current profile selection(s).
// Returns the data without modifying global state, allowing the caller to apply changes.
// Fetches up to 50 profiles concurrently. Returns partial results and first error on failure.
// Concurrency is limited by config.File().MaxConcurrentFetches(). Returns partial results and first error on failure.
func RefreshContextData(ctx context.Context) (region string, accountIDs map[string]string, err error) {
selections := appconfig.Global().Selections()
if len(selections) == 0 {
Expand All @@ -56,7 +52,7 @@ func RefreshContextData(ctx context.Context) (region string, accountIDs map[stri
accountIDs = make(map[string]string)
var mu sync.Mutex
errChan := make(chan error, len(selections))
sem := make(chan struct{}, maxConcurrentProfileFetches)
sem := make(chan struct{}, appconfig.File().MaxConcurrentFetches())

for _, sel := range selections {
wg.Add(1)
Expand Down
13 changes: 0 additions & 13 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ func (s ProfileSelection) ID() string {
}
}

// Config holds global application configuration
type Config struct {
mu sync.RWMutex
regions []string
Expand All @@ -189,18 +188,6 @@ var (
initOnce sync.Once
)

func withRLock[T any](mu *sync.RWMutex, fn func() T) T {
mu.RLock()
defer mu.RUnlock()
return fn()
}

func doWithLock(mu *sync.RWMutex, fn func()) {
mu.Lock()
defer mu.Unlock()
fn()
}

// Global returns the global config instance
func Global() *Config {
initOnce.Do(func() {
Expand Down
Loading