Skip to content

error exit code on command not found#288

Merged
kindermax merged 3 commits intomasterfrom
error-exit-code-on-command-not-found
Mar 3, 2026
Merged

error exit code on command not found#288
kindermax merged 3 commits intomasterfrom
error-exit-code-on-command-not-found

Conversation

@kindermax
Copy link
Collaborator

@kindermax kindermax commented Mar 1, 2026

  • Error code 2 on command not found
  • Enable commands suggestions for when wrong command used
  • Add AGENTS.md for codding agents

Summary by Sourcery

Ensure unknown commands exit with a consistent non-zero status and provide suggestions, and document agent usage and project structure.

Bug Fixes:

  • Return exit code 2 when an unknown root or self subcommand is invoked.

Enhancements:

  • Add suggestion messages for mistyped commands and subcommands leveraging Cobra’s suggestion mechanism.
  • Introduce a generic exit-code helper that respects errors implementing an ExitCode method.

Documentation:

  • Add AGENTS.md and CLAUDE.md documenting expectations and guidelines for coding agents and project structure.

Tests:

  • Add unit and Bats integration tests covering exit code 2 and suggestion output for unknown commands and self subcommands.

Previously when we run lets with subcommand that does not exist, we
showed help message and exit code was 0.

Now we chaning this behavour - if lets was run with subcommand that does
not exist we show error message and return exit code 2
@sourcery-ai
Copy link

sourcery-ai bot commented Mar 1, 2026

Reviewer's Guide

Implements structured unknown-command handling that returns exit code 2 and prints Cobra suggestions for mistyped commands (including self subcommands), refactors exit-code extraction into a shared helper, and adds both automated tests and agent-facing documentation (AGENTS.md/CLAUDE.md).

Sequence diagram for unknown command handling with exit code 2

sequenceDiagram
  actor User
  participant OS
  participant main
  participant cobraRootCmd as cobra_Command_root
  participant validateCommandArgs
  participant unknownCommandError
  participant getExitCode

  User->>OS: run lets wrongcmd
  OS->>main: start process with args

  main->>cobraRootCmd: ExecuteContext(ctx)
  cobraRootCmd->>validateCommandArgs: validateCommandArgs(cmd, args)
  validateCommandArgs-->>cobraRootCmd: *unknownCommandError
  cobraRootCmd-->>main: error *unknownCommandError

  main->>getExitCode: getExitCode(err, 1)
  getExitCode->>unknownCommandError: ExitCode()
  unknownCommandError-->>getExitCode: 2
  getExitCode-->>main: 2

  main->>OS: os.Exit(2)
  OS-->>User: process exits with code 2 and suggestion message
Loading

Class diagram for unknownCommandError and exit code helper

classDiagram
  class unknownCommandError {
    string message
    Error() string
    ExitCode() int
  }

  class ExitCoder {
    <<interface>>
    ExitCode() int
  }

  class getExitCodeFn {
    getExitCode(err error, defaultCode int) int
  }

  class CobraCommand {
    Use string
    Short string
    DisableSuggestions bool
    SuggestionsMinimumDistance int
    CommandPath() string
    SuggestionsFor(arg string) []string
  }

  class validateCommandArgsFn {
    validateCommandArgs(cmd *CobraCommand, args []string) error
  }

  class buildUnknownCommandMessageFn {
    buildUnknownCommandMessage(cmd *CobraCommand, arg string) string
  }

  ExitCoder <|.. unknownCommandError
  validateCommandArgsFn ..> unknownCommandError : returns
  validateCommandArgsFn ..> buildUnknownCommandMessageFn : calls
  buildUnknownCommandMessageFn ..> CobraCommand : uses
  getExitCodeFn ..> ExitCoder : type assertion
Loading

File-Level Changes

Change Details Files
Introduce structured unknown-command error handling that returns exit code 2 and surfaces Cobra suggestions for mistyped commands.
  • Add unknownCommandError type implementing Error() and ExitCode() to represent unknown-command failures with exit code 2.
  • Add buildUnknownCommandMessage helper to construct Cobra-style unknown command messages with optional suggestions and default SuggestionsMinimumDistance.
  • Add validateCommandArgs Args validator that treats any positional arg as an unknown-command error, using buildUnknownCommandMessage.
