Skip to content

Commit 7afb0bb

Browse files
authored
Merge pull request #288 from lets-cli/error-exit-code-on-command-not-found
error exit code on command not found
2 parents 4ee1706 + 6c06355 commit 7afb0bb

9 files changed

Lines changed: 307 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# lets
2+
3+
@README.md
4+
5+
## Tools
6+
7+
Use `lets` task runner for all build/test/lint operations instead of raw commands. Run `lets build` first if binary is missing.
8+
9+
```bash
10+
lets build [bin] # build CLI with version metadata
11+
lets build-and-install # build and install lets-dev locally
12+
lets test # full suite: unit + bats + completions
13+
lets test-unit # Go unit tests only
14+
lets test-bats [test] # Docker-based Bats integration tests
15+
lets lint # golangci-lint via Docker
16+
lets fmt # go fmt ./...
17+
lets coverage [--html] # coverage report
18+
lets run-docs # local docs dev server (docs/)
19+
lets publish-docs # deploy docs site
20+
```
21+
22+
`lets test-unit`, `lets test-bats`, and `lets lint` require Docker. Use `go test ./...` locally for quick iteration without Docker.
23+
24+
## Agent Behavior
25+
26+
- **Proactive execution** — Don't ask "Can I proceed?" for implementation. DO ask before changing success criteria, test thresholds, or what "working" means.
27+
- **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.
28+
- **Pushback** — Propose alternatives before implementing suboptimal approaches. Ask about design choices.
29+
- **Unify, don't duplicate** — Merge nearly-identical structs/functions rather than adding variants.
30+
- **No over-engineering** — Minimum complexity for current task. No speculative abstractions.
31+
- **Terseness** — Comments for surprising/hairy logic only. Be extremely concise in communication.
32+
33+
## Package Structure
34+
35+
- `main.go` — entry point, flag parsing, signal handling
36+
- `cmd/` — Cobra commands (root, subcommands, completion, LSP, self-update)
37+
- `config/` — config file discovery, loading, validation; `config/config/` defines Config/Command/Mixin structs and YAML unmarshaling
38+
- `executor/` — command execution, dependency resolution, env setup, checksum verification
39+
- `env/` — debug level state (`LETS_DEBUG`, levels 0-2)
40+
- `logging/` — logrus-based logging with command chain formatting
41+
- `lsp/` — Language Server Protocol: definition lookup, completion for depends, tree-sitter YAML parsing; `lets lsp` runs stdio-based server for IDE integration
42+
- `checksum/` — SHA1 file checksumming with glob patterns
43+
- `docopt/` — docopt argument parsing, produces `LETSOPT_*` and `LETSCLI_*` env vars
44+
- `upgrade/` — binary self-update from GitHub releases
45+
- `util/` — file/dir/version helpers
46+
- `workdir/``--init` scaffolding
47+
- `set/` — generic Set data structure
48+
- `test/` — test utilities (temp files, args helpers)
49+
50+
## Key lets.yaml Fields
51+
52+
- Top-level: `shell`, `env`, `eval_env`, `before`, `init`, `mixins`, `commands`
53+
- Command: `cmd`, `description`, `depends`, `env`, `options` (docopt), `work_dir`, `after`, `checksum`, `persist_checksum`, `ref`, `args`, `shell`
54+
55+
## Project Rules
56+
57+
- Follow `gofmt` exactly; tabs for indentation, ~120 char lines
58+
- Unit tests as `*_test.go` next to source; Bats tests in `tests/*.bats`
59+
- Fixtures in matching `tests/<scenario>/` folder, use `lets.yaml` unless variant needed
60+
- Bats tests use `run` + `assert_success`/`assert_line` pattern
61+
- Run at least `go test ./...` before considering work complete; `lets test-bats` for CLI-path changes
62+
- Commits: short imperative subjects (`Add ...`, `Fix ...`, `Use ...`), explain non-obvious context in body
63+
- **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
64+
- Do not commit `lets.my.yaml`, generated binaries, `.lets/`, `coverage.out`, or `node_modules`
65+
- CLI flags: kebab-case only (`--dry-run` not `--dry_run`)
66+
- No "Generated by <agent>" in commits

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

