Skip to content
Open
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
17 changes: 7 additions & 10 deletions .claude/skills/add-event/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Add a new event type `$ARGUMENTS` to the output event system.

Read these files first — they are the source of truth:

- `internal/output/events.go` — all event types, the `Event` union constraint, and emit helpers
- `internal/output/events.go` — all event types, the `Event` marker interface, and its `sealedEvent()` implementations
- `internal/output/plain_format.go``FormatEventLine()` switch for plain text rendering
- `internal/output/plain_format_test.go` — test cases for format parity
- `internal/ui/app.go``Update()` method that handles events in the TUI
Expand All @@ -29,18 +29,14 @@ In `internal/output/events.go`:
}
```

2. Add the new type to the `Event` union constraint:
2. Add the marker method so the type satisfies the `Event` interface and `Sink.Emit` accepts it:
```go
type Event interface {
MessageEvent | AuthEvent | ... | <Name>Event
}
func (<Name>Event) sealedEvent() {}
```

3. Add an emit helper function:
Call sites emit directly on the sink — no helper needed:
```go
func Emit<Name>(sink Sink, ...) {
Emit(sink, <Name>Event{...})
}
sink.Emit(output.<Name>Event{...})
```

## Step 2: Add plain text formatting
Expand Down Expand Up @@ -80,4 +76,5 @@ If the event doesn't need special TUI handling, the `default` case in `Update()`
- Do NOT put pre-rendered UI strings in event fields — use typed domain data
- Do NOT add lipgloss/styling imports to `plain_format.go`
- Do NOT skip the format test — every event type needs parity coverage
- Do NOT forget to add the type to the `Event` union — it won't compile without it
- Do NOT add a package-level emit helper — call sites use `sink.Emit(output.<Name>Event{...})` directly
- Do NOT forget to add `func (<Name>Event) sealedEvent() {}` — without it `Sink.Emit` will reject the type at compile time
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,25 +84,25 @@ Environment variables:

# Output Routing and Events

- Emit typed events through `internal/output` (`EmitInfo`, `EmitSuccess`, `EmitNote`, `EmitWarning`, `EmitStatus`, `EmitProgress`, etc.) instead of printing from domain/command handlers.
- Keep `output.Sink` sealed (unexported `emit`); sink implementations belong in `internal/output`.
- Reuse `FormatEventLine(event any)` for all line-oriented rendering so plain and TUI output stay consistent.
- Emit typed events via `sink.Emit(output.XxxEvent{...})` instead of printing from domain/command handlers. For simple messages use `output.MessageEvent{Severity: output.SeverityInfo, Text: "..."}` (severities: `SeverityInfo`, `SeveritySuccess`, `SeverityNote`, `SeverityWarning`, `SeveritySecondary`).
- Sink implementations belong in `internal/output`; do not implement `output.Sink` outside that package.
- Reuse `FormatEventLine(event Event)` for all line-oriented rendering so plain and TUI output stay consistent.
- Select output mode at the command boundary in `cmd/`: interactive TTY runs Bubble Tea, non-interactive mode uses `output.NewPlainSink(...)`.
- Keep non-TTY mode non-interactive (no stdin prompts or input waits).
- Domain packages must not import Bubble Tea or UI packages.
- Any feature/workflow package that produces user-visible progress should accept an `output.Sink` dependency and emit events through `internal/output`.
- Do not pass UI callbacks like `onProgress func(...)` through domain layers; prefer typed output events.
- Event payloads should be domain facts (phase/status/progress), not pre-rendered UI strings.
- When adding a new event type, update all of:
- `internal/output/events.go` (event type + `Event` union constraint + emit helper)
- `internal/output/events.go` (event struct definition)
- `internal/output/plain_format.go` (line formatting fallback)
- tests in `internal/output/*_test.go` for formatter/sink behavior parity

## User Input Handling

Domain code must never read from stdin or wait for user input directly. Instead:

1. Emit a `UserInputRequestEvent` via `output.EmitUserInputRequest()` with:
1. Emit a `UserInputRequestEvent` via `sink.Emit(output.UserInputRequestEvent{...})` with:
- `Prompt`: message to display
- `Options`: available choices (e.g., `{Key: "enter", Label: "Press ENTER to continue"}`)
- `ResponseCh`: channel to receive the user's response
Expand All @@ -118,7 +118,7 @@ Domain code must never read from stdin or wait for user input directly. Instead:
Example flow in auth login:
```go
responseCh := make(chan output.InputResponse, 1)
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
sink.Emit(output.UserInputRequestEvent{
Prompt: "Waiting for authentication...",
Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}},
ResponseCh: responseCh,
Expand Down
4 changes: 2 additions & 2 deletions cmd/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Examples:
return fmt.Errorf("checking emulator status: %w", err)
}
if !running {
output.EmitError(sink, output.ErrorEvent{
sink.Emit(output.ErrorEvent{
Title: fmt.Sprintf("%s is not running", awsContainer.DisplayName()),
Actions: []output.ErrorAction{
{Label: "Start LocalStack:", Value: "lstk"},
Expand All @@ -80,7 +80,7 @@ Examples:

profileExists, _ := awsconfig.ProfileExists()
if !profileExists {
output.EmitNote(sink, "No AWS profile found, run 'lstk setup aws'")
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "No AWS profile found, run 'lstk setup aws'"})
}

stdout, stderr := io.Writer(os.Stdout), io.Writer(os.Stderr)
Expand Down
2 changes: 1 addition & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra

if rt != nil {
if running, err := container.AnyRunning(cmd.Context(), rt, appConfig.Containers); err == nil && running {
output.EmitNote(sink, "LocalStack is still running in the background")
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack is still running in the background"})
}
}
return nil
Expand Down
20 changes: 10 additions & 10 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,46 +51,46 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) {
if errors.Is(err, context.Canceled) {
return "", err
}
output.EmitWarning(a.sink, "Authentication failed.")
a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: "Authentication failed."})
return "", err
}

if err := a.tokenStorage.SetAuthToken(token); err != nil {
output.EmitWarning(a.sink, fmt.Sprintf("could not store token in keyring: %v", err))
a.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not store token in keyring: %v", err)})
}

output.EmitSuccess(a.sink, "Login successful.")
a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Login successful."})
return token, nil
}

// Logout removes the stored auth token from the keyring
func (a *Auth) Logout() error {
output.EmitSpinnerStart(a.sink, "Logging out...")
a.sink.Emit(output.SpinnerStart("Logging out..."))

_, err := a.tokenStorage.GetAuthToken()
if err != nil {
output.EmitSpinnerStop(a.sink)
a.sink.Emit(output.SpinnerStop())
if a.authToken != "" {
output.EmitNote(a.sink, "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out")
a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Authenticated via LOCALSTACK_AUTH_TOKEN environment variable; unset it to log out"})
return nil
}
if !errors.Is(err, ErrTokenNotFound) {
return fmt.Errorf("failed to read auth token: %w", err)
}
output.EmitNote(a.sink, "Not currently logged in")
a.sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Not currently logged in"})
return ErrNotLoggedIn
}

if err := a.tokenStorage.DeleteAuthToken(); err != nil {
output.EmitSpinnerStop(a.sink)
a.sink.Emit(output.SpinnerStop())
return fmt.Errorf("failed to delete auth token: %w", err)
}

if a.licenseFilePath != "" {
_ = os.Remove(a.licenseFilePath)
}

output.EmitSpinnerStop(a.sink)
output.EmitSuccess(a.sink, "Logged out successfully")
a.sink.Emit(output.SpinnerStop())
a.sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Logged out successfully"})
return nil
}
4 changes: 2 additions & 2 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func TestGetToken_ReturnsTokenWhenKeyringStoreFails(t *testing.T) {
mockStorage := NewMockAuthTokenStorage(ctrl)
mockLogin := NewMockLoginProvider(ctrl)

var events []any
sink := output.SinkFunc(func(event any) {
var events []output.Event
sink := output.SinkFunc(func(event output.Event) {
events = append(events, event)
})

Expand Down
18 changes: 9 additions & 9 deletions internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,35 +40,35 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) {

authURL := buildAuthURL(l.webAppURL, authReq.ID, authReq.Code)

output.EmitAuth(l.sink, output.AuthEvent{
l.sink.Emit(output.AuthEvent{
Preamble: "Welcome to lstk, a command-line interface for LocalStack",
Code: authReq.Code,
URL: authURL,
})
browser.Stdout = io.Discard
browser.Stderr = io.Discard
if err := browser.OpenURL(authURL); err != nil {
output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL))
l.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)})
}

output.EmitSpinnerStart(l.sink, "Waiting for authorization...")
l.sink.Emit(output.SpinnerStart("Waiting for authorization..."))

responseCh := make(chan output.InputResponse, 1)
output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{
l.sink.Emit(output.UserInputRequestEvent{
Prompt: "Waiting for authorization...",
Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}},
ResponseCh: responseCh,
})

select {
case resp := <-responseCh:
output.EmitSpinnerStop(l.sink)
l.sink.Emit(output.SpinnerStop())
if resp.Cancelled {
return "", context.Canceled
}
return l.completeAuth(ctx, authReq)
case <-ctx.Done():
output.EmitSpinnerStop(l.sink)
l.sink.Emit(output.SpinnerStop())
return "", ctx.Err()
}
}
Expand All @@ -85,22 +85,22 @@ func buildAuthURL(webAppURL, authRequestID, code string) string {
}

func (l *loginProvider) completeAuth(ctx context.Context, authReq *api.AuthRequest) (string, error) {
output.EmitInfo(l.sink, "Checking if auth request is confirmed...")
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Checking if auth request is confirmed..."})
confirmed, err := l.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to check auth request: %w", err)
}
if !confirmed {
return "", fmt.Errorf("auth request not confirmed - please complete the authentication in your browser")
}
output.EmitInfo(l.sink, "Auth request confirmed, exchanging for token...")
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Auth request confirmed, exchanging for token..."})

bearerToken, err := l.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
if err != nil {
return "", fmt.Errorf("failed to exchange auth request: %w", err)
}

output.EmitInfo(l.sink, "Fetching license token...")
l.sink.Emit(output.MessageEvent{Severity: output.SeverityInfo, Text: "Fetching license token..."})
licenseToken, err := l.platformClient.GetLicenseToken(ctx, bearerToken)
if err != nil {
return "", fmt.Errorf("failed to get license token: %w", err)
Expand Down
22 changes: 11 additions & 11 deletions internal/awsconfig/awsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func writeCredsProfile(credsPath string) error {
}

func emitMissingProfileNote(sink output.Sink) {
output.EmitNote(sink, "LocalStack AWS profile is incomplete. Run 'lstk setup aws'.")
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is incomplete. Run 'lstk setup aws'."})
}

// checkProfileSetup returns both the profile status (which files need writing) and presence (which files exist).
Expand Down Expand Up @@ -223,7 +223,7 @@ func checkProfileSetup(resolvedHost string) (profileStatus, bool, bool, error) {
func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error {
status, configOK, credsOK, err := checkProfileSetup(resolvedHost)
if err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not check AWS profile: %v", err))
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not check AWS profile: %v", err)})
return nil
}
if !status.anyNeeded() {
Expand All @@ -242,18 +242,18 @@ func EnsureProfile(ctx context.Context, sink output.Sink, interactive bool, reso
// status is passed in from EnsureProfile to avoid re-checking the profile status.
func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status profileStatus) error {
if !status.anyNeeded() {
output.EmitNote(sink, "LocalStack AWS profile is already configured.")
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "LocalStack AWS profile is already configured."})
return nil
}

configPath, credsPath, err := awsPaths()
if err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not determine AWS config paths: %v", err))
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not determine AWS config paths: %v", err)})
return nil
}

responseCh := make(chan output.InputResponse, 1)
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
sink.Emit(output.UserInputRequestEvent{
Prompt: "Set up a LocalStack profile for AWS CLI and SDKs in ~/.aws?",
Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}},
ResponseCh: responseCh,
Expand All @@ -265,27 +265,27 @@ func Setup(ctx context.Context, sink output.Sink, resolvedHost string, status pr
return nil
}
if resp.SelectedKey == "n" {
output.EmitNote(sink, "Skipped adding LocalStack AWS profile.")
sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: "Skipped adding LocalStack AWS profile."})
return nil
}
if status.configNeeded {
if err := writeConfigProfile(configPath, resolvedHost); err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/config: %v", err))
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/config: %v", err)})
return nil
}
}
if status.credsNeeded {
if err := writeCredsProfile(credsPath); err != nil {
output.EmitWarning(sink, fmt.Sprintf("could not update ~/.aws/credentials: %v", err))
sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("could not update ~/.aws/credentials: %v", err)})
return nil
}
}
if status.configNeeded && status.credsNeeded {
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws")
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws"})
} else if status.configNeeded {
output.EmitSuccess(sink, "Created LocalStack profile in ~/.aws/config")
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Created LocalStack profile in ~/.aws/config"})
} else {
output.EmitSuccess(sink, "Updated LocalStack credentials in ~/.aws/credentials")
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: "Updated LocalStack credentials in ~/.aws/credentials"})
}
case <-ctx.Done():
return ctx.Err()
Expand Down
2 changes: 1 addition & 1 deletion internal/container/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func Logs(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers
continue
}
level, _ := parseLogLine(line)
output.EmitLogLine(sink, output.LogSourceEmulator, line, level)
sink.Emit(output.LogLineEvent{Source: output.LogSourceEmulator, Line: line, Level: level})
}
if err := scanner.Err(); err != nil && ctx.Err() == nil {
return err
Expand Down
Loading