---
phase: 06-output-reporting
plan: 06
type: execute
wave: 2
depends_on: [06-01, 06-02, 06-03]
files_modified:
  - cmd/scan.go
  - cmd/scan_output_test.go
autonomous: true
requirements: [OUT-05, OUT-06]

must_haves:
  truths:
    - "scan --output accepts table, json, sarif, csv and dispatches to the registered formatter"
    - "invalid --output values fail with a clear error listing valid formats"
    - "scan exit code is 0 on clean scans, 1 on findings, 2 on scan errors"
    - "key masking is the default; --unmask propagates through opts.Unmask to all formatters"
  artifacts:
    - path: cmd/scan.go
      provides: "Refactored output dispatch via output.Get + exit-code handling"
      contains: "output.Get(flagOutput"
  key_links:
    - from: cmd/scan.go
      to: pkg/output/formatter.go
      via: "output.Get(flagOutput).Format(findings, os.Stdout, Options{Unmask, ToolName, ToolVersion})"
      pattern: "output\\.Get\\(flagOutput"
---

<objective>
Wire the scan command to the formatter registry established in Plans 01-03. Replace the inline json/table switch with `output.Get(name)` and finalize exit-code semantics (0/1/2).

Purpose: Surfaces all four output formats through `scan --output=` and enforces OUT-06 exit-code contract for CI/CD consumers.
Output: Updated `cmd/scan.go`, a small test file covering format dispatch and exit codes.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/06-output-reporting/06-CONTEXT.md
@.planning/phases/06-output-reporting/06-01-PLAN.md
@cmd/scan.go
@cmd/root.go

<interfaces>
From Plans 06-01..03:
```go
output.Get(name string) (Formatter, error) // returns ErrUnknownFormat wrapped
output.Names() []string
output.Options{ Unmask, ToolName, ToolVersion }
```

