You are doing a FINAL whole-feature code review on the birdseye known-modules autocomplete cache feature in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Per-task reviews already happened; your job is the holistic view — cross-cutting concerns, integration coherence, things visible only when seeing the whole picture.

## Feature Scope

```
BASE_SHA: 474d56d (prior whole-branch state)
HEAD_SHA: 4f53099 (this feature complete)
```

5 commits, 564 LOC added across 8 files:

```
32e305b feat(birdseye): known-modules autocomplete cache with Path/Load/Add
171c32a test(birdseye): harden known-modules cache (dedup, sort, atomic, validation)
51a2c0e feat(birdseye): cache module address after successful CLI diff
4d747fc feat(birdseye): shell completion for diff and versions from known-modules cache
4f53099 feat(birdseye): TUI textinput suggestions and best-effort cache from interactive picker
```

## Spec & Plan

- Spec: `docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md`
- Plan: `docs/superpowers/plans/2026-05-13-birdseye-known-modules.md`

## Feature Architecture Recap

New `internal/known` package persists module addresses the user has successfully diff'd or browsed in `os.UserConfigDir()/birdseye/known-modules` (plain text, sorted, atomic writes). Two consumption surfaces share that cache:

1. Cobra shell completion (`ValidArgsFunction`) on both `diff` and `versions` subcommands
2. `bubbles/textinput.SetSuggestions` in the interactive TUI picker

Two write triggers (per spec, intentionally):
- After successful positional CLI `diff` (in `runDiff` after diff is built, before render)
- After picker's `ListVersions` returns successfully (fire-and-forget via `addKnownCmd`)

The `versions` subcommand intentionally does NOT write — it only reads for completion.

## Review Focus — Cross-cutting Only

You are NOT re-reviewing individual tasks. Focus on:

1. **Integration coherence**: does the `cmd → internal/known → internal/tui` graph make sense? Any unexpected cross-package deps? Does `internal/known` stay pure (no logging, no CLI)?