cmd/root.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,58 @@ import (
1111
"github.com/spf13/cobra"
1212
)
1313

14+
type unknownCommandError struct {
15+
message string
16+
}
17+
18+
func (e *unknownCommandError) Error() string {
19+
return e.message
20+
}
21+
22+
func (e *unknownCommandError) ExitCode() int {
23+
return 2
24+
}
25+
26+
func buildUnknownCommandMessage(cmd *cobra.Command, arg string) string {
27+
message := fmt.Sprintf("unknown command %q for %q", arg, cmd.CommandPath())
28+
29+
if cmd.DisableSuggestions {
30+
return message
31+
}
32+
33+
if cmd.SuggestionsMinimumDistance <= 0 {
34+
cmd.SuggestionsMinimumDistance = 2
35+
}
36+
37+
suggestions := cmd.SuggestionsFor(arg)
38+
if len(suggestions) == 0 {
39+
return message
40+
}
41+
42+
message += "\n\nDid you mean this?\n"
43+
for _, suggestion := range suggestions {
44+
message += fmt.Sprintf("\t%s\n", suggestion)
45+
}
46+
47+
return message
48+
}
49+
50+
func validateCommandArgs(cmd *cobra.Command, args []string) error {
51+
if len(args) == 0 {
52+
return nil
53+
}
54+
55+
return &unknownCommandError{
56+
message: buildUnknownCommandMessage(cmd, args[0]),
57+
}
58+
}
59+
1460
// newRootCmd represents the base command when called without any subcommands.
1561
func newRootCmd(version string) *cobra.Command {
1662
cmd := &cobra.Command{
1763
Use: "lets",
1864
Short: "A CLI task runner",
19-
Args: cobra.ArbitraryArgs,
65+
Args: validateCommandArgs,
2066
RunE: func(cmd *cobra.Command, args []string) error {
2167
return PrintHelpMessage(cmd)
2268
},

cmd/root_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cmd
22

33
import (
44
"bytes"
5+
"errors"
6+
"strings"
57
"testing"
68

79
"github.com/lets-cli/lets/config/config"
@@ -78,4 +80,130 @@ func TestRootCmdWithConfig(t *testing.T) {
7880
)
7981
}
8082
})
83+
84+
t.Run("should return exit code 2 for unknown command", func(t *testing.T) {
85+
rootCmd, _ := newTestRootCmdWithConfig([]string{"fo"})
86+
87+
err := rootCmd.Execute()
88+
if err == nil {
89+
t.Fatal("expected unknown command error")
90+
}
91+
92+
var exitCoder interface{ ExitCode() int }
93+
if !errors.As(err, &exitCoder) {
94+
t.Fatal("expected error with exit code")
95+
}
96+
97+
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
98+
t.Fatalf("expected exit code 2, got %d", exitCode)
99+
}
100+
101+
if !strings.Contains(err.Error(), `unknown command "fo"`) {
102+
t.Fatalf("expected unknown command error, got %q", err.Error())
103+
}
104+
105+
if !strings.Contains(err.Error(), "Did you mean this?") {
106+
t.Fatalf("expected suggestions in error, got %q", err.Error())
107+
}
108+
109+
if !strings.Contains(err.Error(), "\tfoo\n") {
110+
t.Fatalf("expected foo suggestion, got %q", err.Error())
111+
}
112+
})
113+
114+
t.Run("should return exit code 2 for unknown command with no suggestions", func(t *testing.T) {
115+
rootCmd, _ := newTestRootCmdWithConfig([]string{"zzzznotacommand"})
116+
117+
err := rootCmd.Execute()
118+
if err == nil {
119+
t.Fatal("expected unknown command error")
120+
}
121+
122+
var exitCoder interface{ ExitCode() int }
123+
if !errors.As(err, &exitCoder) {
124+
t.Fatal("expected error with exit code")
125+
}
126+
127+
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
128+
t.Fatalf("expected exit code 2, got %d", exitCode)
129+
}
130+
131+
if !strings.Contains(err.Error(), `unknown command "zzzznotacommand"`) {
132+
t.Fatalf("expected unknown command error, got %q", err.Error())
133+
}
134+
135+
if strings.Contains(err.Error(), "Did you mean this?") {
136+
t.Fatalf("expected no suggestions, got %q", err.Error())
137+
}
138+
})
139+
}
140+
141+
func TestSelfCmd(t *testing.T) {
142+
t.Run("should return exit code 2 for unknown self subcommand", func(t *testing.T) {
143+
bufOut := new(bytes.Buffer)
144+
145+
rootCmd := CreateRootCommand("v0.0.0-test")
146+
rootCmd.SetArgs([]string{"self", "ls"})
147+
rootCmd.SetOut(bufOut)
148+
rootCmd.SetErr(bufOut)
149+
InitSelfCmd(rootCmd, "v0.0.0-test")
150+
151+
err := rootCmd.Execute()
152+
if err == nil {
153+
t.Fatal("expected unknown command error")
154+
}
155+
156+
var exitCoder interface{ ExitCode() int }
157+
if !errors.As(err, &exitCoder) {
158+
t.Fatal("expected error with exit code")
159+
}
160+
161+
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
162+
t.Fatalf("expected exit code 2, got %d", exitCode)
163+
}
164+
165+
if !strings.Contains(err.Error(), `unknown command "ls" for "lets self"`) {
166+
t.Fatalf("expected unknown self subcommand error, got %q", err.Error())
167+
}
168+
169+
if !strings.Contains(err.Error(), "Did you mean this?") {
170+
t.Fatalf("expected suggestions in error, got %q", err.Error())
171+
}
172+
173+
if !strings.Contains(err.Error(), "\tlsp\n") {
174+
t.Fatalf("expected lsp suggestion, got %q", err.Error())
175+
}
176+
})
177+
178+
t.Run("should return exit code 2 for unknown self subcommand with no suggestions", func(t *testing.T) {
179+
bufOut := new(bytes.Buffer)
180+
181+
rootCmd := CreateRootCommand("v0.0.0-test")
182+
rootCmd.SetArgs([]string{"self", "zzzznotacommand"})
183+
rootCmd.SetOut(bufOut)
184+
rootCmd.SetErr(bufOut)
185+
InitSelfCmd(rootCmd, "v0.0.0-test")
186+
187+
err := rootCmd.Execute()
188+
if err == nil {
189+
t.Fatal("expected unknown command error")
190+
}
191+
192+
var exitCoder interface{ ExitCode() int }
193+
if !errors.As(err, &exitCoder) {
194+
t.Fatal("expected error with exit code")
195+
}
196+
197+
if exitCode := exitCoder.ExitCode(); exitCode != 2 {
198+
t.Fatalf("expected exit code 2, got %d", exitCode)
199+
}
200+
201+
if !strings.Contains(err.Error(), `unknown command "zzzznotacommand" for "lets self"`) {
202+
t.Fatalf("expected unknown self subcommand error, got %q", err.Error())
203+
}
204+
205+
if strings.Contains(err.Error(), "Did you mean this?") {
206+
t.Fatalf("expected no suggestions, got %q", err.Error())
207+
}
208+
})
81209
}

