You are implementing Task 3 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits: Task 1 (`ae477d5`), Task 2 (`3b73e14`), Task 2 fixes (`59cd0b6`)
- Use absolute paths or `cd` into the worktree. Do not operate on the main repo at `/Users/steve.briggs/projects/operations`.
- **NEVER push.** **NEVER use `git add .` or `git add -A`.** Always `git add` specific paths.

## Context

This task does NOT modify `apps/birdseye/`. It extends Immuta's existing Crow service client at `services/crow/pkg/client/` with a new `Download` method. The Crow service implements the Terraform Module Registry Protocol: when you GET `/v1/modules/{ns}/{name}/{provider}/{version}/download`, it responds with HTTP 204 and an `X-Terraform-Get` header pointing at an S3 presigned URL. You then GET that URL to fetch the actual tarball bytes.

Existing client methods (read `services/crow/pkg/client/client.go` to confirm):
- `Upload(ctx, m api.Module, body io.Reader) error`
- `Exists(ctx, m api.Module) (bool, error)`
- `ListVersions(ctx, id api.ModuleIdentifier) ([]string, error)`
- Sentinel errors: `ErrAlreadyExists`, `ErrNotFound`

The existing tests at `services/crow/pkg/client/client_test.go` use `httptest.NewServer` patterns. You'll follow that style.

Use strict TDD: write the failing tests first, run them, then implement.

## Task Description (Verbatim)

### Task 3: Add `Download` method to Crow client

**Files:**
- Modify: `services/crow/pkg/client/client.go`
- Modify: `services/crow/pkg/client/client_test.go`

- [ ] **Step 1: Write failing test** — append to `services/crow/pkg/client/client_test.go`:

```go
func TestDownloadOK(t *testing.T) {
	mux := http.NewServeMux()
	// Tarball server: serves the actual gzipped bytes.
	tarball := []byte("fake-tarball-bytes")
	tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/blob/iac/comply/aws/1.0.0.tar.gz" {
			t.Errorf("tarball server got unexpected path: %s", r.URL.Path)
		}
		_, _ = w.Write(tarball)
	}))
	defer tarSrv.Close()

	// Crow server: returns 204 + X-Terraform-Get pointing at tarSrv.
	mux.HandleFunc("/v1/modules/iac/comply/aws/1.0.0/download", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			t.Errorf("expected GET, got %s", r.Method)
		}
		w.Header().Set("X-Terraform-Get", tarSrv.URL+"/blob/iac/comply/aws/1.0.0.tar.gz")
		w.WriteHeader(http.StatusNoContent)
	})
	crowSrv := httptest.NewServer(mux)
	defer crowSrv.Close()

	cli := New(crowSrv.URL, WithHTTPClient(crowSrv.Client()))
	rc, err := cli.Download(context.Background(), testModule())
	if err != nil {
		t.Fatalf("Download: %v", err)
	}
	defer rc.Close()
	got, err := io.ReadAll(rc)
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	if !bytes.Equal(got, tarball) {
		t.Errorf("body = %q, want %q", got, tarball)
	}
}

func TestDownloadNotFound(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.Download(context.Background(), testModule())
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestDownloadMissingHeader(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent) // no X-Terraform-Get header
	}))
	defer srv.Close()

	cli := New(srv.URL, WithHTTPClient(srv.Client()))
	_, err := cli.Download(context.Background(), testModule())
	if err == nil {
		t.Fatal("expected error for missing X-Terraform-Get header")
	}
}
```

NOTE: `testModule()` already exists in `client_test.go` and returns an `api.Module` for `iac/comply/aws@1.0.0`. The download path resolves to `/v1/modules/iac/comply/aws/1.0.0/download`.

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... -run TestDownload -v`
Expected: compile error (undefined Download).

- [ ] **Step 3: Implement `Download` in `services/crow/pkg/client/client.go`**

Append below `ListVersions`:

```go
// Download fetches the gzipped tarball bytes for the given module
// version. It follows the X-Terraform-Get redirect returned by Crow's
// 204 response (per the Terraform module registry protocol). The caller
// is responsible for closing the returned ReadCloser.
//
// Returns ErrNotFound on HTTP 404 from Crow.
func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error) {
	if err := m.Validate(); err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+m.DownloadPath(), nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}

	// The protocol uses 204 + X-Terraform-Get to point clients at the
	// real artifact location (S3 presigned URL in production).
	if resp.StatusCode == http.StatusNotFound {
		_ = resp.Body.Close()
		return nil, ErrNotFound
	}
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		_ = resp.Body.Close()
		return nil, fmt.Errorf("unexpected status %d resolving %s", resp.StatusCode, m.DownloadPath())
	}
	location := resp.Header.Get("X-Terraform-Get")
	_ = resp.Body.Close()
	if location == "" {
		return nil, fmt.Errorf("crow returned %d with no X-Terraform-Get header for %s", resp.StatusCode, m.DownloadPath())
	}

	blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
	if err != nil {
		return nil, err
	}
	blobResp, err := c.httpClient.Do(blobReq)
	if err != nil {
		return nil, fmt.Errorf("fetching presigned blob: %w", err)
	}
	if blobResp.StatusCode != http.StatusOK {
		_ = blobResp.Body.Close()
		return nil, fmt.Errorf("unexpected status %d fetching blob %s", blobResp.StatusCode, location)
	}
	return blobResp.Body, nil
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... -v`
Expected: All PASS (existing tests + new Download tests).

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add services/crow/pkg/client/client.go services/crow/pkg/client/client_test.go && git commit -m "feat(crow/client): add Download method for module tarballs"
```

## Notes & Style

- The existing `client_test.go` imports `bytes`, `context`, `encoding/json`, `errors`, `io`, `net/http`, `net/http/httptest`, `testing`, `time`, and `github.com/immuta/operations/services/crow/pkg/api`. You should NOT need to add new imports — `bytes`, `io`, `errors`, `context`, `net/http`, `net/http/httptest` are all already imported. Confirm before editing.
- Style: match the existing test patterns (no testify; bare `t.Errorf`/`t.Fatalf`).
- Style: match the existing implementation patterns — return a switch-based status handler when natural; keep error wrapping with `fmt.Errorf("...: %w", err)`.
- Only modify `services/crow/pkg/client/client.go` and `services/crow/pkg/client/client_test.go`. Do not edit `pkg/api`, the server handlers, or anything else.

## Code Organization

The crow client is a single-file package by convention; keep `Download` in `client.go` next to the other methods. Do NOT split into a separate file.

## Before You Begin

Ask if anything is unclear. Otherwise proceed.

## Self-Review Checklist

- [ ] All 3 new tests pass (`TestDownloadOK`, `TestDownloadNotFound`, `TestDownloadMissingHeader`)
- [ ] All pre-existing tests in `services/crow/pkg/client/` still pass
- [ ] `errors.Is(err, ErrNotFound)` returns true for the 404 case
- [ ] No bytes are leaked — the response body of the discovery request (204) is closed before the second request runs
- [ ] Caller's `defer rc.Close()` on the returned body works
- [ ] Commit message exactly: `feat(crow/client): add Download method for module tarballs`
- [ ] Only `services/crow/pkg/client/client.go` and `services/crow/pkg/client/client_test.go` are staged
- [ ] `go test -race ./pkg/client/... -v` from within `services/crow/` is fully green

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Implementation summary
- Test count (existing + 3 new = ?)
- Files committed
- Commit SHA
- Self-review findings
- 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 read the existing files before making any changes.


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


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


Good. All imports needed for the new tests (`bytes`, `context`, `errors`, `io`, `net/http`, `net/http/httptest`) are already present. Now I'll follow the TDD steps.

**Step 1: Append the failing tests to client_test.go**


Edit

// 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)
	}
}

// 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)
	}
}

func TestDownloadOK(t *testing.T) {
	mux := http.NewServeMux()
	// Tarball server: serves the actual gzipped bytes.
	tarball := []byte("fake-tarball-bytes")
	tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/blob/iac/comply/aws/1.0.0.tar.gz" {
			t.Errorf("tarball server got unexpected path: %s", r.URL.Path)
		}
		_, _ = w.Write(tarball)
	}))
	defer tarSrv.Close()

	// Crow server: returns 204 + X-Terraform-Get pointing at tarSrv.
	mux.HandleFunc("/v1/modules/iac/comply/aws/1.0.0/download", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			t.Errorf("expected GET, got %s", r.Method)
		}
		w.Header().Set("X-Terraform-Get", tarSrv.URL+"/blob/iac/comply/aws/1.0.0.tar.gz")
		w.WriteHeader(http.StatusNoContent)
	})
	crowSrv := httptest.NewServer(mux)
	defer crowSrv.Close()

	cli := New(crowSrv.URL, WithHTTPClient(crowSrv.Client()))
	rc, err := cli.Download(context.Background(), testModule())
	if err != nil {
		t.Fatalf("Download: %v", err)
	}
	defer rc.Close()
	got, err := io.ReadAll(rc)
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	if !bytes.Equal(got, tarball) {
		t.Errorf("body = %q, want %q", got, tarball)
	}
}

func TestDownloadNotFound(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.Download(context.Background(), testModule())
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestDownloadMissingHeader(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent) // no X-Terraform-Get header
	}))
	defer srv.Close()

	cli := New(srv.URL, WithHTTPClient(srv.Client()))
	_, err := cli.Download(context.Background(), testModule())
	if err == nil {
		t.Fatal("expected error for missing X-Terraform-Get header")
	}
}


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


**Step 2: Run tests — expect compile failure**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... -run TestDownload -v 2>&1

Run Download tests expecting compile error


Error: Exit code 1
# github.com/immuta/operations/services/crow/pkg/client [github.com/immuta/operations/services/crow/pkg/client.test]
pkg/client/client_test.go:217:17: cli.Download undefined (type *Client has no field or method Download)
pkg/client/client_test.go:238:16: cli.Download undefined (type *Client has no field or method Download)
pkg/client/client_test.go:251:16: cli.Download undefined (type *Client has no field or method Download)
FAIL	github.com/immuta/operations/services/crow/pkg/client [build failed]
FAIL


Good — compile error as expected. Now implement the `Download` method.

**Step 3: Append `Download` to client.go**


Edit

// ListVersions returns the module's version list in server order.
// Returns ErrNotFound when the module has no published versions.
func (c *Client) ListVersions(ctx context.Context, id api.ModuleIdentifier) ([]string, error) {
	if err := id.Validate(); err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+id.VersionsPath(), nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	switch resp.StatusCode {
	case http.StatusOK:
		var body api.ListVersionsResponse
		if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
			return nil, fmt.Errorf("decoding response: %w", err)
		}
		if len(body.Modules) == 0 {
			return nil, ErrNotFound
		}
		out := make([]string, len(body.Modules[0].Versions))
		for i, v := range body.Modules[0].Versions {
			out[i] = v.Version
		}
		return out, nil
	case http.StatusNotFound:
		return nil, ErrNotFound
	default:
		return nil, fmt.Errorf("unexpected status %d listing %s", resp.StatusCode, id.VersionsPath())
	}
}

// ListVersions returns the module's version list in server order.
// Returns ErrNotFound when the module has no published versions.
func (c *Client) ListVersions(ctx context.Context, id api.ModuleIdentifier) ([]string, error) {
	if err := id.Validate(); err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+id.VersionsPath(), nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	switch resp.StatusCode {
	case http.StatusOK:
		var body api.ListVersionsResponse
		if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
			return nil, fmt.Errorf("decoding response: %w", err)
		}
		if len(body.Modules) == 0 {
			return nil, ErrNotFound
		}
		out := make([]string, len(body.Modules[0].Versions))
		for i, v := range body.Modules[0].Versions {
			out[i] = v.Version
		}
		return out, nil
	case http.StatusNotFound:
		return nil, ErrNotFound
	default:
		return nil, fmt.Errorf("unexpected status %d listing %s", resp.StatusCode, id.VersionsPath())
	}
}

// Download fetches the gzipped tarball bytes for the given module
// version. It follows the X-Terraform-Get redirect returned by Crow's
// 204 response (per the Terraform module registry protocol). The caller
// is responsible for closing the returned ReadCloser.
//
// Returns ErrNotFound on HTTP 404 from Crow.
func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error) {
	if err := m.Validate(); err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+m.DownloadPath(), nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}

	// The protocol uses 204 + X-Terraform-Get to point clients at the
	// real artifact location (S3 presigned URL in production).
	if resp.StatusCode == http.StatusNotFound {
		_ = resp.Body.Close()
		return nil, ErrNotFound
	}
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		_ = resp.Body.Close()
		return nil, fmt.Errorf("unexpected status %d resolving %s", resp.StatusCode, m.DownloadPath())
	}
	location := resp.Header.Get("X-Terraform-Get")
	_ = resp.Body.Close()
	if location == "" {
		return nil, fmt.Errorf("crow returned %d with no X-Terraform-Get header for %s", resp.StatusCode, m.DownloadPath())
	}

	blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
	if err != nil {
		return nil, err
	}
	blobResp, err := c.httpClient.Do(blobReq)
	if err != nil {
		return nil, fmt.Errorf("fetching presigned blob: %w", err)
	}
	if blobResp.StatusCode != http.StatusOK {
		_ = blobResp.Body.Close()
		return nil, fmt.Errorf("unexpected status %d fetching blob %s", blobResp.StatusCode, location)
	}
	return blobResp.Body, nil
}


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


**Step 4: Run all tests — expect PASS**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... -v 2>&1

Run all client tests with race detector


=== RUN   TestNewDefaults
--- PASS: TestNewDefaults (0.00s)
=== RUN   TestWithHTTPClient
--- PASS: TestWithHTTPClient (0.00s)
=== RUN   TestExists
=== RUN   TestExists/204
=== RUN   TestExists/200
=== RUN   TestExists/404
=== RUN   TestExists/500
--- PASS: TestExists (0.01s)
    --- PASS: TestExists/204 (0.00s)
    --- PASS: TestExists/200 (0.00s)
    --- PASS: TestExists/404 (0.00s)
    --- PASS: TestExists/500 (0.00s)
=== RUN   TestUpload
=== RUN   TestUpload/201
=== RUN   TestUpload/409
=== RUN   TestUpload/500
--- PASS: TestUpload (0.00s)
    --- PASS: TestUpload/201 (0.00s)
    --- PASS: TestUpload/409 (0.00s)
    --- PASS: TestUpload/500 (0.00s)
=== RUN   TestListVersionsOK
--- PASS: TestListVersionsOK (0.00s)
=== RUN   TestListVersionsNotFound
--- PASS: TestListVersionsNotFound (0.00s)
=== RUN   TestListVersionsEmptyModulesIsNotFound
--- PASS: TestListVersionsEmptyModulesIsNotFound (0.00s)
=== RUN   TestDownloadOK
--- PASS: TestDownloadOK (0.00s)
=== RUN   TestDownloadNotFound
--- PASS: TestDownloadNotFound (0.00s)
=== RUN   TestDownloadMissingHeader
--- PASS: TestDownloadMissingHeader (0.00s)
PASS
ok  	github.com/immuta/operations/services/crow/pkg/client	1.748s


All 12 tests pass. Now commit.

**Step 5: Commit**


git add services/crow/pkg/client/client.go services/crow/pkg/client/client_test.go && git commit -m "feat(crow/client): add Download method for module tarballs"

Commit the Download method implementation


[worktree-birdseye ee7794e] feat(crow/client): add Download method for module tarballs
 2 files changed, 114 insertions(+)


---

**Status:** DONE

**Implementation summary:** Added a `Download(ctx context.Context, m api.Module) (io.ReadCloser, error)` method to the Crow client. It GETs the Terraform module registry download endpoint, handles 404 → `ErrNotFound`, validates the `X-Terraform-Get` header is present, then issues a second GET to fetch the actual tarball bytes from the presigned URL, returning the response body as an `io.ReadCloser` for the caller to consume and close.

**Test count:** 9 pre-existing + 3 new = 12 total, all passing with `-race`.

**Files committed:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow/pkg/client/client.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow/pkg/client/client_test.go`

**Commit SHA:** `ee7794e`

**Self-review findings:**
- All 3 new tests pass (`TestDownloadOK`, `TestDownloadNotFound`, `TestDownloadMissingHeader`)
- All 9 pre-existing tests still pass
- `errors.Is(err, ErrNotFound)` works correctly for the 404 case (sentinel error returned directly)
- Discovery response body (204) is explicitly closed before the second request is issued
- Caller's `defer rc.Close()` works — the returned `ReadCloser` is `blobResp.Body`
- Commit message matches exactly
- Only the two specified files are staged and committed
- `go test -race ./pkg/client/... -v` is fully green

Nothing unexpected encountered.