You are implementing Task 10 of the birdseye CLI implementation plan. This is the biggest integration step so far — wiring archive + registry + diff together behind the `birdseye diff` subcommand.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits: Phase A + B + Task 9 textual diff (`16193cc`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The pieces exist:
- `cmd.NewRootCmd()` (Task 2) — root cobra command, persistent flags
- `client.Client.Download` (Task 3) — fetch tarball
- `archive.Extract` (Task 4) — tar.gz → Module
- `registry.Registry.FetchPair` (Task 8) — cache + concurrent fetch
- `diff.Textual` (Task 9) — produces []FileDiff

Your job: a `diff` cobra subcommand that takes 3 positional args (`<module>` `<vA>` `<vB>`), fetches+extracts+diffs, and writes unified diff to stdout when `--plain` is passed. TUI integration comes later (Task 13). The integration tests in this task all use `--plain` so they're hermetic.

The test code is large (a custom `fakeCrowTwoVersions` httptest server). Follow the spec carefully — TDD means writing all tests first, even though they require a lot of setup.

## Task Description (Verbatim)

### Task 10: `cmd diff --plain` — plain-text output integration

**Files:**
- Create: `apps/birdseye/cmd/diff.go`
- Create: `apps/birdseye/cmd/diff_test.go`
- Modify: `apps/birdseye/cmd/root.go` (wire subcommand)

- [ ] **Step 1: Write failing integration test** at `apps/birdseye/cmd/diff_test.go`:

```go
package cmd

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
// (modified) and a new variables.tf.
func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
	t.Helper()
	mkTgz := func(files map[string]string) []byte {
		var buf bytes.Buffer
		gz := gzip.NewWriter(&buf)
		tw := tar.NewWriter(gz)
		for n, b := range files {
			_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
			_, _ = tw.Write([]byte(b))
		}
		_ = tw.Close()
		_ = gz.Close()
		return buf.Bytes()
	}
	v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
	v2 := mkTgz(map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
	})

	mux := http.NewServeMux()
	blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/blob/1.0.0":
			_, _ = w.Write(v1)
		case "/blob/1.1.0":
			_, _ = w.Write(v2)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(blob.Close)
	mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
		if len(parts) != 2 || parts[1] != "download" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
		w.WriteHeader(http.StatusNoContent)
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

func TestDiffPlainOutput(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout, stderr bytes.Buffer
	root.SetOut(&stdout)
	root.SetErr(&stderr)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
	}
	out := stdout.String()
	for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
		if !strings.Contains(out, want) {
			t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
		}
	}
}

func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.0.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "no differences" {
		t.Errorf("output = %q, want \"no differences\"", got)
	}
}

func TestDiffBadModuleAddress(t *testing.T) {
	root := NewRootCmd()
	var stderr bytes.Buffer
	root.SetErr(&stderr)
	root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
	err := root.ExecuteContext(context.Background())
	if err == nil {
		t.Fatal("expected error for bad address")
	}
	if ExitCode(err) != 1 {
		t.Errorf("exit code = %d, want 1", ExitCode(err))
	}
}
```

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiff -v`
Expected: "unknown command diff" failures.

- [ ] **Step 3: Implement `apps/birdseye/cmd/diff.go`**

```go
package cmd

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)

// diffOpts holds the resolved options for `birdseye diff`.
type diffOpts struct {
	Semantic bool
	Plain    bool
	CacheDir string
}

const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size

func newDiffCmd(g *GlobalFlags) *cobra.Command {
	o := &diffOpts{}
	c := &cobra.Command{
		Use:   "diff [module] [versionA] [versionB]",
		Short: "Diff two versions of a Crow module",
		Long: "Diff two versions of a module hosted in Crow.\n\n" +
			"If module/versions are omitted, the interactive TUI launches\n" +
			"(see picker task). With --plain, prints unified diff to stdout.",
		Args:          cobra.MaximumNArgs(3),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runDiff(cmd.Context(), cmd, args, g, o)
		},
	}
	c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
	c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
	c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
	c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
	return c
}

func defaultCacheDir() string {
	if home, err := os.UserCacheDir(); err == nil {
		return filepath.Join(home, "birdseye")
	}
	return ".birdseye-cache"
}

func newHTTPClient(d time.Duration) *http.Client {
	return &http.Client{Timeout: d}
}

func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
	if len(args) == 0 {
		// Interactive flow lives in Task 15; for now fall through to error.
		return fmt.Errorf("%w: interactive mode not yet implemented; provide <module> <versionA> <versionB>", ErrUsage)
	}
	if len(args) != 3 {
		return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
	}
	id, err := parseModuleAddress(args[0])
	if err != nil {
		return err
	}
	if err := api.ValidateVersion(args[1]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if err := api.ValidateVersion(args[2]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	modA := api.Module{ModuleIdentifier: id, Version: args[1]}
	modB := api.Module{ModuleIdentifier: id, Version: args[2]}

	cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
	cache, err := registry.NewDiskCache(o.CacheDir)
	if err != nil {
		return fmt.Errorf("cache init: %w", err)
	}
	reg := registry.New(cli, cache)

	aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
	if err != nil {
		return err
	}

	modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}

	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.
	// TUI mode wired in Task 13.

	return renderPlain(cmd.OutOrStdout(), result)
}

// renderPlain writes the diff result to w in unified-diff format.
// "no differences" is emitted when there are zero files in the result.
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 {
		_, err := fmt.Fprintln(w, "no differences")
		return err
	}
	for i, fd := range r.Files {
		if i > 0 {
			_, _ = fmt.Fprintln(w)
		}
		_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
		_, _ = fmt.Fprint(w, fd.Unified)
	}
	return nil
}
```

- [ ] **Step 4: Wire subcommand** in `apps/birdseye/cmd/root.go` — in `NewRootCmd()`, replace the placeholder comment block:

```go
	// Subcommands will be added in later tasks:
	//   root.AddCommand(newDiffCmd(g))
	//   root.AddCommand(newVersionsCmd(g))
