Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# lets

@README.md

## Tools

Use `lets` task runner for all build/test/lint operations instead of raw commands. Run `lets build` first if binary is missing.

```bash
lets build [bin] # build CLI with version metadata
lets build-and-install # build and install lets-dev locally
lets test # full suite: unit + bats + completions
lets test-unit # Go unit tests only
lets test-bats [test] # Docker-based Bats integration tests
lets lint # golangci-lint via Docker
lets fmt # go fmt ./...
lets coverage [--html] # coverage report
lets run-docs # local docs dev server (docs/)
lets publish-docs # deploy docs site
```

`lets test-unit`, `lets test-bats`, and `lets lint` require Docker. Use `go test ./...` locally for quick iteration without Docker.

## Agent Behavior

- **Proactive execution** — Don't ask "Can I proceed?" for implementation. DO ask before changing success criteria, test thresholds, or what "working" means.
- **Test early, test real** — Don't accumulate 10 changes then debug. After each logical step: does it work? With realistic input, not just edge case that triggered the work.
- **Pushback** — Propose alternatives before implementing suboptimal approaches. Ask about design choices.
- **Unify, don't duplicate** — Merge nearly-identical structs/functions rather than adding variants.
- **No over-engineering** — Minimum complexity for current task. No speculative abstractions.
- **Terseness** — Comments for surprising/hairy logic only. Be extremely concise in communication.

## Package Structure

- `main.go` — entry point, flag parsing, signal handling
- `cmd/` — Cobra commands (root, subcommands, completion, LSP, self-update)
- `config/` — config file discovery, loading, validation; `config/config/` defines Config/Command/Mixin structs and YAML unmarshaling
- `executor/` — command execution, dependency resolution, env setup, checksum verification
- `env/` — debug level state (`LETS_DEBUG`, levels 0-2)
- `logging/` — logrus-based logging with command chain formatting
- `lsp/` — Language Server Protocol: definition lookup, completion for depends, tree-sitter YAML parsing; `lets lsp` runs stdio-based server for IDE integration
- `checksum/` — SHA1 file checksumming with glob patterns
- `docopt/` — docopt argument parsing, produces `LETSOPT_*` and `LETSCLI_*` env vars
- `upgrade/` — binary self-update from GitHub releases
- `util/` — file/dir/version helpers
- `workdir/` — `--init` scaffolding
- `set/` — generic Set data structure
- `test/` — test utilities (temp files, args helpers)

## Key lets.yaml Fields

- Top-level: `shell`, `env`, `eval_env`, `before`, `init`, `mixins`, `commands`
- Command: `cmd`, `description`, `depends`, `env`, `options` (docopt), `work_dir`, `after`, `checksum`, `persist_checksum`, `ref`, `args`, `shell`

## Project Rules

- Follow `gofmt` exactly; tabs for indentation, ~120 char lines
- Unit tests as `*_test.go` next to source; Bats tests in `tests/*.bats`
- Fixtures in matching `tests/<scenario>/` folder, use `lets.yaml` unless variant needed
- Bats tests use `run` + `assert_success`/`assert_line` pattern
- Run at least `go test ./...` before considering work complete; `lets test-bats` for CLI-path changes
- Commits: short imperative subjects (`Add ...`, `Fix ...`, `Use ...`), explain non-obvious context in body
- **Changelog workflow**: add entries to the `Unreleased` section in `docs/docs/changelog.md` with each commit/PR. At release time, rename `Unreleased` to the new tag version
- Do not commit `lets.my.yaml`, generated binaries, `.lets/`, `coverage.out`, or `node_modules`
- CLI flags: kebab-case only (`--dry-run` not `--dry_run`)
- No "Generated by <agent>" in commits
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
48 changes: 47 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,58 @@ import (
"github.com/spf13/cobra"
)

type unknownCommandError struct {
message string
}

func (e *unknownCommandError) Error() string {
return e.message
}

func (e *unknownCommandError) ExitCode() int {
return 2
}

func buildUnknownCommandMessage(cmd *cobra.Command, arg string) string {
message := fmt.Sprintf("unknown command %q for %q", arg, cmd.CommandPath())

if cmd.DisableSuggestions {
return message
}

if cmd.SuggestionsMinimumDistance <= 0 {
cmd.SuggestionsMinimumDistance = 2
}

suggestions := cmd.SuggestionsFor(arg)
if len(suggestions) == 0 {
return message
}

message += "\n\nDid you mean this?\n"
for _, suggestion := range suggestions {
message += fmt.Sprintf("\t%s\n", suggestion)
}

return message
}

func validateCommandArgs(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}

return &unknownCommandError{
message: buildUnknownCommandMessage(cmd, args[0]),
}
}

