diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..794b23f --- /dev/null +++ b/AGENTS.md @@ -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//` 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 " in commits diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/cmd/root.go b/cmd/root.go index 97f9062..f518c76 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) }, diff --git a/cmd/root_test.go b/cmd/root_test.go index 44b6a2b..180407d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "errors" + "strings" "testing" "github.com/lets-cli/lets/config/config" @@ -78,4 +80,130 @@ func TestRootCmdWithConfig(t *testing.T) { ) } }) + + 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()) + } + }) +} + +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()) + } + }) } diff --git a/cmd/self.go b/cmd/self.go index 913652c..fe1cf4d 100644 --- a/cmd/self.go +++ b/cmd/self.go @@ -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) }, diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 17367ff..7f938fb 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -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. diff --git a/main.go b/main.go index f93c60d..c7b1cda 100644 --- a/main.go +++ b/main.go @@ -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" @@ -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) @@ -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)) } } @@ -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") diff --git a/tests/command_not_found.bats b/tests/command_not_found.bats new file mode 100644 index 0000000..5ecfd0c --- /dev/null +++ b/tests/command_not_found.bats @@ -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?' +} diff --git a/tests/command_not_found/lets.yaml b/tests/command_not_found/lets.yaml new file mode 100644 index 0000000..676468f --- /dev/null +++ b/tests/command_not_found/lets.yaml @@ -0,0 +1,5 @@ +shell: bash + +commands: + hello: + cmd: echo Hello