Current scan.go ends with:
```go
switch flagOutput {
case "json":
    // inline jsonFinding encoder
default:
    output.PrintFindings(findings, flagUnmask)
}
if len(findings) > 0 { os.Exit(1) }
return nil
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Replace scan output switch with formatter registry and finalize exit codes</name>
  <files>cmd/scan.go</files>
  <read_first>
    - cmd/scan.go (current dispatch)
    - pkg/output/formatter.go (Get, Options, Names)
    - cmd/root.go (for version constant — if none exists, use "dev")
  </read_first>
  <action>
    1. Remove the inline `jsonFinding` struct and the `switch flagOutput` block.

    2. Replace with:

       ```go
       // Output via the formatter registry (OUT-01..04).
       formatter, err := output.Get(flagOutput)
       if err != nil {
           return fmt.Errorf("%w (valid: %s)", err, strings.Join(output.Names(), ", "))
       }
       if err := formatter.Format(findings, os.Stdout, output.Options{
           Unmask:      flagUnmask,
           ToolName:    "keyhunter",
           ToolVersion: versionString(), // see step 4
       }); err != nil {
           return fmt.Errorf("rendering %s output: %w", flagOutput, err)
       }

       // OUT-06 exit codes: 0=clean, 1=findings, 2=error (errors returned via RunE -> root.Execute).
       if len(findings) > 0 {
           os.Exit(1)
       }
       return nil
       ```

       Add `"strings"` import if missing.

    3. Update the --output flag help text:
       ```go
       scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json, sarif, csv")
       ```

    4. Version helper: if cmd/root.go doesn't already export a version constant, add (in cmd/scan.go or a new cmd/version.go):
       ```go
       // versionString returns the compiled tool version. Set via -ldflags "-X github.com/salvacybersec/keyhunter/cmd.version=...".
       var version = "dev"
       func versionString() string { return version }
       ```
       If a version constant already exists elsewhere, reuse it instead of adding a new one.

    5. Confirm the rootCmd.Execute() path in cmd/root.go already handles errors by `os.Exit(1)` on non-nil. For the OUT-06 "exit 2 on error" requirement, update cmd/root.go Execute():
       ```go
       func Execute() {
           if err := rootCmd.Execute(); err != nil {
               // cobra already prints the error message. Exit 2 signals scan/tool error
               // per OUT-06. (Exit 1 is reserved for "findings present".)
               os.Exit(2)
           }
       }
       ```
       This is a one-line change from `os.Exit(1)` to `os.Exit(2)`. Any subcommand returning an error will now exit 2, which matches the CI contract (findings=1, error=2, clean=0).

    6. Verify the old inline jsonFinding struct (and its imports — check if "encoding/json" is still used anywhere in scan.go; if not, remove the import).
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/...</automated>
  </verify>
  <done>
    - `go build ./...` succeeds
    - `grep -q "output\\.Get(flagOutput)" cmd/scan.go`
    - `grep -q "output\\.Options{" cmd/scan.go`
    - `grep -vq "jsonFinding" cmd/scan.go` (inline struct removed)
    - `grep -q "os\\.Exit(2)" cmd/root.go`
    - Help text lists all four formats
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Tests for unknown format error and exit-code contract</name>
  <files>cmd/scan_output_test.go</files>
  <read_first>
    - cmd/scan.go (updated dispatch)
    - pkg/output/formatter.go
  </read_first>
  <behavior>
    - Test 1: TestScanOutput_UnknownFormat — setting flagOutput="bogus" and invoking a minimal code path that calls output.Get(flagOutput) returns an error whose message contains "unknown format" and lists valid names.
    - Test 2: TestScanOutput_FormatNamesIncludeAll — output.Names() returns a slice containing "table", "json", "csv", "sarif".
    - Rather than invoking the whole RunE (which requires a real scan target), isolate the dispatch logic into a small helper `renderScanOutput(findings, name string, unmask bool, w io.Writer) error` inside cmd/scan.go. Update Task 1 if not already done so the helper exists. Test exercises the helper directly.
    - Test 3: TestRenderScanOutput_JSONSucceeds — passes an empty findings slice with name="json"; asserts output is valid JSON array `[]`.
    - Test 4: TestRenderScanOutput_UnknownReturnsError — name="bogus"; asserts errors.Is(err, output.ErrUnknownFormat).
  </behavior>
  <action>
    1. If not done in Task 1, add `renderScanOutput(findings []engine.Finding, name string, unmask bool, w io.Writer) error` to cmd/scan.go and use it from the RunE.

    2. Create cmd/scan_output_test.go:

       ```go
       package cmd

       import (
           "bytes"
           "encoding/json"
           "errors"
           "strings"
           "testing"

           "github.com/salvacybersec/keyhunter/pkg/engine"
           "github.com/salvacybersec/keyhunter/pkg/output"
           "github.com/stretchr/testify/assert"
           "github.com/stretchr/testify/require"
       )

       func TestScanOutput_FormatNamesIncludeAll(t *testing.T) {
           names := output.Names()
           for _, want := range []string{"table", "json", "csv", "sarif"} {
               assert.Contains(t, names, want)
           }
       }

       func TestRenderScanOutput_UnknownReturnsError(t *testing.T) {
           err := renderScanOutput(nil, "bogus", false, &bytes.Buffer{})
           require.Error(t, err)
           assert.True(t, errors.Is(err, output.ErrUnknownFormat))
           assert.Contains(t, err.Error(), "valid:")
       }

       func TestRenderScanOutput_JSONSucceeds(t *testing.T) {
           var buf bytes.Buffer
           err := renderScanOutput([]engine.Finding{}, "json", false, &buf)
           require.NoError(t, err)
           var out []any
           require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
           assert.Len(t, out, 0)
       }

       func TestRenderScanOutput_TableEmpty(t *testing.T) {
           var buf bytes.Buffer
           err := renderScanOutput(nil, "table", false, &buf)
           require.NoError(t, err)
           assert.True(t, strings.Contains(buf.String(), "No API keys found"))
       }
       ```
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestScanOutput|TestRenderScanOutput" -count=1</automated>
  </verify>
  <done>
    - All four tests pass
    - `grep -q "func renderScanOutput" cmd/scan.go`
    - errors.Is(err, output.ErrUnknownFormat) works from cmd package
  </done>
</task>

</tasks>

<verification>
- `go build ./...` succeeds
- `go test ./... -count=1` all green
- Manual smoke: `go run . scan --output=bogus /tmp` prints "unknown format" and exits 2
- Manual smoke: `go run . scan --output=sarif testdata/` prints SARIF JSON
</verification>

<success_criteria>
- scan --output dispatches to the formatter registry for all four formats
- Unknown format error lists valid names
- Exit codes: clean=0, findings=1, error=2
- OUT-05 masking default respected via flagUnmask -> Options.Unmask
</success_criteria>

<output>
After completion, create `.planning/phases/06-output-reporting/06-06-SUMMARY.md`.
</output>