2. **End-to-end correctness scenarios**:
   - User runs `birdseye diff ns/n/p vA vB` → on success, file contains `ns/n/p` (sorted, deduped)
   - User runs `birdseye diff <TAB>` after at least one successful diff → completion shows the cached address
   - User runs `birdseye diff` (interactive) → textinput shows ghost suggestion of cached address
   - User runs `birdseye versions ns/n/p` → no file written, but `versions <TAB>` reads the file
   - Two parallel diffs writing simultaneously → last writer wins, no corruption (documented behavior)
   - First-run (file doesn't exist) → everything works, completion returns no suggestions

3. **Failure modes**:
   - `os.UserConfigDir()` fails → `Path()` errors → `resolveKnownPath` returns "" → `Load("")` returns error which Load swallows to nil suggestions; `Add("")` errors which the caller swallows. No crash, no temp file leak (verified: `os.Open("")` fails before any temp creation).
   - Failed diff (404, network) → no cache write (verified by test)
   - Failed render (writer closed) → cache already written (correct behavior — diff conceptually succeeded)
   - Bad address slips into file (manual edit) → `Load` silently skips via `validAddress`

4. **Resource hygiene**:
   - Atomic temp+rename pattern in `Add` — temp cleanup on every error branch?
   - `Picker.addKnownCmd("", ...)` returns nil → no wasted goroutine?
   - `Load` defers Close + checks Scanner.Err()?

5. **Test independence**:
   - Every test uses `t.TempDir()` for the cache file — no host-config pollution?
   - The default `Path()` is never actually written to by any test (assert by running tests and checking that no `~/.config/birdseye/known-modules` file is created or modified)?

6. **Style/idiom consistency with the rest of the birdseye codebase**:
   - Error wrapping style (`fmt.Errorf("x: %w", err)`)?
   - `_ = err` swallow pattern matches the registry/cache pattern?
   - Tests use stdlib `testing` only (no testify)?
   - Hidden flag pattern matches sibling apps?

7. **Build sanity**:
   - `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... && go test -race ./... && go vet ./...` → all clean
   - The `services/crow/pkg/client` tests still pass (we didn't touch them — confirm via `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/...`)

8. **Smoke test the new flags**:
   - `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && make build && ./bin/birdseye diff --help` — assert `--known-modules-file` is NOT shown (hidden flag)
   - `./bin/birdseye versions --help` — same (hidden)
   - Run a completion: `./bin/birdseye __complete diff ""` → should produce no error and either zero or "no suggestions" output

9. **Spec coverage**: walk the spec sections and confirm every requirement maps to a task. List any unaddressed requirements.

10. **Things that could break unrelated tests**: any change that might affect prior birdseye behavior (e.g., a subtle change to `parseModuleAddress`, an export that shadows existing identifiers)?

## Report Format

Lead with overall assessment. Then sections:

**Overall:** READY TO MERGE | NEEDS_FIXES_BEFORE_MERGE

**Integration coherence:**
**End-to-end correctness:**
**Failure modes:**
**Resource hygiene:**
**Test independence:**
**Style consistency:**
**Build sanity:**
**Smoke output:**
**Spec coverage:**

**Critical / Important / Minor issues** (only if any).

Be thorough but terse. This is the merge gate for this feature.


- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll perform a holistic review of this feature. Let me start by gathering the full picture.


Bash

List feature commits


4f53099 feat(birdseye): TUI textinput suggestions and best-effort cache from interactive picker
4d747fc feat(birdseye): shell completion for diff and versions from known-modules cache
51a2c0e feat(birdseye): cache module address after successful CLI diff
171c32a test(birdseye): harden known-modules cache (dedup, sort, atomic, validation)
32e305b feat(birdseye): known-modules autocomplete cache with Path/Load/Add


Diff stats for feature


 apps/birdseye/cmd/diff.go                  |  39 +++++-
 apps/birdseye/cmd/diff_test.go             |  77 ++++++++++++
 apps/birdseye/cmd/versions.go              |  14 ++-
 apps/birdseye/cmd/versions_test.go         |  46 +++++++
 apps/birdseye/internal/known/known.go      | 152 +++++++++++++++++++++++
 apps/birdseye/internal/known/known_test.go | 186 +++++++++++++++++++++++++++++
 apps/birdseye/internal/tui/picker.go       |  39 ++++--
 apps/birdseye/internal/tui/picker_test.go  |  27 ++++-
 8 files changed, 564 insertions(+), 16 deletions(-)


1	// Package known persists a sorted, deduplicated list of Crow module
2	// addresses the user has successfully diff'd or browsed, so future
3	// invocations can offer shell-completion and TUI suggestions.
4	//
5	// The on-disk format is plain text, one ns/name/provider per line,
6	// alphabetically sorted, with a trailing newline. Empty or missing
7	// file means "no suggestions yet" — not an error.
8	//
9	// Concurrent writers: last writer wins. Two birdseye processes running
10	// simultaneously may each see the file before the other's Rename
11	// lands; whichever Rename completes last sets the final contents.
12	// Acceptable for a low-frequency, append-only-ish list.
13	package known
14
15	import (
16		"bufio"
17		"fmt"
18		"os"
19		"path/filepath"
20		"sort"
21		"strings"
22
23		"github.com/immuta/operations/services/crow/pkg/api"
24	)
25
26	// Path returns the default known-modules file path, honoring
27	// os.UserConfigDir() (XDG_CONFIG_HOME on Linux, Application Support on
28	// macOS). The directory is NOT created here — Add creates it lazily on
29	// first write.
30	func Path() (string, error) {
31		cfg, err := os.UserConfigDir()
32		if err != nil {
33			return "", fmt.Errorf("resolve user config dir: %w", err)
34		}
35		return filepath.Join(cfg, "birdseye", "known-modules"), nil
36	}
37
38	// Load reads the file at path. Returns an empty slice if the file
39	// does not exist (first run is normal). Lines failing the
40	// ns/name/provider validation are silently skipped. The result is
41	// sorted ascending.
42	func Load(path string) ([]string, error) {
43		f, err := os.Open(path)
44		if err != nil {
45			if os.IsNotExist(err) {
46				return nil, nil
47			}
48			return nil, fmt.Errorf("open known-modules: %w", err)
49		}
50		defer f.Close()
51
52		var out []string
53		scanner := bufio.NewScanner(f)
54		for scanner.Scan() {
55			line := strings.TrimSpace(scanner.Text())
56			if line == "" {
57				continue
58			}
59			if !validAddress(line) {
60				continue
61			}
62			out = append(out, line)
63		}
64		if err := scanner.Err(); err != nil {
65			return nil, fmt.Errorf("scan known-modules: %w", err)
66		}
67		sort.Strings(out)
68		return out, nil
69	}
70
71	// Add inserts address into the file at path. Existing entries are
72	// preserved; duplicates deduplicated; the final list is sorted.
73	// Writes are atomic via temp file + rename in the same directory.
74	// The directory is created with 0o755 if missing. If address is not a
75	// well-formed ns/name/provider triple, Add returns an error without
76	// touching the file.
77	func Add(path, address string) error {
78		if !validAddress(address) {
79			return fmt.Errorf("invalid module address %q", address)
80		}
81		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
82			return fmt.Errorf("mkdir known-modules dir: %w", err)
83		}
84
85		existing, err := Load(path)
86		if err != nil {
87			return err
88		}
89		merged := mergeAndSort(existing, address)
90
91		tmp, err := os.CreateTemp(filepath.Dir(path), ".known-modules-*.tmp")
92		if err != nil {
93			return fmt.Errorf("create temp: %w", err)
94		}
95		tmpName := tmp.Name()
96		w := bufio.NewWriter(tmp)
97		for _, addr := range merged {
98			if _, err := w.WriteString(addr); err != nil {
99				_ = tmp.Close()
100				_ = os.Remove(tmpName)
101				return fmt.Errorf("write temp: %w", err)
102			}
103			if err := w.WriteByte('\n'); err != nil {
104				_ = tmp.Close()
105				_ = os.Remove(tmpName)
106				return fmt.Errorf("write temp: %w", err)
107			}
108		}
109		if err := w.Flush(); err != nil {
110			_ = tmp.Close()
111			_ = os.Remove(tmpName)
112			return fmt.Errorf("flush temp: %w", err)
113		}
114		if err := tmp.Close(); err != nil {
115			_ = os.Remove(tmpName)
116			return fmt.Errorf("close temp: %w", err)
117		}
118		if err := os.Rename(tmpName, path); err != nil {
119			_ = os.Remove(tmpName)
120			return fmt.Errorf("rename temp: %w", err)
121		}
122		return nil
123	}
124
125	// mergeAndSort returns a deduplicated, sorted slice combining existing
126	// with the new address.
127	func mergeAndSort(existing []string, address string) []string {
128		seen := make(map[string]struct{}, len(existing)+1)
129		out := make([]string, 0, len(existing)+1)
130		for _, a := range existing {
131			if _, dup := seen[a]; dup {
132				continue
133			}
134			seen[a] = struct{}{}
135			out = append(out, a)
136		}
137		if _, dup := seen[address]; !dup {
138			out = append(out, address)
139		}
140		sort.Strings(out)
141		return out
142	}
143
144	// validAddress checks that s is exactly three slash-separated segments
145	// each passing api.ValidateSegments.
146	func validAddress(s string) bool {
147		parts := strings.Split(s, "/")
148		if len(parts) != 3 {
149			return false
150		}
151		return api.ValidateSegments(parts) == nil
152	}
153


1	package known
2
3	import (
4		"os"
5		"path/filepath"
6		"strings"
7		"testing"
8	)
9
10	func TestLoadMissingFileReturnsEmpty(t *testing.T) {
11		dir := t.TempDir()
12		got, err := Load(filepath.Join(dir, "missing"))
13		if err != nil {
14			t.Fatalf("Load: %v", err)
15		}
16		if len(got) != 0 {
17			t.Errorf("expected empty slice, got %v", got)
18		}
19	}
20
21	func TestAddCreatesDirectoryAndFile(t *testing.T) {
22		dir := t.TempDir()
23		path := filepath.Join(dir, "subdir", "known-modules")
24		if err := Add(path, "iac/comply/aws"); err != nil {
25			t.Fatalf("Add: %v", err)
26		}
27		body, err := os.ReadFile(path)
28		if err != nil {
29			t.Fatalf("ReadFile: %v", err)
30		}
31		if string(body) != "iac/comply/aws\n" {
32			t.Errorf("body = %q", body)
33		}
34	}
35
36	func TestAddThenLoadRoundTrip(t *testing.T) {
37		dir := t.TempDir()
38		path := filepath.Join(dir, "known-modules")
39		if err := Add(path, "iac/comply/aws"); err != nil {
40			t.Fatalf("Add: %v", err)
41		}
42		got, err := Load(path)
43		if err != nil {
44			t.Fatalf("Load: %v", err)
45		}
46		if len(got) != 1 || got[0] != "iac/comply/aws" {
47			t.Errorf("Load = %v, want [iac/comply/aws]", got)
48		}
49	}
50
51	func TestPathHonorsUserConfigDir(t *testing.T) {
52		got, err := Path()
53		if err != nil {
54			t.Fatalf("Path: %v", err)
55		}
56		if !filepath.IsAbs(got) {
57			t.Errorf("expected absolute path, got %q", got)
58		}
59		base := filepath.Base(got)
60		if base != "known-modules" {
61			t.Errorf("expected basename %q, got %q", "known-modules", base)
62		}
63		parent := filepath.Base(filepath.Dir(got))
64		if parent != "birdseye" {
65			t.Errorf("expected parent dir %q, got %q", "birdseye", parent)
66		}
67	}
68
69	func TestAddDeduplicates(t *testing.T) {
70		dir := t.TempDir()
71		path := filepath.Join(dir, "known-modules")
72		for i := 0; i < 3; i++ {
73			if err := Add(path, "iac/comply/aws"); err != nil {
74				t.Fatalf("Add iteration %d: %v", i, err)
75			}
76		}
77		body, err := os.ReadFile(path)
78		if err != nil {
79			t.Fatal(err)
80		}
81		lines := strings.Split(strings.TrimRight(string(body), "\n"), "\n")
82		if len(lines) != 1 {
83			t.Errorf("expected 1 unique line, got %d: %v", len(lines), lines)
84		}
85	}
86
87	func TestAddSortsAlphabetically(t *testing.T) {
88		dir := t.TempDir()
89		path := filepath.Join(dir, "known-modules")
90		for _, addr := range []string{"z/y/x", "a/b/c", "m/n/o"} {
91			if err := Add(path, addr); err != nil {
92				t.Fatalf("Add %s: %v", addr, err)
93			}
94		}
95		got, err := Load(path)
96		if err != nil {
97			t.Fatal(err)
98		}
99		want := []string{"a/b/c", "m/n/o", "z/y/x"}
100		if len(got) != len(want) {
101			t.Fatalf("got %d lines, want %d", len(got), len(want))
102		}
103		for i := range got {
104			if got[i] != want[i] {
105				t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
106			}
107		}
108	}
109
110	func TestAddAtomicNoLingeringTemp(t *testing.T) {
111		dir := t.TempDir()
112		path := filepath.Join(dir, "known-modules")
113		if err := Add(path, "iac/comply/aws"); err != nil {
114			t.Fatalf("Add: %v", err)
115		}
116		entries, err := os.ReadDir(dir)
117		if err != nil {
118			t.Fatal(err)
119		}
120		for _, e := range entries {
121			if strings.HasPrefix(e.Name(), ".known-modules-") && strings.HasSuffix(e.Name(), ".tmp") {
122				t.Errorf("found lingering temp file: %s", e.Name())
123			}
124		}
125	}
126
127	func TestAddRejectsBadAddress(t *testing.T) {
128		dir := t.TempDir()
129		path := filepath.Join(dir, "known-modules")
130		cases := []string{
131			"not-three-parts",
132			"two/parts",
133			"four/parts/here/extra",
134			"has spaces/in/it",
135			"",
136		}
137		for _, addr := range cases {
138			t.Run(addr, func(t *testing.T) {
139				if err := Add(path, addr); err == nil {
140					t.Errorf("expected error for %q, got nil", addr)
141				}
142			})
143		}
144		// File must not have been created.
145		if _, err := os.Stat(path); !os.IsNotExist(err) {
146			t.Errorf("expected file to NOT exist, got err=%v", err)
147		}
148	}
149
150	func TestLoadSkipsMalformedLines(t *testing.T) {
151		dir := t.TempDir()
152		path := filepath.Join(dir, "known-modules")
153		content := "iac/comply/aws\nnot-valid\ntwo/parts\nplatform/networking/aws\n"
154		if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
155			t.Fatal(err)
156		}
157		got, err := Load(path)
158		if err != nil {
159			t.Fatalf("Load: %v", err)
160		}
161		want := []string{"iac/comply/aws", "platform/networking/aws"}
162		if len(got) != len(want) {
163			t.Fatalf("got %v, want %v", got, want)
164		}
165		for i := range got {
166			if got[i] != want[i] {
167				t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
168			}
169		}
170	}
171
172	func TestLoadSkipsBlankLines(t *testing.T) {
173		dir := t.TempDir()
174		path := filepath.Join(dir, "known-modules")
175		content := "\niac/comply/aws\n\n\nplatform/networking/aws\n"
176		if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
177			t.Fatal(err)
178		}
179		got, err := Load(path)
180		if err != nil {
181			t.Fatalf("Load: %v", err)
182		}
183		if len(got) != 2 {
184			t.Errorf("got %d lines, want 2: %v", len(got), got)
185		}
186	}
187


