---
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