--- phase: 06-output-reporting plan: 05 type: execute wave: 2 depends_on: [06-01, 06-02, 06-03, 06-04] files_modified: - cmd/keys.go - cmd/keys_test.go - cmd/stubs.go - cmd/root.go autonomous: true requirements: [KEYS-01, KEYS-02, KEYS-03, KEYS-04, KEYS-05, KEYS-06] must_haves: truths: - "keyhunter keys list prints stored findings (masked by default)" - "keyhunter keys show prints a single finding in full detail" - "keyhunter keys export --format=json|csv writes all findings to stdout or --output file" - "keyhunter keys copy places the full key on the system clipboard" - "keyhunter keys delete removes a finding with confirmation (bypassed by --yes)" - "keyhunter keys verify re-runs HTTPVerifier against the stored key" artifacts: - path: cmd/keys.go provides: "keysCmd + list/show/export/copy/delete/verify subcommands" contains: "keysCmd" key_links: - from: cmd/keys.go to: pkg/storage/queries.go via: "db.ListFindingsFiltered / GetFinding / DeleteFinding" pattern: "ListFindingsFiltered|GetFinding|DeleteFinding" - from: cmd/keys.go to: pkg/output/formatter.go via: "output.Get for json/csv export" pattern: "output\\.Get\\(" - from: cmd/keys.go to: github.com/atotto/clipboard via: "clipboard.WriteAll for keys copy" pattern: "clipboard\\.WriteAll" - from: cmd/root.go to: cmd/keys.go via: "AddCommand(keysCmd)" pattern: "AddCommand\\(keysCmd\\)" --- Replace the `keys` stub with a real command tree implementing KEYS-01..06. Reuses the storage query layer from Plan 04 and the formatter registry from Plans 01-03. Purpose: Fulfils all six key-management requirements. Output: `cmd/keys.go` with subcommands, removal of stub, tests for list/show/export/delete using in-memory DB. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/06-output-reporting/06-CONTEXT.md @.planning/phases/06-output-reporting/06-01-PLAN.md @.planning/phases/06-output-reporting/06-04-PLAN.md @cmd/scan.go @cmd/stubs.go @cmd/root.go @pkg/storage/findings.go From Plan 06-04: ```go type Filters struct { Provider string; Verified *bool; Limit, Offset int } func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error) func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error) func (db *DB) DeleteFinding(id int64) (int64, error) ``` From cmd/scan.go, reusable helper: ```go func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error) ``` From Plan 06-01..03: ```go output.Get("json"|"csv"|"sarif"|"table") (Formatter, error) ``` clipboard: github.com/atotto/clipboard (already in go.mod) — clipboard.WriteAll(string) error. verify package (Phase 5): verify.NewHTTPVerifier(timeout), VerifyAll(ctx, findings, reg, workers) — for `keys verify `. Task 1: Implement keys command tree (list/show/export/copy/delete/verify) cmd/keys.go, cmd/stubs.go, cmd/root.go - cmd/stubs.go (remove keysCmd stub) - cmd/scan.go (loadOrCreateEncKey, db open pattern, verify wiring) - cmd/root.go (AddCommand registration) - pkg/storage/queries.go (Plan 04 output) - pkg/output/formatter.go (Get, Options) 1. Delete the `keysCmd` stub from cmd/stubs.go (keep the other stubs). Leave a comment if needed. 2. Create cmd/keys.go with the full command tree. Skeleton: ```go package cmd import ( "bufio" "context" "fmt" "os" "strconv" "strings" "time" "github.com/atotto/clipboard" "github.com/salvacybersec/keyhunter/pkg/config" "github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/output" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/storage" "github.com/salvacybersec/keyhunter/pkg/verify" "github.com/spf13/cobra" "github.com/spf13/viper" ) var ( flagKeysUnmask bool flagKeysProvider string flagKeysVerified bool flagKeysVerifiedSet bool flagKeysLimit int flagKeysFormat string flagKeysOutFile string flagKeysYes bool ) var keysCmd = &cobra.Command{ Use: "keys", Short: "Manage stored API key findings", } // ... list/show/export/copy/delete/verify subcommands below ``` 3. Implement subcommands: - `keys list`: * Flags: --unmask, --provider=string, --verified (tri-state via Changed()), --limit=int * Opens DB via same pattern as scan.go; derives encKey via loadOrCreateEncKey. * Builds storage.Filters. If cmd.Flag("verified").Changed, set Verified=&flagKeysVerified. * Calls db.ListFindingsFiltered. * Converts storage.Finding -> engine.Finding (inline helper: storageToEngine(f)). * Prepends ID column by printing a preamble line per finding or by printing a compact table. Simplest: iterate and print `[ID] provider confidence masked/unmask source:line verify_status` to stdout. Use lipgloss only if output.ColorsEnabled(os.Stdout). * Footer: "N key(s).". * Exit code 0 always for list (no findings is not an error). - `keys show `: * Args: cobra.ExactArgs(1). Parse id as int64. * db.GetFinding(id, encKey). If sql.ErrNoRows: "no finding with id N", exit 1. * ALWAYS unmasked (per KEYS-02). * Print labeled fields: ID, Provider, Confidence, Key (full), Source, Line, SourceType, CreatedAt, Verified, VerifyStatus, VerifyHTTPCode, VerifyMetadata (sorted keys), VerifyError. - `keys export`: * Flags: --format=json|csv (default json), --output=file (default stdout). * Rejects format != "json" && != "csv" with clear error (mentions SARIF is scan-only, for now). * Looks up formatter via output.Get(flagKeysFormat). Unmask=true (export implies full keys per KEYS-03). * If --output set: atomic write: write to .tmp then os.Rename. Use 0600 perms. * Else write to os.Stdout. - `keys copy `: * Args: ExactArgs(1). * GetFinding; if not found exit 1. * clipboard.WriteAll(f.KeyValue). * Print "Copied key for finding # (, ) to clipboard." - `keys delete `: * Args: ExactArgs(1). * GetFinding first to show masked preview. * If !flagKeysYes: prompt `"Delete finding #%d (%s, %s)? [y/N]: "` reading from stdin (bufio.NewReader). Accept "y"/"Y"/"yes". * db.DeleteFinding(id). Print "Deleted finding #." or "No finding with id .". - `keys verify `: * Args: ExactArgs(1). * GetFinding; load providers.NewRegistry(); build one engine.Finding from the stored row. * Use verify.EnsureConsent(db, os.Stdin, os.Stderr); if not granted, exit 2. * verifier := verify.NewHTTPVerifier(10*time.Second); results := verifier.VerifyAll(ctx, []engine.Finding{f}, reg, 1). * Read single result, apply to the stored record (re-SaveFinding with updated verify fields? — simpler: use a new helper `db.UpdateFindingVerify(id, status, httpCode, metadata, errMsg)`; if that helper doesn't exist, do it inline via `db.SQL().Exec("UPDATE findings SET verified=?, verify_status=?, verify_http_code=?, verify_metadata_json=? WHERE id=?", ...)` with JSON-marshaled metadata). * Print the updated finding using the "show" rendering. 4. Helper: `storageToEngine(f storage.Finding) engine.Finding` — maps fields. Put it in cmd/keys.go as unexported. 5. Helper: `openDBWithKey() (*storage.DB, []byte, error)` that mirrors scan.go's DB-open sequence (config load, mkdir, storage.Open, loadOrCreateEncKey). Extract this so all keys subcommands share one path. 6. In cmd/root.go the existing `rootCmd.AddCommand(keysCmd)` line already registers the stub's keysCmd. Since cmd/keys.go now declares `var keysCmd`, ensure the old declaration in cmd/stubs.go is removed (Task 1 step 1) so there is exactly one declaration. Run `go build ./cmd/...` to confirm. 7. Register subcommands in an init() in cmd/keys.go: ```go func init() { // list keysListCmd.Flags().BoolVar(&flagKeysUnmask, "unmask", false, "show full key values") keysListCmd.Flags().StringVar(&flagKeysProvider, "provider", "", "filter by provider name") keysListCmd.Flags().BoolVar(&flagKeysVerified, "verified", false, "filter: verified only (use --verified=false for unverified only)") keysListCmd.Flags().IntVar(&flagKeysLimit, "limit", 0, "max rows (0 = unlimited)") // export keysExportCmd.Flags().StringVar(&flagKeysFormat, "format", "json", "export format: json, csv") keysExportCmd.Flags().StringVar(&flagKeysOutFile, "output", "", "write to file instead of stdout") // delete keysDeleteCmd.Flags().BoolVar(&flagKeysYes, "yes", false, "skip confirmation") // wiring keysCmd.AddCommand(keysListCmd, keysShowCmd, keysExportCmd, keysCopyCmd, keysDeleteCmd, keysVerifyCmd) _ = viper.BindPFlag("keys.unmask", keysListCmd.Flags().Lookup("unmask")) } ``` cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/... - `go build ./...` succeeds - `cmd/stubs.go` no longer declares keysCmd - `cmd/keys.go` declares keysCmd + 6 subcommands - `grep -q "keysListCmd\|keysShowCmd\|keysExportCmd\|keysCopyCmd\|keysDeleteCmd\|keysVerifyCmd" cmd/keys.go` - `keyhunter keys --help` (via `go run ./ keys --help`) lists all 6 subcommands Task 2: Integration tests for keys list/show/export/delete against in-memory DB cmd/keys_test.go - cmd/keys.go (from Task 1) - pkg/storage/queries.go - Tests use a temp file SQLite DB (not :memory: because cobra commands open by path). - Each test sets viper.Set("database.path", tmpPath) and config passphrase via env, seeds findings, then invokes the cobra subcommand via rootCmd.SetArgs() + Execute() OR directly invokes the RunE function with captured stdout. - Prefer direct RunE invocation with cmd.SetOut/SetErr buffers to isolate from global os.Stdout. - Seed: 3 findings (2 openai, 1 anthropic; one verified). - Tests: * TestKeysList_Default: output contains both providers and all 3 ids, key column masked. * TestKeysList_FilterProvider: --provider=openai shows only 2 rows. * TestKeysShow_Hit: `keys show ` output contains the full plaintext KeyValue, not masked. * TestKeysShow_Miss: `keys show 9999` returns a non-nil error. * TestKeysExport_JSON: --format=json to stdout parses as JSON array of length 3, unmasked keys present. * TestKeysExport_CSVFile: --format=csv --output=; file exists, header row matches, 3 data rows. * TestKeysDelete_WithYes: --yes deletes finding; subsequent list returns 2. - Skip TestKeysCopy (clipboard not available in test env) and TestKeysVerify (requires network). Document skip with a comment. Create cmd/keys_test.go using testify. Use t.TempDir() for DB path. Seed findings with a helper similar to storage/queries_test. For each test, reset viper state and flag vars between runs. Example scaffold: ```go func seedDB(t *testing.T) (string, func()) { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "k.db") viper.Set("database.path", dbPath) t.Setenv("KEYHUNTER_PASSPHRASE", "test-pass") db, err := storage.Open(dbPath) require.NoError(t, err) encKey, err := loadOrCreateEncKey(db, "test-pass") require.NoError(t, err) seed := []storage.Finding{ {ProviderName: "openai", KeyValue: "sk-aaaaaaaaaaaaaaaaaaaa", KeyMasked: "sk-aaaa...aaaa", Confidence: "high", SourcePath: "a.go", LineNumber: 10}, {ProviderName: "openai", KeyValue: "sk-bbbbbbbbbbbbbbbbbbbb", KeyMasked: "sk-bbbb...bbbb", Confidence: "medium", SourcePath: "b.go", LineNumber: 20, Verified: true, VerifyStatus: "live"}, {ProviderName: "anthropic", KeyValue: "sk-ant-cccccccccccccccc", KeyMasked: "sk-ant-c...cccc", Confidence: "high", SourcePath: "c.go", LineNumber: 30}, } for _, f := range seed { _, err := db.SaveFinding(f, encKey) require.NoError(t, err) } require.NoError(t, db.Close()) return dbPath, func() { viper.Reset() } } ``` Capture output by using `cmd.SetOut(buf); cmd.SetErr(buf)` then `cmd.Execute()` on a copy of keysCmd, OR directly call `keysListCmd.RunE(keysListCmd, []string{})` after redirecting `os.Stdout` to a pipe (prefer SetOut if subcommands write via `cmd.OutOrStdout()`; update keys.go to use that helper in Task 1 so tests are clean). NOTE: If Task 1 wrote fmt.Fprintln(os.Stdout, ...), adjust it to use `cmd.OutOrStdout()` to make these tests hermetic. This is a cheap refactor — do it during Task 2 if missed in Task 1. cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestKeysList|TestKeysShow|TestKeysExport|TestKeysDelete" -count=1 - All listed keys tests pass - `go test ./cmd/... -count=1` has no regressions - Test file uses cmd.OutOrStdout() pattern (cmd/keys.go updated if needed) - `go build ./...` succeeds - `go test ./cmd/... ./pkg/storage/... ./pkg/output/... -count=1` all green - Manual smoke: `go run . keys --help` lists list/show/export/copy/delete/verify - All of KEYS-01..06 are implemented - keys export reuses the formatter registry (JSON/CSV) - keys copy uses atotto/clipboard - keys delete requires confirmation unless --yes - Integration tests cover list/show/export/delete After completion, create `.planning/phases/06-output-reporting/06-05-SUMMARY.md`.