cmd/self.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ func InitSelfCmd(rootCmd *cobra.Command, version string) {
1111
Hidden: false,
1212
Short: "Manage lets CLI itself",
1313
GroupID: "internal",
14+
Args: validateCommandArgs,
1415
RunE: func(cmd *cobra.Command, args []string) error {
1516
return PrintHelpMessage(cmd)
1617
},

docs/docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ title: Changelog
55

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

8+
* `[Added]` Show similar command suggestions on typos.
9+
* `[Changed]` Exit code 2 on unknown command.
10+
811
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
912

1013
* `[Fixed]` Fixed indentation issues for long commands in help output. Command names are now properly padded for consistent alignment.

main.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/lets-cli/lets/cmd"
1212
"github.com/lets-cli/lets/config"
1313
"github.com/lets-cli/lets/env"
14-
"github.com/lets-cli/lets/executor"
1514
"github.com/lets-cli/lets/logging"
1615
"github.com/lets-cli/lets/set"
1716
"github.com/lets-cli/lets/upgrade"
@@ -40,7 +39,7 @@ func main() {
4039
command, args, err := rootCmd.Traverse(os.Args[1:])
4140
if err != nil {
4241
log.Errorf("lets: traverse commands error: %s", err)
43-
os.Exit(1)
42+
os.Exit(getExitCode(err, 1))
4443
}
4544

4645
rootFlags, err := parseRootFlags(args)
@@ -120,14 +119,7 @@ func main() {
120119

121120
if err := rootCmd.ExecuteContext(ctx); err != nil {
122121
log.Error(err.Error())
123-
124-
exitCode := 1
125-
var execErr *executor.ExecuteError
126-
if errors.As(err, &execErr) {
127-
exitCode = execErr.ExitCode()
128-
}
129-
130-
os.Exit(exitCode)
122+
os.Exit(getExitCode(err, 1))
131123
}
132124
}
133125

@@ -152,6 +144,15 @@ func getContext() context.Context {
152144
return ctx
153145
}
154146

147+
func getExitCode(err error, defaultCode int) int {
148+
var exitCoder interface{ ExitCode() int }
149+
if errors.As(err, &exitCoder) {
150+
return exitCoder.ExitCode()
151+
}
152+
153+
return defaultCode
154+
}
155+
155156
// do not fail on config error in it is help (-h, --help) or --init or completion command.
156157
func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool {
157158
rootCommands := set.NewSet("completion", "help", "lsp")

tests/command_not_found.bats

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
setup() {
2+
load "${BATS_UTILS_PATH}/bats-support/load.bash"
3+
load "${BATS_UTILS_PATH}/bats-assert/load.bash"
4+
cd ./tests/command_not_found
5+
}
6+
7+
@test "command_not_found: exit code is 2 when command does not exist" {
8+
run lets no_such_command
9+
assert_failure 2
10+
}
11+
12+
@test "command_not_found: exit code is 2 when self subcommand does not exist" {
13+
run lets self no_such_command
14+
assert_failure 2
15+
}
16+
17+
@test "command_not_found: suggest root command for close typo" {
18+
run lets slef
19+
assert_failure 2
20+
assert_output --partial 'unknown command "slef" for "lets"'
21+
assert_output --partial 'Did you mean this?'
22+
assert_output --partial 'self'
23+
}
24+
25+
@test "command_not_found: suggest self subcommand for close typo" {
26+
run lets self ls
27+
assert_failure 2
28+
assert_output --partial 'unknown command "ls" for "lets self"'
29+
assert_output --partial 'Did you mean this?'
30+
assert_output --partial 'lsp'
31+
}
32+
33+
@test "command_not_found: no suggestions for completely unrelated command" {
34+
run lets zzzznotacommand
35+
assert_failure 2
36+
assert_output --partial 'unknown command "zzzznotacommand" for "lets"'
37+
refute_output --partial 'Did you mean this?'
38+
}
39+
40+
@test "command_not_found: no suggestions for completely unrelated self subcommand" {
41+
run lets self zzzznotacommand
42+
assert_failure 2
43+
assert_output --partial 'unknown command "zzzznotacommand" for "lets self"'
44+
refute_output --partial 'Did you mean this?'
45+
}

tests/command_not_found/lets.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
shell: bash
2+
3+
commands:
4+
hello:
5+
cmd: echo Hello

0 commit comments

Comments
 (0)