---
phase: 06-output-reporting
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
  - pkg/output/formatter.go
  - pkg/output/colors.go
  - pkg/output/table.go
  - pkg/output/table_test.go
  - pkg/output/colors_test.go
  - pkg/output/formatter_test.go
  - go.mod
autonomous: true
requirements: [OUT-01, OUT-06]

must_haves:
  truths:
    - "pkg/output exposes a Formatter interface all formats implement"
    - "TableFormatter renders findings with colors only on TTY"
    - "Non-TTY stdout produces no ANSI escape sequences"
    - "An output.Registry maps format names to Formatter implementations"
  artifacts:
    - path: pkg/output/formatter.go
      provides: "Formatter interface, Registry, Options struct"
      exports: ["Formatter", "Options", "Register", "Get", "ErrUnknownFormat"]
    - path: pkg/output/colors.go
      provides: "TTY detection + profile selection"
      exports: ["IsTTY", "ColorsEnabled"]
    - path: pkg/output/table.go
      provides: "Refactored TableFormatter implementing Formatter"
      contains: "type TableFormatter struct"
  key_links:
    - from: pkg/output/table.go
      to: pkg/output/formatter.go
      via: "TableFormatter implements Formatter.Format(findings, w, opts)"
      pattern: "func \\(.*TableFormatter\\) Format"
    - from: pkg/output/table.go
      to: pkg/output/colors.go
      via: "Strips lipgloss colors when ColorsEnabled(w) is false"
      pattern: "ColorsEnabled"
---

<objective>
Establish the Formatter interface and refactor the existing table output to implement it. Add TTY detection so colored output is only emitted to real terminals, and introduce a Registry so scan.go (Plan 06) can select formatters by name. This is the foundation all other formatter plans build on.

Purpose: Unify output paths under one interface so JSON/SARIF/CSV formatters (Plans 02-03) can be added in parallel.
Output: `pkg/output/formatter.go`, `pkg/output/colors.go`, refactored `pkg/output/table.go`, unit tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-output-reporting/06-CONTEXT.md
@pkg/output/table.go
@pkg/engine/finding.go

<interfaces>
From pkg/engine/finding.go:
```go
type Finding struct {
    ProviderName   string
    KeyValue       string
    KeyMasked      string
    Confidence     string // "high"|"medium"|"low"
    Source         string
    SourceType     string
    LineNumber     int
    Offset         int64
    DetectedAt     time.Time
    Verified       bool
    VerifyStatus   string
    VerifyHTTPCode int
    VerifyMetadata map[string]string
    VerifyError    string
}
func MaskKey(key string) string
```