```

with:

```go
	root.AddCommand(newDiffCmd(g))
	// versions subcommand added in Task 11
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All PASS (7 existing tests + 3 new = 10 total).

- [ ] **Step 6: Verify build**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./...`
Expected: success.

- [ ] **Step 7: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): plain unified diff output for one-shot CLI usage"
```

If go.mod / go.sum changed (unlikely — all deps already added), include them.

## Important Implementation Notes

- The `RunE: cmd.Help()` workaround from Task 2 lives on the ROOT command. Now that a subcommand exists, cobra resolves `birdseye diff ...` to `newDiffCmd` and runs ITS RunE, never the root's RunE. So the `cmd.Help()` workaround is dormant — no removal needed.
- `--no-tui` is a flag alias for `--plain` via two `BoolVar` calls on the same `o.Plain` pointer. Both flags share state.
- `cobra.MaximumNArgs(3)` allows 0 to 3 args. 0 is reserved for interactive mode (future); 3 is normal. 1-2 returns `ErrUsage`.
- `parseModuleAddress` uses `fmt.Errorf("%w: ...", ErrUsage, ...)` so `errors.Is(err, ErrUsage)` works in tests; `ExitCode` returns 1.

## Code Organization

`diff.go` holds the subcommand factory, `runDiff`, helpers (`parseModuleAddress`, `defaultCacheDir`, `newHTTPClient`, `renderPlain`). One file. Don't split.

## Self-Review Checklist

