--- phase: 04-input-sources plan: 05 type: execute wave: 2 depends_on: ["04-02", "04-03", "04-04"] files_modified: - cmd/scan.go - cmd/scan_sources_test.go autonomous: true requirements: - INPUT-06 must_haves: truths: - "keyhunter scan uses DirSource when target is a directory (not FileSource)" - "keyhunter scan continues to use FileSource when target is a single file" - "keyhunter scan --git uses GitSource, honoring --since YYYY-MM-DD" - "keyhunter scan stdin and keyhunter scan - both use StdinSource" - "keyhunter scan --url uses URLSource" - "keyhunter scan --clipboard uses ClipboardSource (no positional arg required)" - "--exclude flags are forwarded to DirSource" - "Exactly one source is selected — conflicting flags return an error" artifacts: - path: "cmd/scan.go" provides: "Source-selection logic dispatching to the appropriate Source implementation" contains: "selectSource" min_lines: 180 - path: "cmd/scan_sources_test.go" provides: "Unit tests for selectSource covering every flag combination" min_lines: 80 key_links: - from: "cmd/scan.go" to: "pkg/engine/sources" via: "sources.NewDirSource/NewGitSource/NewStdinSource/NewURLSource/NewClipboardSource" pattern: "sources\\.New(Dir|Git|Stdin|URL|Clipboard)Source" - from: "cmd/scan.go" to: "cobra flags" via: "--git, --url, --clipboard, --since, --exclude" pattern: "\\-\\-git|\\-\\-url|\\-\\-clipboard|\\-\\-since" --- Wire the four new source adapters (DirSource, GitSource, StdinSource, URLSource, ClipboardSource) into `cmd/scan.go` via a new `selectSource` helper that inspects CLI flags and positional args to pick exactly one source. Satisfies INPUT-06 (the "all inputs flow through the same pipeline" integration requirement). Purpose: Plans 04-02 through 04-04 deliver the Source implementations in isolation. This plan is the single integration point that makes them reachable from the CLI, with argument validation to prevent ambiguous invocations like `keyhunter scan --git --url https://...`. Output: Updated `cmd/scan.go` with new flags and dispatching logic, plus a focused test file exercising `selectSource` directly. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/04-input-sources/04-CONTEXT.md @cmd/scan.go @pkg/engine/sources/source.go Source constructors from Wave 1 plans: ```go // Plan 04-02 func NewFileSource(path string) *FileSource func NewDirSource(root string, extraExcludes ...string) *DirSource func NewDirSourceRaw(root string, excludes []string) *DirSource // Plan 04-03 func NewGitSource(repoPath string) *GitSource type GitSource struct { RepoPath string Since time.Time ChunkSize int } // Plan 04-04 func NewStdinSource() *StdinSource func NewURLSource(rawURL string) *URLSource func NewClipboardSource() *ClipboardSource ``` Existing cmd/scan.go contract (see file for full body): - Package `cmd` - Uses `sources.NewFileSource(target)` unconditionally today - Has `flagExclude []string` already declared - init() registers flags: --workers, --verify, --unmask, --output, --exclude Task 1: Add source-selection flags and dispatch logic to cmd/scan.go - cmd/scan.go (full file) - pkg/engine/sources/source.go - pkg/engine/sources/dir.go (produced by 04-02) - pkg/engine/sources/git.go (produced by 04-03) - pkg/engine/sources/stdin.go (produced by 04-04) - pkg/engine/sources/url.go (produced by 04-04) - pkg/engine/sources/clipboard.go (produced by 04-04) cmd/scan.go, cmd/scan_sources_test.go - Test 1: selectSource with target="." on a directory returns a *DirSource - Test 2: selectSource with target pointing to a file returns a *FileSource - Test 3: selectSource with flagGit=true and target="./repo" returns a *GitSource - Test 4: selectSource with flagGit=true and flagSince="2024-01-01" sets GitSource.Since correctly - Test 5: selectSource with invalid --since format returns a parse error - Test 6: selectSource with flagURL set returns a *URLSource - Test 7: selectSource with flagClipboard=true and no args returns a *ClipboardSource - Test 8: selectSource with target="stdin" returns a *StdinSource - Test 9: selectSource with target="-" returns a *StdinSource - Test 10: selectSource with both --git and --url set returns an error - Test 11: selectSource with --clipboard and a positional target returns an error - Test 12: selectSource forwards --exclude patterns into DirSource.Excludes Edit `cmd/scan.go`. The end state must: 1. Add new package-level flag vars alongside the existing ones: ```go var ( flagWorkers int flagVerify bool flagUnmask bool flagOutput string flagExclude []string flagGit bool flagURL string flagClipboard bool flagSince string flagMaxFileSize int64 flagInsecure bool ) ``` 2. Change `scanCmd.Args` so a positional target is optional when `--url` or `--clipboard` is used: ```go var scanCmd = &cobra.Command{ Use: "scan [path|stdin|-]", Short: "Scan files, directories, git history, stdin, URLs, or clipboard for leaked API keys", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // ... existing config load ... src, err := selectSource(args, sourceFlags{ Git: flagGit, URL: flagURL, Clipboard: flagClipboard, Since: flagSince, Excludes: flagExclude, }) if err != nil { return err } // Replace the old `src := sources.NewFileSource(target)` line with use of the dispatched src. // Keep all downstream code unchanged (engine, storage, output). // ... rest of existing RunE body, using src ... _ = src return nil // placeholder — keep existing logic }, } ``` 3. Add the selectSource helper and its supporting struct, in `cmd/scan.go`: ```go // sourceFlags captures the CLI inputs that control source selection. // Extracted into a struct so selectSource is straightforward to unit test. type sourceFlags struct { Git bool URL string Clipboard bool Since string Excludes []string } // selectSource inspects positional args and source flags, validates that // exactly one source is specified, and returns the appropriate Source. func selectSource(args []string, f sourceFlags) (sources.Source, error) { // Count explicit source selectors that take no positional path. explicitCount := 0 if f.URL != "" { explicitCount++ } if f.Clipboard { explicitCount++ } if f.Git { explicitCount++ } if explicitCount > 1 { return nil, fmt.Errorf("scan: --git, --url, and --clipboard are mutually exclusive") } // Clipboard and URL take no positional argument. if f.Clipboard { if len(args) > 0 { return nil, fmt.Errorf("scan: --clipboard does not accept a positional argument") } return sources.NewClipboardSource(), nil } if f.URL != "" { if len(args) > 0 { return nil, fmt.Errorf("scan: --url does not accept a positional argument") } return sources.NewURLSource(f.URL), nil } if len(args) == 0 { return nil, fmt.Errorf("scan: missing target (path, stdin, -, or a source flag)") } target := args[0] if target == "stdin" || target == "-" { if f.Git { return nil, fmt.Errorf("scan: --git cannot be combined with stdin") } return sources.NewStdinSource(), nil } if f.Git { gs := sources.NewGitSource(target) if f.Since != "" { t, err := time.Parse("2006-01-02", f.Since) if err != nil { return nil, fmt.Errorf("scan: --since must be YYYY-MM-DD: %w", err) } gs.Since = t } return gs, nil } info, err := os.Stat(target) if err != nil { return nil, fmt.Errorf("scan: stat %q: %w", target, err) } if info.IsDir() { return sources.NewDirSource(target, f.Excludes...), nil } return sources.NewFileSource(target), nil } ``` 4. In the existing `init()`, register the new flags next to the existing ones: ```go 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") scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "extra glob patterns to exclude (e.g. *.min.js)") // Phase 4 source-selection flags. scanCmd.Flags().BoolVar(&flagGit, "git", false, "treat target as a git repo and scan full history") scanCmd.Flags().StringVar(&flagURL, "url", "", "fetch and scan a remote http(s) URL (no positional arg)") scanCmd.Flags().BoolVar(&flagClipboard, "clipboard", false, "scan current clipboard contents") scanCmd.Flags().StringVar(&flagSince, "since", "", "for --git: only scan commits after YYYY-MM-DD") scanCmd.Flags().Int64Var(&flagMaxFileSize, "max-file-size", 0, "max file size in bytes to scan (0 = unlimited)") scanCmd.Flags().BoolVar(&flagInsecure, "insecure", false, "for --url: skip TLS certificate verification") _ = viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers")) } ``` 5. Replace the single line `src := sources.NewFileSource(target)` in the existing RunE body with the `selectSource` dispatch. Leave ALL downstream code (engine.Scan, storage.SaveFinding, output switch, exit code logic) untouched. Ensure the `target` variable is only used where relevant (it is no longer the sole driver of source construction). 6. Add the `time` import to `cmd/scan.go`. Create `cmd/scan_sources_test.go`: ```go package cmd import ( "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/require" "github.com/salvacybersec/keyhunter/pkg/engine/sources" ) func TestSelectSource_Directory(t *testing.T) { dir := t.TempDir() src, err := selectSource([]string{dir}, sourceFlags{}) require.NoError(t, err) _, ok := src.(*sources.DirSource) require.True(t, ok, "expected *DirSource, got %T", src) } func TestSelectSource_File(t *testing.T) { dir := t.TempDir() f := filepath.Join(dir, "a.txt") require.NoError(t, os.WriteFile(f, []byte("x"), 0o644)) src, err := selectSource([]string{f}, sourceFlags{}) require.NoError(t, err) _, ok := src.(*sources.FileSource) require.True(t, ok, "expected *FileSource, got %T", src) } func TestSelectSource_Git(t *testing.T) { src, err := selectSource([]string{"./some-repo"}, sourceFlags{Git: true}) require.NoError(t, err) gs, ok := src.(*sources.GitSource) require.True(t, ok, "expected *GitSource, got %T", src) require.Equal(t, "./some-repo", gs.RepoPath) } func TestSelectSource_GitSince(t *testing.T) { src, err := selectSource([]string{"./repo"}, sourceFlags{Git: true, Since: "2024-01-15"}) require.NoError(t, err) gs := src.(*sources.GitSource) want, _ := time.Parse("2006-01-02", "2024-01-15") require.Equal(t, want, gs.Since) } func TestSelectSource_GitSinceBadFormat(t *testing.T) { _, err := selectSource([]string{"./repo"}, sourceFlags{Git: true, Since: "15/01/2024"}) require.Error(t, err) require.Contains(t, err.Error(), "YYYY-MM-DD") } func TestSelectSource_URL(t *testing.T) { src, err := selectSource(nil, sourceFlags{URL: "https://example.com/a.js"}) require.NoError(t, err) _, ok := src.(*sources.URLSource) require.True(t, ok) } func TestSelectSource_URLRejectsPositional(t *testing.T) { _, err := selectSource([]string{"./foo"}, sourceFlags{URL: "https://x"}) require.Error(t, err) } func TestSelectSource_Clipboard(t *testing.T) { src, err := selectSource(nil, sourceFlags{Clipboard: true}) require.NoError(t, err) _, ok := src.(*sources.ClipboardSource) require.True(t, ok) } func TestSelectSource_ClipboardRejectsPositional(t *testing.T) { _, err := selectSource([]string{"./foo"}, sourceFlags{Clipboard: true}) require.Error(t, err) } func TestSelectSource_Stdin(t *testing.T) { for _, tok := range []string{"stdin", "-"} { src, err := selectSource([]string{tok}, sourceFlags{}) require.NoError(t, err) _, ok := src.(*sources.StdinSource) require.True(t, ok, "token %q: expected *StdinSource, got %T", tok, src) } } func TestSelectSource_MutuallyExclusive(t *testing.T) { _, err := selectSource(nil, sourceFlags{Git: true, URL: "https://x"}) require.Error(t, err) require.Contains(t, err.Error(), "mutually exclusive") } func TestSelectSource_MissingTarget(t *testing.T) { _, err := selectSource(nil, sourceFlags{}) require.Error(t, err) require.Contains(t, err.Error(), "missing target") } func TestSelectSource_DirForwardsExcludes(t *testing.T) { dir := t.TempDir() src, err := selectSource([]string{dir}, sourceFlags{Excludes: []string{"*.log", "tmp/**"}}) require.NoError(t, err) ds := src.(*sources.DirSource) // NewDirSource merges DefaultExcludes with extras, so user patterns must be present. found := 0 for _, e := range ds.Excludes { if e == "*.log" || e == "tmp/**" { found++ } } require.Equal(t, 2, found, "user excludes not forwarded, got %v", ds.Excludes) } ``` After making these changes, run `go build ./...` and fix any import or compile errors. Do NOT modify pkg/engine/sources/* files — they are owned by Wave 1 plans. go build ./... && go test ./cmd/... -run TestSelectSource -race -count=1 - `go build ./...` exits 0 - `go test ./cmd/... -run TestSelectSource -race -count=1` passes all 13 subtests - `go test ./... -race -count=1` full suite passes - `grep -n "selectSource" cmd/scan.go` returns at least two hits (definition + call site) - `grep -n "flagGit\|flagURL\|flagClipboard\|flagSince" cmd/scan.go` returns at least 4 hits - `grep -n "sources.NewDirSource\|sources.NewGitSource\|sources.NewStdinSource\|sources.NewURLSource\|sources.NewClipboardSource" cmd/scan.go` returns 5 hits - `grep -n "mutually exclusive" cmd/scan.go` returns a hit - `keyhunter scan --help` (via `go run . scan --help`) lists --git, --url, --clipboard, --since flags cmd/scan.go dispatches to the correct Source implementation based on positional args and flags, with unambiguous error messages for conflicting selectors. All selectSource tests pass under -race. The existing single-file FileSource path still works unchanged. - `go build ./...` exits 0 - `go test ./... -race -count=1` full suite green (including earlier Wave 1 plan tests) - `go run . scan --help` lists new flags - `go run . scan ./pkg` completes successfully (DirSource path) - `echo "API_KEY=test" | go run . scan -` completes successfully (StdinSource path) Users can invoke every Phase 4 input mode from the CLI and each one flows through the unchanged three-stage detection pipeline. INPUT-01 through INPUT-05 are reachable via CLI, and INPUT-06 (the integration meta-requirement) is satisfied by the passing test suite plus the help-text listing. After completion, create `.planning/phases/04-input-sources/04-05-SUMMARY.md` documenting: - selectSource signature and branches - Flag additions - Test pass summary - A short one-line example invocation per new source (dir, git, stdin, url, clipboard) - Confirmation that existing Phase 1-3 tests still pass