isatty is ALREADY available as an indirect dep in go.mod (github.com/mattn/go-isatty v0.0.20 via lipgloss). This plan promotes it to a direct dep via `go get`.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create Formatter interface, Options, Registry, and colors helper</name>
  <files>pkg/output/formatter.go, pkg/output/colors.go, pkg/output/formatter_test.go, pkg/output/colors_test.go, go.mod</files>
  <read_first>
    - pkg/output/table.go (current API, styles)
    - pkg/engine/finding.go (Finding struct)
    - go.mod (isatty already indirect)
  </read_first>
  <behavior>
    - Formatter interface: Format(findings []engine.Finding, w io.Writer, opts Options) error
    - Options: { Unmask bool, ToolName string, ToolVersion string }
    - Registry: Register(name string, f Formatter), Get(name string) (Formatter, error). ErrUnknownFormat sentinel.
    - ColorsEnabled(w io.Writer) bool: returns true only if w is *os.File pointing at a TTY AND NO_COLOR env var is unset.
    - IsTTY(f *os.File) bool: wraps isatty.IsTerminal(f.Fd()).
    - Test: Register + Get round-trip; Get("nope") returns ErrUnknownFormat.
    - Test: ColorsEnabled on bytes.Buffer returns false.
    - Test: ColorsEnabled with NO_COLOR=1 returns false even if TTY would be true (use t.Setenv).
  </behavior>
  <action>
    1. Promote isatty to a direct dependency: `go get github.com/mattn/go-isatty@v0.0.20` (already resolved, this just moves it from indirect to direct in go.mod).

    2. Create pkg/output/formatter.go:
       ```go
       package output

       import (
           "errors"
           "fmt"
           "io"

           "github.com/salvacybersec/keyhunter/pkg/engine"
       )

       // ErrUnknownFormat is returned by Get when no formatter is registered for the given name.
       var ErrUnknownFormat = errors.New("output: unknown format")

       // Options controls formatter behavior. Unmask reveals full key values.
       // ToolName/ToolVersion are used by SARIF and similar metadata-bearing formats.
       type Options struct {
           Unmask      bool
           ToolName    string
           ToolVersion string
       }

       // Formatter renders a slice of findings to an io.Writer.
       // Implementations must not mutate findings.
       type Formatter interface {
           Format(findings []engine.Finding, w io.Writer, opts Options) error
       }

       var registry = map[string]Formatter{}

       // Register adds a formatter under the given name. Safe to call from init().
       func Register(name string, f Formatter) {
           registry[name] = f
       }

       // Get returns the formatter registered under name, or ErrUnknownFormat.
       func Get(name string) (Formatter, error) {
           f, ok := registry[name]
           if !ok {
               return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, name)
           }
           return f, nil
       }

       // Names returns the sorted list of registered format names (for --help and errors).
       func Names() []string {
           names := make([]string, 0, len(registry))
           for k := range registry {
               names = append(names, k)
           }
           // Sort to stabilize help output.
           for i := 1; i < len(names); i++ {
               for j := i; j > 0 && names[j-1] > names[j]; j-- {
                   names[j-1], names[j] = names[j], names[j-1]
               }
           }
           return names
       }
       ```

    3. Create pkg/output/colors.go:
       ```go
       package output

       import (
           "io"
           "os"

           "github.com/mattn/go-isatty"
       )

       // IsTTY reports whether f is an open terminal.
       func IsTTY(f *os.File) bool {
           if f == nil {
               return false
           }
           return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
       }

       // ColorsEnabled reports whether ANSI color output should be emitted on w.
       // Returns false when w is not an *os.File, when it is not a TTY, or when
       // the NO_COLOR environment variable is set (https://no-color.org/).
       func ColorsEnabled(w io.Writer) bool {
           if _, ok := os.LookupEnv("NO_COLOR"); ok {
               return false
           }
           f, ok := w.(*os.File)
           if !ok {
               return false
           }
           return IsTTY(f)
       }
       ```

    4. Create pkg/output/formatter_test.go with table-driven tests covering Register/Get/ErrUnknownFormat/Names.

    5. Create pkg/output/colors_test.go: ColorsEnabled(&bytes.Buffer{})==false; NO_COLOR via t.Setenv forces false.
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestFormatter|TestColors" -count=1</automated>
  </verify>
  <done>
    - `go build ./...` succeeds
    - `go test ./pkg/output/... -run "TestFormatter|TestColors"` passes
    - `grep -q "github.com/mattn/go-isatty" go.mod` confirms direct dep
    - `grep -q "type Formatter interface" pkg/output/formatter.go`
    - `grep -q "ErrUnknownFormat" pkg/output/formatter.go`
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Refactor table.go into TableFormatter, strip colors for non-TTY, register under "table"</name>
  <files>pkg/output/table.go, pkg/output/table_test.go</files>
  <read_first>
    - pkg/output/formatter.go (from Task 1)
    - pkg/output/colors.go (from Task 1)
    - pkg/output/table.go (current impl)
  </read_first>
  <behavior>
    - TableFormatter{} implements Formatter.
    - Writes to the provided io.Writer (not os.Stdout).
    - When ColorsEnabled(w)==false, no ANSI escape sequences appear in output (strip by using lipgloss.SetColorProfile or by constructing plain styles).
    - Preserves existing layout: PROVIDER/KEY/CONFIDENCE/SOURCE/LINE columns; VERIFY column when any finding is verified; indented metadata line.
    - Empty slice -> "No API keys found.\n".
    - Non-empty -> footer "\n{N} key(s) found.\n".
    - Respects opts.Unmask: Unmask=true uses KeyValue, false uses KeyMasked.
    - Keeps PrintFindings(findings, unmask) as a thin backward-compatible wrapper that delegates to TableFormatter.Format(findings, os.Stdout, Options{Unmask: unmask}) — existing scan.go calls still compile until Plan 06.
    - Tests assert: (a) empty case string equality; (b) verified+unverified columns; (c) NO_COLOR=1 output contains no "\x1b["; (d) metadata line is sorted.
  </behavior>
  <action>
    Rewrite pkg/output/table.go:

    ```go
    package output

    import (
        "fmt"
        "io"
        "os"
        "sort"
        "strings"

        "github.com/charmbracelet/lipgloss"
        "github.com/salvacybersec/keyhunter/pkg/engine"
    )

    func init() {
        Register("table", TableFormatter{})
    }

    // TableFormatter renders findings as a colored terminal table.
    // Colors are automatically stripped when the writer is not a TTY or
    // when NO_COLOR is set.
    type TableFormatter struct{}

    func (TableFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
        if len(findings) == 0 {
            _, err := fmt.Fprintln(w, "No API keys found.")
            return err
        }

        colored := ColorsEnabled(w)
        style := newTableStyles(colored)

        anyVerified := false
        for _, f := range findings {
            if f.Verified {
                anyVerified = true
                break
            }
        }

        if anyVerified {
            fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %-5s  %s\n",
                style.header.Render("PROVIDER"),
                style.header.Render("KEY"),
                style.header.Render("CONFIDENCE"),
                style.header.Render("SOURCE"),
                style.header.Render("LINE"),
                style.header.Render("VERIFY"),
            )
        } else {
            fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %s\n",
                style.header.Render("PROVIDER"),
                style.header.Render("KEY"),
                style.header.Render("CONFIDENCE"),
                style.header.Render("SOURCE"),
                style.header.Render("LINE"),
            )
        }
        fmt.Fprintln(w, style.divider.Render(strings.Repeat("─", 106)))

        for _, f := range findings {
            keyDisplay := f.KeyMasked
            if opts.Unmask {
                keyDisplay = f.KeyValue
            }
            confStyle := style.low
            switch f.Confidence {
            case "high":
                confStyle = style.high
            case "medium":
                confStyle = style.medium
            }
            if anyVerified {
                fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %-5d  %s\n",
                    f.ProviderName, keyDisplay, confStyle.Render(f.Confidence),
                    truncate(f.Source, 28), f.LineNumber, verifySymbolStyled(f, style),
                )
            } else {
                fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %d\n",
                    f.ProviderName, keyDisplay, confStyle.Render(f.Confidence),
                    truncate(f.Source, 28), f.LineNumber,
                )
            }
            if len(f.VerifyMetadata) > 0 {
                parts := make([]string, 0, len(f.VerifyMetadata))
                for k, v := range f.VerifyMetadata {
                    parts = append(parts, fmt.Sprintf("%s: %s", k, v))
                }
                sort.Strings(parts)
                fmt.Fprintf(w, "    ↳ %s\n", strings.Join(parts, ", "))
            }
        }
        fmt.Fprintf(w, "\n%d key(s) found.\n", len(findings))
        return nil
    }

    type tableStyles struct {
        header, divider, high, medium, low         lipgloss.Style
        verifyLive, verifyDead, verifyRate, verifyErr, verifyUnk lipgloss.Style
    }

    func newTableStyles(colored bool) tableStyles {
        if !colored {
            plain := lipgloss.NewStyle()
            return tableStyles{
                header: plain, divider: plain, high: plain, medium: plain, low: plain,
                verifyLive: plain, verifyDead: plain, verifyRate: plain, verifyErr: plain, verifyUnk: plain,
            }
        }
        return tableStyles{
            header:     lipgloss.NewStyle().Bold(true).Underline(true),
            divider:    lipgloss.NewStyle().Foreground(lipgloss.Color("8")),
            high:       lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
            medium:     lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
            low:        lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
            verifyLive: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
            verifyDead: lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
            verifyRate: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
            verifyErr:  lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
            verifyUnk:  lipgloss.NewStyle().Foreground(lipgloss.Color("8")),
        }
    }

    func verifySymbolStyled(f engine.Finding, s tableStyles) string {
        if !f.Verified {
            return ""
        }
        switch f.VerifyStatus {
        case "live":
            return s.verifyLive.Render("✓ live")
        case "dead":
            return s.verifyDead.Render("✗ dead")
        case "rate_limited":
            return s.verifyRate.Render("⚠ rate")
        case "error":
            return s.verifyErr.Render("! err")
        default:
            return s.verifyUnk.Render("? unk")
        }
    }

    // PrintFindings is a backward-compatible wrapper for existing callers.
    // Deprecated: use TableFormatter.Format directly.
    func PrintFindings(findings []engine.Finding, unmask bool) {
        _ = TableFormatter{}.Format(findings, os.Stdout, Options{Unmask: unmask})
    }

    func truncate(s string, max int) string {
        if len(s) <= max {
            return s
        }
        return "..." + s[len(s)-max+3:]
    }
    ```

    Create pkg/output/table_test.go:
    - TestTableFormatter_Empty: asserts exact string "No API keys found.\n" to bytes.Buffer.
    - TestTableFormatter_NoColorInBuffer: two findings, one verified; asserts output does NOT contain "\x1b[" (bytes.Buffer is not a TTY).
    - TestTableFormatter_UnverifiedLayout: asserts header line does not contain "VERIFY".
    - TestTableFormatter_VerifiedLayout: asserts header includes "VERIFY" and row contains "live" when VerifyStatus=="live".
    - TestTableFormatter_Masking: Unmask=false renders f.KeyMasked; Unmask=true renders f.KeyValue.
    - TestTableFormatter_MetadataSorted: VerifyMetadata={"z":"1","a":"2"} renders "a: 2, z: 1".
  </action>
  <verify>
    <automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestTableFormatter" -count=1 && go build ./...</automated>
  </verify>
  <done>
    - All TestTableFormatter_* tests pass
    - `go build ./...` succeeds (scan.go still uses PrintFindings wrapper)
    - `grep -q "Register(\"table\"" pkg/output/table.go`
    - `grep -q "TableFormatter" pkg/output/table.go`
    - Test output confirms no "\x1b[" when writing to bytes.Buffer
  </done>
</task>

</tasks>

<verification>
- `go build ./...` succeeds
- `go test ./pkg/output/... -count=1` all green
- `grep -q "ErrUnknownFormat\|type Formatter interface\|TableFormatter" pkg/output/*.go`
- isatty is a direct dependency: `grep -E "^\tgithub.com/mattn/go-isatty" go.mod` (not in indirect block)
</verification>

<success_criteria>
- Formatter interface + Registry + Options exist and are tested
- TableFormatter implements Formatter, registered as "table"
- Colors stripped when writer is not a TTY or NO_COLOR set
- Existing PrintFindings wrapper keeps scan.go compiling
- Foundation ready for JSON/SARIF/CSV formatters in Wave 1
</success_criteria>

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