--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 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`. Task 1: Create Formatter interface, Options, Registry, and colors helper pkg/output/formatter.go, pkg/output/colors.go, pkg/output/formatter_test.go, pkg/output/colors_test.go, go.mod - pkg/output/table.go (current API, styles) - pkg/engine/finding.go (Finding struct) - go.mod (isatty already indirect) - 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). 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. cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestFormatter|TestColors" -count=1 - `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` Task 2: Refactor table.go into TableFormatter, strip colors for non-TTY, register under "table" pkg/output/table.go, pkg/output/table_test.go - pkg/output/formatter.go (from Task 1) - pkg/output/colors.go (from Task 1) - pkg/output/table.go (current impl) - 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. 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". cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestTableFormatter" -count=1 && go build ./... - 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 - `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) - 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 After completion, create `.planning/phases/06-output-reporting/06-01-SUMMARY.md`.