1	package cmd
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"io"
8		"net/http"
9		"os"
10		"path/filepath"
11		"strings"
12		"time"
13
14		tea "github.com/charmbracelet/bubbletea"
15		"github.com/spf13/cobra"
16		"golang.org/x/term"
17
18		"github.com/immuta/operations/apps/birdseye/internal/archive"
19		"github.com/immuta/operations/apps/birdseye/internal/diff"
20		"github.com/immuta/operations/apps/birdseye/internal/known"
21		"github.com/immuta/operations/apps/birdseye/internal/registry"
22		"github.com/immuta/operations/apps/birdseye/internal/tui"
23		"github.com/immuta/operations/services/crow/pkg/api"
24		"github.com/immuta/operations/services/crow/pkg/client"
25	)
26
27	// diffOpts holds the resolved options for `birdseye diff`.
28	type diffOpts struct {
29		Semantic         bool
30		Plain            bool
31		CacheDir         string
32		KnownModulesFile string // test override for known-modules cache path
33	}
34
35	const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size
36
37	func newDiffCmd(g *GlobalFlags) *cobra.Command {
38		o := &diffOpts{}
39		c := &cobra.Command{
40			Use:   "diff [module] [versionA] [versionB]",
41			Short: "Diff two versions of a Crow module",
42			Long: "Diff two versions of a module hosted in Crow.\n\n" +
43				"If module/versions are omitted, the interactive TUI launches\n" +
44				"(see picker task). With --plain, prints unified diff to stdout.",
45			Args:          cobra.MaximumNArgs(3),
46			SilenceUsage:  true,
47			SilenceErrors: true,
48			RunE: func(cmd *cobra.Command, args []string) error {
49				return runDiff(cmd.Context(), cmd, args, g, o)
50			},
51		}
52		c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
53		c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
54		c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
55		c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
56		c.Flags().StringVar(&o.KnownModulesFile, "known-modules-file", "", "override known-modules autocomplete file (mostly for tests)")
57		_ = c.Flags().MarkHidden("known-modules-file")
58		c.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
59			if len(args) > 0 {
60				return nil, cobra.ShellCompDirectiveNoFileComp
61			}
62			suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
63			return suggestions, cobra.ShellCompDirectiveNoFileComp
64		}
65		return c
66	}
67
68	func defaultCacheDir() string {
69		if home, err := os.UserCacheDir(); err == nil {
70			return filepath.Join(home, "birdseye")
71		}
72		return ".birdseye-cache"
73	}
74
75	// resolveKnownPath returns override if non-empty, otherwise the default
76	// known.Path(). Errors fall back to "" so Load and Add become no-ops.
77	func resolveKnownPath(override string) string {
78		if override != "" {
79			return override
80		}
81		p, err := known.Path()
82		if err != nil {
83			return ""
84		}
85		return p
86	}
87
88	func newHTTPClient(d time.Duration) *http.Client {
89		return &http.Client{Timeout: d}
90	}
91
92	func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
93		parts := strings.Split(s, "/")
94		if len(parts) != 3 {
95			return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
96		}
97		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
98		if err := id.Validate(); err != nil {
99			return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
100		}
101		return id, nil
102	}
103
104	func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
105		if len(args) == 0 {
106			return runInteractive(ctx, cmd, g, o)
107		}
108		if len(args) != 3 {
109			return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
110		}
111		id, err := parseModuleAddress(args[0])
112		if err != nil {
113			return err
114		}
115		if err := api.ValidateVersion(args[1]); err != nil {
116			return fmt.Errorf("%w: %v", ErrUsage, err)
117		}
118		if err := api.ValidateVersion(args[2]); err != nil {
119			return fmt.Errorf("%w: %v", ErrUsage, err)
120		}
121		modA := api.Module{ModuleIdentifier: id, Version: args[1]}
122		modB := api.Module{ModuleIdentifier: id, Version: args[2]}
123
124		cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
125		cache, err := registry.NewDiskCache(o.CacheDir)
126		if err != nil {
127			return fmt.Errorf("cache init: %w", err)
128		}
129		reg := registry.New(cli, cache)
130
131		aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
132		if err != nil {
133			return err
134		}
135
136		modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
137		if err != nil {
138			return fmt.Errorf("%w: %v", ErrArchive, err)
139		}
140		modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
141		if err != nil {
142			return fmt.Errorf("%w: %v", ErrArchive, err)
143		}
144
145		result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
146		if o.Semantic {
147			sem, err := diff.Semantic(modAExtracted, modBExtracted)
148			if err != nil {
149				return fmt.Errorf("%w: %v", ErrSemantic, err)
150			}
151			result.Semantic = sem
152		}
153
154		if err := known.Add(resolveKnownPath(o.KnownModulesFile),
155			id.Namespace+"/"+id.Name+"/"+id.Provider); err != nil {
156			_ = err // best-effort: cache failure does not block diff
157		}
158
159		if g.JSON {
160			return renderJSON(cmd.OutOrStdout(), result)
161		}
162		if !o.Plain && isTTY(cmd.OutOrStdout()) {
163			return tui.RunBrowser(ctx, result)
164		}
165		return renderPlain(cmd.OutOrStdout(), result)
166	}
167
168	// isTTY reports whether w is a *os.File backed by a terminal.
169	func isTTY(w io.Writer) bool {
170		f, ok := w.(*os.File)
171		if !ok {
172			return false
173		}
174		return term.IsTerminal(int(f.Fd()))
175	}
176
177	func runInteractive(ctx context.Context, cmd *cobra.Command, g *GlobalFlags, o *diffOpts) error {
178		if !isTTY(cmd.OutOrStdout()) {
179			return fmt.Errorf("%w: interactive mode requires a TTY", ErrUsage)
180		}
181		cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
182		cache, err := registry.NewDiskCache(o.CacheDir)
183		if err != nil {
184			return fmt.Errorf("cache init: %w", err)
185		}
186		reg := registry.New(cli, cache)
187
188		// Stage 1: picker → fetched bytes
189		knownPath := resolveKnownPath(o.KnownModulesFile)
190		suggestions, _ := known.Load(knownPath)
191		p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg}, suggestions, knownPath)
192		prog := tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))
193		if _, err := prog.Run(); err != nil {
194			return err
195		}
196		aBytes, bBytes := p.Bytes()
197		if aBytes == nil || bBytes == nil {
198			// User quit before completing picker; not an error.
199			return nil
200		}
201
202		// Stage 2: extract + diff + browser
203		modA, err := archive.Extract(aBytes, maxArchiveBytes)
204		if err != nil {
205			return fmt.Errorf("%w: %v", ErrArchive, err)
206		}
207		modB, err := archive.Extract(bBytes, maxArchiveBytes)
208		if err != nil {
209			return fmt.Errorf("%w: %v", ErrArchive, err)
210		}
211		result := diff.Result{Files: diff.Textual(modA, modB)}
212		if o.Semantic {
213			sem, err := diff.Semantic(modA, modB)
214			if err != nil {
215				return fmt.Errorf("%w: %v", ErrSemantic, err)
216			}
217			result.Semantic = sem
218		}
219		return tui.RunBrowser(ctx, result)
220	}
221
222	// renderPlain writes the diff result to w in unified-diff format.
223	// "no differences" is emitted when there are zero files in the result.
224	func renderPlain(w io.Writer, r diff.Result) error {
225		if len(r.Files) == 0 && len(r.Semantic) == 0 {
226			_, err := fmt.Fprintln(w, "no differences")
227			return err
228		}
229		for i, fd := range r.Files {
230			if i > 0 {
231				_, _ = fmt.Fprintln(w)
232			}
233			_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
234			_, _ = fmt.Fprint(w, fd.Unified)
235		}
236		if len(r.Semantic) > 0 {
237			_, _ = fmt.Fprintln(w)
238			_, _ = fmt.Fprintln(w, "=== semantic changes ===")
239			for _, c := range r.Semantic {
240				_, _ = fmt.Fprintf(w, "%s %s %s\n", c.Status, c.Kind, c.Name)
241			}
242		}
243		return nil
244	}
245
246	func renderJSON(w io.Writer, r diff.Result) error {
247		type fileOut struct {
248			Path    string `json:"path"`
249			Status  string `json:"status"`
250			Adds    int    `json:"adds"`
251			Dels    int    `json:"dels"`
252			Unified string `json:"unified"`
253		}
254		type semOut struct {
255			Kind   string `json:"kind"`
256			Name   string `json:"name"`
257			Status string `json:"status"`
258			Detail string `json:"detail,omitempty"`
259		}
260		type payload struct {
261			Files    []fileOut `json:"files"`
262			Semantic []semOut  `json:"semantic,omitempty"`
263		}
264		out := payload{Files: make([]fileOut, 0, len(r.Files))}
265		for _, fd := range r.Files {
266			out.Files = append(out.Files, fileOut{Path: fd.Path, Status: fd.Status.String(), Adds: fd.Adds, Dels: fd.Dels, Unified: fd.Unified})
267		}
268		for _, c := range r.Semantic {
269			out.Semantic = append(out.Semantic, semOut{Kind: c.Kind, Name: c.Name, Status: c.Status.String(), Detail: c.Detail})
270		}
271		return json.NewEncoder(w).Encode(out)
272	}
273