- [ ] All 3 new tests pass under `-race`: `TestDiffPlainOutput`, `TestDiffIdenticalVersionsNoChanges`, `TestDiffBadModuleAddress`
- [ ] All previous `cmd` tests still pass (root + errors)
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` succeeds
- [ ] `newDiffCmd(g)` is wired into root via `root.AddCommand(...)`
- [ ] Both `--plain` and `--no-tui` work (they share `o.Plain`)
- [ ] `defaultCacheDir()` returns `$XDG_CACHE_HOME/birdseye` (or platform equivalent via `os.UserCacheDir`)
- [ ] `TestDiffIdenticalVersionsNoChanges` outputs literally `no differences\n`
- [ ] Bad module address → `ExitCode == 1`
- [ ] Commit message exactly: `feat(birdseye): plain unified diff output for one-shot CLI usage`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- `go build` / `go vet` output
- Commit SHA
- Anything unexpected


- 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 implement Task 10 following the TDD approach. Let me start by exploring the existing codebase.


Bash

find /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye -type f | sort

List all files in birdseye app


List top-level birdseye directory


drwxr-xr-x steve.briggs staff 256 B  Tue May 12 18:19:50 2026 ..
.rw-r--r-- steve.briggs staff 226 B  Tue May 12 18:19:59 2026 Makefile
.rw-r--r-- steve.briggs staff   6 B  Tue May 12 18:20:01 2026 .gitignore
.rw-r--r-- steve.briggs staff 602 B  Tue May 12 18:20:06 2026 main.go
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:04:43 2026 cmd
drwxr-xr-x steve.briggs staff 288 B  Tue May 12 23:12:50 2026 .
drwxr-xr-x steve.briggs staff 160 B  Tue May 12 23:39:15 2026 internal
.rw-r--r-- steve.briggs staff 1.2 KB Tue May 12 23:40:00 2026 go.sum
.rw-r--r-- steve.briggs staff 503 B  Tue May 12 23:40:18 2026 go.mod


1	// Package cmd wires up the cobra command tree for birdseye. Functions
2	// return *cobra.Command (rather than using package-level vars) so each
3	// test builds a fresh tree and flag state does not leak between cases.
4	package cmd
5
6	import (
7		"time"
8
9		"github.com/spf13/cobra"
10	)
11
12	// GlobalFlags are the cross-subcommand options exposed on the root command.
13	type GlobalFlags struct {
14		CrowURL string
15		Timeout time.Duration
16		JSON    bool
17	}
18
19	const defaultCrowURL = "https://crow.infrastructure.immuta.io"
20
21	// NewRootCmd returns the configured root command. Subcommands are wired
22	// in here.
23	func NewRootCmd() *cobra.Command {
24		g := &GlobalFlags{}
25		root := &cobra.Command{
26			Use:           "birdseye",
27			Short:         "Diff two versions of a Terraform module hosted in Crow",
28			SilenceUsage:  true,
29			SilenceErrors: true,
30			// Without a RunE, cobra v1.x prints help via its internal stderr
31			// writer rather than the writer set by SetOut, which breaks
32			// TestNewRootCmdHelp's captured buffer. Calling Help() explicitly
33			// routes the output correctly. Subcommands will not invoke this.
34			RunE: func(cmd *cobra.Command, args []string) error {
35				return cmd.Help()
36			},
37		}
38		root.PersistentFlags().StringVar(&g.CrowURL, "crow-url", defaultCrowURL, "Crow base URL (env CROW_URL)")
39		root.PersistentFlags().DurationVar(&g.Timeout, "timeout", 30*time.Second, "per-operation HTTP timeout")
40		root.PersistentFlags().BoolVar(&g.JSON, "json", false, "emit structured JSON to stdout where supported")
41
42		// Subcommands will be added in later tasks:
43		//   root.AddCommand(newDiffCmd(g))
44		//   root.AddCommand(newVersionsCmd(g))
45		return root
46	}
47


1	package cmd
2
3	import (
4		"context"
5		"errors"
6
7		"github.com/immuta/operations/services/crow/pkg/client"
8	)
9
10	// Sentinel errors mapped to exit codes. Packages that want a specific exit
11	// behaviour wrap these (e.g. `fmt.Errorf("extract: %w", ErrArchive)`).
12	var (
13		// ErrUsage indicates a CLI usage error (bad args, malformed module
14		// address, missing TTY for interactive mode). Maps to exit code 1.
15		ErrUsage = errors.New("usage error")
16
17		// ErrArchive indicates the fetched module tarball was corrupt or
18		// contained unsafe paths. Maps to exit code 4.
19		ErrArchive = errors.New("archive corrupt or unsafe")
20
21		// ErrSemantic indicates a parse or validation error during semantic
22		// (HCL-aware) diffing. Maps to exit code 5.
23		ErrSemantic = errors.New("semantic diff parse error")
24	)
25
26	const (
27		exitOK            = 0
28		exitUsage         = 1
29		exitNotFound      = 2
30		exitGeneric       = 3
31		exitArchive       = 4
32		exitSemantic      = 5
33		exitUserCancelled = 130
34	)
35
36	// ExitCode maps an error to a process exit code per the spec table.
37	// Returns 0 for nil.
38	func ExitCode(err error) int {
39		switch {
40		case err == nil:
41			return exitOK
42		case errors.Is(err, ErrUsage):
43			return exitUsage
44		case errors.Is(err, client.ErrNotFound):
45			return exitNotFound
46		case errors.Is(err, ErrArchive):
47			return exitArchive
48		case errors.Is(err, ErrSemantic):
49			return exitSemantic
50		case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
51			return exitUserCancelled
52		default:
53			return exitGeneric
54		}
55	}
56


1	// Package diff produces FileDiff entries and optional SemanticChange
2	// entries from a pair of extracted Modules. All functions are pure: no
3	// I/O, no globals.
4	package diff
5
6	// FileStatus describes what happened to a file between the two versions.
7	type FileStatus int
8
9	const (
10		StatusModified FileStatus = iota
11		StatusAdded
12		StatusRemoved
13	)
14
15	func (s FileStatus) String() string {
16		switch s {
17		case StatusAdded:
18			return "added"
19		case StatusRemoved:
20			return "removed"
21		default:
22			return "modified"
23		}
24	}
25
26	// FileDiff is a per-file diff result. Unified holds the rendered diff
27	// hunks; Adds/Dels are the count of added/removed lines for summary
28	// rendering.
29	type FileDiff struct {
30		Path    string
31		Status  FileStatus
32		Unified string
33		Adds    int
34		Dels    int
35	}
36
37	// SemanticChange is a single HCL-aware change. Kind is one of "resource",
38	// "variable", "output", "provider", "locals", "data", or "module".
39	type SemanticChange struct {
40		Kind   string
41		Name   string
42		Status FileStatus
43		Detail string
44	}
45
46	// Result is what cmd/diff renders. Semantic is nil unless --semantic was
47	// requested.
48	type Result struct {
49		Files    []FileDiff
50		Semantic []SemanticChange
51	}
52


1	package diff
2
3	import (
4		"fmt"
5		"sort"
6		"strings"
7
8		"github.com/hexops/gotextdiff"
9		"github.com/hexops/gotextdiff/myers"
10		"github.com/hexops/gotextdiff/span"
11
12		"github.com/immuta/operations/apps/birdseye/internal/archive"
13	)
14
15	// Textual returns a FileDiff per file that differs between a and b,
16	// sorted by path. Files identical in both archives are not included.
17	func Textual(a, b archive.Module) []FileDiff {
18		all := make(map[string]struct{})
19		for k := range a.Files {
20			all[k] = struct{}{}
21		}
22		for k := range b.Files {
23			all[k] = struct{}{}
24		}
25		paths := make([]string, 0, len(all))
26		for k := range all {
27			paths = append(paths, k)
28		}
29		sort.Strings(paths)
30
31		out := make([]FileDiff, 0, len(paths))
32		for _, p := range paths {
33			ab, aok := a.Files[p]
34			bb, bok := b.Files[p]
35			switch {
36			case aok && bok:
37				if string(ab) == string(bb) {
38					continue
39				}
40				out = append(out, modifiedDiff(p, string(ab), string(bb)))
41			case !aok && bok:
42				out = append(out, addedDiff(p, string(bb)))
43			case aok && !bok:
44				out = append(out, removedDiff(p, string(ab)))
45			}
46		}
47		return out
48	}
49
50	func modifiedDiff(path, oldText, newText string) FileDiff {
51		edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
52		unified := gotextdiff.ToUnified(path, path, oldText, edits)
53		u := fmt.Sprintf("%v", unified)
54		adds, dels := countLines(u)
55		return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
56	}
57
58	func addedDiff(path, newText string) FileDiff {
59		return FileDiff{
60			Path:    path,
61			Status:  StatusAdded,
62			Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
63			Adds:    countNonEmptyLines(newText),
64		}
65	}
66
67	func removedDiff(path, oldText string) FileDiff {
68		return FileDiff{
69			Path:    path,
70			Status:  StatusRemoved,
71			Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
72			Dels:    countNonEmptyLines(oldText),
73		}
74	}
75
76	func prefixLines(s, prefix string) string {
77		if s == "" {
78			return ""
79		}
80		lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
81		var sb strings.Builder
82		sb.Grow(len(s) + len(lines)*len(prefix))
83		for _, l := range lines {
84			sb.WriteString(prefix)
85			sb.WriteString(l)
86			sb.WriteByte('\n')
87		}
88		return sb.String()
89	}
90
91	func countNonEmptyLines(s string) int {
92		if s == "" {
93			return 0
94		}
95		n := strings.Count(s, "\n")
96		if !strings.HasSuffix(s, "\n") {
97			n++
98		}
99		return n
100	}
101
102	func countLines(unified string) (adds, dels int) {
103		for _, line := range strings.Split(unified, "\n") {
104			if len(line) == 0 {
105				continue
106			}
107			switch line[0] {
108			case '+':
109				if !strings.HasPrefix(line, "+++") {
110					adds++
111				}
112			case '-':
113				if !strings.HasPrefix(line, "---") {
114					dels++
115				}
116			}
117		}
118		return adds, dels
119	}
120


1	package registry
2
3	import (
4		"context"
5		"fmt"
6		"io"
7
8		"github.com/immuta/operations/services/crow/pkg/api"
9		"github.com/immuta/operations/services/crow/pkg/client"
10		"golang.org/x/sync/errgroup"
11	)
12
13	// Registry composes a typed Crow client with a Cache. Fetch is cache-first;
14	// FetchPair fans Fetch out concurrently with shared cancellation.
15	type Registry struct {
16		client *client.Client
17		cache  Cache
18	}
19
20	// New constructs a Registry. Both arguments must be non-nil.
21	func New(c *client.Client, cache Cache) *Registry {
22		return &Registry{client: c, cache: cache}
23	}
24
25	// Fetch returns the gzipped tarball bytes for the given module version,
26	// consulting the cache first. Network errors and 404s from the
27	// underlying client surface unchanged (callers can errors.Is against
28	// client.ErrNotFound).
29	func (r *Registry) Fetch(ctx context.Context, m api.Module) ([]byte, error) {
30		if body, hit, err := r.cache.Get(m); err != nil {
31			return nil, fmt.Errorf("cache get: %w", err)
32		} else if hit {
33			return body, nil
34		}
35		rc, err := r.client.Download(ctx, m)
36		if err != nil {
37			return nil, err
38		}
39		defer rc.Close()
40		body, err := io.ReadAll(rc)
41		if err != nil {
42			return nil, fmt.Errorf("read tarball: %w", err)
43		}
44		if err := r.cache.Put(m, body); err != nil {
45			// Cache write failure is not fatal — we still have the body to return.
46			_ = err
47		}
48		return body, nil
49	}
50
51	// FetchPair fetches a and b concurrently. The first error cancels its
52	// sibling via errgroup.WithContext.
53	func (r *Registry) FetchPair(ctx context.Context, a, b api.Module) ([]byte, []byte, error) {
54		g, gctx := errgroup.WithContext(ctx)
55		var aBytes, bBytes []byte
56		g.Go(func() error {
57			body, err := r.Fetch(gctx, a)
58			if err != nil {
59				return err
60			}
61			aBytes = body
62			return nil
63		})
64		g.Go(func() error {
65			body, err := r.Fetch(gctx, b)
66			if err != nil {
67				return err
68			}
69			bBytes = body
70			return nil
71		})
72		if err := g.Wait(); err != nil {
73			return nil, nil, err
74		}
75		return aBytes, bBytes, nil
76	}
77


1	module github.com/immuta/operations/apps/birdseye
2
3	go 1.25.5
4
5	require (
6		github.com/hexops/gotextdiff v1.0.3
7		github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
8		github.com/spf13/cobra v1.9.1
9		golang.org/x/sync v0.20.0
10	)
11
12	require (
13		github.com/inconshreveable/mousetrap v1.1.0 // indirect
14		github.com/spf13/pflag v1.0.6 // indirect
15	)
16
17	replace github.com/immuta/operations/services/crow => ../../services/crow
18
19	replace github.com/immuta/operations/libs/clog => ../../libs/clog
20


Now let me check the crow client and api packages to understand the interfaces:


find /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow -type f | sort

List crow service files


1	// Package registry talks to a Crow server through its typed client and
2	// caches module tarballs on disk so repeated diffs against the same
3	// versions stay fast.
4	package registry
5
6	import (
7		"errors"
8		"fmt"
9		"os"
10		"path/filepath"
11		"sync"
12
13		"github.com/immuta/operations/services/crow/pkg/api"
14	)
15
16	// Cache is the storage seam used by Registry. Production uses DiskCache;
17	// tests inject MemCache.
18	type Cache interface {
19		Get(api.Module) ([]byte, bool, error)
20		Put(api.Module, []byte) error
21	}
22
23	// MemCache is a thread-safe in-memory Cache implementation.
24	type MemCache struct {
25		mu    sync.RWMutex
26		items map[string][]byte
27	}
28
29	// NewMemCache returns an empty MemCache.
30	func NewMemCache() *MemCache {
31		return &MemCache{items: make(map[string][]byte)}
32	}
33
34	func memKey(m api.Module) string {
35		return m.Namespace + "/" + m.Name + "/" + m.Provider + "/" + m.Version
36	}
37
38	func (c *MemCache) Get(m api.Module) ([]byte, bool, error) {
39		c.mu.RLock()
40		defer c.mu.RUnlock()
41		b, ok := c.items[memKey(m)]
42		if !ok {
43			return nil, false, nil
44		}
45		return b, true, nil
46	}
47
48	func (c *MemCache) Put(m api.Module, body []byte) error {
49		c.mu.Lock()
50		defer c.mu.Unlock()
51		c.items[memKey(m)] = body
52		return nil
53	}
54
55	// DiskCache stores tarballs under
56	//
57	//	<root>/<namespace>/<name>/<provider>/<version>.tar.gz
58	//
59	// Writes are atomic via a temp file + rename.
60	type DiskCache struct {
61		root string
62	}
63
64	// NewDiskCache creates the root directory if it does not exist.
65	func NewDiskCache(root string) (*DiskCache, error) {
66		if err := os.MkdirAll(root, 0o755); err != nil {
67			return nil, fmt.Errorf("mkdir cache root: %w", err)
68		}
69		return &DiskCache{root: root}, nil
70	}
71
72	func (c *DiskCache) path(m api.Module) string {
73		return filepath.Join(c.root, m.Namespace, m.Name, m.Provider, m.Version+".tar.gz")
74	}
75
76	func (c *DiskCache) Get(m api.Module) ([]byte, bool, error) {
77		b, err := os.ReadFile(c.path(m))
78		if errors.Is(err, os.ErrNotExist) {
79			return nil, false, nil
80		}
81		if err != nil {
82			return nil, false, fmt.Errorf("read cache: %w", err)
83		}
84		return b, true, nil
85	}
86
87	func (c *DiskCache) Put(m api.Module, body []byte) error {
88		final := c.path(m)
89		if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil {
90			return fmt.Errorf("mkdir cache dir: %w", err)
91		}
92		tmp, err := os.CreateTemp(filepath.Dir(final), ".birdseye-*.tmp")
93		if err != nil {
94			return fmt.Errorf("create temp: %w", err)
95		}
96		tmpName := tmp.Name()
97		if _, err := tmp.Write(body); err != nil {
98			_ = tmp.Close()
99			_ = os.Remove(tmpName)
100			return fmt.Errorf("write temp: %w", err)
101		}
102		if err := tmp.Close(); err != nil {
103			_ = os.Remove(tmpName)
104			return fmt.Errorf("close temp: %w", err)
105		}
106		if err := os.Rename(tmpName, final); err != nil {
107			_ = os.Remove(tmpName)
108			return fmt.Errorf("rename: %w", err)
109		}
110		return nil
111	}
112


1	// Package api declares the Crow HTTP wire protocol: request/response types
2	// and segment/path helpers. Both the Crow server and its clients import this
3	// package so they cannot drift on what a valid module path or response shape
4	// looks like.
5	package api
6
7	type ModuleIdentifier struct {
8		Namespace string
9		Name      string
10		Provider  string
11	}
12
13	type Module struct {
14		ModuleIdentifier
15		Version string
16	}
17
18	type VersionEntry struct {
19		Version string `json:"version"`
20	}
21
22	type ModuleVersions struct {
23		Versions []VersionEntry `json:"versions"`
24	}
25
26	type ListVersionsResponse struct {
27		Modules []ModuleVersions `json:"modules"`
28	}
29
30	type ErrorResponse struct {
31		Error string `json:"error"`
32	}
33
34	// ServiceDiscoveryResponse is the body of /.well-known/terraform.json. The
35	// "modules.v1" key is dictated by HashiCorp's Terraform service discovery
36	// protocol and cannot be renamed to camelCase.
37	// See https://developer.hashicorp.com/terraform/internals/remote-service-discovery
38	type ServiceDiscoveryResponse struct {
39		ModulesV1 string `json:"modules.v1"` //nolint:tagliatelle // external wire format
40	}
41


1	// Package client is a reusable Crow HTTP client. It is used by apps/crowctl
2	// and is safe to use from tests (point it at an httptest.Server via
3	// WithHTTPClient or a localhost baseURL).
4	package client
5
6	import (
7		"context"
8		"encoding/json"
9		"errors"
10		"fmt"
11		"io"
12		"net/http"
13
14		"github.com/immuta/operations/services/crow/pkg/api"
15	)
16
17	type Client struct {
18		httpClient *http.Client
19		baseURL    string
20	}
21
22	type Option func(*Client)
23
24	// WithHTTPClient overrides the default *http.Client (useful for tests and
25	// for callers that want custom timeouts or transport).
26	func WithHTTPClient(h *http.Client) Option {
27		return func(c *Client) { c.httpClient = h }
28	}
29
30	// New constructs a Client pointed at baseURL (e.g. "https://crow.infrastructure.immuta.io").
31	// baseURL must not end in "/".
32	func New(baseURL string, opts ...Option) *Client {
33		c := &Client{baseURL: baseURL, httpClient: http.DefaultClient}
34		for _, opt := range opts {
35			opt(c)
36		}
37		return c
38	}
39
40	// Typed errors consumers can match with errors.Is.
41	var (
42		ErrAlreadyExists = errors.New("module version already exists")
43		ErrNotFound      = errors.New("module version not found")
44	)
45
46	// Upload PUTs the given gzipped-tar body for the module version. It returns
47	// ErrAlreadyExists on HTTP 409, nil on 201, and a generic error otherwise.
48	func (c *Client) Upload(ctx context.Context, m api.Module, body io.Reader) error {
49		if err := m.Validate(); err != nil {
50			return err
51		}
52		if err := api.ValidateVersion(m.Version); err != nil {
53			return err
54		}
55		req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+m.UploadPath(), body)
56		if err != nil {
57			return err
58		}
59		req.Header.Set("Content-Type", "application/octet-stream")
60		resp, err := c.httpClient.Do(req)
61		if err != nil {
62			return err
63		}
64		defer resp.Body.Close()
65		switch resp.StatusCode {
66		case http.StatusCreated:
67			return nil
68		case http.StatusConflict:
69			return ErrAlreadyExists
70		default:
71			return fmt.Errorf("unexpected status %d uploading %s", resp.StatusCode, m.UploadPath())
72		}
73	}
74
75	// Exists returns (true, nil) on 200 or 204, (false, nil) on 404, and an
76	// error for any other status.
77	func (c *Client) Exists(ctx context.Context, m api.Module) (bool, error) {
78		if err := m.Validate(); err != nil {
79			return false, err
80		}
81		req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.baseURL+m.DownloadPath(), nil)
82		if err != nil {
83			return false, err
84		}
85		resp, err := c.httpClient.Do(req)
86		if err != nil {
87			return false, err
88		}
89		defer resp.Body.Close()
90		switch resp.StatusCode {
91		case http.StatusOK, http.StatusNoContent:
92			return true, nil
93		case http.StatusNotFound:
94			return false, nil
95		default:
96			return false, fmt.Errorf("unexpected status %d checking %s", resp.StatusCode, m.DownloadPath())
97		}
98	}
99
100	// ListVersions returns the module's version list in server order.
101	// Returns ErrNotFound when the module has no published versions.
102	func (c *Client) ListVersions(ctx context.Context, id api.ModuleIdentifier) ([]string, error) {
103		if err := id.Validate(); err != nil {
104			return nil, err
105		}
106		req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+id.VersionsPath(), nil)
107		if err != nil {
108			return nil, err
109		}
110		resp, err := c.httpClient.Do(req)
111		if err != nil {
112			return nil, err
113		}
114		defer resp.Body.Close()
115		switch resp.StatusCode {
116		case http.StatusOK:
117			var body api.ListVersionsResponse
118			if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
119				return nil, fmt.Errorf("decoding response: %w", err)
120			}
121			if len(body.Modules) == 0 {
122				return nil, ErrNotFound
123			}
124			out := make([]string, len(body.Modules[0].Versions))
125			for i, v := range body.Modules[0].Versions {
126				out[i] = v.Version
127			}
128			return out, nil
129		case http.StatusNotFound:
130			return nil, ErrNotFound
131		default:
132			return nil, fmt.Errorf("unexpected status %d listing %s", resp.StatusCode, id.VersionsPath())
133		}
134	}
135
136	// Download fetches the gzipped tarball bytes for the given module
137	// version. It follows the X-Terraform-Get redirect returned by Crow's
138	// 204 response (per the Terraform module registry protocol). The caller
139	// is responsible for closing the returned ReadCloser.
140	//
141	// Returns ErrNotFound on HTTP 404 from Crow.
142	func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error) {
143		if err := m.Validate(); err != nil {
144			return nil, err
145		}
146		req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+m.DownloadPath(), nil)
147		if err != nil {
148			return nil, err
149		}
150		resp, err := c.httpClient.Do(req)
151		if err != nil {
152			return nil, err
153		}
154
155		// The protocol uses 204 + X-Terraform-Get to point clients at the
156		// real artifact location (S3 presigned URL in production).
157		if resp.StatusCode == http.StatusNotFound {
158			_ = resp.Body.Close()
159			return nil, ErrNotFound
160		}
161		if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
162			_ = resp.Body.Close()
163			return nil, fmt.Errorf("unexpected status %d resolving %s", resp.StatusCode, m.DownloadPath())
164		}
165		location := resp.Header.Get("X-Terraform-Get")
166		_ = resp.Body.Close()
167		if location == "" {
168			return nil, fmt.Errorf("crow returned %d with no X-Terraform-Get header for %s", resp.StatusCode, m.DownloadPath())
169		}
170
171		blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
172		if err != nil {
173			return nil, err
174		}
175		blobResp, err := c.httpClient.Do(blobReq)
176		if err != nil {
177			return nil, fmt.Errorf("fetching presigned blob: %w", err)
178		}
179		if blobResp.StatusCode != http.StatusOK {
180			_ = blobResp.Body.Close()
181			return nil, fmt.Errorf("unexpected status %d fetching blob %s", blobResp.StatusCode, location)
182		}
183		return blobResp.Body, nil
184	}
185


Now let me check the api types for `Validate` and `ValidateVersion`:


1	package api
2
3	import (
4		"fmt"
5		"regexp"
6		"strings"
7	)
8
9	// segmentRegex matches a single path segment: starts with alphanumeric,
10	// followed by alphanumeric, dots, underscores, or hyphens. It is the single
11	// source of truth for segment validation — the Crow server and its clients
12	// both import this package so they cannot drift.
13	var segmentRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
14
15	// versionSemverRegex is the SemVer 2.0.0 grammar from semver.org, minus the
16	// build metadata suffix. Two intentional deviations from the spec:
17	//   - No leading 'v' allowed. Terraform's native module loader strips the
18	//     prefix when building download URLs (per hashicorp/go-version
19	//     normalization), while terragrunt's tfr:// passes the version through
20	//     verbatim. A v-prefixed version uploaded today would resolve via
21	//     terragrunt and 404 via terraform — failing fast on upload prevents
22	//     the divergence.
23	//   - No '+build.metadata' suffix allowed. segmentRegex above doesn't
24	//     permit '+', so a build-tagged version couldn't be served from the
25	//     URL even if we accepted it on upload.
26	var versionSemverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$`)
27
28	type InvalidSegmentError struct {
29		Segment string
30	}
31
32	func (e InvalidSegmentError) Error() string {
33		return fmt.Sprintf("invalid segment %q: must match %s", e.Segment, segmentRegex)
34	}
35
36	// InvalidVersionError is returned when a version string is not valid SemVer
37	// 2.0.0 (no leading 'v', no '+build' metadata).
38	type InvalidVersionError struct {
39		Version string
40	}
41
42	func (e InvalidVersionError) Error() string {
43		return fmt.Sprintf("invalid version %q: expected bare SemVer 2.0.0 (e.g. \"1.2.3\", \"1.2.3-rc.1\") per https://semver.org", e.Version)
44	}
45
46	// ValidateSegments returns an InvalidSegmentError for the first segment that
47	// fails the regex. An empty slice passes.
48	func ValidateSegments(segments []string) error {
49		for _, s := range segments {
50			if !segmentRegex.MatchString(s) {
51				return InvalidSegmentError{Segment: s}
52			}
53		}
54		return nil
55	}
56
57	// ValidateVersion enforces SemVer 2.0.0 on a module version string. It is
58	// applied at upload boundaries (server handler + client) so non-semver
59	// versions can't enter storage. Download/list paths intentionally don't
60	// call this so that any v-prefixed versions still in S3 from before the
61	// guard remain reachable.
62	func ValidateVersion(version string) error {
63		if !versionSemverRegex.MatchString(version) {
64			return InvalidVersionError{Version: version}
65		}
66		return nil
67	}
68
69	func (id ModuleIdentifier) Validate() error {
70		return ValidateSegments([]string{id.Namespace, id.Name, id.Provider})
71	}
72
73	func (id ModuleIdentifier) VersionsPath() string {
74		return "/v1/modules/" + strings.Join([]string{id.Namespace, id.Name, id.Provider}, "/") + "/versions"
75	}
76
77	func (m Module) Validate() error {
78		return ValidateSegments([]string{m.Namespace, m.Name, m.Provider, m.Version})
79	}
80
81	func (m Module) UploadPath() string {
82		return "/v1/modules/" + strings.Join([]string{m.Namespace, m.Name, m.Provider, m.Version}, "/")
83	}
84
85	func (m Module) DownloadPath() string {
86		return m.UploadPath() + "/download"
87	}
88


