Files
keyhunter/.planning/phases/01-foundation/01-05-PLAN.md

34 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 05 execute 3
01-02
01-03
01-04
cmd/root.go
cmd/scan.go
cmd/providers.go
cmd/config.go
cmd/stubs.go
pkg/config/config.go
pkg/output/table.go
true
CLI-01
CLI-02
CLI-03
CLI-04
CLI-05
truths artifacts key_links
`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
path provides contains
cmd/root.go Cobra root command with PersistentPreRunE config loading cobra.Command
path provides exports
cmd/scan.go scan command wiring Engine + FileSource + output table + salt from settings
scanCmd
path provides exports
cmd/stubs.go stub commands for verify, import, recon, keys, serve, dorks, hook, schedule
verifyCmd
importCmd
reconCmd
keysCmd
serveCmd
dorksCmd
hookCmd
scheduleCmd
path provides exports
cmd/providers.go providers list/info/stats subcommands using Registry
providersCmd
path provides exports
cmd/config.go config init/set/get subcommands using Viper
configCmd
path provides exports
pkg/config/config.go Config struct with Load() and defaults
Config
Load
path provides exports
pkg/output/table.go lipgloss terminal table for printing Findings
PrintFindings
from to via pattern
cmd/scan.go pkg/engine/engine.go engine.NewEngine(registry).Scan() called in RunE engine.NewEngine
from to via pattern
cmd/scan.go pkg/storage/db.go storage.Open() called, SaveFinding for each result storage.Open
from to via pattern
cmd/scan.go pkg/storage/crypto.go loadOrCreateSalt() reads salt from settings table via storage.GetSetting/SetSetting, then calls storage.DeriveKey DeriveKey|GetSetting|SetSetting
from to via pattern
cmd/root.go github.com/spf13/viper viper.SetConfigFile in PersistentPreRunE viper.SetConfigFile
from to via pattern
cmd/providers.go pkg/providers/registry.go Registry.List(), Registry.Get(), Registry.Stats() called 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.

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

@.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:

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

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 <name>",
    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:

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 <key> <value>",
    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 <key>",
    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.

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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` following the summary template.