// newRootCmd represents the base command when called without any subcommands.
func newRootCmd(version string) *cobra.Command {
cmd := &cobra.Command{
Use: "lets",
Short: "A CLI task runner",
Args: cobra.ArbitraryArgs,
Args: validateCommandArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return PrintHelpMessage(cmd)
},
Expand Down
128 changes: 128 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"bytes"
"errors"
"strings"
"testing"

"github.com/lets-cli/lets/config/config"
Expand Down Expand Up @@ -78,4 +80,130 @@ func TestRootCmdWithConfig(t *testing.T) {
)
}
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for self subcommands with suggestions explicitly disabled

Since buildUnknownCommandMessage has a branch for cmd.DisableSuggestions == true, please add a subtest under TestSelfCmd that constructs a self command with DisableSuggestions = true, runs an unknown subcommand, asserts exit code 2, and verifies the error message does not include the suggestion header (Did you mean this?). This will confirm DisableSuggestions works correctly for self subcommands.


t.Run("should return exit code 2 for unknown command", func(t *testing.T) {
rootCmd, _ := newTestRootCmdWithConfig([]string{"fo"})

err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}

var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}

if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}

if !strings.Contains(err.Error(), `unknown command "fo"`) {
t.Fatalf("expected unknown command error, got %q", err.Error())
}

if !strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected suggestions in error, got %q", err.Error())
}

if !strings.Contains(err.Error(), "\tfoo\n") {
t.Fatalf("expected foo suggestion, got %q", err.Error())
}
})

t.Run("should return exit code 2 for unknown command with no suggestions", func(t *testing.T) {
rootCmd, _ := newTestRootCmdWithConfig([]string{"zzzznotacommand"})

err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}

var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}

if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}

if !strings.Contains(err.Error(), `unknown command "zzzznotacommand"`) {
t.Fatalf("expected unknown command error, got %q", err.Error())
}

if strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected no suggestions, got %q", err.Error())
}
})
}
Comment on lines +84 to +139
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for unknown commands that do not produce any suggestions

This subtest only covers the case where an unknown command yields suggestions (e.g., foo). The new code has a separate branch for when SuggestionsFor returns no results, where the "Did you mean this?" section should be omitted. Please add another subtest using an argument that won’t match any command (e.g. zzzz_unknown_command) to assert that:

  • the exit code remains 2, and
  • the error message includes the unknown command text but omits Did you mean this?.

That will exercise the len(suggestions) == 0 path in buildUnknownCommandMessage and guard against regressions.

Suggested change
t.Run("should return exit code 2 for unknown command", func(t *testing.T) {
rootCmd, _ := newTestRootCmdWithConfig([]string{"fo"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}
var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if !strings.Contains(err.Error(), `unknown command "fo"`) {
t.Fatalf("expected unknown command error, got %q", err.Error())
}
if !strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected suggestions in error, got %q", err.Error())
}
if !strings.Contains(err.Error(), "\tfoo\n") {
t.Fatalf("expected foo suggestion, got %q", err.Error())
}
})
}
t.Run("should return exit code 2 for unknown command", func(t *testing.T) {
rootCmd, _ := newTestRootCmdWithConfig([]string{"fo"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}
var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if !strings.Contains(err.Error(), `unknown command "fo"`) {
t.Fatalf("expected unknown command error, got %q", err.Error())
}
if !strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected suggestions in error, got %q", err.Error())
}
if !strings.Contains(err.Error(), "\tfoo\n") {
t.Fatalf("expected foo suggestion, got %q", err.Error())
}
})
t.Run("should return exit code 2 for unknown command without suggestions", func(t *testing.T) {
rootCmd, _ := newTestRootCmdWithConfig([]string{"zzzz_unknown_command"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}
var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if !strings.Contains(err.Error(), `unknown command "zzzz_unknown_command"`) {
t.Fatalf("expected unknown command error, got %q", err.Error())
}
if strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("did not expect suggestions in error, got %q", err.Error())
}
})
}


func TestSelfCmd(t *testing.T) {
t.Run("should return exit code 2 for unknown self subcommand", func(t *testing.T) {
bufOut := new(bytes.Buffer)

rootCmd := CreateRootCommand("v0.0.0-test")
rootCmd.SetArgs([]string{"self", "ls"})
rootCmd.SetOut(bufOut)
rootCmd.SetErr(bufOut)
InitSelfCmd(rootCmd, "v0.0.0-test")

err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}

var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}

if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}

if !strings.Contains(err.Error(), `unknown command "ls" for "lets self"`) {
t.Fatalf("expected unknown self subcommand error, got %q", err.Error())
}

if !strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected suggestions in error, got %q", err.Error())
}

if !strings.Contains(err.Error(), "\tlsp\n") {
t.Fatalf("expected lsp suggestion, got %q", err.Error())
}
})