1	package cmd
2
3	import (
4		"archive/tar"
5		"bytes"
6		"compress/gzip"
7		"context"
8		"encoding/json"
9		"net/http"
10		"net/http/httptest"
11		"os"
12		"path/filepath"
13		"strings"
14		"testing"
15	)
16
17	// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
18	// (modified) and a new variables.tf.
19	func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
20		t.Helper()
21		mkTgz := func(files map[string]string) []byte {
22			var buf bytes.Buffer
23			gz := gzip.NewWriter(&buf)
24			tw := tar.NewWriter(gz)
25			for n, b := range files {
26				_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
27				_, _ = tw.Write([]byte(b))
28			}
29			_ = tw.Close()
30			_ = gz.Close()
31			return buf.Bytes()
32		}
33		v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
34		v2 := mkTgz(map[string]string{
35			"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
36			"variables.tf": "variable \"name\" { type = string }\n",
37		})
38
39		mux := http.NewServeMux()
40		blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41			switch r.URL.Path {
42			case "/blob/1.0.0":
43				_, _ = w.Write(v1)
44			case "/blob/1.1.0":
45				_, _ = w.Write(v2)
46			default:
47				http.NotFound(w, r)
48			}
49		}))
50		t.Cleanup(blob.Close)
51		mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
52			parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
53			if len(parts) != 2 || parts[1] != "download" {
54				http.NotFound(w, r)
55				return
56			}
57			w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
58			w.WriteHeader(http.StatusNoContent)
59		})
60		srv := httptest.NewServer(mux)
61		t.Cleanup(srv.Close)
62		return srv
63	}
64
65	func TestDiffPlainOutput(t *testing.T) {
66		srv := fakeCrowTwoVersions(t)
67		root := NewRootCmd()
68		var stdout, stderr bytes.Buffer
69		root.SetOut(&stdout)
70		root.SetErr(&stderr)
71		root.SetArgs([]string{
72			"diff",
73			"iac/comply/aws", "1.0.0", "1.1.0",
74			"--crow-url", srv.URL,
75			"--plain",
76			"--cache-dir", t.TempDir(),
77		})
78		if err := root.ExecuteContext(context.Background()); err != nil {
79			t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
80		}
81		out := stdout.String()
82		for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
83			if !strings.Contains(out, want) {
84				t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
85			}
86		}
87	}
88
89	func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
90		srv := fakeCrowTwoVersions(t)
91		root := NewRootCmd()
92		var stdout bytes.Buffer
93		root.SetOut(&stdout)
94		root.SetArgs([]string{
95			"diff",
96			"iac/comply/aws", "1.0.0", "1.0.0",
97			"--crow-url", srv.URL,
98			"--plain",
99			"--cache-dir", t.TempDir(),
100		})
101		if err := root.ExecuteContext(context.Background()); err != nil {
102			t.Fatalf("execute: %v", err)
103		}
104		if got := strings.TrimSpace(stdout.String()); got != "no differences" {
105			t.Errorf("output = %q, want \"no differences\"", got)
106		}
107	}
108
109	func TestDiffBadModuleAddress(t *testing.T) {
110		root := NewRootCmd()
111		var stderr bytes.Buffer
112		root.SetErr(&stderr)
113		root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
114		err := root.ExecuteContext(context.Background())
115		if err == nil {
116			t.Fatal("expected error for bad address")
117		}
118		if ExitCode(err) != 1 {
119			t.Errorf("exit code = %d, want 1", ExitCode(err))
120		}
121	}
122
123	func TestDiffJSONOutput(t *testing.T) {
124		srv := fakeCrowTwoVersions(t)
125		root := NewRootCmd()
126		var stdout bytes.Buffer
127		root.SetOut(&stdout)
128		root.SetArgs([]string{
129			"diff",
130			"iac/comply/aws", "1.0.0", "1.1.0",
131			"--crow-url", srv.URL,
132			"--plain",
133			"--json",
134			"--cache-dir", t.TempDir(),
135		})
136		if err := root.ExecuteContext(context.Background()); err != nil {
137			t.Fatalf("execute: %v", err)
138		}
139		var payload struct {
140			Files []struct {
141				Path   string `json:"path"`
142				Status string `json:"status"`
143			} `json:"files"`
144		}
145		if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
146			t.Fatalf("invalid json: %v\nraw:\n%s", err, stdout.String())
147		}
148		if len(payload.Files) == 0 {
149			t.Errorf("expected at least one file entry")
150		}
151	}
152
153	func TestDiffSemanticIncluded(t *testing.T) {
154		srv := fakeCrowTwoVersions(t)
155		root := NewRootCmd()
156		var stdout bytes.Buffer
157		root.SetOut(&stdout)
158		root.SetArgs([]string{
159			"diff",
160			"iac/comply/aws", "1.0.0", "1.1.0",
161			"--crow-url", srv.URL,
162			"--plain",
163			"--semantic",
164			"--cache-dir", t.TempDir(),
165		})
166		if err := root.ExecuteContext(context.Background()); err != nil {
167			t.Fatalf("execute: %v", err)
168		}
169		out := stdout.String()
170		for _, want := range []string{
171			"=== semantic changes ===",
172			"resource",
173			"aws_s3_bucket.alpha",
174			"aws_s3_bucket.beta",
175			"variable",
176			"name",
177		} {
178			if !strings.Contains(out, want) {
179				t.Errorf("semantic output missing %q\n--- output ---\n%s", want, out)
180			}
181		}
182	}
183
184	func TestDiffAddsToKnownAfterSuccess(t *testing.T) {
185		srv := fakeCrowTwoVersions(t)
186		knownFile := filepath.Join(t.TempDir(), "known-modules")
187		root := NewRootCmd()
188		var stdout bytes.Buffer
189		root.SetOut(&stdout)
190		root.SetArgs([]string{
191			"diff",
192			"iac/comply/aws", "1.0.0", "1.1.0",
193			"--crow-url", srv.URL,
194			"--plain",
195			"--cache-dir", t.TempDir(),
196			"--known-modules-file", knownFile,
197		})
198		if err := root.ExecuteContext(context.Background()); err != nil {
199			t.Fatalf("execute: %v", err)
200		}
201		body, err := os.ReadFile(knownFile)
202		if err != nil {
203			t.Fatalf("known-modules file not created: %v", err)
204		}
205		if !strings.Contains(string(body), "iac/comply/aws") {
206			t.Errorf("known-modules missing address:\n%s", body)
207		}
208	}
209
210	func TestDiffDoesNotAddOnFailure(t *testing.T) {
211		mux := http.NewServeMux()
212		// All download attempts return 404
213		mux.HandleFunc("/v1/modules/", func(w http.ResponseWriter, _ *http.Request) {
214			w.WriteHeader(http.StatusNotFound)
215		})
216		srv := httptest.NewServer(mux)
217		t.Cleanup(srv.Close)
218
219		knownFile := filepath.Join(t.TempDir(), "known-modules")
220		root := NewRootCmd()
221		root.SetErr(&bytes.Buffer{})
222		root.SetArgs([]string{
223			"diff",
224			"iac/comply/aws", "1.0.0", "1.1.0",
225			"--crow-url", srv.URL,
226			"--plain",
227			"--cache-dir", t.TempDir(),
228			"--known-modules-file", knownFile,
229		})
230		if err := root.ExecuteContext(context.Background()); err == nil {
231			t.Fatal("expected error for 404s")
232		}
233		if _, statErr := os.Stat(knownFile); !os.IsNotExist(statErr) {
234			t.Errorf("known-modules file should NOT exist after failure, stat err=%v", statErr)
235		}
236	}
237
238	func TestDiffValidArgsFunctionLoadsCache(t *testing.T) {
239		knownFile := filepath.Join(t.TempDir(), "known-modules")
240		if err := os.WriteFile(knownFile, []byte("alpha/aaa/aws\nbravo/bbb/gcp\n"), 0o600); err != nil {
241			t.Fatal(err)
242		}
243
244		root := NewRootCmd()
245		root.SetArgs([]string{"__complete", "diff", "--known-modules-file", knownFile, ""})
246		var stdout bytes.Buffer
247		root.SetOut(&stdout)
248		if err := root.ExecuteContext(context.Background()); err != nil {
249			t.Fatalf("execute completion: %v", err)
250		}
251		out := stdout.String()
252		for _, want := range []string{"alpha/aaa/aws", "bravo/bbb/gcp"} {
253			if !strings.Contains(out, want) {
254				t.Errorf("completion output missing %q\n%s", want, out)
255			}
256		}
257	}
258


