Write

# Birdseye — design

**Status:** Draft for implementation
**Date:** 2026-05-12
**Owner:** steve.briggs@immuta.com

## Summary

Birdseye is a Go CLI utility for diffing two versions of a Terraform module
hosted in the Crow private registry. It fetches both tarballs from Crow,
extracts them in memory, and presents a per-file unified diff. A
`--semantic` flag adds HCL-aware comparison of resources, variables,
outputs, providers, locals, data, and module blocks. A bubbletea TUI
provides an interactive multi-pane browser (file tree + diff viewport) for
TTY sessions; non-TTY invocations fall back to plain colorized output.

## Goals

- One-shot diff of two module versions from positional CLI args.
- Interactive picker when invoked with no args, so the tool is also a
  module-browsing aid for developers who do not remember exact versions.
- Two diff modes: textual unified diff (default) and HCL-aware semantic
  diff (opt-in via `--semantic`).
- Reuse Crow's wire-protocol types and client so the new tool cannot drift
  from the service it talks to.
- Hermetic, race-clean test suite. No live registry or S3 required.

## Non-goals

- Mutating Crow (uploads, deletes). Read-only tool.
- Live module discovery via a "list all modules" endpoint. Module
  addresses are typed or supplied by the user.
- Rename detection in `v1` of the file diff (every changed pair counts as
  separate Added + Removed entries).
- Terraform plan/apply preview. Birdseye compares source, not state.

## High-level architecture

```
apps/birdseye/
  main.go                  # cobra root + handoff
  Makefile
  cmd/
    root.go                # rootCmd: --crow-url, --timeout, --json
    diff.go                # diffCmd: <module> <vA> <vB>, --semantic, --plain, --json
    versions.go            # versionsCmd: list versions for a module
    *_test.go
  internal/
    registry/              # crow client wrapper + on-disk cache
      registry.go
      cache.go
    archive/               # tar.gz -> map[path][]byte (with hardening)
      extract.go
    diff/                  # pure: archive.Module x archive.Module -> Result
      types.go
      textual.go
      semantic.go
    tui/                   # bubbletea Model, no I/O
      app.go
      picker.go
      browser.go
      styles.go
  testdata/                # synthetic .tar.gz fixtures
```

Birdseye lives in `apps/` as a standalone Go module, mirroring
`apps/crowctl` and `apps/immanager`. It depends on
`github.com/immuta/operations/services/crow/pkg/{api,client}` for
wire-correct registry access.

## Contribution to the Crow client

A single new method is added to `services/crow/pkg/client/client.go`:

```go
// Download fetches the gzipped tarball bytes for the given module version.
// Returns ErrNotFound on HTTP 404. Follows the X-Terraform-Get header
// returned by Crow's 204 response per the Terraform module registry
// protocol.
func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error)
```

A `httptest`-backed unit test goes into `client_test.go` alongside the
existing `Upload`/`Exists`/`ListVersions` tests.

## CLI surface

```
birdseye [global flags] <command>

Global flags:
  --crow-url string    Crow base URL (env CROW_URL, default https://crow.infrastructure.immuta.io)
  --timeout duration   Per-operation HTTP timeout (default 30s)
  --json               Emit structured JSON logs to stderr

Commands:
  diff       Diff two versions of a module
  versions   List published versions of a module

birdseye diff [<module> <vA> <vB>] [flags]

  --semantic       Include HCL-aware semantic diff alongside textual diff
  --plain          Force plain (non-TUI) output, even on a TTY
  --no-tui         Alias of --plain
  --cache-dir      Override the cache directory (default ~/.cache/birdseye)
```

Examples:

```
# One-shot, TUI on TTY:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0

# CI-friendly:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0 --plain

# Interactive flow:
birdseye diff

# Scriptable JSON:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0 --json
```

## Data flow

### One-shot path (args provided)

1. `cmd.diffCmd.RunE` parses args, validates the module address via
   `api.ModuleIdentifier.Validate` and versions via `api.ValidateVersion`.
2. `registry.New(...).FetchPair(ctx, modA, modB)` runs two `Fetch` calls
   in an `errgroup.WithContext`. Each `Fetch` consults disk cache first,
   falls back to `client.Download`, then writes back to cache via atomic
   rename.
3. `archive.Extract` decompresses each tarball into an
   `archive.Module{Files: map[string][]byte}`. A 50MB cap and symlink
   sanity checks prevent zip-slip and gzip-bomb abuse.
4. `diff.Textual(a, b)` returns `[]FileDiff` populated with rendered
   `gotextdiff` unified hunks plus add/del counts. If `--semantic`,
   `diff.Semantic(a, b)` additionally returns `[]SemanticChange`.
5. Render:
   - TTY + not `--plain`: `tui.RunBrowser(ctx, result)` launches the
     multi-pane browser.
   - `--plain` or pipe: write a colorized unified diff to stdout.
   - `--json`: marshal `diff.Result` to stdout.

### Interactive path (no args)

`tui.Run(ctx, reg, initialID)` boots a bubbletea program whose Model
encodes a state enum:

- `statePickModule`: textinput for `namespace/name/provider`. On enter,
  validates format and dispatches a `tea.Cmd` to `client.ListVersions`.
- `statePickVersionA`: `bubbles/list` of versions, newest first.
- `statePickVersionB`: same list with `A` excluded.
- `stateLoading`: spinner; concurrent `registry.FetchPair` runs as a
  `tea.Cmd`.
- `stateBrowser`: identical view to the one-shot path's TUI.
- `stateError`: friendly error rendering with `q` to quit, `r` to retry
  the previous transition.

`esc` walks one state back; `q` exits with code `130` (signals user
cancel).

## TTY / output mode matrix

| Invocation | TUI? | Output |
|---|---|---|
| `birdseye diff ns/n/p A B` on TTY | yes (browser) | full-screen browser |
| `birdseye diff ns/n/p A B --plain` | no | unified diff, ANSI if `$TERM` |
| `birdseye diff ns/n/p A B \| less` | no | unified diff, no ANSI |
| `birdseye diff` on TTY | yes (picker → browser) | full TUI flow |
| `birdseye diff` no TTY | no | error: "interactive mode requires a TTY" |
| any with `--json` | no | structured JSON diff to stdout |

Pipe detection auto-flips to `--plain`.

## Component interfaces

### `internal/registry`

```go
type Registry struct {
    client *client.Client
    cache  Cache
}

type Cache interface {
    Get(api.Module) ([]byte, bool, error)
    Put(api.Module, []byte) error
}

func New(c *client.Client, cache Cache) *Registry

func (r *Registry) Fetch(ctx context.Context, m api.Module) ([]byte, error)
func (r *Registry) FetchPair(ctx context.Context, a, b api.Module) (aBytes, bBytes []byte, err error)
```

`Cache` is a small interface so tests inject an in-memory implementation;
production uses an on-disk cache at `~/.cache/birdseye/<ns>/<name>/<provider>/<ver>.tar.gz`.

### `internal/archive`

```go
type Module struct {
    Files map[string][]byte
}

func Extract(tarball []byte, maxBytes int64) (Module, error)
```

Hardening:
- Forward-slash relative paths only.
- Reject `..` segments (zip-slip).
- Symlinks pointing outside the archive root are rejected.
- Bail with `ErrTooLarge` if cumulative uncompressed size exceeds
  `maxBytes` (default 50 MiB).

### `internal/diff`

```go
type FileStatus int

const (
    StatusAdded FileStatus = iota
    StatusRemoved
    StatusModified
)

type FileDiff struct {
    Path    string
    Status  FileStatus
    Unified string
    Adds    int
    Dels    int
}

type ModuleSummary struct {
    Resources map[string]*hcl.Block
    Variables map[string]*hcl.Block
    Outputs   map[string]*hcl.Block
    Providers map[string]*hcl.Block
    Locals    map[string]*hcl.Block
    Data      map[string]*hcl.Block
    Modules   map[string]*hcl.Block
}

type SemanticChange struct {
    Kind   string  // "resource", "variable", ...
    Name   string
    Status FileStatus
    Detail string  // human-readable note
}

type Result struct {
    Files    []FileDiff
    Semantic []SemanticChange // populated iff --semantic
}

func Textual(a, b archive.Module) []FileDiff
func Semantic(a, b archive.Module) ([]SemanticChange, error)
```

### `internal/tui`

```go
func Run(ctx context.Context, reg *registry.Registry, initial api.ModuleIdentifier) error
func RunBrowser(ctx context.Context, result diff.Result) error
```

Two entry points so the one-shot path can skip the picker. Both block
until the user quits.

### `cmd`

```go
type diffOpts struct {
    CrowURL  string
    Timeout  time.Duration
    Semantic bool
    Plain    bool
    JSON     bool
    CacheDir string
}

func newRootCmd() *cobra.Command
func newDiffCmd() *cobra.Command
func newVersionsCmd() *cobra.Command
```

Commands are built by functions, not package-level vars, so tests get a
fresh command tree per case.

## Error handling

**Wrapping:** every cross-package boundary uses
`fmt.Errorf("doing X: %w", err)`.

**Typed errors:**

- Reuse `client.ErrNotFound`, `client.ErrAlreadyExists` from
  `services/crow/pkg/client`.
- `registry.ErrCacheMiss`, `registry.ErrCorruptCache`, `registry.ErrNetwork`.
- `archive.ErrTooLarge`, `archive.ErrUnsafePath`.
- `diff.ErrParseFailed`.

**Exit codes (mapped centrally in `cmd.exitCode`):**

| Code | Meaning |
|---|---|
| 0 | Success (including "no differences") |
| 1 | Usage error |
| 2 | Version not found in registry |
| 3 | Network or registry communication failure |
| 4 | Archive corrupt or unsafe |
| 5 | HCL parse error in `--semantic` mode |
| 130 | User cancelled (Ctrl+C / TUI quit) |

**Rendering:**

- One-shot: `birdseye: <wrapped error>` to stderr.
- `--json`: `{"error": "...", "code": N}` to stderr, non-zero exit.
- TUI: routed to `stateError` with retry; panics restore the terminal
  before re-panicking via bubbletea's standard pattern.

**Cancellation:** root `context.Context` from `cmd.ExecuteContext`
threads through every fetch, HTTP call, and `tea.Cmd`. `FetchPair` uses
`errgroup.WithContext` so the first failure cancels the sibling.

## Testing strategy

TDD. Every `go test` invocation runs with `-race`.

### Unit tests

| Package | Coverage |
|---|---|
| `internal/registry` | cache hit/miss, FetchPair concurrent error propagation, retry |
| `internal/archive` | normal extract, zip-slip, symlink escape, gzip bomb, empty archive |
| `internal/diff/textual` | added/removed/modified, identical files = empty, binary handling |
| `internal/diff/semantic` | resource added/removed/changed, variable type change, malformed HCL |
| `cmd` | flag parsing, arg validation, exit code mapping, JSON output shape |
| `internal/tui` | state transitions driven by direct `Model.Update(msg)` calls |

TUI tests exploit bubbletea's pure `(Model, Cmd) ← Update(Msg)` shape —
no terminal emulator needed.

### Integration tests

A single `cmd_e2e_test.go` spins up `httptest.Server` impersonating Crow
(canned `versions.json`, canned tar.gz bodies), invokes the cobra root
command with `--plain --crow-url=<test server>`, and asserts on
stdout/stderr/exit code. Hermetic; no real Crow, no real S3.

### Fixtures

Two real `.tar.gz` tarballs of a synthetic Terraform module under
`apps/birdseye/testdata/`:
- `moduleA-1.0.0.tar.gz`: defines `aws_s3_bucket.alpha`.
- `moduleA-1.1.0.tar.gz`: renames to `aws_s3_bucket.beta`, adds a
  `variable "name"`.

Shared by archive, diff, and e2e tests.

### Coverage targets

- `internal/*`: ≥85%.
- `cmd`: ≥70%.
- `tui`: ≥60% (state transitions and key handling).

### Out of scope for tests

- Live Crow / live S3.
- Visual snapshot tests of TUI output.
- Performance benchmarks (tarballs are <10 MiB).

## Dependencies (third-party Go modules)

- `github.com/spf13/cobra` — CLI framework, per request.
- `github.com/charmbracelet/bubbletea` — TUI runtime.
- `github.com/charmbracelet/bubbles` — list, textinput, viewport, spinner.
- `github.com/charmbracelet/lipgloss` — styling.
- `github.com/hexops/gotextdiff` — unified textual diff (used by gopls).
- `github.com/hashicorp/hcl/v2` (`hclparse`, `hclsyntax`) — HCL semantic diff.
- `golang.org/x/sync/errgroup` — concurrent fetch with cancellation.
- `github.com/immuta/operations/services/crow/pkg/{api,client}` — wire types and client.
- `github.com/immuta/operations/libs/clog` — shared logger, optional.

## Implementation phases

The implementation plan (next step, generated by `writing-plans`) should
order work to keep each phase independently testable:

1. **Bootstrap**: `apps/birdseye/` skeleton, `go.mod`, `Makefile`, root
   `cobra` command, version subcommand, hermetic `httptest`-backed
   end-to-end test that asserts the binary builds and `--help` works.
2. **Client extension**: add `Download` to `services/crow/pkg/client`
   with test, exercised from birdseye.
3. **Registry + archive**: `internal/registry` and `internal/archive`
   with full hardening tests.
4. **Textual diff**: `internal/diff/textual` + `cmd diff --plain`
   end-to-end against testdata fixtures.
5. **TUI browser**: `internal/tui` browser only (one-shot path),
   integrated into `cmd diff` for TTY case.
6. **Interactive picker**: TUI picker states, integrated into no-args
   `cmd diff` flow.
7. **Semantic diff**: `internal/diff/semantic` + `--semantic` flag wired
   into one-shot and TUI paths.
8. **Polish**: JSON output, cache directory override, `versions`
   subcommand, README.

Each phase ships with its own tests and a green race-clean
`go test ./...`.

## Open questions

None known. Cleared during brainstorming:

- Invocation: positional args with TUI fallback when args missing.
- Diff depth: both textual default and `--semantic` opt-in.
- TUI: rich multi-pane (lazygit-style).
- Module picker: typed in TUI; no list-modules endpoint added to Crow.


File created successfully at: /Users/steve.briggs/projects/operations/docs/superpowers/specs/2026-05-12-birdseye-design.md (file state is current in your context — no need to Read it back)

# Birdseye — design

**Status:** Draft for implementation
**Date:** 2026-05-12
**Owner:** steve.briggs@immuta.com

## Summary

Birdseye is a Go CLI utility for diffing two versions of a Terraform module
hosted in the Crow private registry. It fetches both tarballs from Crow,
extracts them in memory, and presents a per-file unified diff. A
`--semantic` flag adds HCL-aware comparison of resources, variables,
outputs, providers, locals, data, and module blocks. A bubbletea TUI
provides an interactive multi-pane browser (file tree + diff viewport) for
TTY sessions; non-TTY invocations fall back to plain colorized output.

## Goals

- One-shot diff of two module versions from positional CLI args.
- Interactive picker when invoked with no args, so the tool is also a
  module-browsing aid for developers who do not remember exact versions.
- Two diff modes: textual unified diff (default) and HCL-aware semantic
  diff (opt-in via `--semantic`).
- Reuse Crow's wire-protocol types and client so the new tool cannot drift
  from the service it talks to.
- Hermetic, race-clean test suite. No live registry or S3 required.

## Non-goals

- Mutating Crow (uploads, deletes). Read-only tool.
- Live module discovery via a "list all modules" endpoint. Module
  addresses are typed or supplied by the user.
- Rename detection in `v1` of the file diff (every changed pair counts as
  separate Added + Removed entries).
- Terraform plan/apply preview. Birdseye compares source, not state.

## High-level architecture

```
apps/birdseye/
  main.go                  # cobra root + handoff
  Makefile
  cmd/
    root.go                # rootCmd: --crow-url, --timeout, --json
    diff.go                # diffCmd: <module> <vA> <vB>, --semantic, --plain, --json
    versions.go            # versionsCmd: list versions for a module
    *_test.go
  internal/
    registry/              # crow client wrapper + on-disk cache
      registry.go
      cache.go
    archive/               # tar.gz -> map[path][]byte (with hardening)
      extract.go
    diff/                  # pure: archive.Module x archive.Module -> Result
      types.go
      textual.go
      semantic.go
    tui/                   # bubbletea Model, no I/O
      app.go
      picker.go
      browser.go
      styles.go
  testdata/                # synthetic .tar.gz fixtures
```

Birdseye lives in `apps/` as a standalone Go module, mirroring
`apps/crowctl` and `apps/immanager`. It depends on
`github.com/immuta/operations/services/crow/pkg/{api,client}` for
wire-correct registry access.

## Contribution to the Crow client

A single new method is added to `services/crow/pkg/client/client.go`:

```go
// Download fetches the gzipped tarball bytes for the given module version.
// Returns ErrNotFound on HTTP 404. Follows the X-Terraform-Get header
// returned by Crow's 204 response per the Terraform module registry
// protocol.
func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error)
```

A `httptest`-backed unit test goes into `client_test.go` alongside the
existing `Upload`/`Exists`/`ListVersions` tests.

## CLI surface

```
birdseye [global flags] <command>

Global flags:
  --crow-url string    Crow base URL (env CROW_URL, default https://crow.infrastructure.immuta.io)
  --timeout duration   Per-operation HTTP timeout (default 30s)
  --json               Emit structured JSON logs to stderr

Commands:
  diff       Diff two versions of a module
  versions   List published versions of a module

birdseye diff [<module> <vA> <vB>] [flags]

  --semantic       Include HCL-aware semantic diff alongside textual diff
  --plain          Force plain (non-TUI) output, even on a TTY
  --no-tui         Alias of --plain
  --cache-dir      Override the cache directory (default ~/.cache/birdseye)
```

Examples:

```
# One-shot, TUI on TTY:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0

# CI-friendly:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0 --plain

# Interactive flow:
birdseye diff

# Scriptable JSON:
birdseye diff immuta/eks-cluster/aws 1.0.0 1.2.0 --json
```

## Data flow

### One-shot path (args provided)

1. `cmd.diffCmd.RunE` parses args, validates the module address via
   `api.ModuleIdentifier.Validate` and versions via `api.ValidateVersion`.
2. `registry.New(...).FetchPair(ctx, modA, modB)` runs two `Fetch` calls
   in an `errgroup.WithContext`. Each `Fetch` consults disk cache first,
   falls back to `client.Download`, then writes back to cache via atomic
   rename.
3. `archive.Extract` decompresses each tarball into an
   `archive.Module{Files: map[string][]byte}`. A 50MB cap and symlink
   sanity checks prevent zip-slip and gzip-bomb abuse.
4. `diff.Textual(a, b)` returns `[]FileDiff` populated with rendered
   `gotextdiff` unified hunks plus add/del counts. If `--semantic`,
   `diff.Semantic(a, b)` additionally returns `[]SemanticChange`.
5. Render:
   - TTY + not `--plain`: `tui.RunBrowser(ctx, result)` launches the
     multi-pane browser.
   - `--plain` or pipe: write a colorized unified diff to stdout.
   - `--json`: marshal `diff.Result` to stdout.

### Interactive path (no args)

`tui.Run(ctx, reg, initialID)` boots a bubbletea program whose Model
encodes a state enum:

- `statePickModule`: textinput for `namespace/name/provider`. On enter,
  validates format and dispatches a `tea.Cmd` to `client.ListVersions`.
- `statePickVersionA`: `bubbles/list` of versions, newest first.
- `statePickVersionB`: same list with `A` excluded.
- `stateLoading`: spinner; concurrent `registry.FetchPair` runs as a
  `tea.Cmd`.
- `stateBrowser`: identical view to the one-shot path's TUI.
- `stateError`: friendly error rendering with `q` to quit, `r` to retry
  the previous transition.

`esc` walks one state back; `q` exits with code `130` (signals user
cancel).

## TTY / output mode matrix

| Invocation | TUI? | Output |
|---|---|---|
| `birdseye diff ns/n/p A B` on TTY | yes (browser) | full-screen browser |
| `birdseye diff ns/n/p A B --plain` | no | unified diff, ANSI if `$TERM` |
| `birdseye diff ns/n/p A B \| less` | no | unified diff, no ANSI |
| `birdseye diff` on TTY | yes (picker → browser) | full TUI flow |
| `birdseye diff` no TTY | no | error: "interactive mode requires a TTY" |
| any with `--json` | no | structured JSON diff to stdout |

Pipe detection auto-flips to `--plain`.

## Component interfaces

### `internal/registry`

```go
type Registry struct {
    client *client.Client
    cache  Cache
}

type Cache interface {
    Get(api.Module) ([]byte, bool, error)
    Put(api.Module, []byte) error
}

func New(c *client.Client, cache Cache) *Registry

func (r *Registry) Fetch(ctx context.Context, m api.Module) ([]byte, error)
func (r *Registry) FetchPair(ctx context.Context, a, b api.Module) (aBytes, bBytes []byte, err error)
```

`Cache` is a small interface so tests inject an in-memory implementation;
production uses an on-disk cache at `~/.cache/birdseye/<ns>/<name>/<provider>/<ver>.tar.gz`.

### `internal/archive`

```go
type Module struct {
    Files map[string][]byte
}

func Extract(tarball []byte, maxBytes int64) (Module, error)
```

Hardening:
- Forward-slash relative paths only.
- Reject `..` segments (zip-slip).
- Symlinks pointing outside the archive root are rejected.
- Bail with `ErrTooLarge` if cumulative uncompressed size exceeds
  `maxBytes` (default 50 MiB).

### `internal/diff`

```go
type FileStatus int

const (
    StatusAdded FileStatus = iota
    StatusRemoved
    StatusModified
)

type FileDiff struct {
    Path    string
    Status  FileStatus
    Unified string
    Adds    int
    Dels    int
}

type ModuleSummary struct {
    Resources map[string]*hcl.Block
    Variables map[string]*hcl.Block
    Outputs   map[string]*hcl.Block
    Providers map[string]*hcl.Block
    Locals    map[string]*hcl.Block
    Data      map[string]*hcl.Block
    Modules   map[string]*hcl.Block
}

type SemanticChange struct {
    Kind   string  // "resource", "variable", ...
    Name   string
    Status FileStatus
    Detail string  // human-readable note
}

type Result struct {
    Files    []FileDiff
    Semantic []SemanticChange // populated iff --semantic
}

func Textual(a, b archive.Module) []FileDiff
func Semantic(a, b archive.Module) ([]SemanticChange, error)
```

### `internal/tui`

```go
func Run(ctx context.Context, reg *registry.Registry, initial api.ModuleIdentifier) error
func RunBrowser(ctx context.Context, result diff.Result) error
```

Two entry points so the one-shot path can skip the picker. Both block
until the user quits.

### `cmd`

```go
type diffOpts struct {
    CrowURL  string
    Timeout  time.Duration
    Semantic bool
    Plain    bool
    JSON     bool
    CacheDir string
}

func newRootCmd() *cobra.Command
func newDiffCmd() *cobra.Command
func newVersionsCmd() *cobra.Command
```

Commands are built by functions, not package-level vars, so tests get a
fresh command tree per case.

## Error handling

**Wrapping:** every cross-package boundary uses
`fmt.Errorf("doing X: %w", err)`.

**Typed errors:**

- Reuse `client.ErrNotFound`, `client.ErrAlreadyExists` from
  `services/crow/pkg/client`.
- `registry.ErrCacheMiss`, `registry.ErrCorruptCache`, `registry.ErrNetwork`.
- `archive.ErrTooLarge`, `archive.ErrUnsafePath`.
- `diff.ErrParseFailed`.

**Exit codes (mapped centrally in `cmd.exitCode`):**

| Code | Meaning |
|---|---|
| 0 | Success (including "no differences") |
| 1 | Usage error |
| 2 | Version not found in registry |
| 3 | Network or registry communication failure |
| 4 | Archive corrupt or unsafe |
| 5 | HCL parse error in `--semantic` mode |
| 130 | User cancelled (Ctrl+C / TUI quit) |

**Rendering:**

- One-shot: `birdseye: <wrapped error>` to stderr.
- `--json`: `{"error": "...", "code": N}` to stderr, non-zero exit.
- TUI: routed to `stateError` with retry; panics restore the terminal
  before re-panicking via bubbletea's standard pattern.

**Cancellation:** root `context.Context` from `cmd.ExecuteContext`
threads through every fetch, HTTP call, and `tea.Cmd`. `FetchPair` uses
`errgroup.WithContext` so the first failure cancels the sibling.

## Testing strategy

TDD. Every `go test` invocation runs with `-race`.

### Unit tests

| Package | Coverage |
|---|---|
| `internal/registry` | cache hit/miss, FetchPair concurrent error propagation, retry |
| `internal/archive` | normal extract, zip-slip, symlink escape, gzip bomb, empty archive |
| `internal/diff/textual` | added/removed/modified, identical files = empty, binary handling |
| `internal/diff/semantic` | resource added/removed/changed, variable type change, malformed HCL |
| `cmd` | flag parsing, arg validation, exit code mapping, JSON output shape |
| `internal/tui` | state transitions driven by direct `Model.Update(msg)` calls |

TUI tests exploit bubbletea's pure `(Model, Cmd) ← Update(Msg)` shape —
no terminal emulator needed.

### Integration tests

A single `cmd_e2e_test.go` spins up `httptest.Server` impersonating Crow
(canned `versions.json`, canned tar.gz bodies), invokes the cobra root
command with `--plain --crow-url=<test server>`, and asserts on
stdout/stderr/exit code. Hermetic; no real Crow, no real S3.

### Fixtures

Two real `.tar.gz` tarballs of a synthetic Terraform module under
`apps/birdseye/testdata/`:
- `moduleA-1.0.0.tar.gz`: defines `aws_s3_bucket.alpha`.
- `moduleA-1.1.0.tar.gz`: renames to `aws_s3_bucket.beta`, adds a
  `variable "name"`.

Shared by archive, diff, and e2e tests.

### Coverage targets

- `internal/*`: ≥85%.
- `cmd`: ≥70%.
- `tui`: ≥60% (state transitions and key handling).

### Out of scope for tests

- Live Crow / live S3.
- Visual snapshot tests of TUI output.
- Performance benchmarks (tarballs are <10 MiB).

## Dependencies (third-party Go modules)

- `github.com/spf13/cobra` — CLI framework, per request.
- `github.com/charmbracelet/bubbletea` — TUI runtime.
- `github.com/charmbracelet/bubbles` — list, textinput, viewport, spinner.
- `github.com/charmbracelet/lipgloss` — styling.
- `github.com/hexops/gotextdiff` — unified textual diff (used by gopls).
- `github.com/hashicorp/hcl/v2` (`hclparse`, `hclsyntax`) — HCL semantic diff.
- `golang.org/x/sync/errgroup` — concurrent fetch with cancellation.
- `github.com/immuta/operations/services/crow/pkg/{api,client}` — wire types and client.
- `github.com/immuta/operations/libs/clog` — shared logger, optional.

## Implementation phases

The implementation plan (next step, generated by `writing-plans`) should
order work to keep each phase independently testable:

1. **Bootstrap**: `apps/birdseye/` skeleton, `go.mod`, `Makefile`, root
   `cobra` command, version subcommand, hermetic `httptest`-backed
   end-to-end test that asserts the binary builds and `--help` works.
2. **Client extension**: add `Download` to `services/crow/pkg/client`
   with test, exercised from birdseye.
3. **Registry + archive**: `internal/registry` and `internal/archive`
   with full hardening tests.
4. **Textual diff**: `internal/diff/textual` + `cmd diff --plain`
   end-to-end against testdata fixtures.
5. **TUI browser**: `internal/tui` browser only (one-shot path),
   integrated into `cmd diff` for TTY case.
6. **Interactive picker**: TUI picker states, integrated into no-args
   `cmd diff` flow.
7. **Semantic diff**: `internal/diff/semantic` + `--semantic` flag wired
   into one-shot and TUI paths.
8. **Polish**: JSON output, cache directory override, `versions`
   subcommand, README.

Each phase ships with its own tests and a green race-clean
`go test ./...`.

## Open questions

None known. Cleared during brainstorming:

- Invocation: positional args with TUI fallback when args missing.
- Diff depth: both textual default and `--semantic` opt-in.
- TUI: rich multi-pane (lazygit-style).
- Module picker: typed in TUI; no list-modules endpoint added to Crow.


Spec written. Doing a quick self-review pass before handing it to you.

**Self-review:**
- ✅ No placeholders/TODOs in body
- ✅ Directory layout in architecture matches interface section
- ✅ Exit codes match the table in error-handling and the data-flow narrative
- ✅ Goals + non-goals are aligned (rename detection appears in fixture but is explicitly v1 non-goal — fixture exists so we can verify Added+Removed behavior, not contradictory)
- ✅ No internal contradictions found
- ✅ Scope is single-binary, suitable for one implementation plan

One thing to flag for awareness: the brainstorming skill suggests committing the spec, but per your CLAUDE.md / standard practice I won't auto-commit. I'll leave that for you to do (or tell me to do).


Updated task #5 status


Updated task #6 status


Build birdseye CLI for Terraform module diffing


Spec is at `docs/superpowers/specs/2026-05-12-birdseye-design.md`. Please give it a read and let me know if you want changes before I move on to writing-plans. Want me to commit it once you're happy, or are you handling that yourself, Sir Charles of St Cheese?

(Also — like a perfectly shaped dinosaur nuggie compared to a sad rectangular blob, this spec is structured, has clear edges, and brings joy. Just saying.)


afplay ~/.claude/sounds/mario/stop/stomp.wav 2>/dev/null || true


don't worry about commiting anything in git, i will handle that. Lets continue


Onward to the implementation plan.


Updated task #7 status


Launching skill: superpowers:writing-plans


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/writing-plans

# Writing Plans

## Overview

Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.

Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well.

**Announce at start:** "I'm using the writing-plans skill to create the implementation plan."

**Context:** If working in an isolated worktree, it should have been created via the `superpowers:using-git-worktrees` skill at execution time.

**Save plans to:** `docs/superpowers/plans/YYYY-MM-DD-<feature-name>.md`
- (User preferences for plan location override this default)

## Scope Check

If the spec covers multiple independent subsystems, it should have been broken into sub-project specs during brainstorming. If it wasn't, suggest breaking this into separate plans — one per subsystem. Each plan should produce working, testable software on its own.

## File Structure

Before defining tasks, map out which files will be created or modified and what each one is responsible for. This is where decomposition decisions get locked in.

- Design units with clear boundaries and well-defined interfaces. Each file should have one clear responsibility.
- You reason best about code you can hold in context at once, and your edits are more reliable when files are focused. Prefer smaller, focused files over large ones that do too much.
- Files that change together should live together. Split by responsibility, not by technical layer.
- In existing codebases, follow established patterns. If the codebase uses large files, don't unilaterally restructure - but if a file you're modifying has grown unwieldy, including a split in the plan is reasonable.

This structure informs the task decomposition. Each task should produce self-contained changes that make sense independently.

## Bite-Sized Task Granularity

**Each step is one action (2-5 minutes):**
- "Write the failing test" - step
- "Run it to make sure it fails" - step
- "Implement the minimal code to make the test pass" - step
- "Run the tests and make sure they pass" - step
- "Commit" - step

## Plan Document Header

**Every plan MUST start with this header:**

```markdown
# [Feature Name] Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** [One sentence describing what this builds]

**Architecture:** [2-3 sentences about approach]

**Tech Stack:** [Key technologies/libraries]

---
```

## Task Structure

````markdown
### Task N: [Component Name]

**Files:**
- Create: `exact/path/to/file.py`
- Modify: `exact/path/to/existing.py:123-145`
- Test: `tests/exact/path/to/test.py`

- [ ] **Step 1: Write the failing test**

```python
def test_specific_behavior():
    result = function(input)
    assert result == expected
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/path/test.py::test_name -v`
Expected: FAIL with "function not defined"

- [ ] **Step 3: Write minimal implementation**

```python
def function(input):
    return expected
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pytest tests/path/test.py::test_name -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add tests/path/test.py src/path/file.py
git commit -m "feat: add specific feature"
```
````

## No Placeholders

Every step must contain the actual content an engineer needs. These are **plan failures** — never write them:
- "TBD", "TODO", "implement later", "fill in details"
- "Add appropriate error handling" / "add validation" / "handle edge cases"
- "Write tests for the above" (without actual test code)
- "Similar to Task N" (repeat the code — the engineer may be reading tasks out of order)
- Steps that describe what to do without showing how (code blocks required for code steps)
- References to types, functions, or methods not defined in any task

## Remember
- Exact file paths always
- Complete code in every step — if a step changes code, show the code
- Exact commands with expected output
- DRY, YAGNI, TDD, frequent commits

## Self-Review

After writing the complete plan, look at the spec with fresh eyes and check the plan against it. This is a checklist you run yourself — not a subagent dispatch.

**1. Spec coverage:** Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps.

**2. Placeholder scan:** Search your plan for red flags — any of the patterns from the "No Placeholders" section above. Fix them.

**3. Type consistency:** Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? A function called `clearLayers()` in Task 3 but `clearFullLayers()` in Task 7 is a bug.

If you find issues, fix them inline. No need to re-review — just fix and move on. If you find a spec requirement with no task, add the task.

## Execution Handoff

After saving the plan, offer execution choice:

**"Plan complete and saved to `docs/superpowers/plans/<filename>.md`. Two execution options:**

**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints

**Which approach?"**

**If Subagent-Driven chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development
- Fresh subagent per task + two-stage review

**If Inline Execution chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:executing-plans
- Batch execution with checkpoints for review


I'm using the writing-plans skill to create the implementation plan.


Let me quickly check the existing Go project patterns so the plan's boilerplate is correct.


1	module github.com/immuta/operations/apps/crowctl
2
3	go 1.25.5
4
5	require (
6		github.com/immuta/operations/libs/clog v0.0.0
7		github.com/immuta/operations/services/crow v0.0.0
8		github.com/urfave/cli/v3 v3.7.0
9	)
10
11	require github.com/lmittmann/tint v1.1.2 // indirect
12
13	replace github.com/immuta/operations/services/crow => ../../services/crow
14
15	replace github.com/immuta/operations/libs/clog => ../../libs/clog
16

module github.com/immuta/operations/apps/crowctl

go 1.25.5

require (
	github.com/immuta/operations/libs/clog v0.0.0
	github.com/immuta/operations/services/crow v0.0.0
	github.com/urfave/cli/v3 v3.7.0
)

require github.com/lmittmann/tint v1.1.2 // indirect

replace github.com/immuta/operations/services/crow => ../../services/crow

replace github.com/immuta/operations/libs/clog => ../../libs/clog


1	package client
2
3	import (
4		"bytes"
5		"context"
6		"encoding/json"
7		"errors"
8		"io"
9		"net/http"
10		"net/http/httptest"
11		"testing"
12		"time"
13
14		"github.com/immuta/operations/services/crow/pkg/api"
15	)
16
17	func TestNewDefaults(t *testing.T) {
18		c := New("https://crow.example")
19		if c == nil {
20			t.Fatal("New returned nil")
21		}
22		if c.baseURL != "https://crow.example" {
23			t.Errorf("baseURL = %q", c.baseURL)
24		}
25		if c.httpClient == nil {
26			t.Error("httpClient should default to non-nil")
27		}
28	}
29
30	func TestWithHTTPClient(t *testing.T) {
31		custom := &http.Client{Timeout: time.Second}
32		c := New("https://crow.example", WithHTTPClient(custom))
33		if c.httpClient != custom {
34			t.Error("WithHTTPClient did not apply")
35		}
36	}
37
38	func testModule() api.Module {
39		return api.Module{
40			ModuleIdentifier: api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"},
41			Version:          "1.0.0",
42		}
43	}
44
45	func TestExists(t *testing.T) {
46		cases := []struct {
47			name       string
48			status     int
49			wantExists bool
50			wantErr    bool
51		}{
52			{"204", http.StatusNoContent, true, false},
53			{"200", http.StatusOK, true, false},
54			{"404", http.StatusNotFound, false, false},
55			{"500", http.StatusInternalServerError, false, true},
56		}
57		for _, c := range cases {
58			t.Run(c.name, func(t *testing.T) {
59				srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60					if r.Method != http.MethodHead {
61						t.Errorf("expected HEAD, got %s", r.Method)
62					}
63					if r.URL.Path != "/v1/modules/iac/comply/aws/1.0.0/download" {
64						t.Errorf("unexpected path: %s", r.URL.Path)
65					}
66					w.WriteHeader(c.status)
67				}))
68				defer srv.Close()
69
70				cli := New(srv.URL, WithHTTPClient(srv.Client()))
71				exists, err := cli.Exists(context.Background(), testModule())
72				if (err != nil) != c.wantErr {
73					t.Errorf("err = %v, wantErr %v", err, c.wantErr)
74				}
75				if exists != c.wantExists {
76					t.Errorf("exists = %v, want %v", exists, c.wantExists)
77				}
78			})
79		}
80	}
81
82	func TestUpload(t *testing.T) {
83		cases := []struct {
84			name    string
85			status  int
86			wantErr error
87		}{
88			{"201", http.StatusCreated, nil},
89			{"409", http.StatusConflict, ErrAlreadyExists},
90			{"500", http.StatusInternalServerError, errors.New("unexpected status")},
91		}
92		for _, c := range cases {
93			t.Run(c.name, func(t *testing.T) {
94				srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95					if r.Method != http.MethodPut {
96						t.Errorf("expected PUT, got %s", r.Method)
97					}
98					if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/1.0.0" {
99						t.Errorf("unexpected path: %s", got)
100					}
101					if got := r.Header.Get("Content-Type"); got != "application/octet-stream" {
102						t.Errorf("unexpected content-type: %s", got)
103					}
104					body, _ := io.ReadAll(r.Body)
105					if string(body) != "fake-tarball" {
106						t.Errorf("unexpected body: %s", body)
107					}
108					w.WriteHeader(c.status)
109				}))
110				defer srv.Close()
111
112				cli := New(srv.URL, WithHTTPClient(srv.Client()))
113				err := cli.Upload(context.Background(), testModule(), bytes.NewReader([]byte("fake-tarball")))
114				switch {
115				case c.wantErr == nil && err != nil:
116					t.Errorf("unexpected error: %v", err)
117				case c.wantErr != nil && err == nil:
118					t.Error("expected error, got nil")
119				case errors.Is(c.wantErr, ErrAlreadyExists) && !errors.Is(err, ErrAlreadyExists):
120					t.Errorf("expected ErrAlreadyExists, got %v", err)
121				default:
122					// wantErr matched err, or both nil: pass.
123				}
124			})
125		}
126	}
127
128	func testIdentifier() api.ModuleIdentifier {
129		return api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"}
130	}
131
132	func TestListVersionsOK(t *testing.T) {
133		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134			if r.Method != http.MethodGet {
135				t.Errorf("expected GET, got %s", r.Method)
136			}
137			if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/versions" {
138				t.Errorf("unexpected path: %s", got)
139			}
140			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
141				Modules: []api.ModuleVersions{
142					{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
143				},
144			})
145		}))
146		defer srv.Close()
147
148		cli := New(srv.URL, WithHTTPClient(srv.Client()))
149		got, err := cli.ListVersions(context.Background(), testIdentifier())
150		if err != nil {
151			t.Fatalf("ListVersions: %v", err)
152		}
153		want := []string{"1.0.0", "1.1.0"}
154		if len(got) != len(want) {
155			t.Fatalf("len = %d, want %d (got %v)", len(got), len(want), got)
156		}
157		for i := range got {
158			if got[i] != want[i] {
159				t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
160			}
161		}
162	}
163
164	func TestListVersionsNotFound(t *testing.T) {
165		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
166			w.WriteHeader(http.StatusNotFound)
167		}))
168		defer srv.Close()
169
170		cli := New(srv.URL, WithHTTPClient(srv.Client()))
171		_, err := cli.ListVersions(context.Background(), testIdentifier())
172		if !errors.Is(err, ErrNotFound) {
173			t.Fatalf("expected ErrNotFound, got %v", err)
174		}
175	}
176
177	// A 200 with an empty Modules array is semantically "no versions" and must
178	// surface as ErrNotFound, same as a 404. The current server always uses 404,
179	// but the contract needs to hold regardless.
180	func TestListVersionsEmptyModulesIsNotFound(t *testing.T) {
181		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
182			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{Modules: nil})
183		}))
184		defer srv.Close()
185
186		cli := New(srv.URL, WithHTTPClient(srv.Client()))
187		_, err := cli.ListVersions(context.Background(), testIdentifier())
188		if !errors.Is(err, ErrNotFound) {
189			t.Fatalf("expected ErrNotFound, got %v", err)
190		}
191	}
192

package client

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/immuta/operations/services/crow/pkg/api"
)

func TestNewDefaults(t *testing.T) {
	c := New("https://crow.example")
	if c == nil {
		t.Fatal("New returned nil")
	}
	if c.baseURL != "https://crow.example" {
		t.Errorf("baseURL = %q", c.baseURL)
	}
	if c.httpClient == nil {
		t.Error("httpClient should default to non-nil")
	}
}

func TestWithHTTPClient(t *testing.T) {
	custom := &http.Client{Timeout: time.Second}
	c := New("https://crow.example", WithHTTPClient(custom))
	if c.httpClient != custom {
		t.Error("WithHTTPClient did not apply")
	}
}

func testModule() api.Module {
	return api.Module{
		ModuleIdentifier: api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"},
		Version:          "1.0.0",
	}
}

func TestExists(t *testing.T) {
	cases := []struct {
		name       string
		status     int
		wantExists bool
		wantErr    bool
	}{
		{"204", http.StatusNoContent, true, false},
		{"200", http.StatusOK, true, false},
		{"404", http.StatusNotFound, false, false},
		{"500", http.StatusInternalServerError, false, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if r.Method != http.MethodHead {
					t.Errorf("expected HEAD, got %s", r.Method)
				}
				if r.URL.Path != "/v1/modules/iac/comply/aws/1.0.0/download" {
					t.Errorf("unexpected path: %s", r.URL.Path)
				}
				w.WriteHeader(c.status)
			}))
			defer srv.Close()

			cli := New(srv.URL, WithHTTPClient(srv.Client()))
			exists, err := cli.Exists(context.Background(), testModule())
			if (err != nil) != c.wantErr {
				t.Errorf("err = %v, wantErr %v", err, c.wantErr)
			}
			if exists != c.wantExists {
				t.Errorf("exists = %v, want %v", exists, c.wantExists)
			}
		})
	}
}

func TestUpload(t *testing.T) {
	cases := []struct {
		name    string
		status  int
		wantErr error
	}{
		{"201", http.StatusCreated, nil},
		{"409", http.StatusConflict, ErrAlreadyExists},
		{"500", http.StatusInternalServerError, errors.New("unexpected status")},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if r.Method != http.MethodPut {
					t.Errorf("expected PUT, got %s", r.Method)
				}
				if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/1.0.0" {
					t.Errorf("unexpected path: %s", got)
				}
				if got := r.Header.Get("Content-Type"); got != "application/octet-stream" {
					t.Errorf("unexpected content-type: %s", got)
				}
				body, _ := io.ReadAll(r.Body)
				if string(body) != "fake-tarball" {
					t.Errorf("unexpected body: %s", body)
				}
				w.WriteHeader(c.status)
			}))
			defer srv.Close()

			cli := New(srv.URL, WithHTTPClient(srv.Client()))
			err := cli.Upload(context.Background(), testModule(), bytes.NewReader([]byte("fake-tarball")))
			switch {
			case c.wantErr == nil && err != nil:
				t.Errorf("unexpected error: %v", err)
			case c.wantErr != nil && err == nil:
				t.Error("expected error, got nil")
			case errors.Is(c.wantErr, ErrAlreadyExists) && !errors.Is(err, ErrAlreadyExists):
				t.Errorf("expected ErrAlreadyExists, got %v", err)
			default:
				// wantErr matched err, or both nil: pass.
			}
		})
	}
}

func testIdentifier() api.ModuleIdentifier {
	return api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"}
}

func TestListVersionsOK(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			t.Errorf("expected GET, got %s", r.Method)
		}
		if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/versions" {
			t.Errorf("unexpected path: %s", got)
		}
		_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
			Modules: []api.ModuleVersions{
				{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
			},
		})
	}))
	defer srv.Close()

	cli := New(srv.URL, WithHTTPClient(srv.Client()))
	got, err := cli.ListVersions(context.Background(), testIdentifier())
	if err != nil {
		t.Fatalf("ListVersions: %v", err)
	}
	want := []string{"1.0.0", "1.1.0"}
	if len(got) != len(want) {
		t.Fatalf("len = %d, want %d (got %v)", len(got), len(want), got)
	}
	for i := range got {
		if got[i] != want[i] {
			t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
		}
	}
}

func TestListVersionsNotFound(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNotFound)
	}))
	defer srv.Close()

	cli := New(srv.URL, WithHTTPClient(srv.Client()))
	_, err := cli.ListVersions(context.Background(), testIdentifier())
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

// A 200 with an empty Modules array is semantically "no versions" and must
// surface as ErrNotFound, same as a 404. The current server always uses 404,
// but the contract needs to hold regardless.
func TestListVersionsEmptyModulesIsNotFound(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{Modules: nil})
	}))
	defer srv.Close()

	cli := New(srv.URL, WithHTTPClient(srv.Client()))
	_, err := cli.ListVersions(context.Background(), testIdentifier())
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}


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

package api

import (
	"fmt"
	"regexp"
	"strings"
)

// segmentRegex matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens. It is the single
// source of truth for segment validation — the Crow server and its clients
// both import this package so they cannot drift.
var segmentRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// versionSemverRegex is the SemVer 2.0.0 grammar from semver.org, minus the
// build metadata suffix. Two intentional deviations from the spec:
//   - No leading 'v' allowed. Terraform's native module loader strips the
//     prefix when building download URLs (per hashicorp/go-version
//     normalization), while terragrunt's tfr:// passes the version through
//     verbatim. A v-prefixed version uploaded today would resolve via
//     terragrunt and 404 via terraform — failing fast on upload prevents
//     the divergence.
//   - No '+build.metadata' suffix allowed. segmentRegex above doesn't
//     permit '+', so a build-tagged version couldn't be served from the
//     URL even if we accepted it on upload.
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-]*))*))?$`)

type InvalidSegmentError struct {
	Segment string
}

func (e InvalidSegmentError) Error() string {
	return fmt.Sprintf("invalid segment %q: must match %s", e.Segment, segmentRegex)
}

// InvalidVersionError is returned when a version string is not valid SemVer
// 2.0.0 (no leading 'v', no '+build' metadata).
type InvalidVersionError struct {
	Version string
}

func (e InvalidVersionError) Error() string {
	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)
}

// ValidateSegments returns an InvalidSegmentError for the first segment that
// fails the regex. An empty slice passes.
func ValidateSegments(segments []string) error {
	for _, s := range segments {
		if !segmentRegex.MatchString(s) {
			return InvalidSegmentError{Segment: s}
		}
	}
	return nil
}

// ValidateVersion enforces SemVer 2.0.0 on a module version string. It is
// applied at upload boundaries (server handler + client) so non-semver
// versions can't enter storage. Download/list paths intentionally don't
// call this so that any v-prefixed versions still in S3 from before the
// guard remain reachable.
func ValidateVersion(version string) error {
	if !versionSemverRegex.MatchString(version) {
		return InvalidVersionError{Version: version}
	}
	return nil
}

func (id ModuleIdentifier) Validate() error {
	return ValidateSegments([]string{id.Namespace, id.Name, id.Provider})
}

func (id ModuleIdentifier) VersionsPath() string {
	return "/v1/modules/" + strings.Join([]string{id.Namespace, id.Name, id.Provider}, "/") + "/versions"
}

func (m Module) Validate() error {
	return ValidateSegments([]string{m.Namespace, m.Name, m.Provider, m.Version})
}

func (m Module) UploadPath() string {
	return "/v1/modules/" + strings.Join([]string{m.Namespace, m.Name, m.Provider, m.Version}, "/")
}

func (m Module) DownloadPath() string {
	return m.UploadPath() + "/download"
}


Bash

ls /Users/steve.briggs/projects/operations/libs/ 2>&1 && cat /Users/steve.briggs/projects/operations/apps/immanager/Makefile 2>&1 | head -40

List libs, peek immanager makefile


drwxr-xr-x steve.briggs staff 160 B Tue May 12 16:37:45 2026 archive
drwxr-xr-x steve.briggs staff 160 B Tue May 12 16:37:45 2026 cd
drwxr-xr-x steve.briggs staff 224 B Tue May 12 16:37:45 2026 clog
drwxr-xr-x steve.briggs staff 128 B Tue May 12 16:37:45 2026 fluxcannon
drwxr-xr-x steve.briggs staff 320 B Tue May 12 16:37:45 2026 git
drwxr-xr-x steve.briggs staff 256 B Tue May 12 16:37:45 2026 .
drwxr-xr-x steve.briggs staff 256 B Tue May 12 16:37:45 2026 github
drwxr-xr-x steve.briggs staff 736 B Tue May 12 16:37:45 2026 ..
cat: /Users/steve.briggs/projects/operations/apps/immanager/Makefile: No such file or directory


ls /Users/steve.briggs/projects/operations/libs/archive/ && find /Users/steve.briggs/projects/operations/apps -name "Makefile" -maxdepth 3 2>&1

Inspect libs/archive and Makefiles


.rw-r--r-- steve.briggs staff 1.8 KB Tue May 12 16:37:45 2026 archive.go
.rw-r--r-- steve.briggs staff  14 KB Tue May 12 16:37:45 2026 archive_test.go
drwxr-xr-x steve.briggs staff 160 B  Tue May 12 16:37:45 2026 .
.rw-r--r-- steve.briggs staff  60 B  Tue May 12 16:37:45 2026 go.mod
drwxr-xr-x steve.briggs staff 256 B  Tue May 12 16:37:45 2026 ..


Error: EISDIR: illegal operation on a directory, read '/Users/steve.briggs/projects/operations/libs/archive'


Explore project context

Read crow service, crowctl, client/api packages to understand reusable bits

Ask clarifying questions

One at a time - purpose, success criteria, scope constraints for birdseye

Propose 2-3 architectural approaches

Present trade-offs with a recommendation

Present design sections for approval

Architecture, components, data flow, error handling, testing - approval at each section

Write and self-review design doc

Save to docs/superpowers/specs/, self-review for placeholders/contradictions, commit

User reviews written spec

Hand off for user spec review before invoking writing-plans

Invoke writing-plans skill

Transition to implementation plan once spec is approved


1	package archive
2
3	import (
4		"archive/tar"
5		"compress/gzip"
6		"errors"
7		"io"
8		"os"
9	)
10
11	// CreateArchive Create new Writers for gzip and tar
12	// These writers are chained. Writing to the tar writer will
13	// write to the gzip writer which in turn will write to
14	// the "buf" writer
15	func CreateArchive(files []string, buf io.Writer, preserveDirs bool) error {
16		if buf == nil {
17			return errors.New("cannot create archive with nil buffer")
18		}
19
20		gw := gzip.NewWriter(buf)
21		defer func() {
22			_ = gw.Close()
23		}()
24
25		tw := tar.NewWriter(gw)
26		defer func() {
27			_ = tw.Close()
28		}()
29
30		// Iterate over files and add them to the tar archive
31		for _, file := range files {
32			err := addToArchive(tw, file, preserveDirs)
33			if err != nil {
34				return err
35			}
36		}
37
38		return nil
39	}
40
41	// addToArchive adds a specified file to the archive using the provided tar.Writer.
42	func addToArchive(tw *tar.Writer, filename string, preserveDirs bool) error {
43		// Open the file which will be written into the archive
44		file, err := os.Open(filename)
45		if err != nil {
46			return err
47		}
48		defer func() {
49			_ = file.Close()
50		}()
51
52		// Get FileInfo about our file providing file size, mode, etc.
53		info, err := file.Stat()
54		if err != nil {
55			return err
56		}
57
58		// Create a tar Header from the FileInfo data
59		header, err := tar.FileInfoHeader(info, info.Name())
60		if err != nil {
61			return err
62		}
63
64		if preserveDirs {
65			// Use full path as name (FileInfoHeader only takes the basename)
66			// If we don't do this the directory structure would
67			// not be preserved
68			// https://golang.org/src/archive/tar/common.go?#L626
69			header.Name = filename
70		}
71
72		// Write file header to the tar archive
73		err = tw.WriteHeader(header)
74		if err != nil {
75			return err
76		}
77
78		// Copy file content to tar archive
79		_, err = io.Copy(tw, file)
80		if err != nil {
81			return err
82		}
83
84		return nil
85	}
86

package archive

import (
	"archive/tar"
	"compress/gzip"
	"errors"
	"io"
	"os"
)

// CreateArchive Create new Writers for gzip and tar
// These writers are chained. Writing to the tar writer will
// write to the gzip writer which in turn will write to
// the "buf" writer
func CreateArchive(files []string, buf io.Writer, preserveDirs bool) error {
	if buf == nil {
		return errors.New("cannot create archive with nil buffer")
	}

	gw := gzip.NewWriter(buf)
	defer func() {
		_ = gw.Close()
	}()

	tw := tar.NewWriter(gw)
	defer func() {
		_ = tw.Close()
	}()

	// Iterate over files and add them to the tar archive
	for _, file := range files {
		err := addToArchive(tw, file, preserveDirs)
		if err != nil {
			return err
		}
	}

	return nil
}

// addToArchive adds a specified file to the archive using the provided tar.Writer.
func addToArchive(tw *tar.Writer, filename string, preserveDirs bool) error {
	// Open the file which will be written into the archive
	file, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer func() {
		_ = file.Close()
	}()

	// Get FileInfo about our file providing file size, mode, etc.
	info, err := file.Stat()
	if err != nil {
		return err
	}

	// Create a tar Header from the FileInfo data
	header, err := tar.FileInfoHeader(info, info.Name())
	if err != nil {
		return err
	}

	if preserveDirs {
		// Use full path as name (FileInfoHeader only takes the basename)
		// If we don't do this the directory structure would
		// not be preserved
		// https://golang.org/src/archive/tar/common.go?#L626
		header.Name = filename
	}

	// Write file header to the tar archive
	err = tw.WriteHeader(header)
	if err != nil {
		return err
	}

	// Copy file content to tar archive
	_, err = io.Copy(tw, file)
	if err != nil {
		return err
	}

	return nil
}


I have enough context. Writing the plan now.


Create plans dir


(Bash completed with no output)