t.Run("should return exit code 2 for unknown self subcommand with no suggestions", func(t *testing.T) {
bufOut := new(bytes.Buffer)

rootCmd := CreateRootCommand("v0.0.0-test")
rootCmd.SetArgs([]string{"self", "zzzznotacommand"})
rootCmd.SetOut(bufOut)
rootCmd.SetErr(bufOut)
InitSelfCmd(rootCmd, "v0.0.0-test")

err := rootCmd.Execute()
if err == nil {
t.Fatal("expected unknown command error")
}

var exitCoder interface{ ExitCode() int }
if !errors.As(err, &exitCoder) {
t.Fatal("expected error with exit code")
}

if exitCode := exitCoder.ExitCode(); exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}

if !strings.Contains(err.Error(), `unknown command "zzzznotacommand" for "lets self"`) {
t.Fatalf("expected unknown self subcommand error, got %q", err.Error())
}

if strings.Contains(err.Error(), "Did you mean this?") {
t.Fatalf("expected no suggestions, got %q", err.Error())
}
})
}
1 change: 1 addition & 0 deletions cmd/self.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func InitSelfCmd(rootCmd *cobra.Command, version string) {
Hidden: false,
Short: "Manage lets CLI itself",
GroupID: "internal",
Args: validateCommandArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return PrintHelpMessage(cmd)
},
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ title: Changelog

## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X)

* `[Added]` Show similar command suggestions on typos.
* `[Changed]` Exit code 2 on unknown command.

## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)

* `[Fixed]` Fixed indentation issues for long commands in help output. Command names are now properly padded for consistent alignment.
Expand Down
21 changes: 11 additions & 10 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/lets-cli/lets/cmd"
"github.com/lets-cli/lets/config"
"github.com/lets-cli/lets/env"
"github.com/lets-cli/lets/executor"
"github.com/lets-cli/lets/logging"
"github.com/lets-cli/lets/set"
"github.com/lets-cli/lets/upgrade"
Expand Down Expand Up @@ -40,7 +39,7 @@ func main() {
command, args, err := rootCmd.Traverse(os.Args[1:])
if err != nil {
log.Errorf("lets: traverse commands error: %s", err)
os.Exit(1)
os.Exit(getExitCode(err, 1))
}

rootFlags, err := parseRootFlags(args)
Expand Down Expand Up @@ -120,14 +119,7 @@ func main() {

if err := rootCmd.ExecuteContext(ctx); err != nil {
log.Error(err.Error())

exitCode := 1
var execErr *executor.ExecuteError
if errors.As(err, &execErr) {
exitCode = execErr.ExitCode()
}

os.Exit(exitCode)
os.Exit(getExitCode(err, 1))
}
}

Expand All @@ -152,6 +144,15 @@ func getContext() context.Context {
return ctx
}

func getExitCode(err error, defaultCode int) int {
var exitCoder interface{ ExitCode() int }
if errors.As(err, &exitCoder) {
return exitCoder.ExitCode()
}

return defaultCode
}

// do not fail on config error in it is help (-h, --help) or --init or completion command.
func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool {
rootCommands := set.NewSet("completion", "help", "lsp")
Expand Down
45 changes: 45 additions & 0 deletions tests/command_not_found.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
setup() {
load "${BATS_UTILS_PATH}/bats-support/load.bash"
load "${BATS_UTILS_PATH}/bats-assert/load.bash"
cd ./tests/command_not_found
}

@test "command_not_found: exit code is 2 when command does not exist" {
run lets no_such_command
assert_failure 2
}

@test "command_not_found: exit code is 2 when self subcommand does not exist" {
run lets self no_such_command
assert_failure 2
}

@test "command_not_found: suggest root command for close typo" {
run lets slef
assert_failure 2
assert_output --partial 'unknown command "slef" for "lets"'
assert_output --partial 'Did you mean this?'
assert_output --partial 'self'
}

@test "command_not_found: suggest self subcommand for close typo" {
run lets self ls
assert_failure 2
assert_output --partial 'unknown command "ls" for "lets self"'
assert_output --partial 'Did you mean this?'
assert_output --partial 'lsp'
}

@test "command_not_found: no suggestions for completely unrelated command" {
run lets zzzznotacommand
assert_failure 2
assert_output --partial 'unknown command "zzzznotacommand" for "lets"'
refute_output --partial 'Did you mean this?'
}

@test "command_not_found: no suggestions for completely unrelated self subcommand" {
run lets self zzzznotacommand
assert_failure 2
assert_output --partial 'unknown command "zzzznotacommand" for "lets self"'
refute_output --partial 'Did you mean this?'
}
5 changes: 5 additions & 0 deletions tests/command_not_found/lets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
shell: bash

commands:
hello:
cmd: echo Hello