From c041ef6a3ae13fb4815a8f6301a0ebd4597d3373 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Tue, 8 Sep 2026 16:18:43 +0200 Subject: [PATCH 1/8] Show only one tip on first start Co-Authored-By: Claude --- CLAUDE.md | 12 +++- cmd/restart.go | 2 +- cmd/root.go | 6 +- cmd/snapshot.go | 2 +- internal/container/start.go | 42 ++++++------- internal/container/start_test.go | 17 ------ internal/container/tips.go | 64 +++++++++++++++++++ internal/container/tips_test.go | 81 +++++++++++++++++++++++++ internal/ui/run.go | 22 +------ test/integration/completion_tip_test.go | 28 ++++++++- 10 files changed, 206 insertions(+), 70 deletions(-) create mode 100644 internal/container/tips.go create mode 100644 internal/container/tips_test.go diff --git a/CLAUDE.md b/CLAUDE.md index b5e23402..3c875658 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,7 @@ The release job (`.github/workflows/ci.yml`) builds the npm packages with `gorel Cobra's generated bash completion script requires `_get_comp_words_by_ref` from the bash-completion package on both of its init paths, and stock macOS (bash 3.2) ships without that package — so completion failed with "command not found" on every Tab (DEVX-950). `selfContainBashCompletion` in `cmd/completion.go` wraps the autogenerated `completion bash` command to prepend a guarded pure-bash fallback (defined only when the package is absent, the git-completion.bash approach) and replaces the help text. The fallback body must stay bash 3.2 compatible (no `declare -A`, namerefs, `mapfile`, case-conversion expansions). It covers only `_get_comp_words_by_ref`; Cobra's script still calls bash-completion's `_filedir` for `ShellCompDirectiveFilterFileExt`/`ShellCompDirectiveFilterDirs` (`MarkFlagFilename`/`MarkFlagDirname`) and the ActiveHelp second-Tab path — lstk uses none of these today, so adopting one means growing the fallback. In docs/help, never recommend `source <(lstk completion bash)` — it is a silent no-op on bash 3.2; recommend `eval "$(lstk completion bash)"` instead. Zsh/fish/powershell scripts are self-contained upstream and untouched. -Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` pointing at `lstk completion [bash|zsh|fish|powershell]` and the docs. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/ui/run.go`). A second such nudge must be gated the same way: `firstRun` only, interactive only, after the emulator is up. +Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` pointing at `lstk completion [bash|zsh|fish|powershell]` and the docs. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/container/tips.go`). On that run it is also the *only* tip shown — see [Post-start tips](#post-start-tips). `lstk aws ` completes AWS services/operations/parameters by delegating to the AWS CLI's own `aws_completer` from a `ValidArgsFunction` on the `aws` command (DEVX-846) — `awscli.Complete` in `internal/awscli/complete.go`, wired in `cmd/aws.go`. Going through Cobra rather than registering `complete -C aws_completer lstk` is what makes it work in every shell `lstk completion` supports (the native registration is bash/zsh-only) and keeps the bash fallback above unchanged. `aws_completer` speaks bash's `complete -C` protocol: `COMP_LINE`/`COMP_POINT` in, candidates one-per-line out. Two constraints it imposes: the line must start with the literal word `aws` (the completer drops the first word before matching, so `lstk aws s3 l` returns nothing), and `COMP_POINT` is a **character** offset, not a byte one. Cobra never runs `PreRunE` on the `__complete` path, so completion stays offline — no config load, no Docker health check, no endpoint resolution — which matches the completer, which never contacts an endpoint. A missing or failing completer must return `ShellCompDirectiveDefault` and print nothing: any output on this path is read by the shell as a candidate. The Tab-press timeout is set by the caller in `cmd/aws.go` (`awsCompletionTimeout`), not inside `awscli.Complete` — an internal deadline made the unit test flaky on cold CI runs. That deadline only actually bounds a Tab press because `Complete` also sets `cmd.WaitDelay`: `exec.CommandContext` kills the completer on expiry but does not close a pipe the completer's own children still hold, so a completer leaving a grandchild on stdout (a wrapper script that forks instead of exec'ing — what `/bin/sh` does on Linux, where it is dash) made `cmd.Output()` block for the grandchild's full lifetime regardless of the context. Keep `WaitDelay` set on any similar short captured-output exec. `lstk az` needs the same treatment but a different protocol (argcomplete: `_ARGCOMPLETE=1`, output on fd 8). @@ -270,6 +270,16 @@ When drafting Slack messages, PR descriptions, review replies, release notes, or - `internal/output/plain_format.go` (line formatting fallback) - tests in `internal/output/*_test.go` for formatter/sink behavior parity +## Post-start tips + +**At most one `> Tip:` line per run, ever.** Two tips side by side compete for attention and neither lands, and every new nudge otherwise grows its own emit site and races the existing ones (the regression raised on [#484](https://github.com/localstack/lstk/pull/484)). The limit is structural, not a convention to remember: + +- `selectTip` (`internal/container/tips.go`) is the only place that decides which tip to show, and it returns one string. Add a new tip **there**, ranked against the others — never as a new `sink.Emit`. +- `container.Start` is the only place that emits it. No other package emits a tip; `internal/ui` deliberately emits none. +- Priority: the first-run tip (shell completion) outranks the rotating per-emulator tips, because first run happens once per install while the rotating tips come back on every later start. + +A first-run nudge is gated `firstRun` only, interactive only, after the emulator is up. `firstRun` (config.toml was absent) reaches the domain layer as `StartOptions.FirstRun`, resolved at the command boundary in `cmd/root.go`. + ## Structured output (`--json`) A JSON-capable command emits a single `output.Envelope` (schema version, `data`/`error` discriminated on `status`, an enumerated `error.code`) instead of formatted lines — see [docs/structured-output.md](docs/structured-output.md) for the full envelope contract, error-code table, exit-code conventions, and the per-command catalog (implemented vs. planned). `output.EnvelopeSink` builds the envelope from the same event vocabulary described above; adding `--json` support to a command is documented step by step in that file's "Adding `--json` support to a command" section. Command opt-in is explicit via the `jsonSupportedAnnotation` on the `cobra.Command` in `cmd/`. diff --git a/cmd/restart.go b/cmd/restart.go index 06964ff2..c93ada9d 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -44,7 +44,7 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr stopOpts := container.StopOptions{ Telemetry: tel, } - startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist) + startOpts := buildStartOptions(cfg, appConfig, logger, tel, persist, false) if isInteractiveMode(cfg) { return ui.RunRestart(cmd.Context(), rt, stopOpts, startOpts) diff --git a/cmd/root.go b/cmd/root.go index fa4064c7..76e24285 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -324,7 +324,7 @@ func configureCommandExecution(root *cobra.Command, cfg *env.Env, tel *telemetry wrapPreRunEForJSON(root, cfg, stdout) } -func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool) container.StartOptions { +func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist, firstRun bool) container.StartOptions { return container.StartOptions{ PlatformClient: api.NewPlatformClient(cfg.APIEndpoint, logger), AuthToken: cfg.AuthToken, @@ -337,6 +337,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger StartupTimeout: cfg.StartupTimeout, Logger: logger, Telemetry: tel, + FirstRun: firstRun, } } @@ -381,7 +382,7 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t return err } - opts := buildStartOptions(cfg, appConfig, logger, tel, persist) + opts := buildStartOptions(cfg, appConfig, logger, tel, persist, wasFirstRun) notifyOpts := update.NotifyOptions{ GitHubToken: cfg.GitHubToken, @@ -399,7 +400,6 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t ConfigPath: configPath, EmulatorLabel: config.CachedPlanLabel(), NeedsEmulatorSelection: firstRun, - CompletionTip: wasFirstRun, PostStart: autoLoad, }) } diff --git a/cmd/snapshot.go b/cmd/snapshot.go index c44b3a65..d61bbc7b 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -212,7 +212,7 @@ func newSnapshotAutoLoader(cfg *env.Env, rt runtime.Runtime, appConfig *config.C func buildStarter(cfg *env.Env, rt runtime.Runtime, appConfig *config.Config, logger log.Logger, tel *telemetry.Client) snapshot.Starter { return func(ctx context.Context, sink output.Sink) error { - opts := buildStartOptions(cfg, appConfig, logger, tel, false) + opts := buildStartOptions(cfg, appConfig, logger, tel, false, false) _, err := container.Start(ctx, rt, sink, opts, false) return err } diff --git a/internal/container/start.go b/internal/container/start.go index 99f99dd2..9acd1055 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math/rand/v2" "net/http" "os" "path/filepath" @@ -72,9 +71,26 @@ type StartOptions struct { // AuthOptions is passed through to auth.New; tests use it to inject a fake // browser opener so a re-login flow never opens a real tab. AuthOptions []auth.Option + // FirstRun reports that lstk had no config.toml when this run began; it + // selects the first-run tip. + FirstRun bool } +// Start brings up the configured emulator, recovering from a definitive license +// rejection with an in-place re-login when interactive. +// +// The post-start tip is emitted here, on the single entry point, so a run can +// only ever show one — see selectTip. func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (StartResult, error) { + result, err := start(ctx, rt, sink, opts, interactive) + if err != nil { + return result, err + } + emitPostStartTip(sink, result.Type, opts.FirstRun, interactive) + return result, nil +} + +func start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) (StartResult, error) { // Fail fast on unsupported multi-container configs before any health/auth // checks or image pulls, so we don't leave a partial startup that later dies // on container-name conflicts or shared port collisions. @@ -468,30 +484,6 @@ func emitPostStartPointers(sink output.Sink, emulatorType config.EmulatorType, r if webAppURL != "" { sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: fmt.Sprintf("• Web app: %s", strings.TrimRight(webAppURL, "/"))}) } - if tips := tipsForType(emulatorType); len(tips) > 0 { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tips[rand.IntN(len(tips))]}) - } -} - -func tipsForType(t config.EmulatorType) []string { - switch t { - case config.EmulatorAWS: - return []string{ - "> Tip: View emulator logs: lstk logs --follow", - "> Tip: View deployed resources: lstk status", - } - case config.EmulatorSnowflake: - return []string{ - "> Tip: View emulator logs: lstk logs --follow", - "> Tip: Check emulator status: lstk status", - } - case config.EmulatorAzure: - return []string{ - "> Tip: View emulator logs: lstk logs --follow", - "> Tip: Check emulator status: lstk status", - } - } - return nil } func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, interactive bool) (map[string]bool, error) { diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 84325562..00834a95 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -121,7 +121,6 @@ func TestEmitPostStartPointers_WithWebApp(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") assert.NotContains(t, got, "• Snowflake endpoint:", "AWS path must not show the snowflake-prefixed endpoint") assert.NotContains(t, got, "• Persistence:", @@ -136,7 +135,6 @@ func TestEmitPostStartPointers_WithoutWebApp(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n") - assert.Contains(t, got, "> Tip:") } func TestEmitPostStartPointers_WithPersist(t *testing.T) { @@ -235,7 +233,6 @@ func TestEmitPostStartPointers_Snowflake_ReplacesEndpointWithSnowflakeEndpoint(t assert.NotContains(t, got, "• Endpoint: localhost.localstack.cloud:4566", "Snowflake should not show the bare endpoint — clients connect via the snowflake-prefixed host") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") } func TestEmitPostStartPointers_Snowflake_OmitsPersistenceBullet(t *testing.T) { @@ -259,7 +256,6 @@ func TestEmitPostStartPointers_Snowflake_FallsBackToBareEndpointForIPHost(t *tes assert.Contains(t, got, "• Endpoint: 127.0.0.1:4566\n", "falls back to bare endpoint when snowflake. would be invalid") assert.NotContains(t, got, "• Snowflake endpoint:") - assert.Contains(t, got, "> Tip:") } func TestSelectContainersToStart_AttachesWhenExternalContainerOnConfiguredPort(t *testing.T) { @@ -396,23 +392,10 @@ func TestEmitPostStartPointers_Azure(t *testing.T) { got := out.String() assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.Contains(t, got, "> Tip:") assert.NotContains(t, got, "• Snowflake endpoint:", "Azure must not show the snowflake-prefixed endpoint") } -func TestEmitPostStartPointers_UnknownEmulator_NoTip(t *testing.T) { - var out bytes.Buffer - sink := output.NewPlainSink(&out) - - emitPostStartPointers(sink, config.EmulatorType("other"), "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", false) - - got := out.String() - assert.Contains(t, got, "• Endpoint: localhost.localstack.cloud:4566\n") - assert.Contains(t, got, "• Web app: https://app.localstack.cloud\n") - assert.NotContains(t, got, "> Tip:") -} - func TestServicePortRange_ReturnsExpectedPorts(t *testing.T) { ports := servicePortRange() diff --git a/internal/container/tips.go b/internal/container/tips.go new file mode 100644 index 00000000..da0df4da --- /dev/null +++ b/internal/container/tips.go @@ -0,0 +1,64 @@ +package container + +import ( + "math/rand/v2" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" +) + +// completionTip fires on first run, not install: no install path has a usable +// hook (npm's package.json is generated, binaries have none). Must stay a plain +// MessageEvent — ui.Run renders no DeferredOutput, so a deferred event is lost. +const completionTip = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + + "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" + +// emitPostStartTip emits this run's tip. Start is its only caller: one emit site +// is what makes selectTip's limit hold. +func emitPostStartTip(sink output.Sink, emulatorType config.EmulatorType, firstRun, interactive bool) { + // Nothing came up (no containers configured), so nothing to tip about. + if emulatorType == "" { + return + } + if tip := selectTip(emulatorType, firstRun, interactive); tip != "" { + sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: tip}) + } +} + +// selectTip returns the one tip to show after a start, or "" for none. +// +// One tip per run, never two: side by side they compete and neither lands (#484 +// review). Rank a new tip in here, don't emit it separately. firstRun wins — it +// happens once per install, the rotating tips return on every later start. +func selectTip(emulatorType config.EmulatorType, firstRun, interactive bool) string { + // Interactive only: completion means nothing to CI, agents, or --json. + if firstRun && interactive { + return completionTip + } + tips := tipsForType(emulatorType) + if len(tips) == 0 { + return "" + } + return tips[rand.IntN(len(tips))] +} + +func tipsForType(t config.EmulatorType) []string { + switch t { + case config.EmulatorAWS: + return []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: View deployed resources: lstk status", + } + case config.EmulatorSnowflake: + return []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: Check emulator status: lstk status", + } + case config.EmulatorAzure: + return []string{ + "> Tip: View emulator logs: lstk logs --follow", + "> Tip: Check emulator status: lstk status", + } + } + return nil +} diff --git a/internal/container/tips_test.go b/internal/container/tips_test.go new file mode 100644 index 00000000..ecab0c19 --- /dev/null +++ b/internal/container/tips_test.go @@ -0,0 +1,81 @@ +package container + +import ( + "bytes" + "strings" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelectTip_FirstRunInteractive_PrefersCompletionTip(t *testing.T) { + got := selectTip(config.EmulatorAWS, true, true) + + assert.Equal(t, completionTip, got, "first run is the only moment the completion pointer is worth a line") +} + +func TestSelectTip_FirstRunNonInteractive_FallsBackToRotatingTip(t *testing.T) { + got := selectTip(config.EmulatorAWS, true, false) + + assert.NotEqual(t, completionTip, got, "shell completion is irrelevant to CI and agents") + assert.Contains(t, tipsForType(config.EmulatorAWS), got) +} + +func TestSelectTip_SubsequentRun_ReturnsRotatingTip(t *testing.T) { + for range 20 { + got := selectTip(config.EmulatorAWS, false, true) + + assert.NotEqual(t, completionTip, got, "the completion tip must not repeat past the first run") + assert.Contains(t, tipsForType(config.EmulatorAWS), got) + } +} + +func TestSelectTip_UnknownEmulator_ReturnsNoTip(t *testing.T) { + assert.Empty(t, selectTip(config.EmulatorType("other"), false, true)) +} + +func TestSelectTip_UnknownEmulatorOnFirstRun_StillReturnsCompletionTip(t *testing.T) { + assert.Equal(t, completionTip, selectTip(config.EmulatorType("other"), true, true), + "the completion tip is about lstk itself, not the emulator that happens to be configured") +} + +// No input combination can produce two lines. +func TestEmitPostStartTip_EmitsAtMostOneTipLine(t *testing.T) { + for _, tc := range []struct { + name string + emulatorType config.EmulatorType + firstRun bool + interactive bool + wantTipLineCount int + }{ + {"first run interactive", config.EmulatorAWS, true, true, 1}, + {"first run non-interactive", config.EmulatorAWS, true, false, 1}, + {"subsequent run", config.EmulatorAWS, false, true, 1}, + {"unknown emulator", config.EmulatorType("other"), false, true, 0}, + {"nothing started", config.EmulatorType(""), true, true, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartTip(sink, tc.emulatorType, tc.firstRun, tc.interactive) + + assert.Equal(t, tc.wantTipLineCount, strings.Count(out.String(), "> Tip:")) + }) + } +} + +// The pointers block must not carry a tip of its own: Start is the single emit site. +func TestEmitPostStartPointers_EmitsNoTip(t *testing.T) { + for _, emulatorType := range []config.EmulatorType{config.EmulatorAWS, config.EmulatorSnowflake, config.EmulatorAzure} { + var out bytes.Buffer + sink := output.NewPlainSink(&out) + + emitPostStartPointers(sink, emulatorType, "localhost.localstack.cloud:4566", "https://app.localstack.cloud/", true) + + require.NotContains(t, out.String(), "> Tip:", "%s pointers block emitted a tip", emulatorType) + } +} diff --git a/internal/ui/run.go b/internal/ui/run.go index 8cdd763d..558f5b09 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -41,25 +41,12 @@ type RunOptions struct { // auto-load a configured snapshot). It is skipped when the emulator was // already running. PostStart func(ctx context.Context, sink output.Sink) error - // CompletionTip shows completionTip once the emulator is up. The caller - // decides when it applies (first run), so this package holds no policy. - CompletionTip bool } -// completionTip points at the completion scripts lstk ships. Only Homebrew -// installs wire them up automatically (homebrew_casks.completions in -// .goreleaser.yaml), so npm and binary users never find them. First run is the -// trigger because no install path offers a usable hook (generated npm -// package.json, no hook at all for binaries) and it needs no new persisted -// state: config.toml was absent, and that same run creates it. +// Run drives an interactive emulator start through the Bubble Tea program. // -// Must stay a plain MessageEvent, not a DeferredEvent — Run does not render -// DeferredOutput (only runWithTUI does), so a deferred event would be dropped. -// The "> Tip: " prefix, SeveritySecondary, and verb-colon-command wording match -// tipsForType (internal/container/start.go), whose tip renders right above it. -const completionTip = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + - "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" - +// It emits no "> Tip:" line — container.Start owns the run's single post-start +// tip (see selectTip there). func Run(parentCtx context.Context, runOpts RunOptions) error { ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -155,9 +142,6 @@ func Run(parentCtx context.Context, runOpts RunOptions) error { } else { go container.ResolveAndCacheLabel(ctx, runOpts.StartOptions, result.Version, labelCh) } - if runOpts.CompletionTip { - sink.Emit(output.MessageEvent{Severity: output.SeveritySecondary, Text: completionTip}) - } p.Send(runDoneMsg{}) }() diff --git a/test/integration/completion_tip_test.go b/test/integration/completion_tip_test.go index efca0c76..75b92339 100644 --- a/test/integration/completion_tip_test.go +++ b/test/integration/completion_tip_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "slices" + "strings" "testing" "time" @@ -13,8 +15,8 @@ import ( ) // completionTipText is asserted verbatim, "> Tip: " prefix included: that -// prefix is the convention the neighbouring post-start tips use (tipsForType in -// internal/container/start.go), so it is observable behavior, not styling. +// prefix is the convention the other post-start tips use (tipsForType in +// internal/container/tips.go), so it is observable behavior, not styling. const completionTipText = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" @@ -35,6 +37,23 @@ func firstRunHome(t *testing.T) (env.Environ, string) { return e, configPath } +// distinctTips returns the unique "> Tip:" lines in out. Bubble Tea repaints +// lines, so a raw occurrence count would overstate. +func distinctTips(out string) []string { + var tips []string + for _, line := range strings.Split(out, "\n") { + i := strings.Index(line, "> Tip:") + if i < 0 { + continue + } + tip := strings.TrimSpace(line[i:]) + if !slices.Contains(tips, tip) { + tips = append(tips, tip) + } + } + return tips +} + func TestFirstRunShowsCompletionTip(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) @@ -60,7 +79,9 @@ func TestFirstRunShowsCompletionTip(t *testing.T) { out, err := p.wait() require.NoError(t, err, "lstk start should exit successfully") - assert.Contains(t, out, completionTipText, "first successful interactive start should point at shell completion setup") + // Exactly one tip, and it is this one — two tips compete and neither lands (#484). + assert.Equal(t, []string{completionTipText}, distinctTips(out), + "first successful interactive start should point at shell completion setup, and show no other tip") } // --type answers the first-run picker, so it suppresses it — but the run is @@ -117,6 +138,7 @@ func TestSubsequentRunDoesNotShowCompletionTip(t *testing.T) { require.NoError(t, err, "lstk start should exit successfully") assert.NotContains(t, out, completionTipText, "the tip must not repeat once lstk has been configured") + assert.Len(t, distinctTips(out), 1, "a configured run should show the rotating tip, and only that") } func TestFirstRunNonInteractiveShowsNoCompletionTip(t *testing.T) { From c38dcb9493bdcf72a0d1587f1bf8b04fdc0f5196 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Thu, 10 Sep 2026 17:14:09 +0200 Subject: [PATCH 2/8] Make lstk completion self-documenting, drop the tip URL Co-Authored-By: Claude --- CLAUDE.md | 4 +- cmd/completion.go | 121 ++++++++++++++++++++---- cmd/completion_test.go | 78 +++++++++++++++ cmd/root.go | 1 + internal/container/tips.go | 10 +- internal/container/tips_test.go | 8 ++ test/integration/completion_tip_test.go | 3 +- 7 files changed, 199 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c875658..4db1904e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,9 @@ The release job (`.github/workflows/ci.yml`) builds the npm packages with `gorel Cobra's generated bash completion script requires `_get_comp_words_by_ref` from the bash-completion package on both of its init paths, and stock macOS (bash 3.2) ships without that package — so completion failed with "command not found" on every Tab (DEVX-950). `selfContainBashCompletion` in `cmd/completion.go` wraps the autogenerated `completion bash` command to prepend a guarded pure-bash fallback (defined only when the package is absent, the git-completion.bash approach) and replaces the help text. The fallback body must stay bash 3.2 compatible (no `declare -A`, namerefs, `mapfile`, case-conversion expansions). It covers only `_get_comp_words_by_ref`; Cobra's script still calls bash-completion's `_filedir` for `ShellCompDirectiveFilterFileExt`/`ShellCompDirectiveFilterDirs` (`MarkFlagFilename`/`MarkFlagDirname`) and the ActiveHelp second-Tab path — lstk uses none of these today, so adopting one means growing the fallback. In docs/help, never recommend `source <(lstk completion bash)` — it is a silent no-op on bash 3.2; recommend `eval "$(lstk completion bash)"` instead. Zsh/fish/powershell scripts are self-contained upstream and untouched. -Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` pointing at `lstk completion [bash|zsh|fish|powershell]` and the docs. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/container/tips.go`). On that run it is also the *only* tip shown — see [Post-start tips](#post-start-tips). +Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` naming `lstk completion`. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/container/tips.go`). On that run it is also the *only* tip shown — see [Post-start tips](#post-start-tips). + +Per-shell setup instructions live in `completionShells` (`cmd/completion.go`) and nowhere else in the CLI: one table renders into the `completion` parent help, each shell subcommand's help, and `lstk docs`. The tip carries no URL on purpose — the command answers the question on its own, so terminal output never points at a link that can rot or drift from the shipped binary. Keep the docs site's shell-completions section in step with that help, not the reverse. `lstk aws ` completes AWS services/operations/parameters by delegating to the AWS CLI's own `aws_completer` from a `ValidArgsFunction` on the `aws` command (DEVX-846) — `awscli.Complete` in `internal/awscli/complete.go`, wired in `cmd/aws.go`. Going through Cobra rather than registering `complete -C aws_completer lstk` is what makes it work in every shell `lstk completion` supports (the native registration is bash/zsh-only) and keeps the bash fallback above unchanged. `aws_completer` speaks bash's `complete -C` protocol: `COMP_LINE`/`COMP_POINT` in, candidates one-per-line out. Two constraints it imposes: the line must start with the literal word `aws` (the completer drops the first word before matching, so `lstk aws s3 l` returns nothing), and `COMP_POINT` is a **character** offset, not a byte one. Cobra never runs `PreRunE` on the `__complete` path, so completion stays offline — no config load, no Docker health check, no endpoint resolution — which matches the completer, which never contacts an endpoint. A missing or failing completer must return `ShellCompDirectiveDefault` and print nothing: any output on this path is read by the shell as a candidate. The Tab-press timeout is set by the caller in `cmd/aws.go` (`awsCompletionTimeout`), not inside `awscli.Complete` — an internal deadline made the unit test flaky on cold CI runs. That deadline only actually bounds a Tab press because `Complete` also sets `cmd.WaitDelay`: `exec.CommandContext` kills the completer on expiry but does not close a pipe the completer's own children still hold, so a completer leaving a grandchild on stdout (a wrapper script that forks instead of exec'ing — what `/bin/sh` does on Linux, where it is dash) made `cmd.Output()` block for the grandchild's full lifetime regardless of the context. Keep `WaitDelay` set on any similar short captured-output exec. `lstk az` needs the same treatment but a different protocol (argcomplete: `_ARGCOMPLETE=1`, output on fd 8). diff --git a/cmd/completion.go b/cmd/completion.go index db5161bd..8e310b8b 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "io" + "strings" "github.com/spf13/cobra" ) @@ -103,10 +104,8 @@ fi // package (DEVX-950). Cobra's own RunE writes to a writer captured when // InitDefaultCompletionCmd ran, so both halves are generated here against the // writer resolved at execution time — otherwise SetOut after NewRootCmd would -// split them across two destinations. It also replaces the help text: Cobra's -// default recommends 'source <(lstk completion bash)', which is a silent -// no-op on macOS's stock bash 3.2, and states a package dependency that no -// longer holds. +// split them across two destinations. Help text is documentCompletionCommands' +// job, not this function's. func selfContainBashCompletion(completionCmd *cobra.Command) { var bashCmd *cobra.Command for _, sub := range completionCmd.Commands() { @@ -132,26 +131,110 @@ func selfContainBashCompletion(completionCmd *cobra.Command) { } return cmd.Root().GenBashCompletionV2(out, !noDesc) } +} - name := bashCmd.Root().Name() - bashCmd.Long = fmt.Sprintf(`Generate the autocompletion script for the bash shell. - -The script works with or without the 'bash-completion' package: when the package is absent (e.g. stock macOS bash), a bundled fallback is used instead. - -To load completions in your current shell session: - +// completionShells documents setup for every shell lstk generates a script for. +// It is the single source for both the `completion` parent help and each shell +// subcommand's help, so the two cannot drift, and `lstk docs` renders it — the +// docs site follows the CLI instead of being kept in step by hand. +// +// %[1]s is the binary name. Command lines are tab-indented because wrapText +// (cmd/help.go) reflows unindented prose to the terminal width but leaves +// indented lines alone. Homebrew paths are deliberately absent: those installs +// wire completion up themselves (homebrew_casks.completions in .goreleaser.yaml). +var completionShells = []struct { + name string + title string + setup string + note string +}{ + { + name: "bash", + title: "Bash", + setup: ` # Load in current session eval "$(%[1]s completion bash)" -To load completions for every new session, add the line above to ~/.bashrc (or ~/.bash_profile on macOS), or execute once: - -#### Linux: + # Persist (Linux, and Windows under WSL or Git Bash) + mkdir -p ~/.local/share/bash-completion/completions + %[1]s completion bash > ~/.local/share/bash-completion/completions/%[1]s + + # Persist (macOS) + echo 'eval "$(%[1]s completion bash)"' >> ~/.bash_profile`, + note: `The script carries its own fallback for the bash-completion package, so it works on the stock macOS bash 3.2 — which is also why macOS persists through ~/.bash_profile: bash 3.2 reads no completion directory. Process substitution silently does nothing on that bash, so use the eval form above and never source the script.`, + }, + { + name: "zsh", + title: "Zsh", + setup: ` # Load in current session + source <(%[1]s completion zsh) + + # Persist (Linux, macOS, Windows) + %[1]s completion zsh > "${fpath[1]}/_%[1]s"`, + note: `Any writable ${fpath} entry works; ${fpath[1]} needs sudo when it is a system directory. Completion also has to be enabled — if your ~/.zshrc never calls compinit, add 'autoload -Uz compinit && compinit'.`, + }, + { + name: "fish", + title: "Fish", + setup: ` # Load in current session + %[1]s completion fish | source + + # Persist (Linux, macOS) + mkdir -p ~/.config/fish/completions + %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish`, + }, + { + name: "powershell", + title: "PowerShell", + setup: ` # Load in current session + %[1]s completion powershell | Out-String | Invoke-Expression + + # Persist (Windows, Linux, macOS) + if (!(Test-Path $PROFILE)) { New-Item -Type File -Path $PROFILE -Force } + %[1]s completion powershell | Out-File -Append -Encoding utf8 $PROFILE`, + note: `The explicit encoding matters: '>>' writes UTF-16LE on PowerShell 5.1, which the profile then fails to parse. Appending twice installs two copies, so edit $PROFILE rather than re-running.`, + }, +} - %[1]s completion bash > /etc/bash_completion.d/%[1]s +const completionEffectNote = "Persisted completion takes effect in your next shell session." -#### macOS (with the bash-completion Homebrew package): +// documentCompletionCommands replaces Cobra's autogenerated help on the +// `completion` command and its per-shell subcommands with completionShells. +// Cobra's own text recommends process substitution for bash (a silent no-op on +// macOS bash 3.2) and offers no persist path for PowerShell. +// +// The parent carries every shell because the first-start tip points at bare +// `lstk completion` (PR #495 review): the command has to answer the question on +// its own, with no URL to follow. +func documentCompletionCommands(completionCmd *cobra.Command) { + name := completionCmd.Root().Name() + + // Bodies carry no shell title — the parent adds one to separate the shells, + // while a subcommand's own help already names the shell in its first line. + bodies := make([]string, 0, len(completionShells)) + titled := make([]string, 0, len(completionShells)) + for _, sh := range completionShells { + body := sh.setup + if sh.note != "" { + body += "\n\n" + sh.note + } + bodies = append(bodies, fmt.Sprintf(body, name)) + titled = append(titled, sh.title+":\n\n"+bodies[len(bodies)-1]) + } - %[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s + completionCmd.Short = "Set up tab completion for your shell" + completionCmd.Long = strings.Join(append([]string{ + "Generate a tab-completion script for your shell.", + `Run the "Load in current session" command to try completion out, then the "Persist" commands to keep it for new sessions. Homebrew installs configure completion automatically and need none of this.`, + }, append(titled, completionEffectNote)...), "\n\n") -You will need to start a new shell for this setup to take effect. -`, name) + for _, sub := range completionCmd.Commands() { + for i, sh := range completionShells { + if sub.Name() != sh.name { + continue + } + sub.Short = fmt.Sprintf("Set up tab completion for %s", sh.title) + sub.Long = fmt.Sprintf("Generate the tab-completion script for %s.", sh.title) + + "\n\n" + bodies[i] + "\n\n" + completionEffectNote + } + } } diff --git a/cmd/completion_test.go b/cmd/completion_test.go index 18e1404c..a566d083 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -31,3 +31,81 @@ func TestCompletionBashNoDescriptionsFlagStillHonored(t *testing.T) { assertContains(t, out, "_get_comp_words_by_ref()") assertContains(t, out, "__completeNoDesc") } + +// completionShellHelp is what a user must be able to copy straight out of the +// help for each shell. Asserted on the indented command lines only: wrapText +// reflows unindented prose to the terminal width, so prose is not stable text. +var completionShellHelp = []struct { + shell string + title string + load string + persist string +}{ + {"bash", "Bash:", `eval "$(lstk completion bash)"`, "~/.local/share/bash-completion/completions/lstk"}, + {"zsh", "Zsh:", "source <(lstk completion zsh)", `lstk completion zsh > "${fpath[1]}/_lstk"`}, + {"fish", "Fish:", "lstk completion fish | source", "~/.config/fish/completions/lstk.fish"}, + {"powershell", "PowerShell:", "lstk completion powershell | Out-String | Invoke-Expression", "$PROFILE"}, +} + +// The tip on first start now says only `lstk completion` (PR #495 review), so +// that command has to carry the setup instructions the tip used to link to. +func TestCompletionHelpDocumentsEveryShell(t *testing.T) { + out, err := executeWithArgs(t, "completion") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + for _, tc := range completionShellHelp { + assertContains(t, out, tc.title) + assertContains(t, out, tc.load) + assertContains(t, out, tc.persist) + } +} + +func TestCompletionHelpOffersLoadAndPersistPerOS(t *testing.T) { + out, err := executeWithArgs(t, "completion") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertContains(t, out, "# Load in current session") + assertContains(t, out, "# Persist (Linux") + assertContains(t, out, "# Persist (macOS") + assertContains(t, out, "# Persist (Windows") +} + +// Process substitution is a silent no-op on stock macOS bash 3.2, so the help +// must never recommend it for bash (DEVX-950). +func TestCompletionHelpNeverRecommendsSourceForBash(t *testing.T) { + out, err := executeWithArgs(t, "completion") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertNotContains(t, out, "source <(lstk completion bash)") +} + +// Homebrew wires completion up on its own (homebrew_casks.completions), so its +// paths are noise in instructions aimed at everyone else. +func TestCompletionHelpOmitsHomebrewSetup(t *testing.T) { + out, err := executeWithArgs(t, "completion") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + assertNotContains(t, out, "brew --prefix") +} + +// Each shell's own help and the parent's must show the same instructions, so a +// user gets one answer whichever they run. +func TestCompletionShellHelpMatchesParentHelp(t *testing.T) { + for _, tc := range completionShellHelp { + out, err := executeWithArgs(t, "completion", tc.shell, "--help") + if err != nil { + t.Fatalf("completion %s --help: expected no error, got %v", tc.shell, err) + } + + assertContains(t, out, tc.load) + assertContains(t, out, tc.persist) + } +} diff --git a/cmd/root.go b/cmd/root.go index 76e24285..46eae9b9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -227,6 +227,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C if completionCmd, _, err := root.Find([]string{"completion"}); err == nil && completionCmd.Name() == "completion" { requireSubcommand(completionCmd) selfContainBashCompletion(completionCmd) + documentCompletionCommands(completionCmd) } return root diff --git a/internal/container/tips.go b/internal/container/tips.go index da0df4da..3ffe22a3 100644 --- a/internal/container/tips.go +++ b/internal/container/tips.go @@ -8,10 +8,12 @@ import ( ) // completionTip fires on first run, not install: no install path has a usable -// hook (npm's package.json is generated, binaries have none). Must stay a plain -// MessageEvent — ui.Run renders no DeferredOutput, so a deferred event is lost. -const completionTip = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + - "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" +// hook (npm's package.json is generated, binaries have none). It names the bare +// command rather than a URL — `lstk completion` carries the per-shell setup +// itself (completionShells in cmd/completion.go), so there is nothing to follow +// and nothing to keep in sync. Must stay a plain MessageEvent — ui.Run renders +// no DeferredOutput, so a deferred event is lost. +const completionTip = "> Tip: Set up tab completion: lstk completion" // emitPostStartTip emits this run's tip. Start is its only caller: one emit site // is what makes selectTip's limit hold. diff --git a/internal/container/tips_test.go b/internal/container/tips_test.go index ecab0c19..2f870f17 100644 --- a/internal/container/tips_test.go +++ b/internal/container/tips_test.go @@ -11,6 +11,14 @@ import ( "github.com/stretchr/testify/require" ) +// The tip names the command instead of linking to docs (#495 review): a URL in +// terminal output cannot be clicked, rots, and drifts from the CLI, while +// `lstk completion` documents itself. +func TestCompletionTip_NamesTheCommandWithoutAURL(t *testing.T) { + assert.Contains(t, completionTip, "lstk completion") + assert.NotContains(t, completionTip, "http") +} + func TestSelectTip_FirstRunInteractive_PrefersCompletionTip(t *testing.T) { got := selectTip(config.EmulatorAWS, true, true) diff --git a/test/integration/completion_tip_test.go b/test/integration/completion_tip_test.go index 75b92339..193f25c5 100644 --- a/test/integration/completion_tip_test.go +++ b/test/integration/completion_tip_test.go @@ -17,8 +17,7 @@ import ( // completionTipText is asserted verbatim, "> Tip: " prefix included: that // prefix is the convention the other post-start tips use (tipsForType in // internal/container/tips.go), so it is observable behavior, not styling. -const completionTipText = "> Tip: Enable tab completion for your shell: lstk completion [bash|zsh|fish|powershell] " + - "See https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/#shell-completions" +const completionTipText = "> Tip: Set up tab completion: lstk completion" // firstRunHome returns an isolated home with no lstk config, so the run under // test is a first run (config.toml absent is what firstRun means). From 2299c0767506231ebe65f2376fb91a401d9e2f4a Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Thu, 10 Sep 2026 19:04:03 +0200 Subject: [PATCH 3/8] Trim shell completion setup to the recipes that work everywhere Co-Authored-By: Claude --- CLAUDE.md | 2 +- cmd/completion.go | 160 +++++++++++++++++------------------------ cmd/completion_test.go | 97 +++++++++++++------------ 3 files changed, 116 insertions(+), 143 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4db1904e..016cb62e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,7 @@ Cobra's generated bash completion script requires `_get_comp_words_by_ref` from Only Homebrew installs wire up completion automatically (`homebrew_casks.completions` in `.goreleaser.yaml`), so the first successful *interactive* start emits a one-line `> Tip:` naming `lstk completion`. It is only a pointer — lstk never writes to the user's shell config, and there is no install flag (rationale on the `completionTip` const in `internal/container/tips.go`). On that run it is also the *only* tip shown — see [Post-start tips](#post-start-tips). -Per-shell setup instructions live in `completionShells` (`cmd/completion.go`) and nowhere else in the CLI: one table renders into the `completion` parent help, each shell subcommand's help, and `lstk docs`. The tip carries no URL on purpose — the command answers the question on its own, so terminal output never points at a link that can rot or drift from the shipped binary. Keep the docs site's shell-completions section in step with that help, not the reverse. +Per-shell setup instructions live in `completionSetup` (`cmd/completion.go`) and nowhere else: it is the `completion` command's own help, the per-shell subcommands forward to it rather than keeping a second copy, and `lstk docs` renders it. The tip carries no URL on purpose — the command answers the question on its own, so terminal output never points at a link that can rot or drift from the shipped binary. Keep the docs site's shell-completions section in step with that help, not the reverse. `lstk aws ` completes AWS services/operations/parameters by delegating to the AWS CLI's own `aws_completer` from a `ValidArgsFunction` on the `aws` command (DEVX-846) — `awscli.Complete` in `internal/awscli/complete.go`, wired in `cmd/aws.go`. Going through Cobra rather than registering `complete -C aws_completer lstk` is what makes it work in every shell `lstk completion` supports (the native registration is bash/zsh-only) and keeps the bash fallback above unchanged. `aws_completer` speaks bash's `complete -C` protocol: `COMP_LINE`/`COMP_POINT` in, candidates one-per-line out. Two constraints it imposes: the line must start with the literal word `aws` (the completer drops the first word before matching, so `lstk aws s3 l` returns nothing), and `COMP_POINT` is a **character** offset, not a byte one. Cobra never runs `PreRunE` on the `__complete` path, so completion stays offline — no config load, no Docker health check, no endpoint resolution — which matches the completer, which never contacts an endpoint. A missing or failing completer must return `ShellCompDirectiveDefault` and print nothing: any output on this path is read by the shell as a candidate. The Tab-press timeout is set by the caller in `cmd/aws.go` (`awsCompletionTimeout`), not inside `awscli.Complete` — an internal deadline made the unit test flaky on cold CI runs. That deadline only actually bounds a Tab press because `Complete` also sets `cmd.WaitDelay`: `exec.CommandContext` kills the completer on expiry but does not close a pipe the completer's own children still hold, so a completer leaving a grandchild on stdout (a wrapper script that forks instead of exec'ing — what `/bin/sh` does on Linux, where it is dash) made `cmd.Output()` block for the grandchild's full lifetime regardless of the context. Keep `WaitDelay` set on any similar short captured-output exec. `lstk az` needs the same treatment but a different protocol (argcomplete: `_ARGCOMPLETE=1`, output on fd 8). diff --git a/cmd/completion.go b/cmd/completion.go index 8e310b8b..0666d3aa 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "io" - "strings" "github.com/spf13/cobra" ) @@ -133,108 +132,83 @@ func selfContainBashCompletion(completionCmd *cobra.Command) { } } -// completionShells documents setup for every shell lstk generates a script for. -// It is the single source for both the `completion` parent help and each shell -// subcommand's help, so the two cannot drift, and `lstk docs` renders it — the -// docs site follows the CLI instead of being kept in step by hand. +// completionSetup is the whole of lstk's shell-completion setup documentation. +// It lives on the `completion` command alone — the first-start tip points at +// bare `lstk completion` (PR #495 review), so that command has to answer the +// question with no URL to follow, and the per-shell subcommands forward here +// rather than keeping a second copy. `lstk docs` renders it too, so the docs +// site follows the CLI instead of being kept in step by hand. // -// %[1]s is the binary name. Command lines are tab-indented because wrapText -// (cmd/help.go) reflows unindented prose to the terminal width but leaves -// indented lines alone. Homebrew paths are deliberately absent: those installs -// wire completion up themselves (homebrew_casks.completions in .goreleaser.yaml). -var completionShells = []struct { - name string - title string - setup string - note string -}{ - { - name: "bash", - title: "Bash", - setup: ` # Load in current session - eval "$(%[1]s completion bash)" - - # Persist (Linux, and Windows under WSL or Git Bash) - mkdir -p ~/.local/share/bash-completion/completions - %[1]s completion bash > ~/.local/share/bash-completion/completions/%[1]s - - # Persist (macOS) - echo 'eval "$(%[1]s completion bash)"' >> ~/.bash_profile`, - note: `The script carries its own fallback for the bash-completion package, so it works on the stock macOS bash 3.2 — which is also why macOS persists through ~/.bash_profile: bash 3.2 reads no completion directory. Process substitution silently does nothing on that bash, so use the eval form above and never source the script.`, - }, - { - name: "zsh", - title: "Zsh", - setup: ` # Load in current session - source <(%[1]s completion zsh) - - # Persist (Linux, macOS, Windows) - %[1]s completion zsh > "${fpath[1]}/_%[1]s"`, - note: `Any writable ${fpath} entry works; ${fpath[1]} needs sudo when it is a system directory. Completion also has to be enabled — if your ~/.zshrc never calls compinit, add 'autoload -Uz compinit && compinit'.`, - }, - { - name: "fish", - title: "Fish", - setup: ` # Load in current session - %[1]s completion fish | source - - # Persist (Linux, macOS) - mkdir -p ~/.config/fish/completions - %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish`, - }, - { - name: "powershell", - title: "PowerShell", - setup: ` # Load in current session - %[1]s completion powershell | Out-String | Invoke-Expression - - # Persist (Windows, Linux, macOS) - if (!(Test-Path $PROFILE)) { New-Item -Type File -Path $PROFILE -Force } - %[1]s completion powershell | Out-File -Append -Encoding utf8 $PROFILE`, - note: `The explicit encoding matters: '>>' writes UTF-16LE on PowerShell 5.1, which the profile then fails to parse. Appending twice installs two copies, so edit $PROFILE rather than re-running.`, - }, -} +// %[1]s is the binary name. Command lines are indented so wrapText +// (cmd/help.go) leaves them intact — it reflows unindented prose to the +// terminal width, which would break a command mid-word on a narrow one. +const completionSetup = `Generate a tab-completion script for your shell. Homebrew installs of %[1]s set this up already. + +To load completions temporarily or permanently: + +Bash: + + # Load in current session + eval "$(%[1]s completion bash)" + + # Load in future sessions (Linux) + echo 'eval "$(%[1]s completion bash)"' >> ~/.bashrc + + # Load in future sessions (macOS) + echo 'eval "$(%[1]s completion bash)"' >> ~/.bash_profile + +Zsh: + + # Load in current session + autoload -Uz compinit && compinit + source <(%[1]s completion zsh) + + # Load in future sessions (Linux, macOS) + echo 'autoload -Uz compinit && compinit' >> ~/.zshrc + echo 'source <(%[1]s completion zsh)' >> ~/.zshrc + +Fish: -const completionEffectNote = "Persisted completion takes effect in your next shell session." + # Load in current session + %[1]s completion fish | source + + # Load in future sessions (Linux, macOS) + %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish + +PowerShell: + + # Load in current session + %[1]s completion powershell | Out-String | Invoke-Expression + + # Load in future sessions (Windows, Linux, macOS) + if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } + %[1]s completion powershell | Out-File -Append -Encoding utf8 $PROFILE` + +// completionShellTitles spells the shells the way their projects do, for the +// per-shell help and command list. +var completionShellTitles = map[string]string{ + "bash": "Bash", + "zsh": "Zsh", + "fish": "Fish", + "powershell": "PowerShell", +} // documentCompletionCommands replaces Cobra's autogenerated help on the -// `completion` command and its per-shell subcommands with completionShells. -// Cobra's own text recommends process substitution for bash (a silent no-op on -// macOS bash 3.2) and offers no persist path for PowerShell. -// -// The parent carries every shell because the first-start tip points at bare -// `lstk completion` (PR #495 review): the command has to answer the question on -// its own, with no URL to follow. +// `completion` command and its per-shell subcommands. Cobra's own text +// recommends process substitution for bash — a silent no-op on macOS bash 3.2 +// (DEVX-950) — and offers no persist path for PowerShell. func documentCompletionCommands(completionCmd *cobra.Command) { name := completionCmd.Root().Name() - // Bodies carry no shell title — the parent adds one to separate the shells, - // while a subcommand's own help already names the shell in its first line. - bodies := make([]string, 0, len(completionShells)) - titled := make([]string, 0, len(completionShells)) - for _, sh := range completionShells { - body := sh.setup - if sh.note != "" { - body += "\n\n" + sh.note - } - bodies = append(bodies, fmt.Sprintf(body, name)) - titled = append(titled, sh.title+":\n\n"+bodies[len(bodies)-1]) - } - completionCmd.Short = "Set up tab completion for your shell" - completionCmd.Long = strings.Join(append([]string{ - "Generate a tab-completion script for your shell.", - `Run the "Load in current session" command to try completion out, then the "Persist" commands to keep it for new sessions. Homebrew installs configure completion automatically and need none of this.`, - }, append(titled, completionEffectNote)...), "\n\n") + completionCmd.Long = fmt.Sprintf(completionSetup, name) for _, sub := range completionCmd.Commands() { - for i, sh := range completionShells { - if sub.Name() != sh.name { - continue - } - sub.Short = fmt.Sprintf("Set up tab completion for %s", sh.title) - sub.Long = fmt.Sprintf("Generate the tab-completion script for %s.", sh.title) + - "\n\n" + bodies[i] + "\n\n" + completionEffectNote + title, ok := completionShellTitles[sub.Name()] + if !ok { + continue } + sub.Short = fmt.Sprintf("Generate the tab-completion script for %s", title) + sub.Long = fmt.Sprintf("Generate the tab-completion script for %s.\n\nRun '%s completion --help' for setup instructions.", title, name) } } diff --git a/cmd/completion_test.go b/cmd/completion_test.go index a566d083..925843c7 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -1,6 +1,7 @@ package cmd import ( + "strings" "testing" ) @@ -32,23 +33,35 @@ func TestCompletionBashNoDescriptionsFlagStillHonored(t *testing.T) { assertContains(t, out, "__completeNoDesc") } -// completionShellHelp is what a user must be able to copy straight out of the -// help for each shell. Asserted on the indented command lines only: wrapText -// reflows unindented prose to the terminal width, so prose is not stable text. +// The first-start tip says only `lstk completion` (PR #495 review), so that one +// command has to answer the whole question — these are the commands a user must +// be able to copy straight out of it. Asserted on the indented command lines +// only: wrapText reflows unindented prose to the terminal width. var completionShellHelp = []struct { - shell string - title string - load string - persist string + shell string + title string + lines []string }{ - {"bash", "Bash:", `eval "$(lstk completion bash)"`, "~/.local/share/bash-completion/completions/lstk"}, - {"zsh", "Zsh:", "source <(lstk completion zsh)", `lstk completion zsh > "${fpath[1]}/_lstk"`}, - {"fish", "Fish:", "lstk completion fish | source", "~/.config/fish/completions/lstk.fish"}, - {"powershell", "PowerShell:", "lstk completion powershell | Out-String | Invoke-Expression", "$PROFILE"}, + {"bash", "Bash:", []string{ + `eval "$(lstk completion bash)"`, + `echo 'eval "$(lstk completion bash)"' >> ~/.bashrc`, + `echo 'eval "$(lstk completion bash)"' >> ~/.bash_profile`, + }}, + {"zsh", "Zsh:", []string{ + "source <(lstk completion zsh)", + "echo 'autoload -Uz compinit && compinit' >> ~/.zshrc", + "echo 'source <(lstk completion zsh)' >> ~/.zshrc", + }}, + {"fish", "Fish:", []string{ + "lstk completion fish | source", + "lstk completion fish > ~/.config/fish/completions/lstk.fish", + }}, + {"powershell", "PowerShell:", []string{ + "lstk completion powershell | Out-String | Invoke-Expression", + "lstk completion powershell | Out-File -Append -Encoding utf8 $PROFILE", + }}, } -// The tip on first start now says only `lstk completion` (PR #495 review), so -// that command has to carry the setup instructions the tip used to link to. func TestCompletionHelpDocumentsEveryShell(t *testing.T) { out, err := executeWithArgs(t, "completion") if err != nil { @@ -57,55 +70,41 @@ func TestCompletionHelpDocumentsEveryShell(t *testing.T) { for _, tc := range completionShellHelp { assertContains(t, out, tc.title) - assertContains(t, out, tc.load) - assertContains(t, out, tc.persist) - } -} - -func TestCompletionHelpOffersLoadAndPersistPerOS(t *testing.T) { - out, err := executeWithArgs(t, "completion") - if err != nil { - t.Fatalf("expected no error, got %v", err) + for _, line := range tc.lines { + assertContains(t, out, line) + } } - assertContains(t, out, "# Load in current session") - assertContains(t, out, "# Persist (Linux") - assertContains(t, out, "# Persist (macOS") - assertContains(t, out, "# Persist (Windows") -} - -// Process substitution is a silent no-op on stock macOS bash 3.2, so the help -// must never recommend it for bash (DEVX-950). -func TestCompletionHelpNeverRecommendsSourceForBash(t *testing.T) { - out, err := executeWithArgs(t, "completion") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } + assertContains(t, out, "# Load in future sessions (Linux)") + assertContains(t, out, "# Load in future sessions (macOS)") + // Process substitution is a silent no-op on stock macOS bash 3.2, and the + // eval form needs no bash-completion package at all — that is what DEVX-950's + // bundled fallback bought. assertNotContains(t, out, "source <(lstk completion bash)") -} + assertNotContains(t, out, "bash_completion.d") -// Homebrew wires completion up on its own (homebrew_casks.completions), so its -// paths are noise in instructions aimed at everyone else. -func TestCompletionHelpOmitsHomebrewSetup(t *testing.T) { - out, err := executeWithArgs(t, "completion") - if err != nil { - t.Fatalf("expected no error, got %v", err) + // Cobra's zsh script calls compdef on line 2, which does not exist until + // compinit has run: sourcing it first fails with "compdef: command not found" + // and registers nothing. + zsh := out[strings.Index(out, "Zsh:"):strings.Index(out, "Fish:")] + for _, recipe := range strings.Split(zsh, "\n\n") { + if strings.Contains(recipe, "completion zsh") && !strings.Contains(recipe, "compinit") { + t.Fatalf("zsh recipe loads the script without compinit:\n%s", recipe) + } } - - assertNotContains(t, out, "brew --prefix") } -// Each shell's own help and the parent's must show the same instructions, so a -// user gets one answer whichever they run. -func TestCompletionShellHelpMatchesParentHelp(t *testing.T) { +// Per-shell help forwards rather than repeating the instructions, so there is +// one copy to read and one to maintain. +func TestCompletionShellHelpForwardsToParent(t *testing.T) { for _, tc := range completionShellHelp { out, err := executeWithArgs(t, "completion", tc.shell, "--help") if err != nil { t.Fatalf("completion %s --help: expected no error, got %v", tc.shell, err) } - assertContains(t, out, tc.load) - assertContains(t, out, tc.persist) + assertContains(t, out, "lstk completion --help") + assertNotContains(t, out, "# Load in current session") } } From c3b6862003ab4fa4d3d25ec775007dee41dac331 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Thu, 10 Sep 2026 20:56:13 +0200 Subject: [PATCH 4/8] Update help snapshots and reword the Homebrew note Co-Authored-By: Claude --- cmd/completion.go | 2 +- .../__snapshots__/exit_code_test.snap | 52 ++++++++++++++++--- .../__snapshots__/extension_test.snap | 6 +-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/cmd/completion.go b/cmd/completion.go index 0666d3aa..bbb62294 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -142,7 +142,7 @@ func selfContainBashCompletion(completionCmd *cobra.Command) { // %[1]s is the binary name. Command lines are indented so wrapText // (cmd/help.go) leaves them intact — it reflows unindented prose to the // terminal width, which would break a command mid-word on a narrow one. -const completionSetup = `Generate a tab-completion script for your shell. Homebrew installs of %[1]s set this up already. +const completionSetup = `Generate a tab-completion script for your shell. If you installed via Homebrew, completions are set up automatically. To load completions temporarily or permanently: diff --git a/test/integration/__snapshots__/exit_code_test.snap b/test/integration/__snapshots__/exit_code_test.snap index 9dc0ecd7..17b68b57 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -2,16 +2,56 @@ Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites this file. [TestBareParentCommandExitsZero_completion_1] -Generate the autocompletion script for lstk for the specified shell. -See each sub-command's help for details on how to use the generated script. +Generate a tab-completion script for your shell. If you installed via Homebrew, +completions are set up automatically. + +To load completions temporarily or permanently: + +Bash: + + # Load in current session + eval "$(lstk completion bash)" + + # Load in future sessions (Linux) + echo 'eval "$(lstk completion bash)"' >> ~/.bashrc + + # Load in future sessions (macOS) + echo 'eval "$(lstk completion bash)"' >> ~/.bash_profile + +Zsh: + + # Load in current session + autoload -Uz compinit && compinit + source <(lstk completion zsh) + + # Load in future sessions (Linux, macOS) + echo 'autoload -Uz compinit && compinit' >> ~/.zshrc + echo 'source <(lstk completion zsh)' >> ~/.zshrc + +Fish: + + # Load in current session + lstk completion fish | source + + # Load in future sessions (Linux, macOS) + lstk completion fish > ~/.config/fish/completions/lstk.fish + +PowerShell: + + # Load in current session + lstk completion powershell | Out-String | Invoke-Expression + + # Load in future sessions (Windows, Linux, macOS) + if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } + lstk completion powershell | Out-File -Append -Encoding utf8 $PROFILE Usage: lstk completion [flags] Commands: - bash Generate the autocompletion script for bash - fish Generate the autocompletion script for fish - powershell Generate the autocompletion script for powershell - zsh Generate the autocompletion script for zsh + bash Generate the tab-completion script for Bash + fish Generate the tab-completion script for Fish + powershell Generate the tab-completion script for PowerShell + zsh Generate the tab-completion script for Zsh Options: -h, --help help for completion diff --git a/test/integration/__snapshots__/extension_test.snap b/test/integration/__snapshots__/extension_test.snap index 80efdd3b..58ea2cc2 100644 --- a/test/integration/__snapshots__/extension_test.snap +++ b/test/integration/__snapshots__/extension_test.snap @@ -7,7 +7,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator @@ -67,7 +67,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator @@ -116,7 +116,7 @@ Usage: lstk [options] [command] LSTK - LocalStack command-line interface Commands: - completion Generate the autocompletion script for the specified shell + completion Set up tab completion for your shell config Manage configuration help Help about any command load Load a snapshot into the running emulator From 8f027871f596b6d49f43d7616fe71f851bbfe29d Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Thu, 10 Sep 2026 21:04:50 +0200 Subject: [PATCH 5/8] Say "new sessions" rather than "future sessions" in completion help Co-Authored-By: Claude --- cmd/completion.go | 10 +++++----- cmd/completion_test.go | 4 ++-- test/integration/__snapshots__/exit_code_test.snap | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/completion.go b/cmd/completion.go index bbb62294..261f0df7 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -151,10 +151,10 @@ Bash: # Load in current session eval "$(%[1]s completion bash)" - # Load in future sessions (Linux) + # Load in new sessions (Linux) echo 'eval "$(%[1]s completion bash)"' >> ~/.bashrc - # Load in future sessions (macOS) + # Load in new sessions (macOS) echo 'eval "$(%[1]s completion bash)"' >> ~/.bash_profile Zsh: @@ -163,7 +163,7 @@ Zsh: autoload -Uz compinit && compinit source <(%[1]s completion zsh) - # Load in future sessions (Linux, macOS) + # Load in new sessions (Linux, macOS) echo 'autoload -Uz compinit && compinit' >> ~/.zshrc echo 'source <(%[1]s completion zsh)' >> ~/.zshrc @@ -172,7 +172,7 @@ Fish: # Load in current session %[1]s completion fish | source - # Load in future sessions (Linux, macOS) + # Load in new sessions (Linux, macOS) %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish PowerShell: @@ -180,7 +180,7 @@ PowerShell: # Load in current session %[1]s completion powershell | Out-String | Invoke-Expression - # Load in future sessions (Windows, Linux, macOS) + # Load in new sessions (Windows, Linux, macOS) if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } %[1]s completion powershell | Out-File -Append -Encoding utf8 $PROFILE` diff --git a/cmd/completion_test.go b/cmd/completion_test.go index 925843c7..ae2035ca 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -75,8 +75,8 @@ func TestCompletionHelpDocumentsEveryShell(t *testing.T) { } } assertContains(t, out, "# Load in current session") - assertContains(t, out, "# Load in future sessions (Linux)") - assertContains(t, out, "# Load in future sessions (macOS)") + assertContains(t, out, "# Load in new sessions (Linux)") + assertContains(t, out, "# Load in new sessions (macOS)") // Process substitution is a silent no-op on stock macOS bash 3.2, and the // eval form needs no bash-completion package at all — that is what DEVX-950's diff --git a/test/integration/__snapshots__/exit_code_test.snap b/test/integration/__snapshots__/exit_code_test.snap index 17b68b57..17e82377 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -12,10 +12,10 @@ Bash: # Load in current session eval "$(lstk completion bash)" - # Load in future sessions (Linux) + # Load in new sessions (Linux) echo 'eval "$(lstk completion bash)"' >> ~/.bashrc - # Load in future sessions (macOS) + # Load in new sessions (macOS) echo 'eval "$(lstk completion bash)"' >> ~/.bash_profile Zsh: @@ -24,7 +24,7 @@ Zsh: autoload -Uz compinit && compinit source <(lstk completion zsh) - # Load in future sessions (Linux, macOS) + # Load in new sessions (Linux, macOS) echo 'autoload -Uz compinit && compinit' >> ~/.zshrc echo 'source <(lstk completion zsh)' >> ~/.zshrc @@ -33,7 +33,7 @@ Fish: # Load in current session lstk completion fish | source - # Load in future sessions (Linux, macOS) + # Load in new sessions (Linux, macOS) lstk completion fish > ~/.config/fish/completions/lstk.fish PowerShell: @@ -41,7 +41,7 @@ PowerShell: # Load in current session lstk completion powershell | Out-String | Invoke-Expression - # Load in future sessions (Windows, Linux, macOS) + # Load in new sessions (Windows, Linux, macOS) if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } lstk completion powershell | Out-File -Append -Encoding utf8 $PROFILE From e59aa5276defb0e543c778439860b8cfa125be25 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 11 Sep 2026 16:23:32 +0200 Subject: [PATCH 6/8] Simplify completion setup message Co-authored-by: George Tsiolis --- cmd/completion.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/completion.go b/cmd/completion.go index 261f0df7..c622299c 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -142,7 +142,7 @@ func selfContainBashCompletion(completionCmd *cobra.Command) { // %[1]s is the binary name. Command lines are indented so wrapText // (cmd/help.go) leaves them intact — it reflows unindented prose to the // terminal width, which would break a command mid-word on a narrow one. -const completionSetup = `Generate a tab-completion script for your shell. If you installed via Homebrew, completions are set up automatically. +const completionSetup = `Generate shell completion scripts for lstk. To load completions temporarily or permanently: From 813c8a2e420b42c426afdd4678b599c1c3b39e1a Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 11 Sep 2026 16:23:58 +0200 Subject: [PATCH 7/8] Shorten load completion title Co-authored-by: George Tsiolis --- cmd/completion.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/completion.go b/cmd/completion.go index c622299c..a1ff4c86 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -144,7 +144,7 @@ func selfContainBashCompletion(completionCmd *cobra.Command) { // terminal width, which would break a command mid-word on a narrow one. const completionSetup = `Generate shell completion scripts for lstk. -To load completions temporarily or permanently: +To load completions: Bash: From 0a32fc78b9b24ca76fe1a91a6e536b24eaf23d0a Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Fri, 11 Sep 2026 16:26:51 +0200 Subject: [PATCH 8/8] Update completion help snapshot for the reworded setup message Co-Authored-By: Claude Opus 5 --- test/integration/__snapshots__/exit_code_test.snap | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/integration/__snapshots__/exit_code_test.snap b/test/integration/__snapshots__/exit_code_test.snap index 17e82377..85bb0e62 100644 --- a/test/integration/__snapshots__/exit_code_test.snap +++ b/test/integration/__snapshots__/exit_code_test.snap @@ -2,10 +2,9 @@ Snapshots created by internal/snap. UPDATE_SNAPS=true go test rewrites this file. [TestBareParentCommandExitsZero_completion_1] -Generate a tab-completion script for your shell. If you installed via Homebrew, -completions are set up automatically. +Generate shell completion scripts for lstk. -To load completions temporarily or permanently: +To load completions: Bash: