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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ CLAWS_READ_ONLY=1 claws

# Enable debug logging to file
claws -l debug.log

# Start directly on a service
claws -s ec2 # EC2 instances
claws -s rds/snapshots # RDS snapshots
claws -s cfn # CloudFormation (alias)

# Open detail view for specific resource
claws -s ec2 -i i-1234567890abcdef0
```

## Key Bindings
Expand Down
92 changes: 84 additions & 8 deletions cmd/claws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"fmt"
"os"
"strings"

tea "charm.land/bubbletea/v2"

Expand Down Expand Up @@ -52,6 +53,25 @@ func main() {

applyStartupConfig(opts, fileCfg, cfg)

// Validate and resolve startup service/resource
var startupPath *app.StartupPath
if opts.service != "" {
service, resourceType, err := resolveStartupService(strings.TrimSpace(opts.service))
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
startupPath = &app.StartupPath{
Service: service,
ResourceType: resourceType,
ResourceID: strings.TrimSpace(opts.resourceID),
}
} else if opts.resourceID != "" {
fmt.Fprintln(os.Stderr, "Error: --resource-id requires --service")
fmt.Fprintln(os.Stderr, "Example: claws -s ec2 -i i-1234567890abcdef0")
os.Exit(1)
}

// Enable logging if log file specified
if opts.logFile != "" {
if err := log.EnableFile(opts.logFile); err != nil {
Expand All @@ -63,8 +83,7 @@ func main() {

ctx := context.Background()

// Create the application
application := app.New(ctx, registry.Global)
application := app.New(ctx, registry.Global, startupPath)

// Run the TUI
// Note: In v2, AltScreen and MouseMode are set via the View struct
Expand All @@ -78,12 +97,14 @@ func main() {
}

type cliOptions struct {
profile string
region string
readOnly bool
envCreds bool
persist *bool // nil = use config, true = enable, false = disable
logFile string
profile string
region string
readOnly bool
envCreds bool
persist *bool // nil = use config, true = enable, false = disable
logFile string
service string // startup service (e.g., "ec2", "rds/snapshots", "cfn")
resourceID string // startup resource ID for direct DetailView navigation
}

// parseFlags parses command line flags and returns options
Expand Down Expand Up @@ -120,6 +141,16 @@ func parseFlags() cliOptions {
i++
opts.logFile = args[i]
}
case "-s", "--service":
if i+1 < len(args) {
i++
opts.service = args[i]
}
case "-i", "--resource-id":
if i+1 < len(args) {
i++
opts.resourceID = args[i]
}
case "-h", "--help":
showHelp = true
case "-v", "--version":
Expand Down Expand Up @@ -150,6 +181,11 @@ func printUsage() {
fmt.Println(" AWS profile to use")
fmt.Println(" -r, --region <region>")
fmt.Println(" AWS region to use")
fmt.Println(" -s, --service <service>[/<resource>]")
fmt.Println(" Start directly on a service/resource (e.g., ec2, rds/snapshots, cfn)")
fmt.Println(" Supports aliases: cfn, sg, logs, ddb, etc.")
fmt.Println(" -i, --resource-id <id>")
fmt.Println(" Open detail view for a specific resource (requires --service)")
fmt.Println(" -e, --env")
fmt.Println(" Use environment credentials (ignore ~/.aws config)")
fmt.Println(" Useful for instance profiles, ECS task roles, Lambda, etc.")
Expand All @@ -166,6 +202,12 @@ func printUsage() {
fmt.Println(" -h, --help")
fmt.Println(" Show this help message")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" claws -s ec2 Open EC2 instances browser")
fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser")
fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)")
fmt.Println(" claws -s ec2 -i i-12345 Open detail view for instance i-12345")
fmt.Println()
fmt.Println("Environment Variables:")
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 Expand Up @@ -195,6 +237,40 @@ func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config
}
}

// resolveStartupService validates and resolves a service string (e.g., "ec2", "rds/snapshots", "cfn")
// to a valid service/resourceType pair. Supports aliases and service/resource syntax.
func resolveStartupService(input string) (service, resourceType string, err error) {
parts := strings.SplitN(input, "/", 2)
service = parts[0]
if len(parts) > 1 {
resourceType = parts[1]
}

if strings.Contains(resourceType, "/") {
return "", "", fmt.Errorf("invalid resource type: %s", resourceType)
}

if resolved, resolvedRes, ok := registry.Global.ResolveAlias(service); ok {
service = resolved
if resolvedRes != "" && resourceType == "" {
resourceType = resolvedRes
}
}

if resourceType == "" {
resourceType = registry.Global.DefaultResource(service)
if resourceType == "" {
return "", "", fmt.Errorf("unknown service: %s", input)
}
}

if _, ok := registry.Global.Get(service, resourceType); !ok {
return "", "", fmt.Errorf("unknown resource: %s/%s", service, resourceType)
}

return service, resourceType, nil
}

// 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
120 changes: 120 additions & 0 deletions cmd/claws/startup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import "testing"

func TestResolveStartupService(t *testing.T) {
tests := []struct {
name string
input string
wantService string
wantResource string
wantErr bool
}{
{
name: "ec2 defaults to instances (not alphabetically first)",
input: "ec2",
wantService: "ec2",
wantResource: "instances",
wantErr: false,
},
{
name: "rds defaults to instances",
input: "rds",
wantService: "rds",
wantResource: "instances",
wantErr: false,
},
{
name: "service/resource syntax",
input: "ec2/volumes",
wantService: "ec2",
wantResource: "volumes",
wantErr: false,
},
{
name: "alias resolves to service",
input: "cfn",
wantService: "cloudformation",
wantResource: "stacks",
wantErr: false,
},
{
name: "alias with resource resolves",
input: "sg",
wantService: "ec2",
wantResource: "security-groups",
wantErr: false,
},
{
name: "logs alias",
input: "logs",
wantService: "cloudwatch",
wantResource: "log-groups",
wantErr: false,
},
{
name: "unknown service fails",
input: "nonexistent",
wantErr: true,
},
{
name: "known service unknown resource fails",
input: "ec2/nonexistent",
wantErr: true,
},
{
name: "alias with explicit resource override",
input: "cfn/resources",
wantService: "cloudformation",
wantResource: "resources",
wantErr: false,
},
{
name: "empty string fails",
input: "",
wantErr: true,
},
{
name: "trailing slash uses default resource",
input: "ec2/",
wantService: "ec2",
wantResource: "instances",
wantErr: false,
},
{
name: "multiple slashes rejected",
input: "ec2/volumes/extra",
wantErr: true,
},
{
name: "leading slash fails",
input: "/instances",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, resourceType, err := resolveStartupService(tt.input)

if tt.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
return
}

if err != nil {
t.Errorf("unexpected error: %v", err)
return
}

if service != tt.wantService {
t.Errorf("service = %q, want %q", service, tt.wantService)
}
if resourceType != tt.wantResource {
t.Errorf("resourceType = %q, want %q", resourceType, tt.wantResource)
}
})
}
}
Loading