1	package cmd
2
3	import (
4		"fmt"
5
6		"github.com/spf13/cobra"
7
8		"github.com/immuta/operations/apps/birdseye/internal/known"
9		"github.com/immuta/operations/services/crow/pkg/client"
10	)
11
12	func newVersionsCmd(g *GlobalFlags) *cobra.Command {
13		var knownModulesFile string
14		c := &cobra.Command{
15			Use:           "versions <module>",
16			Short:         "List published versions of a Crow module",
17			Args:          cobra.ExactArgs(1),
18			SilenceUsage:  true,
19			SilenceErrors: true,
20			RunE: func(cmd *cobra.Command, args []string) error {
21				id, err := parseModuleAddress(args[0])
22				if err != nil {
23					return err
24				}
25				cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
26				versions, err := cli.ListVersions(cmd.Context(), id)
27				if err != nil {
28					return err
29				}
30				for _, v := range versions {
31					fmt.Fprintln(cmd.OutOrStdout(), v)
32				}
33				return nil
34			},
35		}
36		c.Flags().StringVar(&knownModulesFile, "known-modules-file", "", "override known-modules autocomplete file (mostly for tests)")
37		_ = c.Flags().MarkHidden("known-modules-file")
38		c.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
39			if len(args) > 0 {
40				return nil, cobra.ShellCompDirectiveNoFileComp
41			}
42			suggestions, _ := known.Load(resolveKnownPath(knownModulesFile))
43			return suggestions, cobra.ShellCompDirectiveNoFileComp
44		}
45		return c
46	}
47


1	package cmd
2
3	import (
4		"bytes"
5		"context"
6		"encoding/json"
7		"net/http"
8		"net/http/httptest"
9		"os"
10		"path/filepath"
11		"strings"
12		"testing"
13
14		"github.com/immuta/operations/services/crow/pkg/api"
15	)
16
17	func TestVersionsCmd(t *testing.T) {
18		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19			if r.URL.Path != "/v1/modules/iac/comply/aws/versions" {
20				http.NotFound(w, r)
21				return
22			}
23			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
24				Modules: []api.ModuleVersions{
25					{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
26				},
27			})
28		}))
29		defer srv.Close()
30
31		root := NewRootCmd()
32		var stdout bytes.Buffer
33		root.SetOut(&stdout)
34		root.SetArgs([]string{"versions", "iac/comply/aws", "--crow-url", srv.URL})
35		if err := root.ExecuteContext(context.Background()); err != nil {
36			t.Fatalf("execute: %v", err)
37		}
38		out := stdout.String()
39		if !strings.Contains(out, "1.0.0") || !strings.Contains(out, "1.1.0") {
40			t.Errorf("versions output missing entries:\n%s", out)
41		}
42	}
43
44	func TestVersionsValidArgsFunctionLoadsCache(t *testing.T) {
45		knownFile := filepath.Join(t.TempDir(), "known-modules")
46		if err := os.WriteFile(knownFile, []byte("alpha/aaa/aws\n"), 0o600); err != nil {
47			t.Fatal(err)
48		}
49
50		root := NewRootCmd()
51		root.SetArgs([]string{"__complete", "versions", "--known-modules-file", knownFile, ""})
52		var stdout bytes.Buffer
53		root.SetOut(&stdout)
54		if err := root.ExecuteContext(context.Background()); err != nil {
55			t.Fatalf("execute completion: %v", err)
56		}
57		if !strings.Contains(stdout.String(), "alpha/aaa/aws") {
58			t.Errorf("completion missing entry:\n%s", stdout.String())
59		}
60	}
61
62	func TestVersionsDoesNotAddToKnown(t *testing.T) {
63		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
65				Modules: []api.ModuleVersions{
66					{Versions: []api.VersionEntry{{Version: "1.0.0"}}},
67				},
68			})
69		}))
70		defer srv.Close()
71
72		knownFile := filepath.Join(t.TempDir(), "known-modules")
73		root := NewRootCmd()
74		var stdout bytes.Buffer
75		root.SetOut(&stdout)
76		root.SetArgs([]string{"versions", "iac/comply/aws",
77			"--crow-url", srv.URL,
78			"--known-modules-file", knownFile,
79		})
80		if err := root.ExecuteContext(context.Background()); err != nil {
81			t.Fatalf("execute: %v", err)
82		}
83		if _, statErr := os.Stat(knownFile); !os.IsNotExist(statErr) {
84			t.Errorf("known-modules file should NOT exist after versions, stat err=%v", statErr)
85		}
86	}
87


