feat: differentiated exit codes (0/1/2/3/4) for the Kosli CLI#714
feat: differentiated exit codes (0/1/2/3/4) for the Kosli CLI#714dangrondahl wants to merge 15 commits intomainfrom
Conversation
|
Given the error code options each command has, I think it's worth to consider if e.g we should write a skill for creating new commands, or have it in our CLAUDE.md |
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code |
- internal/errors: ErrCompliance(1), ErrServer(2), ErrConfig(3), ErrUsage(4) + ExitCodeFor - main.go: os.Exit with ExitCodeFor(err) instead of always returning 0 - assertArtifact: compliance failure now returns ErrCompliance (exit 1)
- Network errors and 5xx (after retries) → ErrServer (exit 2) - 401/403 responses → ErrConfig (exit 3) - Other 4xx responses remain plain errors (exit 1)
- ErrorBeforePrintingUsage returns ErrUsage, covering all PreRunE validation failures - innerMain unknown-flag branch returns ErrUsage instead of the raw Cobra error
- assertSnapshot/assertApproval: INCOMPLIANT/not-approved → ErrCompliance (exit 1) - status.go: unresponsive → ErrServer (exit 2) - docgen: ExitCodeEntry type + ExitCodesSection on Formatter interface (Hugo + Mintlify) - docs.go: per-command exit code sets wired through metaFn; all runnable commands get a default set (0/2/3/4), assert commands additionally get 1
…in docs - All four assert pullrequest commands now return ErrCompliance (exit 1) when no PR/MR is found for the given commit - commandExitCodes map extended with all four pullrequest subcommand paths so their generated docs show exit code 1 in the Exit Codes table
…ode 1 - Wrap compliance assertion failures in attestPROptions.run and attestJira.run as ErrCompliance so --assert exits with code 1 not 0 - Fix wrapAttestationError to preserve typed errors when no string replacement occurs (previously fmt.Errorf stripped the error type) - Add commandExitCodes entries for all four attest pullrequest providers and attest jira so their docs show the exit-code 1 row - Add TestWrapAttestationError covering nil, ErrCompliance, ErrServer, and message-rewrite cases
…itCodesAssert
- exitCodesDefault now includes exit 1 ("Unexpected error.") since unclassified
errors fall through to ExitCompliance=1 in ExitCodeFor
- New exitCodesAttest for attest commands with --assert: exit 1 meaning clarifies
it only applies when --assert is used
- attest pullrequest * and attest jira now use exitCodesAttest instead of
exitCodesAssert
…liance) - printEvaluateResultAsJson and printEvaluateResultAsTable now return ErrCompliance on policy denial so the process exits with code 1 - Add exitCodesEvaluate set (code 1: "Policy denied.") and register kosli evaluate trail and kosli evaluate trails in commandExitCodes - Add TestPolicyDenialIsErrCompliance covering both formatters for allow and deny cases
…cenario tests - ExitCodeFor now recognises Cobra's built-in usage error patterns (unknown flag, required flag not set, wrong arg count) so tests that use executeCommandC get the same exit code as the real binary - Add wantExitCode int to cmdTestCase; runTestCmd asserts ExitCodeFor(err) when the field is non-zero - New TestExitCodeScenarios covers codes 0–4 end-to-end using httptest (no local server required): version (0), deny-all policy (1), 5xx / unreachable (2), 401/403 (3), unknown flag / cobra required flag / wrong arg count (4) - Add wantExitCode: 4 to all arg/flag usage error cases in evaluateTrail and wantExitCode: 1 to all deny-all policy cases - Add wantExitCode annotations to assertSnapshot tests 01 (exit 4) and 04 (exit 1) - Extend TestExitCodeFor with four Cobra error pattern cases
…fore ExitCodeFor Three bugs found by five-agent swarm review: 1. main.go: logger.Error() calls log.Fatalf() which exits 1 immediately, making os.Exit(ExitCodeFor(err)) unreachable dead code. Replaced with fmt.Fprintf to stderr so the differentiated exit code actually takes effect. Same fix in innerMain for the unknown-subcommand path. 2. requests.go: HTTP 5xx responses were returned as plain fmt.Errorf, falling through to exit 1. Now wrapped as ErrServer (exit 2). 3. docs.go/testHelpers/etc: import ordering and alignment (gofmt).
Documents the decision to split the uniform exit-1-on-all-errors into structured codes (1=compliance, 2=server, 3=config, 4=usage), the review process that found the Fatalf dead-code bug, and remaining test coverage work.
…e tests Add wantExitCode to 44 test cases across 12 files (55 total, up from 11). Covers compliance failures (exit 1), usage errors (exit 4), and success regression guards (exit 0) for all assert, attest --assert, and evaluate command families.
…ode annotations - Add exit codes table to 4 docs golden files (hugo/mintlify × snyk/artifact) - Change wantExitCode from 4 to 1 for 3 RunE validation errors that are plain fmt.Errorf (not wrapped via ErrorBeforePrintingUsage as ErrUsage)
caf7b74 to
41ca26a
Compare
Exit code behavior when
|
How exit code 2 (
|
Manual test examples for exit codesFor anyone building locally, here are quick ways to trigger each exit code: Exit 0 — Success: ./kosli version
echo $? # 0Exit 1 — Compliance / unexpected error: # Requires a valid token
# --assert with a non-existent artifact triggers a compliance failure.
./kosli assert artifact --fingerprint 0000000000000000000000000000000000000000000000000000000000000000 \
--org <insert-org-or-have-as-env-var> --api-token <insert-api-token-or-have-as-env-var> --flow nonexistent
echo $? # 1Exit 2 — Server unreachable: ./kosli list flows --org myorg --api-token fake --host http://localhost:9999
echo $? # 2Exit 3 — Auth error (401/403): ./kosli list flows --org <insert-org-or-have-as-env-var> --api-token fake
echo $? # 3Exit 4 — Usage error (missing/invalid flags): ./kosli list flows
echo $? # 4 (--org is not set) |
|
I suggest the following edits. Exit 1 can not be trusted. It can come from anywhere. |
I think I agree. with the starting from the 50s.
or
@ToreMerkely : Example of other error messages that we are not in control of:
|
|
Do we want to consider this, or is that too far out? Leave gaps between categories. If you pack them 51-52-53-54, you can't subdivide later. Then you could later split e.g. 51 = assertion failed, 52 = approval missing, 53 = artifact not HTTP status gaps to consider:
Other edge cases:
The biggest design question: should the codes be stable across minor versions? If yes, document |
Captures swarm investigation of ~80 commands' error surfaces, consumption patterns, and industry research. Evaluates compact exit codes, ranged exit codes, JSON envelopes, and a hybrid. Identifies MCP server as the most concrete near-term consumer that would benefit from structured JSON error output.

Summary
Differentiates the existing uniform exit-1-on-all-errors into structured, semantic exit codes so CI/CD pipelines and shell scripts can distinguish between failure types without parsing stderr.
ErrComplianceErrServerErrConfigErrUsageImportant: read the ADR first
Before reviewing the code, please read
docs/adr/20260319-differentiated-exit-codes.md. It documents:[ $? -eq 1 ]for "any error" will miss exit codes 2, 3, 4The ADR should be discussed and agreed upon before this PR is approved.
What changed
internal/errors/— new error type taxonomy (ErrCompliance,ErrServer,ErrConfig,ErrUsage) andExitCodeFor()dispatch functioncmd/kosli/main.go— bypasseslogger.Error(which callslog.Fatalf→os.Exit(1)) and usesfmt.Fprintf+os.Exit(ExitCodeFor(err))so differentiated codes actually take effectinternal/requests/requests.go— HTTP 5xx →ErrServer, 401/403 →ErrConfigcmd/kosli/*.go— all compliance-producing commands wrap failures asErrCompliancecmd/kosli/docs.go+internal/docgen/— per-command exit code tables in generated CLI docswantExitCodetest annotations across 14 test filesTest plan
make test_integrationpasses./kosli version→ exit 0KOSLI_API_TOKEN=bad ./kosli list environments --org kosli→ exit 3./kosli list environments --host http://localhost:1 --max-api-retries 0 --org x --api-token x→ exit 2./kosli list environments --bogus-flag→ exit 4References