1	package cmd
2
3	import (
4		"bytes"
5		"strings"
6		"testing"
7	)
8
9	func TestNewRootCmdHelp(t *testing.T) {
10		root := NewRootCmd()
11		var buf bytes.Buffer
12		root.SetOut(&buf)
13		root.SetErr(&buf)
14		root.SetArgs([]string{"--help"})
15		if err := root.Execute(); err != nil {
16			t.Fatalf("execute: %v", err)
17		}
18		out := buf.String()
19		for _, want := range []string{"birdseye", "--crow-url", "--timeout"} {
20			if !strings.Contains(out, want) {
21				t.Errorf("help output missing %q\n---\n%s", want, out)
22			}
23		}
24	}
25
26	func TestNewRootCmdDefaults(t *testing.T) {
27		root := NewRootCmd()
28		if got, _ := root.PersistentFlags().GetString("crow-url"); got != "https://crow.infrastructure.immuta.io" {
29			t.Errorf("crow-url default = %q", got)
30		}
31	}
32


Now I have everything I need. Let me proceed with the TDD approach — write the test file first, then run it to confirm failure, then implement, then verify.

**Step 1: Write the failing test file**


Write

package cmd

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
// (modified) and a new variables.tf.
func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
	t.Helper()
	mkTgz := func(files map[string]string) []byte {
		var buf bytes.Buffer
		gz := gzip.NewWriter(&buf)
		tw := tar.NewWriter(gz)
		for n, b := range files {
			_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
			_, _ = tw.Write([]byte(b))
		}
		_ = tw.Close()
		_ = gz.Close()
		return buf.Bytes()
	}
	v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
	v2 := mkTgz(map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
	})

	mux := http.NewServeMux()
	blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/blob/1.0.0":
			_, _ = w.Write(v1)
		case "/blob/1.1.0":
			_, _ = w.Write(v2)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(blob.Close)
	mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
		if len(parts) != 2 || parts[1] != "download" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
		w.WriteHeader(http.StatusNoContent)
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

