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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`:
- Never print directly to stdout/stderr (e.g., `fmt.Fprintf(os.Stderr, …)`). For user-facing output, emit events through `output.Sink`. For internal diagnostics, use `log.Logger`. If neither is available (e.g., during logger setup), return errors to the caller and let them decide.
- Do not call `config.Get()` from domain/business-logic packages. Instead, extract the values you need at the command boundary (`cmd/`) and pass them as explicit function arguments. This keeps domain functions testable without requiring Viper/config initialization.

# Shell Completion

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.

# CLI Help Text

- Write command `Short`/`Long` as unbroken paragraphs (one line each, blank line between); never hard-wrap a sentence in source. `wrapText` in `cmd/help.go` re-wraps to the terminal width at render time and `lstk docs` reads the raw text, so manual breaks fight both. Indented lines (examples, aligned output) are left as-is.
Expand Down
157 changes: 157 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cmd

import (
"fmt"
"io"

"github.com/spf13/cobra"
)

// bashCompletionFallback is prepended to Cobra's generated bash completion
// script. Both init paths of that script end up calling
// _get_comp_words_by_ref from the bash-completion package, but stock macOS
// ships bash 3.2 with no bash-completion at all, so every Tab press failed
// with "_get_comp_words_by_ref: command not found" (DEVX-950). Mirroring the
// approach git-completion.bash ships, this defines a minimal replacement only
// when the package is absent; with bash-completion (v1 or v2) installed the
// guard skips it. The body must stay bash 3.2 compatible.
//
// The fallback covers only _get_comp_words_by_ref — the paths lstk reaches
// today. Cobra's script still calls bash-completion's _filedir (undefined
// without the package) for ShellCompDirectiveFilterFileExt (MarkFlagFilename
// with extensions), ShellCompDirectiveFilterDirs (MarkFlagDirname), and the
// ActiveHelp second-Tab path, so adopting any of those requires growing the
// fallback (or avoiding the directive).
const bashCompletionFallback = `# lstk: self-contained fallback for the bash-completion package.
# The generated script below calls _get_comp_words_by_ref, which stock macOS
# (bash 3.2, no bash-completion package) does not provide. Define a minimal
# replacement when absent so completion works without extra packages.
if ! declare -F _get_comp_words_by_ref >/dev/null 2>&1; then
_get_comp_words_by_ref() {
local exclude="" i w issep attach="" preblank j=-1
local line="$COMP_LINE"
local -a rebuilt=()
local rcword="$COMP_CWORD"
if [[ "$1" == "-n" ]]; then
exclude="$2"
shift 2
fi

# readline splits the command line at every character in
# COMP_WORDBREAKS ('=' and ':' included), so '--flag=value' arrives in
# COMP_WORDS as the three words '--flag', '=', 'value'. Rebuild the
# word list, re-joining pieces around the separators listed in
# $exclude, and track where the word under the cursor lands. Pieces
# re-join only when typed with no whitespace between them; COMP_WORDS
# is identical for '--flag=x' and '--flag = x', so adjacency has to be
# recovered from COMP_LINE.
for ((i = 0; i < ${#COMP_WORDS[@]}; i++)); do
w="${COMP_WORDS[$i]}"
preblank=""
if [[ "$line" == [[:blank:]]* ]]; then
preblank=1
fi
line="${line#*"$w"}"
issep=""
if [[ -n "$w" && -n "$exclude" && -z "${w//[$exclude]/}" ]]; then
issep=1
fi
# A separator (or the piece right after one) attaches to the
# previous word when adjacent on the typed line, except directly
# after the command name.
if [[ $j -ge 1 && -z "$preblank" && ( -n "$issep" || -n "$attach" ) ]]; then
rebuilt[$j]="${rebuilt[$j]}$w"
else
j=$((j + 1))
rebuilt[$j]="$w"
fi
if [[ $i -eq $COMP_CWORD ]]; then
rcword=$j
fi
attach="$issep"
done

while [[ $# -gt 0 ]]; do
case "$1" in
cur)
cur="${rebuilt[$rcword]}"
;;
prev)
prev=""
if [[ $rcword -gt 0 ]]; then
prev="${rebuilt[$((rcword - 1))]}"
fi
;;
words)
words=("${rebuilt[@]}")
;;
cword)
cword="$rcword"
;;
esac
shift
done
return 0
}
fi

`

// selfContainBashCompletion replaces the autogenerated `completion bash`
// command's RunE so the emitted script carries bashCompletionFallback ahead
// of Cobra's output, removing the hard dependency on the bash-completion
// 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.
func selfContainBashCompletion(completionCmd *cobra.Command) {
var bashCmd *cobra.Command
for _, sub := range completionCmd.Commands() {
if sub.Name() == "bash" {
bashCmd = sub
break
}
}
if bashCmd == nil {
return
}

bashCmd.RunE = func(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
if _, err := io.WriteString(out, bashCompletionFallback); err != nil {
return err
}
// Cobra registers --no-descriptions unless CompletionOptions turned
// it off; an absent flag means descriptions stay enabled.
noDesc := false
if v, err := cmd.Flags().GetBool("no-descriptions"); err == nil {
noDesc = v
}
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:

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:

%[1]s completion bash > /etc/bash_completion.d/%[1]s

#### macOS (with the bash-completion Homebrew package):

%[1]s completion bash > $(brew --prefix)/etc/bash_completion.d/%[1]s

You will need to start a new shell for this setup to take effect.
`, name)
}
33 changes: 33 additions & 0 deletions cmd/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cmd

import (
"testing"
)

// TestCompletionBashWritesFallbackAndScriptToSameWriter guards the DEVX-950
// wiring: the fallback prelude and Cobra's generated script must both reach
// the writer configured at execution time. Cobra captures its output writer
// when InitDefaultCompletionCmd runs (before SetOut is called here), so a
// prepend-and-delegate wrapper would send the two halves to different
// destinations.
func TestCompletionBashWritesFallbackAndScriptToSameWriter(t *testing.T) {
out, err := executeWithArgs(t, "completion", "bash")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertContains(t, out, "_get_comp_words_by_ref()")
assertContains(t, out, "__start_lstk")
assertContains(t, out, "__complete")
}

// TestCompletionBashNoDescriptionsFlagStillHonored verifies the wrapped RunE
// keeps Cobra's --no-descriptions behavior: the generated script requests
// completions via __completeNoDesc instead of __complete.
func TestCompletionBashNoDescriptionsFlagStillHonored(t *testing.T) {
out, err := executeWithArgs(t, "completion", "bash", "--no-descriptions")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertContains(t, out, "_get_comp_words_by_ref()")
assertContains(t, out, "__completeNoDesc")
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
root.InitDefaultCompletionCmd()
if completionCmd, _, err := root.Find([]string{"completion"}); err == nil && completionCmd.Name() == "completion" {
requireSubcommand(completionCmd)
selfContainBashCompletion(completionCmd)
}

return root
Expand Down
Loading
Loading