1	package tui
2
3	import (
4		"context"
5		"fmt"
6		"strings"
7
8		"github.com/charmbracelet/bubbles/list"
9		"github.com/charmbracelet/bubbles/spinner"
10		"github.com/charmbracelet/bubbles/textinput"
11		tea "github.com/charmbracelet/bubbletea"
12
13		"github.com/immuta/operations/apps/birdseye/internal/known"
14		"github.com/immuta/operations/apps/birdseye/internal/registry"
15		"github.com/immuta/operations/services/crow/pkg/api"
16		"github.com/immuta/operations/services/crow/pkg/client"
17	)
18
19	// PickerState is the explicit state machine the picker walks.
20	type PickerState int
21
22	const (
23		StateAskModule PickerState = iota
24		StateAskVersionA
25		StateAskVersionB
26		StateLoading
27		StateBrowser
28		StateError
29	)
30
31	// Deps groups the picker's external dependencies. nil is acceptable for
32	// unit tests of the picker's input/state machinery (those tests never
33	// reach StateLoading).
34	type Deps struct {
35		Client *client.Client
36		Reg    *registry.Registry
37	}
38
39	type versionsLoadedMsg struct {
40		versions []string
41		err      error
42	}
43
44	type pairLoadedMsg struct {
45		a, b []byte
46		err  error
47	}
48
49	// versionItem implements bubbles/list.Item for a single version string.
50	type versionItem string
51
52	func (v versionItem) FilterValue() string { return string(v) }
53	func (v versionItem) Title() string       { return string(v) }
54	func (v versionItem) Description() string { return "" }
55
56	// Picker is a single bubbletea Model whose internal state enum drives
57	// which sub-widget is rendered.
58	type Picker struct {
59		ctx        context.Context
60		deps       *Deps
61		state      PickerState
62		input      textinput.Model
63		moduleID   api.ModuleIdentifier
64		versionsA  list.Model
65		versionsB  list.Model
66		chosenA    string
67		chosenB    string
68		spinner    spinner.Model
69		width      int
70		height     int
71		errMessage string
72		pairA      []byte
73		pairB      []byte
74		knownPath  string
75	}
76
77	// NewPicker constructs a picker. deps may be nil for input-only tests
78	// (no network dispatch occurs in that case). suggestions populates
79	// textinput autocomplete; knownPath is the cache file for fire-and-forget
80	// persistence after a successful version load.
81	func NewPicker(ctx context.Context, deps *Deps, suggestions []string, knownPath string) *Picker {
82		ti := textinput.New()
83		ti.Placeholder = "namespace/name/provider"
84		ti.Focus()
85		if len(suggestions) > 0 {
86			ti.SetSuggestions(suggestions)
87			ti.ShowSuggestions = true
88		}
89		sp := spinner.New()
90		sp.Spinner = spinner.Dot
91		return &Picker{
92			ctx:       ctx,
93			deps:      deps,
94			state:     StateAskModule,
95			input:     ti,
96			spinner:   sp,
97			knownPath: knownPath,
98		}
99	}
100
101	func (p *Picker) State() PickerState { return p.state }
102	func (p *Picker) HasError() bool     { return p.errMessage != "" }
103
104	// Bytes returns the fetched tarballs once StateBrowser is reached, so
105	// the cmd integration can extract + diff and build the browser.
106	func (p *Picker) Bytes() ([]byte, []byte) { return p.pairA, p.pairB }
107
108	func (p *Picker) Init() tea.Cmd { return textinput.Blink }
109
110	func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111		switch msg := msg.(type) {
112		case tea.WindowSizeMsg:
113			p.width, p.height = msg.Width, msg.Height
114			return p, nil
115		case tea.KeyMsg:
116			if msg.Type == tea.KeyCtrlC ||
117				(msg.Type == tea.KeyRunes && string(msg.Runes) == "q" && p.state != StateAskModule) {
118				return p, tea.Quit
119			}
120		}
121		switch p.state {
122		case StateAskModule:
123			return p.updateAskModule(msg)
124		case StateAskVersionA:
125			return p.updateAskVersionA(msg)
126		case StateAskVersionB:
127			return p.updateAskVersionB(msg)
128		case StateLoading:
129			return p.updateLoading(msg)
130		}
131		return p, nil
132	}
133
134	func (p *Picker) updateAskModule(msg tea.Msg) (tea.Model, tea.Cmd) {
135		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
136			id, err := parseModuleAddressForPicker(p.input.Value())
137			if err != nil {
138				p.errMessage = err.Error()
139				return p, nil
140			}
141			p.errMessage = ""
142			p.moduleID = id
143			if p.deps == nil || p.deps.Client == nil {
144				// Test harness path: do not dispatch network cmd.
145				return p, nil
146			}
147			p.state = StateLoading
148			return p, p.loadVersionsCmd(id)
149		}
150		var cmd tea.Cmd
151		p.input, cmd = p.input.Update(msg)
152		return p, cmd
153	}
154
155	func (p *Picker) loadVersionsCmd(id api.ModuleIdentifier) tea.Cmd {
156		return func() tea.Msg {
157			versions, err := p.deps.Client.ListVersions(p.ctx, id)
158			return versionsLoadedMsg{versions: versions, err: err}
159		}
160	}
161
162	func (p *Picker) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) {
163		switch m := msg.(type) {
164		case versionsLoadedMsg:
165			if m.err != nil {
166				p.errMessage = m.err.Error()
167				p.state = StateError
168				return p, nil
169			}
170			items := make([]list.Item, 0, len(m.versions))
171			for _, v := range m.versions {
172				items = append(items, versionItem(v))
173			}
174			p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
175			p.versionsA.Title = "Select version A"
176			p.state = StateAskVersionA
177			return p, addKnownCmd(p.knownPath, p.moduleID)
178		case pairLoadedMsg:
179			if m.err != nil {
180				p.errMessage = m.err.Error()
181				p.state = StateError
182				return p, nil
183			}
184			p.pairA = m.a
185			p.pairB = m.b
186			// Stay in StateLoading until SetBrowser is called by the cmd
187			// integration. To make the bubbletea program quit (so the cmd
188			// code can run the second-stage Run), return tea.Quit here. The
189			// cmd integration will inspect Bytes() and either run the
190			// browser or report an error.
191			return p, tea.Quit
192		}
193		var cmd tea.Cmd
194		p.spinner, cmd = p.spinner.Update(msg)
195		return p, cmd
196	}
197
198	func (p *Picker) updateAskVersionA(msg tea.Msg) (tea.Model, tea.Cmd) {
199		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
200			item, ok := p.versionsA.SelectedItem().(versionItem)
201			if !ok {
202				return p, nil
203			}
204			p.chosenA = string(item)
205			items := make([]list.Item, 0, len(p.versionsA.Items()))
206			for _, it := range p.versionsA.Items() {
207				if v := it.(versionItem); string(v) != p.chosenA {
208					items = append(items, v)
209				}
210			}
211			p.versionsB = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
212			p.versionsB.Title = "Select version B"
213			p.state = StateAskVersionB
214			return p, nil
215		}
216		var cmd tea.Cmd
217		p.versionsA, cmd = p.versionsA.Update(msg)
218		return p, cmd
219	}
220
221	func (p *Picker) updateAskVersionB(msg tea.Msg) (tea.Model, tea.Cmd) {
222		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
223			item, ok := p.versionsB.SelectedItem().(versionItem)
224			if !ok {
225				return p, nil
226			}
227			p.chosenB = string(item)
228			if p.deps == nil || p.deps.Reg == nil {
229				return p, nil
230			}
231			p.state = StateLoading
232			return p, p.loadPairCmd(p.moduleID, p.chosenA, p.chosenB)
233		}
234		var cmd tea.Cmd
235		p.versionsB, cmd = p.versionsB.Update(msg)
236		return p, cmd
237	}
238
239	func (p *Picker) loadPairCmd(id api.ModuleIdentifier, va, vb string) tea.Cmd {
240		return func() tea.Msg {
241			a, b, err := p.deps.Reg.FetchPair(p.ctx,
242				api.Module{ModuleIdentifier: id, Version: va},
243				api.Module{ModuleIdentifier: id, Version: vb})
244			return pairLoadedMsg{a: a, b: b, err: err}
245		}
246	}
247
248	// addKnownCmd dispatches a fire-and-forget known.Add. The returned
249	// tea.Msg is nil — bubbletea ignores nil messages cleanly. Errors are
250	// swallowed (best-effort cache update).
251	func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
252		if path == "" {
253			return nil
254		}
255		addr := id.Namespace + "/" + id.Name + "/" + id.Provider
256		return func() tea.Msg {
257			_ = known.Add(path, addr)
258			return nil
259		}
260	}
261
262	func (p *Picker) View() string {
263		switch p.state {
264		case StateAskModule:
265			errBlock := ""
266			if p.errMessage != "" {
267				errBlock = "\nerror: " + p.errMessage
268			}
269			return "module address (namespace/name/provider):\n" + p.input.View() + errBlock
270		case StateLoading:
271			return p.spinner.View() + " loading…"
272		case StateAskVersionA:
273			return p.versionsA.View()
274		case StateAskVersionB:
275			return p.versionsB.View()
276		case StateError:
277			return "error: " + p.errMessage + "\npress q to quit"
278		}
279		return ""
280	}
281
282	// parseModuleAddressForPicker mirrors cmd.parseModuleAddress without
283	// the import cycle. Returns a typed error for inline display in the TUI.
284	func parseModuleAddressForPicker(s string) (api.ModuleIdentifier, error) {
285		parts := strings.Split(s, "/")
286		if len(parts) != 3 {
287			return api.ModuleIdentifier{}, fmt.Errorf("module address must be namespace/name/provider, got %q", s)
288		}
289		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
290		if err := id.Validate(); err != nil {
291			return api.ModuleIdentifier{}, err
292		}
293		return id, nil
294	}
295


1	package tui
2
3	import (
4		"context"
5		"testing"
6
7		tea "github.com/charmbracelet/bubbletea"
8	)
9
10	func TestPickerStartsAtAskModule(t *testing.T) {
11		p := NewPicker(context.Background(), nil, nil, "")
12		if p.State() != StateAskModule {
13			t.Errorf("initial state = %v, want StateAskModule", p.State())
14		}
15	}
16
17	func TestPickerAdvancesOnEnter(t *testing.T) {
18		p := NewPicker(context.Background(), nil, nil, "")
19		for _, r := range "iac/comply/aws" {
20			_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
21		}
22		_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
23		// nil deps means no network cmd is dispatched, but state has advanced
24		// past validation. (See test in next assertion.)
25		_ = cmd
26		if p.HasError() {
27			t.Errorf("did not expect error for valid module; got: %v", p)
28		}
29	}
30
31	func TestPickerRejectsBadAddressInline(t *testing.T) {
32		p := NewPicker(context.Background(), nil, nil, "")
33		for _, r := range "garbage" {
34			_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
35		}
36		_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
37		if cmd != nil {
38			t.Error("did not expect a tea.Cmd for invalid address")
39		}
40		if !p.HasError() {
41			t.Error("expected inline error on invalid address")
42		}
43	}
44
45	func TestPickerAcceptsSuggestionsArg(t *testing.T) {
46		p := NewPicker(context.Background(), nil, []string{"iac/comply/aws", "platform/networking/aws"}, "")
47		if !p.input.ShowSuggestions {
48			t.Error("expected ShowSuggestions == true when suggestions provided")
49		}
50	}
51
52	func TestPickerNoSuggestionsLeavesShowSuggestionsFalse(t *testing.T) {
53		p := NewPicker(context.Background(), nil, nil, "")
54		if p.input.ShowSuggestions {
55			t.Error("expected ShowSuggestions == false when suggestions are nil/empty")
56		}
57	}
58
59	func TestPickerEmptySuggestionsLeavesShowSuggestionsFalse(t *testing.T) {
60		p := NewPicker(context.Background(), nil, []string{}, "")
61		if p.input.ShowSuggestions {
62			t.Error("expected ShowSuggestions == false when suggestions slice is empty")
63		}
64	}
65


Now let me check the spec, plan, and verify the build/test situation.


Error: File does not exist. Note: your current working directory is /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye.


find /Users/steve.briggs/projects/operations/docs/superpowers -name "*known-modules*" -type f 2>/dev/null

Locate spec and plan files


(Bash completed with no output)


find /Users/steve.briggs/projects/operations -name "*known-modules*" -type f 2>/dev/null; ls /Users/steve.briggs/projects/operations/docs/superpowers/ 2>/dev/null; ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs 2>/dev/null

Find spec/plan files


/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs/superpowers/plans/2026-05-13-birdseye-known-modules.md
drwxr-xr-x steve.briggs staff 192 B Tue May 12 17:44:23 2026 ..
drwxr-xr-x steve.briggs staff  96 B Tue May 12 17:45:23 2026 specs
drwxr-xr-x steve.briggs staff 128 B Tue May 12 17:48:54 2026 .
drwxr-xr-x steve.briggs staff  96 B Tue May 12 17:54:35 2026 plans
drwxr-xr-x steve.briggs staff 288 B Tue May 12 18:16:41 2026 deployment
drwxr-xr-x steve.briggs staff 192 B Tue May 12 18:16:41 2026 design
drwxr-xr-x steve.briggs staff 160 B Tue May 12 18:16:41 2026 sre
drwxr-xr-x steve.briggs staff 736 B Tue May 12 18:16:41 2026 ..
drwxr-xr-x steve.briggs staff 192 B Wed May 13 11:27:59 2026 .
drwxr-xr-x steve.briggs staff 128 B Wed May 13 11:30:05 2026 superpowers


1	# Birdseye — known-modules autocomplete cache
2
3	**Status:** Draft for implementation
4	**Date:** 2026-05-13
5	**Owner:** steve.briggs@immuta.com
6	**Builds on:** `2026-05-12-birdseye-design.md`
7
8	## Summary
9
10	Persist the set of module addresses (`namespace/name/provider`) the user
11	has successfully diff'd or browsed in a small text file, and offer them
12	as autocomplete suggestions on subsequent invocations. Two surfaces
13	share the same on-disk cache: cobra shell-completion for the CLI
14	positional argument, and `bubbles/textinput` ghost-text suggestions in
15	the interactive TUI picker.
16
17	## Goals
18
19	- Zero-friction autocomplete: addresses the user has used before should
20	  surface automatically on the next invocation, both in shell tab-completion
21	  and the TUI textinput.
22	- Best-effort persistence: cache writes never block or fail the diff flow.
23	- Hermetic in tests: every test path injects a tempdir, so the host's
24	  real `~/.config/birdseye/known-modules` is never touched.
25
26	## Non-goals
27
28	- Tracking version history (we only remember the `ns/n/p` triple).
29	- LRU ordering, hit counts, or any per-entry metadata — alphabetical
30	  sort is fine for v1.
31	- A management CLI (`birdseye known clear`, `known list`). Users can
32	  inspect/delete the file with standard tools.
33	- Concurrent-write merge semantics. Two parallel birdseye processes
34	  writing at the same instant: last writer wins (documented).
35
36	## File location and format
37
38	**Path** resolved via `os.UserConfigDir()`:
39
40	- Linux: `~/.config/birdseye/known-modules`
41	- macOS: `~/Library/Application Support/birdseye/known-modules`
42
43	**Format:** plain text, one `namespace/name/provider` per line,
44	alphabetically sorted, single trailing newline. Empty file or missing
45	file → no suggestions, not an error.
46
47	Example:
48	```
49	iac/comply/aws
50	iac/eks-cluster/aws
51	platform/networking/aws
52	```
53
54	## Triggers — what adds to the cache
55
56	| Operation | Adds to cache? |
57	|---|---|
58	| `birdseye diff ns/n/p vA vB` (positional CLI), success | yes |
59	| Interactive picker (`birdseye diff`), `ListVersions` returns versions | yes |
60	| `birdseye versions ns/n/p`, success | **no** (intentionally excluded) |
61	| Any failed operation (404, network, archive corrupt) | no |
62	| Picker quit before completing version selection | yes (if `ListVersions` already succeeded) |
63
64	The picker case fires the moment Crow confirms the address resolves —
65	we know the address is valid even if the user changes their mind about
66	which versions to diff.
67
68	## High-level architecture
69
70	```
71	apps/birdseye/
72	  internal/
73	    known/
74	      known.go        # Path() / Load() / Add() — pure besides file I/O
75	      known_test.go
76	  cmd/
77	    diff.go           # ValidArgsFunction for first positional arg
78	                      # +known.Add after successful runDiff
79	                      # +diffOpts.KnownModulesFile flag (hidden)
80	    versions.go       # ValidArgsFunction for first positional arg (no Add)
81	  internal/tui/
82	    picker.go         # NewPicker(ctx, deps, suggestions, knownPath)
83	                      # textinput.SetSuggestions + ShowSuggestions
84	                      # +addKnownCmd fire-and-forget on versionsLoadedMsg
85	```
86
87	`internal/known` is a small, pure data layer. The cmd layer is the only
88	place that resolves the file path (so tests can inject a tempdir).
89
90	## Component interfaces
91
92	### `internal/known`
93
94	```go
95	// Path returns the default known-modules file path, honoring
96	// os.UserConfigDir(). The directory is NOT created here — Add creates
97	// it lazily on first write.
98	func Path() (string, error)
99
100	// Load reads the file at path. Returns an empty slice if the file
101	// does not exist (first run is normal). Each non-empty trimmed line
102	// is returned. The result is sorted ascending. Malformed lines
103	// (lines that don't pass the ns/name/provider segment validation
104	// from services/crow/pkg/api) are skipped silently.
105	func Load(path string) ([]string, error)
106
107	// Add inserts address into the file at path. Existing entries are
108	// preserved; duplicates deduplicated; the final list is sorted.
109	// Writes are atomic via temp file in the same directory + rename.
110	//
111	// The directory is created with 0o755 if missing. If address is not
112	// a well-formed ns/name/provider triple, Add returns an error
113	// without touching the file.
114	func Add(path, address string) error
115	```
116
117	Validation reuses `api.ValidateSegments` from `services/crow/pkg/api`.
118
119	### `cmd/diff.go`
120
121	```go
122	type diffOpts struct {
123	    Semantic         bool
124	    Plain            bool
125	    CacheDir         string
126	    KnownModulesFile string // NEW: defaulted to known.Path(), overridable
127	}
128	```
129
130	`--known-modules-file` is a hidden flag (test override). The diff
131	subcommand and versions subcommand each register a
132	`ValidArgsFunction` that returns:
133
134	```go
135	func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
136	    if len(args) > 0 {
137	        return nil, cobra.ShellCompDirectiveNoFileComp
138	    }
139	    suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
140	    return suggestions, cobra.ShellCompDirectiveNoFileComp
141	}
142	```
143
144	`resolveKnownPath(override string) string` returns `override` if
145	non-empty, else `known.Path()`; on path-resolution error, returns
146	`""` so `Load` and `Add` gracefully no-op.
147
148	After `runDiff` succeeds on the positional path (and before returning),
149	the cmd layer calls:
150
151	```go
152	_ = known.Add(resolveKnownPath(o.KnownModulesFile),
153	    id.Namespace+"/"+id.Name+"/"+id.Provider)
154	```
155
156	Errors are swallowed.
157
158	### `cmd/versions.go`
159
160	Same `ValidArgsFunction` wiring as diff. **No** `Add` call.
161
162	### `internal/tui/picker.go`
163
164	```go
165	func NewPicker(ctx context.Context, deps *Deps, suggestions []string, knownPath string) *Picker
166	```
167
168	In `NewPicker`, after constructing `ti`:
169
170	```go
171	if len(suggestions) > 0 {
172	    ti.SetSuggestions(suggestions)
173	    ti.ShowSuggestions = true
174	}
175	```
176
177	`Picker` gains a `knownPath string` field.
178
179	In `updateLoading`, when `versionsLoadedMsg` arrives with no error and
180	the version list is non-empty, dispatch:
181
182	```go
183	return p, addKnownCmd(p.knownPath, p.moduleID)
184	```
185
186	Helper:
187
188	```go
189	func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
190	    return func() tea.Msg {
191	        _ = known.Add(path, id.Namespace+"/"+id.Name+"/"+id.Provider)
192	        return nil
193	    }
194	}
195	```
196
197	The returned `nil` msg is a no-op — bubbletea ignores nil cleanly.
198
199	### `cmd/diff.go` `runInteractive` changes
200
201	```go
202	suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
203	p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg},
204	    suggestions, resolveKnownPath(o.KnownModulesFile))
205	```
206
207	## Data flow
208
209	### CLI one-shot
210
211	```
212	$ birdseye diff iac/comply/aws 1.0.0 1.1.0
213
214	cobra ValidArgsFunction (when user presses TAB)
215	  -> known.Load(resolveKnownPath(o.KnownModulesFile))
216	  -> returns []string for completion
217
218	runDiff
219	  -> [existing: parse, validate, fetch, extract, diff, render]
220	  -> on success: known.Add(path, "iac/comply/aws")
221	```
222
223	### Interactive picker
224
225	```
226	$ birdseye diff
227
228	runInteractive
229	  -> known.Load(resolveKnownPath(...))    [synchronous, at startup]
230	  -> tui.NewPicker(ctx, deps, suggestions, knownPath)
231	  -> textinput.SetSuggestions(suggestions); ShowSuggestions=true
232	  -> tea.NewProgram(picker).Run()
233
234	picker.updateLoading (on versionsLoadedMsg success)
235	  -> populate versionsA, state = StateAskVersionA
236	  -> dispatch addKnownCmd(p.knownPath, p.moduleID)   [fire-and-forget]
237	```
238
239	## Error handling
240
241	| Failure | Behavior |
242	|---|---|
243	| `os.UserConfigDir()` fails | `Path()` returns the error. Caller falls back to `""`; `Load("")` and `Add("")` no-op. CLI behavior otherwise unchanged. |
244	| File doesn't exist (first run) | `Load` returns `(nil, nil)`. |
245	| Malformed file content | `Load` skips lines failing `api.ValidateSegments`. |
246	| `Add` rejects bad address | Returns error; caller swallows. File unchanged. |
247	| `Add` fails to write | Returns wrapped error; caller swallows. |
248	| Concurrent writers | Last writer wins. Documented in code. |
249
250	`Add` calls never block the diff. They run after the diff result has
251	been written/rendered.
252
253	## Testing strategy
254
255	TDD as elsewhere in this codebase. Race detector on every `go test`.
256	All paths use `t.TempDir()`; the real `~/.config/birdseye/` is never
257	touched.
258
259	### `internal/known/known_test.go`
260
261	- `TestLoadMissingFileReturnsEmpty` — first-run: no file, no error
262	- `TestAddCreatesDirectoryAndFile` — cold start: nested dir + file created
263	- `TestAddDeduplicates` — same address twice → file has one line
264	- `TestAddSortsAlphabetically` — adds in reverse alphabetical order, file is sorted
265	- `TestAddAtomicWrite` — after successful `Add`, no `.known-modules-*.tmp` lingering
266	- `TestAddRejectsBadAddress` — `"not-a-valid-address"` → error, file unchanged
267	- `TestLoadSkipsMalformedLines` — mixed valid/garbage → garbage ignored
268	- `TestLoadStripsBlankLines` — trailing blank lines → empty fields not returned
269
270	### `cmd/diff_test.go` additions
271
272	- `TestDiffAddsToKnownAfterSuccess` — run diff with `--known-modules-file=<tempfile>`, assert file contains the address
273	- `TestDiffDoesNotAddOnFailure` — force a 404 → file unchanged
274	- `TestDiffValidArgsFunctionLoadsCache` — pre-seed cache, invoke completion, assert suggestions match
275
276	### `cmd/versions_test.go` additions
277
278	- `TestVersionsDoesNotAddToKnown` — run versions, assert file unchanged
279	- `TestVersionsValidArgsFunctionLoadsCache` — same completion check as diff
280
281	### `internal/tui/picker_test.go` additions
282
283	- `TestPickerAcceptsSuggestionsArg` — `NewPicker(ctx, nil, []string{"iac/comply/aws"}, "")` → textinput has the suggestion attached, `ShowSuggestions == true`
284	- `TestPickerNoSuggestionsLeavesShowSuggestionsFalse` — empty/nil → `ShowSuggestions == false`
285
286	### Not tested
287
288	- Shell-completion script generation (cobra owns it).
289	- Two-process concurrent writes (documented "last writer wins").
290	- Real `~/.config/birdseye/` interaction.
291
292	## Dependencies
293
294	No new third-party Go modules. Reuses:
295
296	- stdlib: `os`, `path/filepath`, `bufio`, `sort`, `strings`
297	- `services/crow/pkg/api` — `ValidateSegments` for address validation
298	- `github.com/charmbracelet/bubbles/textinput` — already in go.mod
299	- `github.com/spf13/cobra` — already in go.mod
300
301	## Implementation phases
302
303	A single plan should cover this end-to-end. Suggested ordering:
304
305	1. `internal/known/` package with TDD unit tests
306	2. `cmd/diff.go` wire-up: `--known-modules-file` flag, `ValidArgsFunction`, post-success `Add`, plus tests
307	3. `cmd/versions.go` wire-up: `ValidArgsFunction` (no Add), plus test
308	4. `internal/tui/picker.go` signature extension and `addKnownCmd`, plus tests
309	5. `runInteractive` wiring in `cmd/diff.go`
310
311	## Open questions
312
313	None. Cleared during brainstorming:
314
315	- Surface: both shell completion AND TUI suggestions.
316	- Triggers: CLI diff success + picker `ListVersions` success. `versions` subcommand intentionally excluded.
317	- Location: `os.UserConfigDir()/birdseye/known-modules`.
318	- Format: plain text, sorted, atomic temp+rename writes.
319	- Concurrency: last writer wins, documented in code.
320