func TestDiffPlainOutput(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout, stderr bytes.Buffer
	root.SetOut(&stdout)
	root.SetErr(&stderr)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
	}
	out := stdout.String()
	for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
		if !strings.Contains(out, want) {
			t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
		}
	}
}

func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.0.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "no differences" {
		t.Errorf("output = %q, want \"no differences\"", got)
	}
}

func TestDiffBadModuleAddress(t *testing.T) {
	root := NewRootCmd()
	var stderr bytes.Buffer
	root.SetErr(&stderr)
	root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
	err := root.ExecuteContext(context.Background())
	if err == nil {
		t.Fatal("expected error for bad address")
	}
	if ExitCode(err) != 1 {
		t.Errorf("exit code = %d, want 1", ExitCode(err))
	}
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff_test.go (file state is current in your context — no need to Read it back)


**Step 2: Run tests to confirm they fail**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiff -v 2>&1

Run diff tests expecting failure


Error: Exit code 1
=== RUN   TestDiffPlainOutput
    diff_test.go:76: execute: unknown flag: --plain
        stderr:
--- FAIL: TestDiffPlainOutput (0.00s)
=== RUN   TestDiffIdenticalVersionsNoChanges
    diff_test.go:99: execute: unknown flag: --plain
--- FAIL: TestDiffIdenticalVersionsNoChanges (0.00s)
=== RUN   TestDiffBadModuleAddress
Diff two versions of a Terraform module hosted in Crow

Usage:
  birdseye [flags]

Flags:
      --crow-url string    Crow base URL (env CROW_URL) (default "https://crow.infrastructure.immuta.io")
  -h, --help               help for birdseye
      --json               emit structured JSON to stdout where supported
      --timeout duration   per-operation HTTP timeout (default 30s)
    diff_test.go:113: expected error for bad address
--- FAIL: TestDiffBadModuleAddress (0.00s)
FAIL
FAIL	github.com/immuta/operations/apps/birdseye/cmd	0.728s
FAIL


Good — tests fail as expected (no `diff` subcommand yet). **Step 3: Implement `diff.go`**


Write

package cmd

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)

// diffOpts holds the resolved options for `birdseye diff`.
type diffOpts struct {
	Semantic bool
	Plain    bool
	CacheDir string
}

const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size

func newDiffCmd(g *GlobalFlags) *cobra.Command {
	o := &diffOpts{}
	c := &cobra.Command{
		Use:   "diff [module] [versionA] [versionB]",
		Short: "Diff two versions of a Crow module",
		Long: "Diff two versions of a module hosted in Crow.\n\n" +
			"If module/versions are omitted, the interactive TUI launches\n" +
			"(see picker task). With --plain, prints unified diff to stdout.",
		Args:          cobra.MaximumNArgs(3),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runDiff(cmd.Context(), cmd, args, g, o)
		},
	}
	c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
	c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
	c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
	c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
	return c
}

func defaultCacheDir() string {
	if home, err := os.UserCacheDir(); err == nil {
		return filepath.Join(home, "birdseye")
	}
	return ".birdseye-cache"
}

func newHTTPClient(d time.Duration) *http.Client {
	return &http.Client{Timeout: d}
}

func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
	if len(args) == 0 {
		// Interactive flow lives in Task 15; for now fall through to error.
		return fmt.Errorf("%w: interactive mode not yet implemented; provide <module> <versionA> <versionB>", ErrUsage)
	}
	if len(args) != 3 {
		return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
	}
	id, err := parseModuleAddress(args[0])
	if err != nil {
		return err
	}
	if err := api.ValidateVersion(args[1]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if err := api.ValidateVersion(args[2]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	modA := api.Module{ModuleIdentifier: id, Version: args[1]}
	modB := api.Module{ModuleIdentifier: id, Version: args[2]}

	cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
	cache, err := registry.NewDiskCache(o.CacheDir)
	if err != nil {
		return fmt.Errorf("cache init: %w", err)
	}
	reg := registry.New(cli, cache)

	aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
	if err != nil {
		return err
	}

	modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}

	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.
	// TUI mode wired in Task 13.

	return renderPlain(cmd.OutOrStdout(), result)
}

// renderPlain writes the diff result to w in unified-diff format.
// "no differences" is emitted when there are zero files in the result.
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 {
		_, err := fmt.Fprintln(w, "no differences")
		return err
	}
	for i, fd := range r.Files {
		if i > 0 {
			_, _ = fmt.Fprintln(w)
		}
		_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
		_, _ = fmt.Fprint(w, fd.Unified)
	}
	return nil
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go (file state is current in your context — no need to Read it back)


**Step 4: Wire subcommand in root.go**


Edit

	// Subcommands will be added in later tasks:
	//   root.AddCommand(newDiffCmd(g))
	//   root.AddCommand(newVersionsCmd(g))

	root.AddCommand(newDiffCmd(g))
	// versions subcommand added in Task 11


The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/root.go has been updated successfully. (file state is current in your context — no need to Read it back)


**Step 5: Run all tests with race detector**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v 2>&1

Run all cmd tests with race detector


=== RUN   TestDiffPlainOutput
--- PASS: TestDiffPlainOutput (0.01s)
=== RUN   TestDiffIdenticalVersionsNoChanges
--- PASS: TestDiffIdenticalVersionsNoChanges (0.01s)
=== RUN   TestDiffBadModuleAddress
--- PASS: TestDiffBadModuleAddress (0.00s)
=== RUN   TestExitCode
=== RUN   TestExitCode/nil
=== RUN   TestExitCode/not-found
=== RUN   TestExitCode/wrapped-not-found
=== RUN   TestExitCode/context-cancelled
=== RUN   TestExitCode/context-deadline
=== RUN   TestExitCode/generic
--- PASS: TestExitCode (0.00s)
    --- PASS: TestExitCode/nil (0.00s)
    --- PASS: TestExitCode/not-found (0.00s)
    --- PASS: TestExitCode/wrapped-not-found (0.00s)
    --- PASS: TestExitCode/context-cancelled (0.00s)
    --- PASS: TestExitCode/context-deadline (0.00s)
    --- PASS: TestExitCode/generic (0.00s)
