diff --git a/README.md b/README.md index 4790a2c..4374dbc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/claws/main.go b/cmd/claws/main.go index d9c18b4..e4874a0 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "os" + "strings" tea "charm.land/bubbletea/v2" @@ -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 { @@ -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 @@ -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 @@ -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": @@ -150,6 +181,11 @@ func printUsage() { fmt.Println(" AWS profile to use") fmt.Println(" -r, --region ") fmt.Println(" AWS region to use") + fmt.Println(" -s, --service [/]") + 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 ") + 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.") @@ -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") @@ -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() { diff --git a/cmd/claws/startup_test.go b/cmd/claws/startup_test.go new file mode 100644 index 0000000..8864870 --- /dev/null +++ b/cmd/claws/startup_test.go @@ -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) + } + }) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 89e53b8..3de4650 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,8 @@ import ( "github.com/clawscli/claws/internal/aws" "github.com/clawscli/claws/internal/clipboard" "github.com/clawscli/claws/internal/config" + "github.com/clawscli/claws/internal/dao" + apperrors "github.com/clawscli/claws/internal/errors" "github.com/clawscli/claws/internal/log" navmsg "github.com/clawscli/claws/internal/msg" "github.com/clawscli/claws/internal/registry" @@ -24,6 +26,13 @@ type clearErrorMsg struct{} type clearFlashMsg struct{} +// StartupPath specifies the initial view to show when the app starts. +type StartupPath struct { + Service string + ResourceType string + ResourceID string +} + const flashDuration = 2 * time.Second // awsContextReadyMsg is sent when AWS context initialization completes @@ -39,6 +48,13 @@ type profileRefreshDoneMsg struct { err error } +type startupResourceMsg struct { + resource dao.Resource + err error +} + +type noOpMsg struct{} + // App is the main application model // appStyles holds cached lipgloss styles for performance type appStyles struct { @@ -54,7 +70,7 @@ func newAppStyles(width int) appStyles { t := ui.Current() return appStyles{ status: ui.TableHeaderStyle().Padding(0, 1).Width(width), - readOnly: lipgloss.NewStyle().Background(t.Warning).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1), + readOnly: ui.ReadOnlyBadgeStyle(), warningTitle: ui.BoldPendingStyle().MarginBottom(1), warningItem: ui.WarningStyle(), warningDim: ui.DimStyle().MarginTop(1), @@ -63,10 +79,11 @@ func newAppStyles(width int) appStyles { } type App struct { - ctx context.Context - registry *registry.Registry - width int - height int + ctx context.Context + registry *registry.Registry + startupPath *StartupPath + width int + height int currentView view.View viewStack []view.View @@ -97,10 +114,11 @@ type App struct { styles appStyles } -func New(ctx context.Context, reg *registry.Registry) *App { +func New(ctx context.Context, reg *registry.Registry, startupPath *StartupPath) *App { return &App{ ctx: ctx, registry: reg, + startupPath: startupPath, commandInput: view.NewCommandInput(ctx, reg), help: help.New(), keys: defaultKeyMap(), @@ -111,12 +129,17 @@ func New(ctx context.Context, reg *registry.Registry) *App { // Init implements tea.Model func (a *App) Init() tea.Cmd { - // Start with the dashboard view immediately (no blocking on AWS calls) - a.currentView = view.NewDashboardView(a.ctx, a.registry) a.awsInitializing = true - // Initialize AWS context in background (region detection, account ID fetch) - // Use timeout to avoid indefinite hang on network issues + if a.startupPath != nil { + a.currentView = view.NewResourceBrowserWithType( + a.ctx, a.registry, + a.startupPath.Service, a.startupPath.ResourceType, + ) + } else { + a.currentView = view.NewDashboardView(a.ctx, a.registry) + } + initAWSCmd := func() tea.Msg { ctx, cancel := context.WithTimeout(a.ctx, config.File().AWSInitTimeout()) defer cancel() @@ -124,7 +147,13 @@ func (a *App) Init() tea.Cmd { return awsContextReadyMsg{err: err} } - return tea.Batch(a.currentView.Init(), initAWSCmd) + cmds := []tea.Cmd{a.currentView.Init(), initAWSCmd} + + if a.startupPath != nil && a.startupPath.ResourceID != "" { + cmds = append(cmds, a.fetchStartupResource) + } + + return tea.Batch(cmds...) } // Update implements tea.Model @@ -323,6 +352,36 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case startupResourceMsg: + if a.startupPath == nil { + return a, nil + } + if msg.err != nil || msg.resource == nil { + if msg.err != nil { + log.Warn("startup resource fetch failed", "error", msg.err, "id", a.startupPath.ResourceID) + } + a.clipboardFlash = "Resource not found: " + a.startupPath.ResourceID + a.clipboardWarning = true + return a, tea.Tick(flashDuration, func(t time.Time) tea.Msg { + return clearFlashMsg{} + }) + } + renderer, err := a.registry.GetRenderer(a.startupPath.Service, a.startupPath.ResourceType) + if err != nil { + log.Warn("failed to get renderer for startup resource", "error", err) + return a, nil + } + // DAO is optional - DetailView handles nil gracefully (just disables refresh). + // Unlike renderer which is required for display, DAO only enables refresh functionality. + d, err := a.registry.GetDAO(a.ctx, a.startupPath.Service, a.startupPath.ResourceType) + if err != nil { + log.Warn("failed to get DAO for startup resource", "error", err) + } + detailView := view.NewDetailView(a.ctx, msg.resource, renderer, a.startupPath.Service, a.startupPath.ResourceType, a.registry, d) + a.viewStack = append(a.viewStack, a.currentView) + a.currentView = detailView + return a, tea.Batch(detailView.Init(), detailView.SetSize(a.width, a.height-2)) + case navmsg.RegionChangedMsg: return a.handleRegionChanged(msg) @@ -556,6 +615,20 @@ func (a *App) pushOrClearStack(clearStack bool) { } } +func (a *App) fetchStartupResource() tea.Msg { + if a.startupPath == nil || a.startupPath.ResourceID == "" { + return noOpMsg{} + } + + d, err := a.registry.GetDAO(a.ctx, a.startupPath.Service, a.startupPath.ResourceType) + if err != nil { + return startupResourceMsg{err: apperrors.Wrap(err, "get DAO for startup resource")} + } + + resource, err := d.Get(a.ctx, a.startupPath.ResourceID) + return startupResourceMsg{resource: resource, err: apperrors.Wrap(err, "fetch startup resource")} +} + func (a *App) handleRegionChanged(msg navmsg.RegionChangedMsg) (tea.Model, tea.Cmd) { log.Info("regions changed", "regions", msg.Regions) if config.File().PersistenceEnabled() { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 3b2238b..ed5546b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -48,7 +48,7 @@ func newTestApp(t *testing.T) *App { t.Helper() ctx := context.Background() reg := registry.New() - app := New(ctx, reg) + app := New(ctx, reg, nil) app.width = 100 app.height = 50 return app diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 134d68e..673ec66 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -17,7 +17,7 @@ func TestEscKeyIntegration(t *testing.T) { ctx := context.Background() reg := registry.New() - app := New(ctx, reg) + app := New(ctx, reg, nil) // Create a program with custom input/output var out bytes.Buffer @@ -124,7 +124,7 @@ func TestRawEscapeByteHandling(t *testing.T) { ctx := context.Background() reg := registry.New() - app := New(ctx, reg) + app := New(ctx, reg, nil) app.width = 100 app.height = 50 @@ -160,7 +160,7 @@ func TestActualTeaProgram(t *testing.T) { ctx := context.Background() reg := registry.New() - app := New(ctx, reg) + app := New(ctx, reg, nil) // Use a pipe for input pr, pw := io.Pipe() diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 61c17b3..21ae2a7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -54,6 +54,7 @@ type Registry struct { aliases map[string]string // alias -> service name or service/resource displayNames map[string]string // service -> display name for UI categories []ServiceCategory // ordered list of service categories + userDefaults map[string]string // user-configured default resources per service // Cached computed values (aliases are immutable after init, safe to cache) aliasListOnce sync.Once // guards aliasListCache initialization @@ -483,6 +484,87 @@ func (r *Registry) ListResources(service string) []string { return resources } +// defaultResources maps service names to their preferred default resource type. +// When a service is accessed without specifying a resource type (e.g., `:ec2`), +// this resource is used instead of alphabetically first. +var defaultResources = map[string]string{ + "apprunner": "services", + "appsync": "graphql-apis", + "athena": "workgroups", + "autoscaling": "groups", + "backup": "vaults", + "batch": "job-queues", + "bedrock-agent": "agents", + "bedrock-agentcore": "runtimes", + "ce": "costs", + "cloudformation": "stacks", + "cloudtrail": "trails", + "cloudwatch": "alarms", + "codebuild": "projects", + "codepipeline": "pipelines", + "cognito-idp": "user-pools", + "datasync": "tasks", + "directconnect": "connections", + "ec2": "instances", + "ecr": "repositories", + "ecs": "clusters", + "elbv2": "load-balancers", + "emr": "clusters", + "events": "rules", + "glue": "jobs", + "guardduty": "detectors", + "iam": "roles", + "license-manager": "licenses", + "macie2": "findings", + "network-firewall": "firewalls", + "organizations": "accounts", + "rds": "instances", + "redshift": "clusters", + "risp": "reserved-instances", + "route53": "hosted-zones", + "sagemaker": "endpoints", + "service-quotas": "services", + "sns": "topics", + "stepfunctions": "state-machines", + "transfer": "servers", + "vpc": "vpcs", +} + +// DefaultResource returns the preferred default resource type for a service. +// Falls back to alphabetically first resource if no default is configured. +func (r *Registry) DefaultResource(service string) string { + r.mu.RLock() + userDefault := r.userDefaults[service] + r.mu.RUnlock() + + if userDefault != "" { + if _, exists := r.Get(service, userDefault); exists { + return userDefault + } + } + if def, ok := defaultResources[service]; ok { + if _, exists := r.Get(service, def); exists { + return def + } + } + resources := r.ListResources(service) + if len(resources) > 0 { + return resources[0] + } + return "" +} + +// SetDefaultResource allows overriding the default resource for a service. +// User-configured defaults take precedence over built-in defaults. +func (r *Registry) SetDefaultResource(service, resource string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.userDefaults == nil { + r.userDefaults = make(map[string]string) + } + r.userDefaults[service] = resource +} + // subResourceSet contains resources that are only accessible via navigation. // These resources require a parent context (e.g., stack name, log group name) // and should only be accessed via navigation from their parent resource. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index a28aca3..203d3ec 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -189,6 +189,35 @@ func TestRegistry_ListResources_ExcludesSubResources(t *testing.T) { } } +func TestRegistry_DefaultResource(t *testing.T) { + reg := New() + + reg.RegisterCustom("ec2", "capacity-reservations", Entry{}) + reg.RegisterCustom("ec2", "instances", Entry{}) + reg.RegisterCustom("ec2", "volumes", Entry{}) + reg.RegisterCustom("rds", "instances", Entry{}) + reg.RegisterCustom("rds", "snapshots", Entry{}) + reg.RegisterCustom("s3", "buckets", Entry{}) + + tests := []struct { + service string + want string + }{ + {"ec2", "instances"}, + {"rds", "instances"}, + {"s3", "buckets"}, + } + + for _, tt := range tests { + t.Run(tt.service, func(t *testing.T) { + got := reg.DefaultResource(tt.service) + if got != tt.want { + t.Errorf("DefaultResource(%q) = %q, want %q", tt.service, got, tt.want) + } + }) + } +} + func TestRegistry_ResolveAlias(t *testing.T) { reg := New() diff --git a/internal/ui/theme.go b/internal/ui/theme.go index d4ab2ee..6e3859b 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -210,6 +210,15 @@ func InputFieldStyle() lipgloss.Style { Padding(0, 1) } +// ReadOnlyBadgeStyle returns a style for the READ-ONLY indicator badge +func ReadOnlyBadgeStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Background(current.Warning). + Foreground(lipgloss.Color("#000000")). // TODO: extract to theme in #96 + Bold(true). + Padding(0, 1) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 8a6ea5e..f5d4bc9 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -308,31 +308,21 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { } } - // If no resource specified, use first available if resourceType == "" { - resources := c.registry.ListResources(service) - if len(resources) > 0 { - resourceType = resources[0] - } + resourceType = c.registry.DefaultResource(service) } - // Check if service/resource exists if _, ok := c.registry.Get(service, resourceType); !ok { - // Try to find partial match for _, svc := range c.registry.ListServices() { if strings.HasPrefix(svc, service) { service = svc - resources := c.registry.ListResources(svc) - if len(resources) > 0 { - if resourceType == "" { - resourceType = resources[0] - } else { - // Find matching resource - for _, res := range resources { - if strings.HasPrefix(res, resourceType) { - resourceType = res - break - } + if resourceType == "" { + resourceType = c.registry.DefaultResource(svc) + } else { + for _, res := range c.registry.ListResources(svc) { + if strings.HasPrefix(res, resourceType) { + resourceType = res + break } } } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index cc2d981..2695d57 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -124,12 +124,7 @@ type ResourceBrowser struct { // NewResourceBrowser creates a new ResourceBrowser func NewResourceBrowser(ctx context.Context, reg *registry.Registry, service string) *ResourceBrowser { - resources := reg.ListResources(service) - resourceType := "" - if len(resources) > 0 { - resourceType = resources[0] - } - + resourceType := reg.DefaultResource(service) return newResourceBrowser(ctx, reg, service, resourceType) }