cmd/root.go
Wire the new argument validation into root and self commands so unknown commands and subcommands are handled consistently.
  • Change root command Args from cobra.ArbitraryArgs to validateCommandArgs so bare unknown commands are rejected with exit code 2 and suggestions.
  • Set Args: validateCommandArgs on the self command so unknown self subcommands produce the same exit-code-2 error with suggestions.
cmd/root.go
cmd/self.go
Refactor process exit-code selection into a reusable helper that prefers errors with an ExitCode() method.
  • Introduce getExitCode helper that inspects errors for an ExitCode() method and falls back to a default code.
  • Use getExitCode when Traverse returns an error so traversal failures respect ExitCode when available.
  • Use getExitCode in the main ExecuteContext error path instead of inlining executor.ExecuteError-specific handling, and remove the now-unused executor import.
main.go
Add unit and integration tests to verify exit code 2 and suggestion output for unknown commands and self subcommands.
  • Extend cmd/root_test.go with tests that execute the root command and self subcommand with unknown args, asserting ExitCode() == 2 and that the error text includes the expected unknown-command message and suggestions.
  • Add Bats test suite covering unknown root commands, unknown self subcommands, and typo suggestions for both, including exit code 2 and specific suggestion lines.
  • Add a minimal tests/command_not_found/lets.yaml config with a simple hello command to support the Bats tests.
cmd/root_test.go
tests/command_not_found.bats
tests/command_not_found/lets.yaml
Add documentation files for coding agents contributing to the project.
  • Add AGENTS.md describing tools, agent behavior expectations, package structure, config fields, and project rules for this repository.
  • Add CLAUDE.md that references AGENTS.md as a pointer for a specific agent tooling setup.
AGENTS.md
CLAUDE.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • buildUnknownCommandMessage mutates cmd.SuggestionsMinimumDistance when it is <= 0, which can override a deliberate 0 value (meaning "no minimum distance") and introduce hidden side-effects on the command; consider using a local default without writing back to the struct or only changing it when it is exactly the cobra zero-value (e.g. by checking an additional flag).
  • The unknownCommandError now implements the same ExitCode() interface pattern already used by executor.ExecuteError; consider defining a small shared interface{ ExitCode() int } type or helper in a common package to avoid repeating the anonymous interface and keep error-handling conventions centralized.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- `buildUnknownCommandMessage` mutates `cmd.SuggestionsMinimumDistance` when it is `<= 0`, which can override a deliberate `0` value (meaning "no minimum distance") and introduce hidden side-effects on the command; consider using a local default without writing back to the struct or only changing it when it is exactly the cobra zero-value (e.g. by checking an additional flag).
- The `unknownCommandError` now implements the same `ExitCode()` interface pattern already used by `executor.ExecuteError`; consider defining a small shared `interface{ ExitCode() int }` type or helper in a common package to avoid repeating the anonymous interface and keep error-handling conventions centralized.

## Individual Comments

### Comment 1
<location path="cmd/root_test.go" line_range="84-113" />
<code_context>
 		}
 	})
+
+	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())
+		}
+	})
+}
+
</code_context>
<issue_to_address>
**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.

```suggestion
	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())
		}
	})
}
```
</issue_to_address>

### Comment 2
<location path="cmd/root_test.go" line_range="115-82" />
<code_context>
+func TestSelfCmd(t *testing.T) {
</code_context>
<issue_to_address>
**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.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +84 to +113
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())
}
})
}
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())
}
})
}

@@ -78,4 +80,72 @@ 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.

@kindermax kindermax force-pushed the error-exit-code-on-command-not-found branch from e1e8fe2 to 8ded158 Compare March 3, 2026 07:58
@kindermax kindermax force-pushed the error-exit-code-on-command-not-found branch from 8ded158 to 6c06355 Compare March 3, 2026 08:01
@kindermax kindermax merged commit 7afb0bb into master Mar 3, 2026
5 checks passed
@kindermax kindermax deleted the error-exit-code-on-command-not-found branch March 3, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant