--- phase: 01-foundation plan: 05 type: execute wave: 3 depends_on: [01-02, 01-03, 01-04] files_modified: - cmd/root.go - cmd/scan.go - cmd/providers.go - cmd/config.go - cmd/stubs.go - pkg/config/config.go - pkg/output/table.go autonomous: true requirements: [CLI-01, CLI-02, CLI-03, CLI-04, CLI-05] must_haves: truths: - "`keyhunter scan ./testdata/samples/openai_key.txt` runs the pipeline and prints a finding" - "`keyhunter providers list` prints a table with at least 3 providers" - "`keyhunter providers info openai` prints OpenAI provider details" - "`keyhunter config init` creates ~/.keyhunter.yaml without error" - "`keyhunter config set workers 16` persists the value to ~/.keyhunter.yaml" - "`keyhunter --help` shows all top-level commands: scan, verify, import, recon, keys, serve, dorks, hook, schedule, providers, config" - "Findings are stored with a per-installation salt loaded from the settings table — not a hardcoded salt" - "Raw sqlite3 query on the database file does NOT return plaintext key values" artifacts: - path: "cmd/root.go" provides: "Cobra root command with PersistentPreRunE config loading" contains: "cobra.Command" - path: "cmd/scan.go" provides: "scan command wiring Engine + FileSource + output table + salt from settings" exports: ["scanCmd"] - path: "cmd/stubs.go" provides: "stub commands for verify, import, recon, keys, serve, dorks, hook, schedule" exports: ["verifyCmd", "importCmd", "reconCmd", "keysCmd", "serveCmd", "dorksCmd", "hookCmd", "scheduleCmd"] - path: "cmd/providers.go" provides: "providers list/info/stats subcommands using Registry" exports: ["providersCmd"] - path: "cmd/config.go" provides: "config init/set/get subcommands using Viper" exports: ["configCmd"] - path: "pkg/config/config.go" provides: "Config struct with Load() and defaults" exports: ["Config", "Load"] - path: "pkg/output/table.go" provides: "lipgloss terminal table for printing Findings" exports: ["PrintFindings"] key_links: - from: "cmd/scan.go" to: "pkg/engine/engine.go" via: "engine.NewEngine(registry).Scan() called in RunE" pattern: "engine\\.NewEngine" - from: "cmd/scan.go" to: "pkg/storage/db.go" via: "storage.Open() called, SaveFinding for each result" pattern: "storage\\.Open" - from: "cmd/scan.go" to: "pkg/storage/crypto.go" via: "loadOrCreateSalt() reads salt from settings table via storage.GetSetting/SetSetting, then calls storage.DeriveKey" pattern: "DeriveKey|GetSetting|SetSetting" - from: "cmd/root.go" to: "github.com/spf13/viper" via: "viper.SetConfigFile in PersistentPreRunE" pattern: "viper\\.SetConfigFile" - from: "cmd/providers.go" to: "pkg/providers/registry.go" via: "Registry.List(), Registry.Get(), Registry.Stats() called" pattern: "registry\\.List|registry\\.Get|registry\\.Stats" --- Wire all subsystems together through the Cobra CLI: scan command (engine + storage + output), providers list/info/stats commands, config init/set/get commands, and 8 stub commands for future phases. This is the integration layer — all business logic lives in pkg/, cmd/ only wires. Purpose: Satisfies all Phase 1 CLI requirements and delivers the first working `keyhunter scan` command that completes the end-to-end success criteria. Output: cmd/{root,scan,providers,config,stubs}.go, pkg/config/config.go, pkg/output/table.go. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-02-SUMMARY.md @.planning/phases/01-foundation/01-03-SUMMARY.md @.planning/phases/01-foundation/01-04-SUMMARY.md package engine type ScanConfig struct { Workers int; Verify bool; Unmask bool } func NewEngine(registry *providers.Registry) *Engine func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error) package sources func NewFileSource(path string) *FileSource type Finding struct { ProviderName string KeyValue string KeyMasked string Confidence string Source string LineNumber int } package storage func Open(path string) (*DB, error) func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error) func DeriveKey(passphrase []byte, salt []byte) []byte func NewSalt() ([]byte, error) func (db *DB) GetSetting(key string) (string, bool, error) func (db *DB) SetSetting(key string, value string) error package providers func NewRegistry() (*Registry, error) func (r *Registry) List() []Provider func (r *Registry) Get(name string) (Provider, bool) func (r *Registry) Stats() RegistryStats DBPath: ~/.keyhunter/keyhunter.db ConfigPath: ~/.keyhunter.yaml Workers: runtime.NumCPU() * 8 Passphrase: (prompt if not in env KEYHUNTER_PASSPHRASE — Phase 1: use empty string as dev default) "database.path" → DBPath "scan.workers" → Workers "encryption.passphrase" → Passphrase (sensitive — warn in help) Columns: PROVIDER | MASKED KEY | CONFIDENCE | SOURCE | LINE Colors: use lipgloss.NewStyle().Foreground() for confidence: high=green, medium=yellow, low=red On first scan, call storage.NewSalt(), hex-encode it, store in settings table with key "encryption.salt". On subsequent scans, read the salt from the settings table. This ensures all users have a unique per-installation salt instead of a shared hardcoded salt. The helper function loadOrCreateSalt(db *storage.DB) ([]byte, error) handles both cases. Task 1: Config package, output table, root command, and settings helpers pkg/config/config.go, pkg/output/table.go, cmd/root.go, pkg/storage/settings.go - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-01, CLI-02, CLI-03 rows, Standard Stack: cobra v1.10.2 + viper v1.21.0) - /home/salva/Documents/apikey/pkg/engine/finding.go (Finding struct fields for output) - /home/salva/Documents/apikey/pkg/storage/db.go (DB struct, to add GetSetting/SetSetting) Create **pkg/config/config.go**: ```go package config import ( "os" "path/filepath" "runtime" ) // Config holds all KeyHunter runtime configuration. // Values are populated from ~/.keyhunter.yaml, environment variables, and CLI flags (in that precedence order). type Config struct { DBPath string // path to SQLite database file ConfigPath string // path to config YAML file Workers int // number of scanner worker goroutines Passphrase string // encryption passphrase (sensitive) } // Load returns a Config with defaults applied. // Callers should override individual fields after Load() using viper-bound values. func Load() Config { home, _ := os.UserHomeDir() return Config{ DBPath: filepath.Join(home, ".keyhunter", "keyhunter.db"), ConfigPath: filepath.Join(home, ".keyhunter.yaml"), Workers: runtime.NumCPU() * 8, Passphrase: "", // Phase 1: empty passphrase; Phase 6+ will prompt } } ``` Create **pkg/storage/settings.go** — adds GetSetting/SetSetting to the storage package: ```go package storage import ( "database/sql" "fmt" ) // GetSetting retrieves a value from the settings table. // Returns (value, true, nil) if found, ("", false, nil) if not found, ("", false, err) on error. func (db *DB) GetSetting(key string) (string, bool, error) { var value string err := db.sql.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, fmt.Errorf("getting setting %q: %w", key, err) } return value, true, nil } // SetSetting inserts or updates a key-value pair in the settings table. func (db *DB) SetSetting(key, value string) error { _, err := db.sql.Exec( `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, key, value, ) if err != nil { return fmt.Errorf("setting %q: %w", key, err) } return nil } ``` Create **pkg/output/table.go**: ```go package output import ( "fmt" "os" "github.com/charmbracelet/lipgloss" "github.com/salvacybersec/keyhunter/pkg/engine" ) var ( styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red styleHeader = lipgloss.NewStyle().Bold(true).Underline(true) ) // PrintFindings writes findings as a colored terminal table to stdout. // If unmask is true, KeyValue is shown; otherwise KeyMasked is shown. func PrintFindings(findings []engine.Finding, unmask bool) { if len(findings) == 0 { fmt.Println("No API keys found.") return } // Header fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %s\n", styleHeader.Render("PROVIDER"), styleHeader.Render("KEY"), styleHeader.Render("CONFIDENCE"), styleHeader.Render("SOURCE"), styleHeader.Render("LINE"), ) fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render( "──────────────────────────────────────────────────────────────────────────────────────────────────────────", )) for _, f := range findings { keyDisplay := f.KeyMasked if unmask { keyDisplay = f.KeyValue } confStyle := styleLow switch f.Confidence { case "high": confStyle = styleHigh case "medium": confStyle = styleMedium } fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %d\n", f.ProviderName, keyDisplay, confStyle.Render(f.Confidence), truncate(f.Source, 28), f.LineNumber, ) } fmt.Printf("\n%d key(s) found.\n", len(findings)) } func truncate(s string, max int) string { if len(s) <= max { return s } return "..." + s[len(s)-max+3:] } ``` Create **cmd/root.go** (replaces the stub from Plan 01): ```go package cmd import ( "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/spf13/viper" ) var cfgFile string // rootCmd is the base command when called without any subcommands. var rootCmd = &cobra.Command{ Use: "keyhunter", Short: "KeyHunter — detect leaked LLM API keys across 108+ providers", Long: `KeyHunter scans files, git history, and internet sources for leaked LLM API keys. Supports 108+ providers with Aho-Corasick pre-filtering and regex + entropy detection.`, SilenceUsage: true, } // Execute is the entry point called by main.go. func Execute() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.keyhunter.yaml)") rootCmd.AddCommand(scanCmd) rootCmd.AddCommand(providersCmd) rootCmd.AddCommand(configCmd) // Stub commands for future phases (per CLI-01 requirement of 11 commands) rootCmd.AddCommand(verifyCmd) rootCmd.AddCommand(importCmd) rootCmd.AddCommand(reconCmd) rootCmd.AddCommand(keysCmd) rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(dorksCmd) rootCmd.AddCommand(hookCmd) rootCmd.AddCommand(scheduleCmd) } func initConfig() { if cfgFile != "" { viper.SetConfigFile(cfgFile) } else { home, err := os.UserHomeDir() if err != nil { fmt.Fprintln(os.Stderr, "warning: cannot determine home directory:", err) return } viper.SetConfigName(".keyhunter") viper.SetConfigType("yaml") viper.AddConfigPath(home) viper.AddConfigPath(".") } viper.SetEnvPrefix("KEYHUNTER") viper.AutomaticEnv() // Defaults viper.SetDefault("scan.workers", 0) // 0 = auto (CPU*8) viper.SetDefault("database.path", filepath.Join(mustHomeDir(), ".keyhunter", "keyhunter.db")) // Config file is optional — ignore if not found _ = viper.ReadInConfig() } func mustHomeDir() string { h, _ := os.UserHomeDir() return h } ``` cd /home/salva/Documents/apikey && go build ./... && ./keyhunter --help 2>&1 | grep -E "scan|providers|config|verify|recon|keys|serve|dorks|hook|schedule" && echo "HELP OK" - `go build ./...` exits 0 - `./keyhunter --help` shows scan, providers, config, verify, import, recon, keys, serve, dorks, hook, schedule in command list - pkg/config/config.go exports Config and Load - pkg/output/table.go exports PrintFindings - pkg/storage/settings.go exports GetSetting and SetSetting - cmd/root.go declares rootCmd, Execute(), and adds all 11 subcommands - `grep -q 'viper\.SetConfigFile\|viper\.SetConfigName' cmd/root.go` exits 0 - lipgloss used for header and confidence coloring Root command registers all 11 CLI commands. Config package, output table, and settings helpers exist. `keyhunter --help` shows all commands. Task 2: scan, providers, config subcommands, and stub commands cmd/scan.go, cmd/providers.go, cmd/config.go, cmd/stubs.go - /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-04, CLI-05 rows, Pattern 2 pipeline usage) - /home/salva/Documents/apikey/cmd/root.go (rootCmd, viper setup) - /home/salva/Documents/apikey/pkg/engine/engine.go (Engine.Scan, ScanConfig) - /home/salva/Documents/apikey/pkg/storage/db.go (Open, SaveFinding) - /home/salva/Documents/apikey/pkg/storage/settings.go (GetSetting, SetSetting) - /home/salva/Documents/apikey/pkg/providers/registry.go (NewRegistry, List, Get, Stats) Create **cmd/scan.go**: ```go package cmd import ( "context" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "runtime" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/salvacybersec/keyhunter/pkg/config" "github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/engine/sources" "github.com/salvacybersec/keyhunter/pkg/output" "github.com/salvacybersec/keyhunter/pkg/providers" "github.com/salvacybersec/keyhunter/pkg/storage" ) var ( flagWorkers int flagVerify bool flagUnmask bool flagOutput string flagExclude []string ) var scanCmd = &cobra.Command{ Use: "scan ", Short: "Scan a file or directory for leaked API keys", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := args[0] // Load config cfg := config.Load() if viper.GetInt("scan.workers") > 0 { cfg.Workers = viper.GetInt("scan.workers") } // Workers flag overrides config workers := flagWorkers if workers <= 0 { workers = cfg.Workers } if workers <= 0 { workers = runtime.NumCPU() * 8 } // Initialize registry reg, err := providers.NewRegistry() if err != nil { return fmt.Errorf("loading providers: %w", err) } // Initialize engine eng := engine.NewEngine(reg) src := sources.NewFileSource(target) scanCfg := engine.ScanConfig{ Workers: workers, Verify: flagVerify, Unmask: flagUnmask, } // Open database (ensure directory exists) dbPath := viper.GetString("database.path") if dbPath == "" { dbPath = cfg.DBPath } if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { return fmt.Errorf("creating database directory: %w", err) } db, err := storage.Open(dbPath) if err != nil { return fmt.Errorf("opening database: %w", err) } defer db.Close() // Derive encryption key using a per-installation salt stored in settings table. // On first run, NewSalt() generates a random salt and stores it. // On subsequent runs, the same salt is loaded — ensuring consistent encryption. encKey, err := loadOrCreateEncKey(db, cfg.Passphrase) if err != nil { return fmt.Errorf("preparing encryption key: %w", err) } // Run scan ch, err := eng.Scan(context.Background(), src, scanCfg) if err != nil { return fmt.Errorf("starting scan: %w", err) } var findings []engine.Finding for f := range ch { findings = append(findings, f) // Persist to storage storeFinding := storage.Finding{ ProviderName: f.ProviderName, KeyValue: f.KeyValue, KeyMasked: f.KeyMasked, Confidence: f.Confidence, SourcePath: f.Source, SourceType: f.SourceType, LineNumber: f.LineNumber, } if _, err := db.SaveFinding(storeFinding, encKey); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err) } } // Output switch flagOutput { case "json": // Return valid empty JSON array when no findings; full JSON in Phase 6. enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") type jsonFinding struct { Provider string `json:"provider"` KeyMasked string `json:"key_masked"` Confidence string `json:"confidence"` Source string `json:"source"` Line int `json:"line"` } out := make([]jsonFinding, 0, len(findings)) for _, f := range findings { out = append(out, jsonFinding{ Provider: f.ProviderName, KeyMasked: f.KeyMasked, Confidence: f.Confidence, Source: f.Source, Line: f.LineNumber, }) } if err := enc.Encode(out); err != nil { return fmt.Errorf("encoding JSON output: %w", err) } default: output.PrintFindings(findings, flagUnmask) } // Exit code semantics (CLI-05 / OUT-06): 0=clean, 1=found, 2=error if len(findings) > 0 { os.Exit(1) } return nil }, } // loadOrCreateEncKey loads the per-installation salt from the settings table. // On first run it generates a new random salt with storage.NewSalt() and persists it. // The salt is hex-encoded in the settings table under key "encryption.salt". func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error) { const saltKey = "encryption.salt" saltHex, found, err := db.GetSetting(saltKey) if err != nil { return nil, fmt.Errorf("reading salt from settings: %w", err) } var salt []byte if !found { // First run: generate and persist a new random salt. salt, err = storage.NewSalt() if err != nil { return nil, fmt.Errorf("generating salt: %w", err) } if err := db.SetSetting(saltKey, hex.EncodeToString(salt)); err != nil { return nil, fmt.Errorf("storing salt: %w", err) } } else { salt, err = hex.DecodeString(saltHex) if err != nil { return nil, fmt.Errorf("decoding stored salt: %w", err) } } return storage.DeriveKey([]byte(passphrase), salt), nil } func init() { scanCmd.Flags().IntVar(&flagWorkers, "workers", 0, "number of worker goroutines (default: CPU*8)") scanCmd.Flags().BoolVar(&flagVerify, "verify", false, "actively verify found keys (opt-in, Phase 5)") scanCmd.Flags().BoolVar(&flagUnmask, "unmask", false, "show full key values (default: masked)") scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json (full JSON output in Phase 6)") scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "glob patterns to exclude (e.g. *.min.js)") viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers")) } ``` Create **cmd/providers.go**: ```go package cmd import ( "fmt" "os" "strings" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/salvacybersec/keyhunter/pkg/providers" ) var providersCmd = &cobra.Command{ Use: "providers", Short: "Manage and inspect provider definitions", } var providersListCmd = &cobra.Command{ Use: "list", Short: "List all loaded provider definitions", RunE: func(cmd *cobra.Command, args []string) error { reg, err := providers.NewRegistry() if err != nil { return err } bold := lipgloss.NewStyle().Bold(true) fmt.Fprintf(os.Stdout, "%-20s %-6s %-8s %s\n", bold.Render("NAME"), bold.Render("TIER"), bold.Render("PATTERNS"), bold.Render("KEYWORDS")) fmt.Println(strings.Repeat("─", 70)) for _, p := range reg.List() { fmt.Fprintf(os.Stdout, "%-20s %-6d %-8d %s\n", p.Name, p.Tier, len(p.Patterns), strings.Join(p.Keywords, ", ")) } stats := reg.Stats() fmt.Printf("\nTotal: %d providers\n", stats.Total) return nil }, } var providersInfoCmd = &cobra.Command{ Use: "info ", Short: "Show detailed info for a provider", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { reg, err := providers.NewRegistry() if err != nil { return err } p, ok := reg.Get(args[0]) if !ok { return fmt.Errorf("provider %q not found", args[0]) } fmt.Printf("Name: %s\n", p.Name) fmt.Printf("Display Name: %s\n", p.DisplayName) fmt.Printf("Tier: %d\n", p.Tier) fmt.Printf("Last Verified: %s\n", p.LastVerified) fmt.Printf("Keywords: %s\n", strings.Join(p.Keywords, ", ")) fmt.Printf("Patterns: %d\n", len(p.Patterns)) for i, pat := range p.Patterns { fmt.Printf(" [%d] regex=%s confidence=%s entropy_min=%.1f\n", i+1, pat.Regex, pat.Confidence, pat.EntropyMin) } if p.Verify.URL != "" { fmt.Printf("Verify URL: %s %s\n", p.Verify.Method, p.Verify.URL) } return nil }, } var providersStatsCmd = &cobra.Command{ Use: "stats", Short: "Show provider statistics", RunE: func(cmd *cobra.Command, args []string) error { reg, err := providers.NewRegistry() if err != nil { return err } stats := reg.Stats() fmt.Printf("Total providers: %d\n", stats.Total) fmt.Printf("By tier:\n") for tier := 1; tier <= 9; tier++ { if count := stats.ByTier[tier]; count > 0 { fmt.Printf(" Tier %d: %d\n", tier, count) } } fmt.Printf("By confidence:\n") for conf, count := range stats.ByConfidence { fmt.Printf(" %s: %d\n", conf, count) } return nil }, } func init() { providersCmd.AddCommand(providersListCmd) providersCmd.AddCommand(providersInfoCmd) providersCmd.AddCommand(providersStatsCmd) } ``` Create **cmd/config.go**: ```go package cmd import ( "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/spf13/viper" ) var configCmd = &cobra.Command{ Use: "config", Short: "Manage KeyHunter configuration", } var configInitCmd = &cobra.Command{ Use: "init", Short: "Create default configuration file at ~/.keyhunter.yaml", RunE: func(cmd *cobra.Command, args []string) error { home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("cannot determine home directory: %w", err) } configPath := filepath.Join(home, ".keyhunter.yaml") // Set defaults before writing viper.SetDefault("scan.workers", 0) viper.SetDefault("database.path", filepath.Join(home, ".keyhunter", "keyhunter.db")) if err := viper.WriteConfigAs(configPath); err != nil { return fmt.Errorf("writing config: %w", err) } fmt.Printf("Config initialized: %s\n", configPath) return nil }, } var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a configuration value", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { key, value := args[0], args[1] viper.Set(key, value) if err := viper.WriteConfig(); err != nil { // If config file doesn't exist yet, create it home, _ := os.UserHomeDir() configPath := filepath.Join(home, ".keyhunter.yaml") if err2 := viper.WriteConfigAs(configPath); err2 != nil { return fmt.Errorf("writing config: %w", err2) } } fmt.Printf("Set %s = %s\n", key, value) return nil }, } var configGetCmd = &cobra.Command{ Use: "get ", Short: "Get a configuration value", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { val := viper.Get(args[0]) if val == nil { return fmt.Errorf("key %q not found", args[0]) } fmt.Printf("%v\n", val) return nil }, } func init() { configCmd.AddCommand(configInitCmd) configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configGetCmd) } ``` Create **cmd/stubs.go** — stub commands for the 8 phases not yet implemented. These satisfy CLI-01 (11 commands) and print a clear "not implemented" message so users know the command exists but is pending a future phase. ```go package cmd import ( "fmt" "github.com/spf13/cobra" ) // notImplemented returns a RunE function that prints a "not yet implemented" message. // Each stub command is registered in root.go and satisfies CLI-01's 11-command requirement. func notImplemented(name, phase string) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { fmt.Printf("%s: not implemented in this phase (coming in %s)\n", name, phase) return nil } } var verifyCmd = &cobra.Command{ Use: "verify", Short: "Actively verify found API keys (Phase 5)", RunE: notImplemented("verify", "Phase 5"), } var importCmd = &cobra.Command{ Use: "import", Short: "Import findings from TruffleHog or Gitleaks output (Phase 7)", RunE: notImplemented("import", "Phase 7"), } var reconCmd = &cobra.Command{ Use: "recon", Short: "Run OSINT recon across internet sources (Phase 9+)", RunE: notImplemented("recon", "Phase 9"), } var keysCmd = &cobra.Command{ Use: "keys", Short: "Manage stored keys (list, export, delete) (Phase 6)", RunE: notImplemented("keys", "Phase 6"), } var serveCmd = &cobra.Command{ Use: "serve", Short: "Start the web dashboard (Phase 18)", RunE: notImplemented("serve", "Phase 18"), } var dorksCmd = &cobra.Command{ Use: "dorks", Short: "Manage and run dork queries (Phase 8)", RunE: notImplemented("dorks", "Phase 8"), } var hookCmd = &cobra.Command{ Use: "hook", Short: "Install or manage git pre-commit hooks (Phase 7)", RunE: notImplemented("hook", "Phase 7"), } var scheduleCmd = &cobra.Command{ Use: "schedule", Short: "Manage scheduled recurring scans (Phase 17)", RunE: notImplemented("schedule", "Phase 17"), } ``` cd /home/salva/Documents/apikey && go build -o keyhunter . && ./keyhunter providers list && ./keyhunter providers info openai && echo "PROVIDERS OK" - `go build -o keyhunter .` exits 0 - `./keyhunter --help` shows all 11 commands: scan, verify, import, recon, keys, serve, dorks, hook, schedule, providers, config - `./keyhunter providers list` prints table with >= 3 rows including "openai" - `./keyhunter providers info openai` prints Name, Tier, Keywords, Patterns, Verify URL - `./keyhunter providers stats` prints "Total providers: 3" or more - `./keyhunter config init` creates or updates ~/.keyhunter.yaml - `./keyhunter config set scan.workers 16` exits 0 - `./keyhunter verify` prints "not implemented in this phase" - `./keyhunter recon` prints "not implemented in this phase" - `./keyhunter scan testdata/samples/openai_key.txt` exits 1 (keys found) and prints a table row with "openai" - `./keyhunter scan testdata/samples/no_keys.txt` exits 0 and prints "No API keys found." - `./keyhunter scan --output json testdata/samples/no_keys.txt` exits 0 and prints `[]` (valid JSON) - Second run of `./keyhunter scan testdata/samples/openai_key.txt` uses the SAME salt (loaded from settings table) - `grep -q 'viper\.BindPFlag' cmd/scan.go` exits 0 - `grep -q 'loadOrCreateEncKey' cmd/scan.go` exits 0 Full CLI works: scan finds and persists keys with per-installation salt, providers list/info/stats work, config init/set/get work, 8 stub commands registered and respond. Phase 1 success criteria all met. Complete Phase 1 implementation: - Provider registry with 3 YAML definitions, Aho-Corasick automaton, schema validation - Storage layer with AES-256-GCM encryption, Argon2id key derivation, SQLite WAL mode, per-installation salt - Three-stage scan engine: keyword pre-filter → regex + entropy detector → finding channel - CLI: keyhunter scan, providers list/info/stats, config init/set/get - 8 stub commands for future phases (verify, import, recon, keys, serve, dorks, hook, schedule) Run these commands from the project root and confirm each expected output: 1. `cd /home/salva/Documents/apikey && go test ./... -v -count=1` Expected: All tests PASS, zero FAIL, zero SKIP (except original stubs now filled) 2. `./keyhunter scan testdata/samples/openai_key.txt` Expected: Exit code 1, table printed with 1 row showing "openai" provider, masked key 3. `./keyhunter scan testdata/samples/no_keys.txt` Expected: Exit code 0, "No API keys found." printed 4. `./keyhunter scan --output json testdata/samples/no_keys.txt` Expected: Exit code 0, valid JSON printed: `[]` 5. `./keyhunter providers list` Expected: Table with openai, anthropic, huggingface rows 6. `./keyhunter providers info openai` Expected: Name, Tier 1, Keywords including "sk-proj-", Pattern regex shown 7. `./keyhunter config init` Expected: "Config initialized: ~/.keyhunter.yaml" and the file exists 8. `./keyhunter config set scan.workers 16 && ./keyhunter config get scan.workers` Expected: "Set scan.workers = 16" then "16" 9. `./keyhunter verify` Expected: "verify: not implemented in this phase (coming in Phase 5)" 10. Build the binary with production flags: `CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .` Expected: Builds without error, binary produced Type "approved" if all 10 checks pass, or describe which check failed and what output you saw. Full Phase 1 integration check: - `go test ./... -count=1` exits 0 - `./keyhunter scan testdata/samples/openai_key.txt` exits 1 with findings table - `./keyhunter scan testdata/samples/no_keys.txt` exits 0 with "No API keys found." - `./keyhunter scan --output json testdata/samples/no_keys.txt` prints valid JSON `[]` - `./keyhunter providers list` shows 3+ providers - `./keyhunter config init` creates ~/.keyhunter.yaml - `./keyhunter verify` prints "not implemented in this phase" - `CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .` exits 0 - Cobra CLI with all 11 commands: scan, verify, import, recon, keys, serve, dorks, hook, schedule, providers, config (CLI-01) - `keyhunter config init` creates ~/.keyhunter.yaml (CLI-02) - `keyhunter config set key value` persists (CLI-03) - `keyhunter providers list/info/stats` work (CLI-04) - scan flags: --workers, --verify, --unmask, --output, --exclude (CLI-05) - Per-installation salt stored in settings table; no hardcoded salt in production code - JSON output returns valid JSON (not a comment string) - All Phase 1 success criteria from ROADMAP.md satisfied: 1. `keyhunter scan ./somefile` runs three-stage pipeline and returns findings with provider names 2. Findings persisted to SQLite with AES-256 encrypted key_value; raw db does not contain plaintext 3. `keyhunter config init` and `config set` work 4. `keyhunter providers list/info` return provider metadata from YAML 5. Provider YAML has format_version and last_verified, validated at load time After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` following the summary template.