=== RUN   TestNewRootCmdHelp
--- PASS: TestNewRootCmdHelp (0.00s)
=== RUN   TestNewRootCmdDefaults
--- PASS: TestNewRootCmdDefaults (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/cmd	1.698s


All 9 tests pass (3 new + 6 existing). **Step 6: Build and vet**


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... 2>&1 && echo "BUILD OK" && go vet ./... 2>&1 && echo "VET OK"

Build and vet all packages


BUILD OK
VET OK


**Step 7: Commit**


git add apps/birdseye/cmd/diff.go apps/birdseye/cmd/diff_test.go apps/birdseye/cmd/root.go && git status

Stage the three changed/created files


On branch worktree-birdseye
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   apps/birdseye/cmd/diff.go
	new file:   apps/birdseye/cmd/diff_test.go
	modified:   apps/birdseye/cmd/root.go

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/mind.mv2.lock


git commit -m "$(cat <<'EOF'
feat(birdseye): plain unified diff output for one-shot CLI usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit the Task 10 implementation


[worktree-birdseye 835333a] feat(birdseye): plain unified diff output for one-shot CLI usage
 3 files changed, 260 insertions(+), 3 deletions(-)
 create mode 100644 apps/birdseye/cmd/diff.go
 create mode 100644 apps/birdseye/cmd/diff_test.go


---

**Status:** DONE

**Files created/modified:**
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go` — `newDiffCmd`, `runDiff`, `renderPlain`, `parseModuleAddress`, `defaultCacheDir`, `newHTTPClient`
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff_test.go` — 3 integration tests with `fakeCrowTwoVersions` httptest server
- Modified: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/root.go` — wired `newDiffCmd(g)` via `root.AddCommand`

**Test count:** 9 total (3 new + 6 existing), all PASS, race-clean (`go test -race ./cmd/... -v`)

**`go build ./...`:** OK
**`go vet ./...`:** OK

**Commit SHA:** `835333a`

**Nothing unexpected.** The `--no-tui` alias shares `o.Plain` state correctly via two `BoolVar` calls on the same pointer. The `fakeCrowTwoVersions` server accurately simulates Crow's 204 + `X-Terraform-Get` redirect protocol that `client.Download` expects, so the integration test is hermetic with no real network calls.