Add semantic search with embeddings

## What
Add semantic/fuzzy search as alternative to text search. Compute embeddings for endpoint descriptions and schema names during index building. Use cosine similarity for ranking.

## Acceptance Criteria
- [ ] --semantic flag enables embedding-based search
- [ ] Results ranked by cosine similarity
- [ ] Fallback to text search when embeddings unavailable

## Files
- CREATE: src/core/embeddings.rs
- MODIFY: src/core/search.rs (add semantic search mode)


Epic: Sync and Updates


Wire fetch command with local file, stdin, and remote URL support

## Background
The fetch command is the primary entry point for getting specs into swagger-cli. It orchestrates: HTTP download (or local file/stdin read), format detection, YAML normalization, index building, pointer validation, and crash-consistent cache write. It supports auth headers, bearer tokens, auth profiles, --force overwrite, and robot output.

## Approach
Implement src/cli/fetch.rs with FetchArgs struct and async execute() function:

**FetchArgs (clap derive):**
- url: String (positional, required — can be URL, file path, or "-" for stdin)
- alias: String (--alias, required)
- header: Vec<String> (--header, repeatable)
- auth_header: Vec<String> (--auth-header, alias for --header)
- bearer: Option<String> (--bearer)
- auth_profile: Option<String> (--auth-profile)
- force: bool (--force)
- timeout_ms: u64 (--timeout-ms, default 10000)
- max_bytes: u64 (--max-bytes, default 26214400)
- retries: u32 (--retries, default 2)
- input_format: Option<String> (--input-format, values: auto/json/yaml)
- resolve_external_refs: bool (--resolve-external-refs)
- ref_allow_host: Vec<String> (--ref-allow-host, repeatable)
- ref_max_depth: u32 (--ref-max-depth, default 3)
- ref_max_bytes: u64 (--ref-max-bytes, default 10MB)
- allow_private_host: Vec<String> (--allow-private-host, repeatable)
- allow_insecure_http: bool (--allow-insecure-http)

**Execute flow:**
1. Validate alias format (validate_alias)
2. Check if alias exists — error unless --force
3. Resolve auth (merge --auth-profile with explicit headers, --bearer)
4. Determine source: URL (http/https), local file (file:// or path), or stdin (-)
5. For URL: use AsyncHttpClient.fetch_spec() with SSRF/HTTPS policy
6. For local file: canonicalize path, read bytes directly (no network policy)
7. For stdin: read all bytes from stdin
8. Detect format, normalize to JSON
9. Parse as serde_json::Value
10. Build index (build_index)
11. Write cache (write_cache with all artifacts)
12. Output robot JSON or human success message

## Acceptance Criteria
- [ ] `swagger-cli fetch ./petstore.json --alias pet` succeeds (local file)
- [ ] `swagger-cli fetch https://... --alias pet --robot` outputs JSON with ok:true, data.alias, data.endpoint_count
- [ ] `swagger-cli fetch - --alias stdin-api` reads from stdin
- [ ] Alias validation rejects "../bad" before any network call
- [ ] Existing alias without --force returns ALIAS_EXISTS (exit 6)
- [ ] --force overwrites existing alias
- [ ] --bearer TOKEN adds Authorization: Bearer TOKEN header
- [ ] --auth-profile loads from config.toml
- [ ] Robot output includes: alias, url, version, title, endpoint_count, schema_count, cached_at, source_format, cache_dir, files, content_hash
- [ ] Auth header values never appear in output or error messages

## Files
- MODIFY: src/cli/fetch.rs (FetchArgs, execute, auth resolution, source routing)
- MODIFY: src/output/robot.rs (add output_fetch function)
- MODIFY: src/output/human.rs (add output_fetch function)

## TDD Anchor
RED: Write integration test `test_fetch_local_file` — use assert_cmd to run `swagger-cli fetch tests/fixtures/petstore.json --alias test-pet --robot` with SWAGGER_CLI_HOME set to tempdir. Assert exit 0, stdout JSON has ok:true.
GREEN: Implement full fetch pipeline.
VERIFY: `cargo test test_fetch_local_file`

Additional tests:
- test_fetch_alias_exists_error
- test_fetch_force_overwrites
- test_fetch_stdin
- test_fetch_bearer_auth
- test_fetch_yaml_file (if YAML fixture exists)

## Edge Cases
- stdin ("-") is not a URL — don't try to HTTP-fetch it
- Local file paths must be canonicalized (resolve symlinks, relative paths) before reading
- file:// URLs must be converted to local paths (strip scheme)
- If auth-profile references an EnvVar source, resolve the env var at fetch time
- Robot output: redact --bearer and --auth-header values even in success output

## Dependency Context
Uses AsyncHttpClient from bd-3b6 (async HTTP client with SSRF protection). Uses build_index, detect_format, normalize_to_json from bd-189 (spec format detection and index building). Uses CacheManager.write_cache and validate_alias from bd-1ie (cache write path). Uses Config for auth profiles from bd-1sb (configuration system).


Implement spec format detection, YAML normalization, and index building

## Background
After downloading raw spec bytes, swagger-cli must detect the input format (JSON vs YAML), normalize YAML to JSON, parse the spec as serde_json::Value, and build a SpecIndex from it. The index building is the heart of the query performance -- it pre-extracts endpoints, schemas, and tags into a compact, sorted structure with JSON pointers back to raw.json. All pointers must be validated.

## Approach
Create src/core/indexer.rs with:

**Format detection:** `detect_format(bytes, filename_hint, content_type_hint) -> Format` where Format is Json or Yaml. Priority: explicit --input-format flag > Content-Type header > file extension > content sniffing (try JSON parse first, fall back to YAML).

**YAML normalization:** `normalize_to_json(bytes, format) -> Result<Vec<u8>>` -- if YAML, parse with serde_yaml then serialize to serde_json. If JSON, pass through (validate it parses).

**Index building:** `build_index(raw_json: &serde_json::Value, content_hash: &str, generation: u64) -> Result<SpecIndex>`:
1. Extract info.title, info.version, openapi version
2. Iterate paths.* -> methods -> build IndexedEndpoint with: path, method (uppercased), summary, description, operation_id, tags, deprecated, parameters (name/location/required/desc), request_body_required, request_body_content_types, security_schemes (effective: operation-level overrides root-level), security_required, operation_ptr (JSON pointer format: /paths/~1pet~1{petId}/get)
3. Iterate components.schemas.* -> build IndexedSchema with name, schema_ptr
4. Compute tags from endpoints + root-level tags -> IndexedTag with endpoint_count
5. Sort deterministically: endpoints by (path ASC, method_rank ASC), schemas by (name ASC), tags by (name ASC)
6. Validate ALL operation_ptr and schema_ptr resolve in the raw Value
7. Set index_version to current version constant (1)

**Canonical method ranking:** GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, OPTIONS=5, HEAD=6, TRACE=7, unknown=99.

**JSON pointer encoding:** Path segments use RFC 6901: `~` -> `~0`, `/` -> `~1`. So `/pet/{petId}` becomes `/paths/~1pet~1{petId}`.

## Acceptance Criteria
- [ ] JSON input detected and passed through correctly
- [ ] YAML input detected and normalized to equivalent JSON
- [ ] build_index extracts correct endpoint count from petstore spec (19 endpoints)
- [ ] Endpoints sorted by path ASC, method_rank ASC (deterministic)
- [ ] Schemas sorted by name ASC
- [ ] Tags have correct endpoint_count
- [ ] All operation_ptr values resolve in raw Value (validated during build_index)
- [ ] Invalid pointer causes fetch failure (not silent corruption)
- [ ] JSON pointer encoding handles /pet/{petId} correctly (escapes /)
- [ ] Security inheritance: operation without security inherits root; operation with empty [] means no auth

## Files
- CREATE: src/core/indexer.rs (detect_format, normalize_to_json, build_index, method_rank, json_pointer_encode, validate_pointers)
- MODIFY: src/core/mod.rs (pub mod indexer;)

## TDD Anchor
RED: Write `test_build_index_petstore` -- load tests/fixtures/petstore.json as Value, call build_index, assert endpoint_count == 19 and endpoints are sorted.
GREEN: Implement full index building.
VERIFY: `cargo test test_build_index_petstore`

Additional tests:
- test_detect_format_json, test_detect_format_yaml
- test_yaml_normalization_roundtrip
- test_json_pointer_encoding
- test_method_rank_ordering
- test_security_inheritance
- test_invalid_pointer_rejected

## Edge Cases
- Some specs have paths with special chars in operation IDs -- don't assume alphanumeric
- serde_yaml may produce different JSON than direct JSON parse (number types, null handling) -- normalize consistently
- Large specs (8MB+ GitHub) should still build index in <1s
- OpenAPI 3.1 may use `webhooks` key -- ignore for MVP (only extract from `paths`)
- Tags defined at root level but not used by any operation should still appear with endpoint_count=0

## Dependency Context
Uses SpecIndex, IndexedEndpoint, IndexedSchema, IndexedTag, IndexedParam types from bd-ilo (error types + core models bead).

Format detection, YAML normalization, index building with pointer validation


Implement fetch-time external ref bundling

## Background
Optional fetch-time external ref bundling. When --resolve-external-refs is passed during fetch, external $ref targets are fetched and inlined into raw.json. Requires explicit --ref-allow-host allowlist. Bounded by --ref-max-depth and --ref-max-bytes.

## Approach
After fetching the main spec and parsing as Value:
1. Walk the Value tree looking for $ref values that don't start with "#/"
2. For each external ref: parse URL, check host against --ref-allow-host allowlist (reject if not listed)
3. Fetch external ref content (uses AsyncHttpClient, respects SSRF policy)
4. Track total bytes fetched (abort at --ref-max-bytes)
5. Track resolution depth (abort at --ref-max-depth for ref chains)
6. Replace the $ref object with the fetched content
7. Store bundled result as raw.json; original (with $ref pointers) as raw.source

## Acceptance Criteria
- [ ] --resolve-external-refs without --ref-allow-host returns USAGE_ERROR
- [ ] External refs to allowed hosts are fetched and inlined
- [ ] External refs to disallowed hosts are rejected with PolicyBlocked
- [ ] --ref-max-depth limits chain resolution
- [ ] --ref-max-bytes limits total fetched content
- [ ] Bundled raw.json has all external refs resolved; raw.source preserves originals

## Edge Cases
- **Circular external refs:** A.yaml $refs B.yaml which $refs A.yaml. Must detect and break cycles (return error or annotate).
- **Relative refs:** `$ref: "./schemas/Pet.yaml"` must resolve relative to the base spec URL, not CWD.
- **Mixed internal + external refs:** Only resolve external refs (not starting with #/). Internal refs stay as-is.
- **External ref returns non-JSON/YAML:** Return InvalidSpec error for that ref, don't fail the entire fetch.
- **--ref-max-bytes exceeded:** Abort cleanly and report how many refs were resolved before the limit.

## Files
- CREATE: src/core/external_refs.rs (resolve_external_refs, walk_refs, fetch_ref)
- MODIFY: src/cli/fetch.rs (integrate external ref resolution after main fetch)
- MODIFY: src/core/mod.rs (pub mod external_refs;)

## TDD Anchor
RED: Write `test_external_ref_resolution` — create a spec with external $ref, mock the external URL, run fetch --resolve-external-refs --ref-allow-host localhost, verify ref is inlined.
GREEN: Implement ref walking and fetching.
VERIFY: `cargo test test_external_ref_resolution`

## Dependency Context
Uses AsyncHttpClient from bd-3b6. Extends fetch pipeline from bd-16o.


Implement diff command with structural comparison and CI gates

## Background
The diff command compares two spec states structurally. It reports added, removed, and modified endpoints and schemas by comparing normalized indexes. Supports alias-vs-alias, alias-vs-URL, and --fail-on breaking for CI gates.

## Approach
Implement src/cli/diff.rs with DiffArgs and async execute():

**DiffArgs:** left (String, positional), right (String, positional), fail_on (Option<String> — "breaking"), details (bool).

**Source resolution:** LEFT and RIGHT can be alias names or URLs. If URL, fetch into a temp location, build index, use for comparison (don't persist to cache).

**Diff computation (reuse from sync):**
- Compare endpoint sets by (path, method): added = in right not left, removed = in left not right, modified = in both but different (compare summary, tags, deprecated, parameters, security)
- Compare schema sets by name: added, removed, modified (compare schema_ptr target content hashes? or just name-based for Phase 2)
- Summary: total_changes, has_breaking (Phase 2: just structural; Phase 3: semantic classification)

**--fail-on breaking:** If breaking changes detected, exit non-zero (exit code 17). Phase 2 heuristic: removed endpoint = breaking, removed required parameter = breaking. Added endpoint/schema = non-breaking.

## Acceptance Criteria
- [ ] `diff alias1 alias2 --robot` reports structural changes
- [ ] Added/removed/modified endpoints correctly identified
- [ ] --fail-on breaking exits non-zero when breaking changes exist
- [ ] URL as right-side source works (temp fetch, not persisted)
- [ ] Robot output: left, right, changes.endpoints, changes.schemas, summary

## Edge Cases
- **Same spec on both sides:** Return ok:true with changed:false, empty change lists. Not an error.
- **URL-as-source fails to fetch:** Return Network error, not a diff-specific error.
- **Breaking change heuristic false positives:** Endpoint renamed (removed old + added new) looks like a breaking removal. Phase 2 is heuristic-only — document this limitation.
- **Very large diff (thousands of changes):** Apply same 200-item cap as sync --details with truncated flag.
- **Schema comparison:** Phase 2 is name-based only (added/removed by name). Modified schema detection is Phase 3 (requires deep structural comparison).

## Files
- MODIFY: src/cli/diff.rs (DiffArgs, execute, compute_diff, classify_breaking)
- MODIFY: src/output/robot.rs (add output_diff)
- MODIFY: src/output/human.rs (add output_diff)

## TDD Anchor
RED: Write `test_diff_detects_added_endpoint` — fetch petstore twice (one modified), run diff, assert added endpoint appears.
GREEN: Implement index comparison.
VERIFY: `cargo test test_diff_detects_added`

## Dependency Context
Reuses index diff logic from sync command (bd-3f4). Uses AsyncHttpClient for URL-as-source (bd-3b6). Uses indexer for building temp index from URL (bd-189).


Implement global network policy (offline/auto/online-only)

## Background
The --network global flag controls whether swagger-cli makes network calls. "auto" (default) allows network when needed. "offline" blocks all network calls (fetch/sync fail with OFFLINE_MODE). "online-only" flags when network would be needed but is not available. This is critical for CI reproducibility and agent sandboxing.

## Approach

### NetworkPolicy Enum (src/core/network.rs)
Create `NetworkPolicy` enum with three variants:
- **Auto** (default): Allow network calls when needed, no restrictions. Standard behavior.
- **Offline**: Block ALL outbound network calls preemptively. Any command that would make a network request (fetch, sync) returns `SwaggerCliError::OfflineMode` immediately without attempting the call. Commands that are index-only (list, search, show, tags) work normally.
- **OnlineOnly**: Allow network calls but surface a clear, distinct error if a network call would be needed AND DNS resolution fails or the host is unreachable. The error is different from OfflineMode — it indicates "you requested online-only mode but network is unavailable" rather than "network calls are blocked by policy."

### Implementation
1. Parse --network flag in CLI (already in Cli struct from skeleton bead)
2. Create `NetworkPolicy` enum and `check_network_policy()` function in `src/core/network.rs`
3. Check policy before any HTTP call in AsyncHttpClient — if Offline, return `SwaggerCliError::OfflineMode` without making the request
4. For OnlineOnly: attempt the request, but if it fails due to network issues, return a specific `SwaggerCliError::NetworkUnavailable` (different exit code or error code than OfflineMode)
5. `SWAGGER_CLI_NETWORK` env var as alternative to --network flag (flag takes precedence)

### Scope boundaries
- **Local file sources (file:// paths, stdin):** Network policy does NOT apply. These are local I/O operations. `fetch ./local-spec.yaml --network offline` should succeed because no network call is made.
- **Stdin sources:** Same as local files — no network needed, policy irrelevant.
- **sync --dry-run in offline mode:** ALLOWED. Dry-run compares cached state without actually fetching, so no network call is needed. Returns cached state comparison only.

## Acceptance Criteria
- [ ] NetworkPolicy enum with Auto, Offline, OnlineOnly variants in src/core/network.rs
- [ ] --network offline causes fetch (remote URL) to fail with OFFLINE_MODE error (exit 15)
- [ ] --network offline allows fetch from local file path (no network needed)
- [ ] --network offline allows fetch from stdin (no network needed)
- [ ] --network offline allows list/search/show/tags (index-only, no network needed)
- [ ] --network offline allows sync --dry-run (cached comparison only, no network)
- [ ] --network online-only surfaces clear error when network is unavailable (distinct from OFFLINE_MODE)
- [ ] --network auto allows all commands (default behavior, no restrictions)
- [ ] SWAGGER_CLI_NETWORK=offline env var works same as --network offline flag
- [ ] Flag takes precedence over env var when both are set
- [ ] Robot error for offline mode has code OFFLINE_MODE with suggestion to remove --network flag or unset env var
- [ ] Robot error for online-only network failure has code NETWORK_UNAVAILABLE with distinct suggestion

## Edge Cases
- **sync --dry-run in offline mode:** Allowed — returns cached state comparison only, no actual fetch happens. This is a read-only operation on cached data.
- **Local file + offline mode:** Allowed — `fetch ./spec.yaml --network offline` succeeds because it is local I/O, not a network call.
- **OnlineOnly vs Offline distinction:** Offline blocks proactively (never attempts the call). OnlineOnly attempts the call and fails with a specific error if the network is down. This matters for agents that want to know "was this blocked by policy or by actual network unavailability?"
- **Mixed sources:** If a future version supports specs that reference remote $ref URLs but the base spec is local, the network policy should apply to the remote $ref resolution (not the local file read).

## Files
- CREATE: src/core/network.rs (NetworkPolicy enum, check_network_policy function)
- MODIFY: src/core/http.rs (check policy before fetch)
- MODIFY: src/cli/fetch.rs (check policy at start, skip for local/stdin sources)
- MODIFY: src/cli/sync.rs (check policy at start, allow --dry-run in offline)

## TDD Anchor
RED: Write `test_offline_blocks_fetch` — set SWAGGER_CLI_NETWORK=offline, run fetch with remote URL, assert exit 15 and OFFLINE_MODE error.
RED: Write `test_offline_allows_local_file` — set offline, fetch local file, assert success.
RED: Write `test_offline_allows_list` — set offline, run list on cached alias, assert success.
RED: Write `test_online_only_network_unavailable` — set online-only, mock DNS failure, assert NETWORK_UNAVAILABLE error.
GREEN: Implement policy check.
VERIFY: `cargo test test_offline_blocks_fetch`

## Dependency Context
Modifies AsyncHttpClient in bd-3b6 (async HTTP client) to check network policy before requests. Uses SwaggerCliError variants (OfflineMode) from bd-ilo (error types and core data models).


Implement cache lifecycle command with stats, prune, and LRU eviction

## Background
The cache command manages cache growth with stats, pruning, and LRU eviction. Separate from doctor (which validates health). Uses coalesced last_accessed timestamps for LRU ordering.

## Approach
Implement src/cli/cache.rs with CacheArgs and execute():

**CacheArgs:** stats (bool, default action), prune_stale (bool), prune_threshold (u32, default 90 days), max_total_mb (Option<u64>), dry_run (bool).

**Operations:**
- Stats: list_aliases with size computation, show per-alias and total bytes
- Prune: find aliases older than threshold, delete (or dry-run report)
- LRU eviction: sort by last_accessed ASC, delete oldest until total < max_total_mb (or dry-run)

## Acceptance Criteria
- [ ] cache --stats shows per-alias sizes and total
- [ ] cache --prune-stale deletes aliases >90 days old
- [ ] cache --prune-threshold 30 overrides default
- [ ] cache --max-total-mb 500 evicts oldest-accessed aliases
- [ ] --dry-run shows what would be pruned without deleting
- [ ] Robot output: aliases[], total_bytes, pruned[], evicted[]

## Edge Cases
- **No aliases cached:** cache --stats returns ok:true with total_bytes:0, empty aliases array.
- **Concurrent prune + fetch:** If a fetch writes a new alias while prune is deleting, the new alias should not be affected. Prune operates on snapshot of alias list taken at start.
- **last_accessed coalescing:** LRU ordering uses coalesced last_accessed (10-min granularity). Hot-read aliases within the same 10-min window have identical last_accessed — tie-break by fetched_at.
- **--max-total-mb smaller than single largest alias:** Evict everything except the largest, then warn that target cannot be reached.
- **Alias being synced during prune:** Skip locked aliases with warning.

## Files
- MODIFY: src/cli/cache.rs (CacheArgs, execute, prune, evict)
- MODIFY: src/output/robot.rs (add output_cache)
- MODIFY: src/output/human.rs (add output_cache)

## TDD Anchor
RED: Write `test_cache_prune_stale` — create alias with old fetched_at, run cache --prune-stale --robot, assert alias appears in pruned[].
GREEN: Implement stale detection and pruning.
VERIFY: `cargo test test_cache_prune_stale`

## Dependency Context
Uses CacheManager (list_aliases, delete_alias) from bd-3ea. Uses is_stale from CacheMetadata.


Implement show command with ref expansion and pointer navigation

## Background
The show command displays full details for a specific endpoint. Unlike list/search, show MUST load raw.json to extract the complete operation subtree (parameters with schemas, request body, response schemas, etc.). It uses the operation_ptr from the index to locate the exact JSON node in raw.json, avoiding full-spec parsing. Optional --expand-refs resolves internal $ref pointers with cycle detection.

## Approach
Implement src/cli/show.rs with ShowArgs and execute():

**ShowArgs:** alias (Option<String>), path (String, positional), method (Option<String>), format (String, default "pretty"), expand_refs (bool), max_depth (u32, default 3).

**Execute flow:**
1. Resolve alias, load_index
2. Find matching endpoint(s) by path in index
3. If multiple methods and --method not specified → USAGE_ERROR listing available methods
4. Get operation_ptr from matched IndexedEndpoint
5. CacheManager::load_raw(alias, &meta) — loads raw.json as Value, validates raw_hash
6. Navigate to operation subtree using JSON pointer (operation_ptr)
7. If --expand-refs: recursively resolve internal $ref pointers (starting with #/) with cycle detection (max_depth). External refs get annotated as {"$external_ref": "..."}. Circular refs get {"$circular_ref": "..."}. Add warnings to meta.warnings[].
8. Output robot JSON or human formatted details

**JSON Pointer navigation:** Parse `/paths/~1pet~1{petId}/get` → navigate Value tree. `~1` → `/`, `~0` → `~`.

**Ref expansion:** Walk the Value tree. When encountering `{"$ref": "#/components/schemas/Pet"}`, resolve by navigating the pointer in raw Value. Track visited refs for cycle detection. Stop at max_depth.

## Acceptance Criteria
- [ ] `swagger-cli show petstore "/pet/{petId}" --robot` returns full operation details
- [ ] Multiple methods without --method returns USAGE_ERROR with available methods
- [ ] --method POST selects specific method
- [ ] operation_ptr correctly navigates to the right subtree in raw.json
- [ ] --expand-refs resolves internal refs up to max_depth
- [ ] Circular refs produce $circular_ref annotation (not infinite loop)
- [ ] External refs produce $external_ref annotation + warning
- [ ] raw_hash mismatch returns CacheIntegrity error
- [ ] Robot output includes: path, method, summary, description, tags, operation_id, parameters, request_body, responses, security

## Files
- MODIFY: src/cli/show.rs (ShowArgs, execute, pointer navigation, ref expansion)
- CREATE: src/core/refs.rs (expand_refs, resolve_pointer, cycle detection)
- MODIFY: src/core/mod.rs (pub mod refs;)
- MODIFY: src/output/robot.rs (add output_show)
- MODIFY: src/output/human.rs (add output_show)

## TDD Anchor
RED: Write `test_show_endpoint_details` — fetch petstore fixture, run show "/pet/{petId}" --method GET --robot, parse JSON, assert data.path == "/pet/{petId}" and data.method == "GET".
GREEN: Implement pointer navigation and output.
VERIFY: `cargo test test_show_endpoint_details`

Additional tests:
- test_show_multiple_methods_error
- test_expand_refs_basic
- test_expand_refs_circular_detection
- test_expand_refs_external_annotation

## Edge Cases
- JSON pointer decoding: `~1` → `/`, `~0` → `~` (order matters: decode ~1 first, then ~0)
- Path matching should be exact (not regex) — "/pet/{petId}" must match literally
- Some operations may have no request body, no parameters, or no security — handle None gracefully
- ref expansion must handle refs-to-refs (transitive resolution)

## Dependency Context
Uses CacheManager.load_index and load_raw from bd-3ea (cache read). Uses index types from bd-ilo. Requires a fetched spec in cache.


Implement cache write path with crash-consistent protocol and alias validation

## Background
The cache write path is the most safety-critical code in swagger-cli. It implements a crash-consistent multi-file commit protocol: raw.source -> raw.json -> index.json are written as .tmp files with fsync, renamed atomically, then meta.json is written LAST as the commit marker. Per-alias file locking prevents concurrent write corruption. Alias names are validated against a strict regex to prevent path traversal.

## Approach
**Alias validation:** Implement `validate_alias()` that checks against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, rejects path separators (/ \), `..`, reserved device names (CON, PRN, NUL, AUX, COM1-9, LPT1-9 case-insensitive), and leading dots. Return SwaggerCliError::Usage on failure.

**Cache directory management:** Implement `CacheManager` struct with `new()` (uses Config::cache_dir()), `new_with_path()` (for tests), `alias_dir()`, `ensure_dirs()`. Cache layout: `{cache_dir}/aliases/{alias}/`.

**File locking:** Use `fs2::FileExt::try_lock_exclusive()` on `{alias_dir}/.lock` with a bounded timeout (default 1000ms, poll every 50ms). Return SwaggerCliError::CacheLocked on timeout.

**Hash computation:** Implement `compute_hash(bytes: &[u8]) -> String` returning `"sha256:{hex}"` using sha2 crate.

**Crash-consistent write:** Implement `CacheManager::write_cache()` that:
1. Acquires exclusive lock on .lock (bounded timeout)
2. Computes content_hash, raw_hash, next generation, index_hash
3. Writes raw.source.tmp, raw.json.tmp, index.json.tmp (each with sync_all before rename)
4. Renames each .tmp to final name (sync_all after each rename)
5. Writes meta.json.tmp LAST (commit marker)
6. Renames meta.json.tmp -> meta.json (sync_all)
7. Best-effort fsync on alias directory fd (Unix only)
8. Releases lock

**Parameters for write_cache:** Takes alias, raw_source_bytes, raw_json_bytes, index (SpecIndex), url, spec_version, spec_title, source_format, etag, last_modified, previous_generation (Option<u64>).

## Acceptance Criteria
- [ ] validate_alias("petstore") -> Ok
- [ ] validate_alias("../etc/passwd") -> Err(Usage)
- [ ] validate_alias("CON") -> Err(Usage) (case-insensitive)
- [ ] validate_alias(".hidden") -> Err(Usage)
- [ ] validate_alias("a".repeat(65)) -> Err(Usage)
- [ ] write_cache creates all 4 files + .lock in correct directory
- [ ] meta.json is the last file written (verified by checking file mtimes or write order)
- [ ] compute_hash produces deterministic sha256:hex output
- [ ] Lock timeout returns CacheLocked error (not hang)
- [ ] Hash values are deterministic (same input -> same sha256 output)
- [ ] Files survive process kill between steps (no partial meta.json)

## Files
- CREATE: src/core/cache.rs (CacheManager, validate_alias, compute_hash, write_cache, lock helpers)
- MODIFY: src/core/mod.rs (pub mod cache;)

## TDD Anchor
RED: Write `test_validate_alias_rejects_traversal` -- assert validate_alias("../etc") returns Err with Usage variant.
GREEN: Implement regex validation + blocklist checks.
VERIFY: `cargo test test_validate_alias`

Additional tests:
- test_validate_alias_accepts_valid (petstore, my-api, v1.0, API_2)
- test_validate_alias_rejects_reserved (CON, con, NUL, COM1)
- test_write_cache_creates_all_files
- test_compute_hash_deterministic

## Edge Cases
- On macOS, fsync on directory fd may not be supported -- handle gracefully (best-effort)
- Lock file must be created if it doesn't exist (open with create flag)
- If alias directory doesn't exist, create it before acquiring lock
- sync_all() is critical -- without it, data may be in OS page cache but not on disk after rename
- Generation starts at 1 for new aliases, increments from previous for updates

## Dependency Context
Uses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses SwaggerCliError variants (CacheLocked, Cache, Usage) from bd-ilo.

CacheManager with validate_alias, compute_hash, crash-consistent write_cache


Implement sync --all with async concurrency and resumable execution

## Background
sync --all synchronizes all aliases concurrently. It uses bounded async concurrency (--jobs, default 4) with per-host throttling (--per-host, default 2) to avoid abusive request patterns. Supports resumable execution via checkpoint files and a failure budget (--max-failures) to limit blast radius.

## Approach
Build on the single-alias sync from the previous bead:

**Async concurrency:** Use tokio::sync::Semaphore for global concurrency (--jobs). Use a per-host semaphore map (HashMap<String, Semaphore>) for --per-host throttling. Process aliases via futures::stream::StreamExt::buffer_unordered.

**Resumable sync (--resume):** Write a checkpoint file ({cache_dir}/sync-checkpoint.json) after each alias completes. Contains: aliases_completed, aliases_failed, started_at. On --resume, skip already-completed aliases.

**Failure budget (--max-failures):** Track failure count. When exceeded, abort remaining aliases and report partial results.

**Retry-After:** Honor response header (seconds or HTTP-date format). Use exponential backoff + jitter when header absent.

**Per-alias output:** Collect results from all aliases. Report per-alias success/failure in robot output. Don't abort on single-alias failure (unless failure budget exceeded).

## Acceptance Criteria
- [ ] sync --all processes all aliases concurrently
- [ ] --jobs limits concurrent syncs (verified: never more than N in-flight)
- [ ] --per-host limits requests to same host
- [ ] --resume skips already-completed aliases
- [ ] --max-failures aborts after N failures
- [ ] Per-alias success/failure reported in robot output
- [ ] Retry-After header honored

## Edge Cases
- **Empty aliases (no specs cached):** sync --all with no aliases should succeed with empty results, not error.
- **All aliases fail:** If every alias fails sync, the overall command should still exit 0 with per-alias failure details (unless --max-failures triggered, then exit non-zero).
- **Checkpoint file corruption:** If sync-checkpoint.json is malformed, delete it and start fresh (don't error).
- **Per-host semaphore with many aliases to same host:** 10 aliases all pointing to api.example.com with --per-host 2 means only 2 concurrent to that host, even if --jobs allows more.
- **Retry-After as HTTP-date:** Parse both formats: seconds (e.g., "120") and HTTP-date (e.g., "Thu, 01 Jan 2026 00:00:00 GMT").

## Files
- MODIFY: src/cli/sync.rs (add sync_all, concurrency control, checkpoint, failure budget)

## TDD Anchor
RED: Write `test_sync_all_concurrent` — set up 4 aliases, run sync --all --jobs 2 --robot, verify all 4 synced and output includes per-alias results.
GREEN: Implement concurrent sync with semaphore.
VERIFY: `cargo test test_sync_all_concurrent`

## Dependency Context
Extends single-alias sync from bd-3f4 (sync command). Uses tokio Semaphore for concurrency control. Uses CacheManager.list_aliases from bd-3ea (cache read path).


Create CI/CD pipeline and cargo-deny configuration

## Background
The CI/CD pipeline runs tests, linting, security audits, and multi-platform builds on GitLab CI. It also configures cargo-deny for license/advisory policy enforcement.

## Approach

### .gitlab-ci.yml Structure

**Stages:** test, build, release

**Test stage jobs:**
- `test:unit` — `cargo test --lib` (unit tests only, no integration tests)
- `test:integration` — `cargo test --test '*'` (integration tests in tests/ directory)
- `lint` — `cargo fmt --check` + `cargo clippy -- -D warnings` (warnings are errors)
- `security:deps` — `cargo-deny check` (license + advisory) + `cargo-audit` (RUSTSEC advisory DB)

**Build stage:**
- Build template (YAML anchor or extends) shared across 4 target jobs:
  - `build:aarch64-apple-darwin` — Apple Silicon macOS
  - `build:x86_64-apple-darwin` — Intel macOS
  - `build:x86_64-unknown-linux-gnu` — x86 Linux
  - `build:aarch64-unknown-linux-gnu` — ARM Linux
- Each job: `cargo build --release --locked --target $TARGET`, upload binary as artifact

**Release stage:**
- `release` job (runs on tagged commits only):
  - Collect all 4 binaries from build artifacts
  - Generate `SHA256SUMS` file: `sha256sum swagger-cli-* > SHA256SUMS`
  - Sign with minisign: `minisign -S -m SHA256SUMS` (produces SHA256SUMS.minisig)
  - Upload all files to GitLab Package Registry via `curl` to Package Registry API
- `docker` job (runs on tagged commits):
  - `docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .`
  - `docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG`
  - Also tag as `:latest`

### deny.toml Structure

**[licenses]:**
- `unlicensed = "deny"` — all deps must have a license
- `allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Zlib", "Unicode-3.0", "Unicode-DFS-2016", "OpenSSL"]` — common OSS license allow-list
- `copyleft = "deny"` — no copyleft licenses without explicit exception

**[advisories]:**
- `vulnerability = "deny"` — fail on known vulnerabilities
- `unmaintained = "warn"` — warn but don't fail on unmaintained crates
- `unsound = "warn"` — warn on unsound crates
- `yanked = "deny"` — fail on yanked versions
- `notice = "warn"` — warn on advisories with notices

**[bans]:**
- `multiple-versions = "warn"` — warn if same crate appears in multiple versions
- `wildcards = "deny"` — no wildcard version specs
- Specific ban list empty initially (add problematic crates as discovered)

## Acceptance Criteria
- [ ] .gitlab-ci.yml has test, build, release stages in correct order
- [ ] test:unit runs `cargo test --lib`
- [ ] test:integration runs `cargo test --test '*'`
- [ ] lint job runs both fmt --check and clippy -D warnings
- [ ] security:deps runs both cargo-deny check and cargo-audit
- [ ] All 4 platform targets defined as separate build jobs
- [ ] Build jobs use shared template/anchor to avoid duplication
- [ ] Release job generates SHA256SUMS from all binaries
- [ ] Release job signs SHA256SUMS with minisign
- [ ] Release job uploads to GitLab Package Registry
- [ ] Docker job builds and pushes to CI_REGISTRY
- [ ] deny.toml license allow-list covers common OSS licenses
- [ ] deny.toml advisory policy denies known vulnerabilities
- [ ] `cargo deny check` passes locally after creation

## Files
- CREATE: .gitlab-ci.yml
- CREATE: deny.toml

## TDD Anchor
Validate: `cargo deny check` passes locally (after deny.toml is created).
VERIFY: `cargo deny check 2>&1 | head -5`

## Edge Cases
- **Cross-compilation toolchains:** macOS targets require macOS runners or cross-compilation tools. Verify CI has the right runners or use `cross` tool.
- **cargo-deny not installed:** The security:deps job must install cargo-deny and cargo-audit before running. Use `cargo install` with version pinning.
- **Minisign key management:** The release job needs the minisign private key as a CI secret variable. Document the required CI variables.
- **Tag-only release trigger:** Release jobs must have `rules: - if: $CI_COMMIT_TAG` to avoid running on every push.
- **Large binary artifacts:** Rust release binaries can be 10MB+. Verify CI artifact storage limits.


Epic: Distribution and CI


Implement cross-alias discovery (--all-aliases)

## Background
--all-aliases on list and search queries across every cached alias, returning results with an additional "alias" field per result. Useful for agents managing multiple APIs.

## Approach
1. In list execute: if --all-aliases, iterate all aliases via list_aliases(), load each index, filter/sort per-alias, merge results with "alias" field added to each endpoint item
2. In search execute: same pattern — search each alias's index, merge results, re-sort by score
3. Robot output: each endpoint/result includes "alias" string field identifying which spec it came from

**Sorting rules:**
- For `list --all-aliases`: merged endpoints sorted by (alias ASC, path ASC, method_rank ASC) where method_rank follows standard HTTP method ordering (GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, etc.)
- For `search --all-aliases`: merged results sorted by (score DESC, then normal tie-breaking within same score) — the alias field is just additional metadata, it does not affect ranking

**Robot output envelope:**
- `data.endpoints[]` or `data.results[]` — each item includes `"alias": "petstore"` field
- `data.aliases_searched: string[]` — lists which aliases were queried (e.g., `["petstore", "github-api", "stripe"]`)
- This allows agents to detect if an alias was missing/skipped vs simply had no matching results

**Error handling for individual alias failures:**
- If one alias fails to load (corrupted index, missing files), do NOT abort the entire operation
- Skip the failed alias and continue with remaining aliases
- Add warning to `meta.warnings[]` array: `{"alias": "broken-api", "code": "INDEX_LOAD_FAILED", "message": "..."}`
- Robot output still returns ok:true with partial results (this is a degraded success, not an error)
- If ALL aliases fail, then return an error

## Acceptance Criteria
- [ ] `list --all-aliases --robot` returns endpoints from all aliases with alias field on each item
- [ ] `search "pet" --all-aliases --robot` searches across all aliases with alias field on each result
- [ ] List results sorted by alias ASC, then path ASC, then method_rank ASC
- [ ] Search results sorted by score DESC (alias does not affect ranking)
- [ ] Robot output includes data.aliases_searched listing all queried aliases
- [ ] If one alias fails to load, operation continues with remaining aliases and meta.warnings populated
- [ ] If all aliases fail, returns error (not partial success)
- [ ] Human output groups or labels results by alias for readability

## Edge Cases
- **Single alias cached:** --all-aliases works but is equivalent to normal query (aliases_searched has one entry)
- **No aliases cached:** Return error — no specs available
- **One alias fails to load:** Skip with warning in meta.warnings, return results from healthy aliases
- **All aliases fail:** Return error with details about each failure
- **Duplicate endpoint paths across aliases:** Both appear in results, distinguished by alias field

## Files
- MODIFY: src/cli/list.rs (add --all-aliases logic)
- MODIFY: src/cli/search.rs (add --all-aliases logic)

## TDD Anchor
RED: Write `test_list_all_aliases` — fetch 2 different specs, run list --all-aliases --robot, assert results contain endpoints from both with alias field.
RED: Write `test_search_all_aliases` — fetch 2 specs, run search --all-aliases --robot, assert results from both with correct score-based sorting.
RED: Write `test_all_aliases_partial_failure` — corrupt one alias index, run list --all-aliases, assert partial results returned with warning.
GREEN: Implement cross-alias iteration.
VERIFY: `cargo test test_list_all_aliases`

## Dependency Context
Requires list command (bd-3km) and search command (bd-acf) to be implemented first.


Implement configuration system with path resolution and auth profiles

## Background
swagger-cli uses TOML config files for user preferences and auth profiles, stored in XDG config dir. The config system handles path resolution with a specific precedence: --config > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME > XDG defaults. Auth profiles allow loading credentials from config.toml instead of passing raw tokens on the command line.

## Approach
Implement Config, AuthConfig, CredentialSource, AuthType, DisplayConfig structs in src/core/config.rs per the PRD. Config::load() reads from config_path(), Config::save() writes with toml::to_string_pretty(). Both config_path() and cache_dir() implement the D7 override precedence using env vars SWAGGER_CLI_HOME, SWAGGER_CLI_CONFIG, SWAGGER_CLI_CACHE, and directories::ProjectDirs. CredentialSource is a tagged enum (Literal, EnvVar, Keyring) with serde tag="source". AuthType is an enum (Bearer, ApiKey { header }). Default stale_threshold_days is 30.

## Acceptance Criteria
- [ ] Config::load() returns default Config when no file exists
- [ ] Config::save() writes valid TOML that round-trips through load()
- [ ] config_path() respects: --config flag > SWAGGER_CLI_CONFIG env > SWAGGER_CLI_HOME/config/config.toml > XDG
- [ ] cache_dir() respects: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir
- [ ] AuthConfig with CredentialSource::EnvVar serializes/deserializes correctly
- [ ] Default config has stale_threshold_days=30, empty auth_profiles, no default_alias
- [ ] All tests hermetic (use SWAGGER_CLI_HOME with tempdir)

## Files
- CREATE: src/core/config.rs (Config, AuthConfig, CredentialSource, AuthType, DisplayConfig, Default impl, load/save, path resolution)
- MODIFY: src/core/mod.rs (pub mod config;)

## TDD Anchor
RED: Write `test_config_path_precedence` — set SWAGGER_CLI_HOME to a tempdir, verify config_path() returns tempdir/config/config.toml. Then set SWAGGER_CLI_CONFIG to a specific path, verify it takes precedence.
GREEN: Implement config_path() with env var checks.
VERIFY: `cargo test test_config_path_precedence`

## Edge Cases
- CredentialSource::Keyring is Phase 2 — include the enum variant but document it as unimplemented
- Config file might not exist (return default, don't error)
- SWAGGER_CLI_HOME dir might not exist yet (create parent dirs on save)
- toml crate is named `toml`, not `serde_toml`

Config with TOML load/save, path precedence, auth profiles


Implement reliability stress tests and performance benchmarks

## Background
Reliability tests validate the crash-consistency, concurrency safety, and determinism claims. These are the hardest tests but most important for production confidence. Includes fault injection (simulated crash at each write step), multi-process lock contention (32 concurrent processes), and property-based tests (proptest for deterministic ordering).

## Approach
**Crash consistency (tests/reliability/crash_consistency_test.rs):**
- test_crash_before_meta_rename: write raw+index but not meta → read protocol detects → doctor --fix repairs
- test_crash_after_raw_before_index: write raw but not index → doctor --fix rebuilds

**Lock contention (tests/reliability/lock_contention_test.rs):**
- test_concurrent_fetch_32_processes: spawn 32 threads, all fetch same alias with --force → verify all exit 0 or 9 (CACHE_LOCKED), no panics, final state passes doctor

**Property-based (tests/reliability/property_test.rs with proptest):**
- index_ordering_deterministic: random endpoints → build_index → build_index from shuffled → same JSON
- hash_deterministic: same bytes → same hash

Add proptest = '1.0' to [dev-dependencies] in Cargo.toml alongside existing test deps.

**Benchmarks (benches/perf.rs with Criterion):**
- bench_load_index, bench_list_endpoints, bench_search_query on 500+ endpoint spec

## Acceptance Criteria
- [ ] Crash consistency tests verify doctor can repair all simulated crash states
- [ ] 32-process contention test passes without deadlocks or corruption
- [ ] Property-based tests verify deterministic ordering
- [ ] Benchmarks establish baseline performance numbers
- [ ] All reliability tests pass on CI

## Files
- CREATE: tests/reliability/crash_consistency_test.rs
- CREATE: tests/reliability/lock_contention_test.rs
- CREATE: tests/reliability/property_test.rs
- CREATE: benches/perf.rs
- MODIFY: Cargo.toml (add [[bench]] for criterion, add proptest dev-dep)

## TDD Anchor
Run: `cargo test --test crash_consistency && cargo test --test lock_contention && cargo test --test property`
VERIFY: `cargo bench -- --test`

## Dependency Context
Uses CacheManager write/read protocol from cache beads. Uses build_index from indexer bead. Uses compute_hash. Tests the claims made in the cache architecture.

## Edge Cases
- **Crash test cleanup:** Crash simulation tests must clean up temp files even on test failure. Use Drop guards or panic hooks.
- **32-process test may exceed file descriptor limits:** On CI, ulimit may be low. Test should check and skip with a clear message if fd limit < 256.
- **Property test shrinking:** proptest shrinking can be slow for complex inputs. Set max shrink iterations to avoid CI timeouts.
- **Benchmark stability:** Criterion requires multiple iterations. First run creates baseline. Tests should not fail on benchmark regression — only warn.
- **Lock contention test timing:** 32 threads racing for a lock is timing-dependent. Use a barrier to ensure all threads start simultaneously.


Epic: Health and Cache Lifecycle


Epic: Diff Command


Flesh out Phase 3 scope and requirements

## Background
Phase 3 beads (SBOM, keyring, curl gen, breaking-change classification, semantic search, YAML output) are currently stubs without enough detail for implementation. Before any Phase 3 work begins, the requirements and scope for each feature must be fleshed out with proper acceptance criteria, approach, and file lists.

## What Needs to Happen
1. Review each Phase 3 bead against the PRD's Phase 3 section
2. Research any dependencies or design decisions not yet documented
3. Write full bead descriptions (Background, Approach, Acceptance Criteria, Files, TDD Anchor, Edge Cases) for each Phase 3 bead
4. Validate scope boundaries — what's in vs out for each feature

## Acceptance Criteria
- [ ] All Phase 3 beads have full descriptions (score 4+/5 on agent-readiness)
- [ ] Each bead has concrete file lists and TDD anchors
- [ ] Scope boundaries are documented (what's explicitly out of scope)
- [ ] No unresolved ambiguities — all genuinely ambiguous decisions are resolved

## Phase 3 Beads to Flesh Out
- bd-37c: SBOM generation and cosign attestation
- bd-3pz: OS keychain credential backend
- bd-60k: Generate curl commands from endpoints
- bd-j23: Breaking-change classification for diff
- bd-132: Semantic search with embeddings
- bd-b8h: YAML output format


Implement golden robot output tests and index-only invariant tests

## Background
Golden tests are the #1 defense against robot JSON shape regressions. They verify structural invariants (ok, data, meta.schema_version, meta.tool_version, meta.command, meta.duration_ms) and snapshot-compare against golden files. Index-only invariant tests verify that list/search work without raw.json (core performance promise).

## Approach
**Golden tests (tests/integration/golden_test.rs):**
1. For each command (list, show, search, schemas, tags, aliases), run with --robot
2. Parse output as JSON, verify structural invariants (ok is bool, data is object, meta has required fields)
3. Compare against golden snapshot files in tests/integration/golden/
4. Fail CI if shape changes unless schema_version is incremented

**Index-only invariant tests:**
- test_list_does_not_read_raw_json: fetch spec, delete raw.json, run list — must succeed
- test_search_does_not_read_raw_json: same pattern with search
- test_tags_does_not_read_raw_json: same
- test_schemas_list_does_not_read_raw_json: same (list mode only; show mode needs raw)

**JSON Schema validation (optional enhancement):**
- Create docs/robot-schema/v1/success.schema.json and error.schema.json
- Validate robot output against these schemas in golden tests

## Acceptance Criteria
- [ ] Golden test verifies all 6 command outputs have correct structure
- [ ] Index-only tests pass (list/search/tags/schemas-list work without raw.json)
- [ ] Golden files exist in tests/integration/golden/
- [ ] JSON Schema files exist in docs/robot-schema/v1/

## Edge Cases
- **duration_ms non-determinism:** Golden files must NOT include duration_ms in snapshot comparison (it changes every run). Strip or mask it before comparing.
- **tool_version changes:** Updating Cargo.toml version breaks golden files. Golden comparison should either mask tool_version or tests should update golden files via an env var flag.
- **Platform-specific key ordering:** serde_json with BTreeMap ensures deterministic ordering. Verify golden files use sorted keys.
- **Index-only tests must verify raw.json is actually deleted:** Don't just skip loading it — physically remove the file and prove the command works without it.

## Files
- CREATE: tests/integration/golden_test.rs
- CREATE: tests/integration/golden/*.json (golden snapshot files)
- CREATE: docs/robot-schema/v1/success.schema.json
- CREATE: docs/robot-schema/v1/error.schema.json

## TDD Anchor
Run all golden and invariant tests.
VERIFY: `cargo test golden && cargo test does_not_read_raw`

## Dependency Context
Requires all query commands to be implemented. Uses test helpers from bd-lx6 (Create test fixtures and integration test helpers).


Add supply chain hardening and robot JSON Schema artifacts

## Background
Supply chain hardening: release artifacts include SHA256SUMS + minisign signatures. Robot output JSON Schemas published as build artifacts for agent validation.

## Approach

### Supply Chain Artifacts
1. Update release job in .gitlab-ci.yml to generate SHA256SUMS and sign with minisign
2. Upload SHA256SUMS + SHA256SUMS.minisig alongside binaries to GitLab Package Registry
3. Update install.sh to verify signature when minisign is available

### JSON Schema Files

Create `docs/robot-schema/v1/success.schema.json`:
```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["ok", "data", "meta"],
  "properties": {
    "ok": { "type": "boolean", "const": true },
    "data": { "type": "object" },
    "meta": {
      "type": "object",
      "required": ["schema_version", "tool_version", "command", "duration_ms"],
      "properties": {
        "schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$" },
        "tool_version": { "type": "string" },
        "command": { "type": "string" },
        "command_version": { "type": "string", "description": "Per-command payload version for independent evolution" },
        "duration_ms": { "type": "integer", "minimum": 0 }
      }
    }
  },
  "additionalProperties": false
}
```

Create `docs/robot-schema/v1/error.schema.json`:
```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["ok", "error", "meta"],
  "properties": {
    "ok": { "type": "boolean", "const": false },
    "error": {
      "type": "object",
      "required": ["code", "message"],
      "properties": {
        "code": { "type": "string" },
        "message": { "type": "string" },
        "suggestion": { "type": "string" }
      }
    },
    "meta": {
      "type": "object",
      "properties": {
        "schema_version": { "type": "string" },
        "tool_version": { "type": "string" },
        "command": { "type": "string" },
        "duration_ms": { "type": "integer", "minimum": 0 }
      }
    }
  },
  "additionalProperties": false
}
```

### Compatibility Policy
- **No version bump:** Adding new optional fields to data or meta (additive changes)
- **MUST bump schema_version:** Removing fields, renaming fields, changing field types, changing required status
- **meta.command_version:** Each command can independently evolve its data payload structure. When a command's data shape changes in a breaking way, bump command_version without bumping the global schema_version. This allows agents to pin to specific command output shapes.

## Acceptance Criteria
- [ ] Release pipeline generates SHA256SUMS from all binary artifacts
- [ ] minisign signature generated for SHA256SUMS (when key available in CI)
- [ ] SHA256SUMS and SHA256SUMS.minisig uploaded alongside binaries
- [ ] install.sh attempts signature verification when minisign is on PATH
- [ ] docs/robot-schema/v1/success.schema.json matches the structure above
- [ ] docs/robot-schema/v1/error.schema.json matches the structure above
- [ ] success.schema.json meta requires: schema_version, tool_version, command, duration_ms
- [ ] success.schema.json meta includes optional command_version field
- [ ] error.schema.json error requires: code, message (suggestion is optional)
- [ ] Both schemas have additionalProperties: false at top level
- [ ] Schema files are valid JSON Schema (validate with a JSON Schema validator)

## Files
- MODIFY: .gitlab-ci.yml (add checksum + signing to release)
- MODIFY: install.sh (add signature verification)
- CREATE: docs/robot-schema/v1/success.schema.json
- CREATE: docs/robot-schema/v1/error.schema.json

## Dependency Context
Extends CI pipeline from bd-1lj and install script from bd-gvr.

## Edge Cases
- **Schema evolution testing:** When robot output changes, both the code AND the JSON Schema must be updated together. Tests should validate all command outputs against the schemas.
- **Minisign not available in CI:** If minisign is not installed, the release should still succeed but skip signing with a clear warning (don't fail the release).
- **JSON Schema draft version:** Use 2020-12 draft. Older validators may not support it — document the minimum validator version.
- **additionalProperties:false on nested objects:** The top-level schemas have it, but nested objects (data, meta) may need it too if strict validation is desired. Decision: only enforce at top level for now.


Epic: Alias Management


Implement doctor command with integrity validation and --fix repair

## Background
The doctor command validates installation health and cache integrity. It checks config/cache directories exist, validates each alias's cache files (meta, index, raw), detects integrity issues (generation/hash mismatches, stale index versions, missing files), warns on insecure config permissions, and validates all index pointers against raw.json. Optional --fix repairs recoverable issues.

## Approach
Implement src/cli/doctor.rs with DoctorArgs and execute():

**Checks performed:**
1. Config directory exists and is readable/writable
2. Cache directory exists and is readable/writable
3. For each alias: check meta.json exists, validate generation/index_hash/index_version against index.json, validate raw_hash against raw.json, validate all operation_ptr/schema_ptr resolve in raw.json
4. Detect stale caches (>30 days, configurable via config)
5. Check config.toml permissions -- warn if group/world readable when auth tokens present
6. Report disk usage (per-alias and total)

**--fix repair modes (per alias, after acquiring lock):**
1. If raw exists but index missing/invalid or index_version mismatched -> rebuild index from raw
2. If raw + index valid but meta missing -> reconstruct meta from raw + index
3. If raw unreadable/unparseable -> delete alias (last resort)

**Health status:** HEALTHY (no issues), WARNING (stale caches, permission issues), DEGRADED (some aliases have integrity issues but are fixable), UNHEALTHY (unfixable corruption).

## Acceptance Criteria
- [ ] doctor reports HEALTHY for a valid cache
- [ ] doctor detects missing meta.json as integrity issue
- [ ] doctor detects generation mismatch between meta and index
- [ ] doctor detects invalid operation_ptr (pointer doesn't resolve)
- [ ] doctor warns on stale caches (>30 days)
- [ ] doctor warns on insecure config permissions
- [ ] --fix rebuilds index from raw when index is invalid
- [ ] --fix reconstructs meta when meta is missing but raw+index exist
- [ ] --fix deletes alias only when raw is unreadable
- [ ] Robot output: health status, per-alias status, warnings[], disk_usage

## Edge Cases
- **Concurrent doctor + fetch:** Doctor reads while fetch writes. Doctor should acquire read lock or tolerate mid-write state gracefully (report as integrity issue, not crash).
- **Very large cache (hundreds of aliases):** Doctor must not OOM — process aliases one at a time, not load all into memory.
- **Permission denied on cache directory:** Report as WARNING, not crash. Doctor should be resilient to partial access.
- **Empty alias directory (no files):** Skip with warning, don't crash. This can happen from interrupted deletes.
- **--fix on locked alias:** If another process holds the lock, skip that alias with warning (don't block).

## Files
- MODIFY: src/cli/doctor.rs (DoctorArgs, execute, check_alias, fix_alias, permission_check)
- MODIFY: src/output/robot.rs (add output_doctor)
- MODIFY: src/output/human.rs (add output_doctor)

## TDD Anchor
RED: Write `test_doctor_detects_missing_meta` -- create cache with raw+index but no meta, run doctor --robot, assert alias status is "integrity_error".
GREEN: Implement per-alias integrity checking.
VERIFY: `cargo test test_doctor_detects_missing_meta`

## Dependency Context
Uses CacheManager (load_index, load_raw, list_aliases) from bd-3ea (cache read path). Uses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses build_index from bd-189 (indexer) for --fix index rebuild.


Implement aliases command with list, rename, delete, set-default

## Background
The aliases command manages multiple API specs. List all aliases with stats, show details, rename, delete, set default. All operations except delete are metadata-only (fast). Delete removes the entire alias directory after acquiring lock.

## Approach
Implement src/cli/aliases.rs with AliasesArgs and execute():

**AliasesArgs:** list (bool, default), show (Option<String>), rename (Option<Vec<String>> — [old, new], requires 2 values), delete (Option<String>), set_default (Option<String>).

**Operations:**
- **List:** CacheManager.list_aliases() -> display name, url, version, is_default, cached_at, size, endpoint/schema counts
- **Show:** load meta for specific alias, display full details
- **Rename:** validate new alias name format (same rules as alias creation — alphanumeric, hyphens, underscores), check new name does not already exist, rename directory atomically using `std::fs::rename()` syscall (atomic on same filesystem), if renamed alias was the default alias in config, update config.default_alias to the new name and save
- **Delete:** acquire lock on alias directory, remove entire alias directory (`std::fs::remove_dir_all`), if deleted alias was the default alias, clear default_alias in config (set to None), save config. No confirmation prompt — CLI is non-interactive for agent compatibility. PRD says "explicit delete required" meaning the user must explicitly pass --delete, but no interactive Y/N prompt.
- **Set-default:** verify alias exists in cache before setting, update config.default_alias, save config. If alias does not exist, return error with suggestion listing available aliases.

## Error Handling Details

**Rename errors:**
- New name fails format validation -> error with INVALID_ALIAS_NAME code and suggestion showing valid format
- New name already exists -> error with ALIAS_EXISTS code
- Rename to same name -> no-op, return success (idempotent, do not error)
- Old alias does not exist -> error with ALIAS_NOT_FOUND code
- Filesystem rename fails -> error with IO_ERROR code

**Delete errors:**
- Alias does not exist -> error with ALIAS_NOT_FOUND code
- Lock contention (e.g., sync running) -> error with LOCK_CONTENTION code and suggestion to retry
- Deleting the only alias -> allowed (leaves empty aliases state, no special handling)

**Set-default errors:**
- Alias does not exist -> error with ALIAS_NOT_FOUND code and suggestion listing available aliases

## Acceptance Criteria
- [ ] `aliases --robot` lists all aliases with correct metadata (name, url, version, is_default, cached_at, size, endpoint_count, schema_count)
- [ ] `aliases --show petstore` shows full details for one alias
- [ ] `aliases --rename old new` renames directory atomically and updates config if renamed alias was default
- [ ] `aliases --rename old old` (same name) is a no-op, returns success
- [ ] `aliases --delete old-api` removes alias directory and clears default if it was default
- [ ] Delete does NOT prompt for confirmation (non-interactive CLI)
- [ ] `aliases --set-default petstore` updates config, errors if alias does not exist
- [ ] Rename validates new alias format (alphanumeric, hyphens, underscores)
- [ ] Rename checks new name does not already exist
- [ ] Delete of default alias clears default_alias in config
- [ ] Robot output for each operation is well-structured with ok/data/meta envelope
- [ ] Error responses include appropriate error codes and suggestions

## Edge Cases
- **Rename to same name:** No-op, return success (idempotent behavior).
- **Delete the only alias:** Allowed. Leaves cache in empty state. Subsequent commands that need an alias will error with ALIAS_NOT_FOUND suggesting the user fetch a spec.
- **Delete while sync is running:** Lock contention. Return LOCK_CONTENTION error with suggestion to wait or retry. Do not force-delete.
- **Set-default to non-existent alias:** Error with ALIAS_NOT_FOUND and suggestion listing available aliases from cache.
- **Rename when target name has invalid characters:** Error with INVALID_ALIAS_NAME showing the format rules.

## Files
- MODIFY: src/cli/aliases.rs (AliasesArgs, execute, rename/delete/set-default logic)
- MODIFY: src/output/robot.rs (add output_aliases)
- MODIFY: src/output/human.rs (add output_aliases)

## TDD Anchor
RED: Write `test_aliases_list` — fetch two specs, run aliases --robot, assert data.aliases has length 2.
RED: Write `test_aliases_rename` — rename an alias, verify directory moved and config updated.
RED: Write `test_aliases_rename_same_name` — rename to same name, verify no-op success.
RED: Write `test_aliases_delete` — delete alias, verify directory removed and config cleared.
RED: Write `test_aliases_delete_lock_contention` — hold lock on alias, attempt delete, assert LOCK_CONTENTION error.
GREEN: Implement list_aliases and output.
VERIFY: `cargo test test_aliases_list`

## Dependency Context
Uses CacheManager (list_aliases, delete_alias) from bd-3ea. Uses Config (default_alias, save) from bd-1sb.


Add SBOM generation and cosign attestation

## What
Generate SBOM (CycloneDX or SPDX) during CI build. Sign release artifacts with cosign for provenance attestation.

## Acceptance Criteria
- [ ] SBOM generated in CI pipeline
- [ ] cosign attestation attached to release artifacts
- [ ] Verifiable with cosign verify

## Files
- MODIFY: .gitlab-ci.yml (add SBOM + cosign steps)


Epic: Phase 3 Future


Build async HTTP client with SSRF protection and streaming download

## Background
swagger-cli fetches OpenAPI specs over HTTPS with strict security controls. The HTTP client must enforce SSRF protection (blocking private/loopback/link-local/multicast IPs), require HTTPS by default, support streaming downloads with max-bytes enforcement, and handle retries with backoff. This is async (tokio + reqwest).

## Approach
Create src/core/http.rs with:

**AsyncHttpClient struct:** Wraps reqwest::Client configured with rustls-tls, connect timeout (5s), overall timeout (configurable, default 10s), redirect policy (max 5). Provides `fetch_spec()` async method.

**SSRF Protection:** Before connecting, resolve the hostname and check the IP against blocked CIDR ranges: 127.0.0.0/8, ::1, 169.254.0.0/16, fe80::/10, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, multicast (224.0.0.0/4, ff00::/8). Also check resolved IP AFTER redirects (DNS rebinding defense). Return PolicyBlocked error for violations. Accept --allow-private-host exceptions.

**HTTPS Enforcement:** Reject http:// URLs unless --allow-insecure-http is set. Return PolicyBlocked.

**Streaming Download:** Use response.chunk() in a loop, counting bytes. Abort when exceeding max_bytes (default 25MB). This prevents OOM on huge specs.

**Retries:** Retry on 5xx and network errors up to N times (default 2) with exponential backoff + jitter. Honor Retry-After header. Do NOT retry 4xx (except 429 rate limit).

**Auth Headers:** Accept Vec of (name, value) header pairs. Redact auth values in any error messages.

## Acceptance Criteria
- [ ] fetch_spec("https://...") returns body bytes on success
- [ ] Loopback IP (127.0.0.1) is blocked with PolicyBlocked error
- [ ] Private IP (10.0.0.1) is blocked with PolicyBlocked error
- [ ] Link-local (169.254.169.254) is blocked with PolicyBlocked error
- [ ] http:// URL without --allow-insecure-http returns PolicyBlocked
- [ ] Download exceeding max_bytes aborts with InvalidSpec error
- [ ] 401/403 returns Auth error (not retried)
- [ ] 500 is retried up to retry count
- [ ] Auth header values are not included in error messages

## Files
- CREATE: src/core/http.rs (AsyncHttpClient, SSRF checks, streaming download, retries)
- MODIFY: src/core/mod.rs (pub mod http;)

## TDD Anchor
RED: Write `test_ssrf_blocks_loopback` — call the IP validation function with 127.0.0.1, assert it returns Err(PolicyBlocked).
GREEN: Implement CIDR range checking.
VERIFY: `cargo test test_ssrf_blocks`

Additional tests (use mockito for HTTP):
- test_fetch_success_https
- test_fetch_rejects_http
- test_fetch_max_bytes_abort
- test_fetch_retries_on_500
- test_fetch_no_retry_on_401
- test_auth_header_redacted_in_errors

## Edge Cases
- DNS resolution must happen BEFORE connecting — use `tokio::net::lookup_host()` or reqwest's resolve API
- DNS rebinding: a hostname might resolve to public IP initially, then private IP on redirect. Check IP at EACH hop.
- IPv6 mapped IPv4 addresses (::ffff:127.0.0.1) must also be caught
- Retry-After header may be seconds or HTTP-date — parse both formats
- connect_timeout (5s) is separate from overall timeout (10s)

## Dependency Context
Uses SwaggerCliError variants (Network, Auth, PolicyBlocked, InvalidSpec) from bd-ilo (error types and core data models).

Async HTTP client with SSRF protection, HTTPS enforcement, DNS resolution checks, streaming download with retries


Implement tags command

## Background
The tags command is simple — it lists OpenAPI tags with their endpoint counts and descriptions. Pure index-backed, fast.

## Approach
Implement src/cli/tags.rs with TagsArgs (alias only) and execute(). Load index, output tags from index.tags (already sorted and counted during index building). Robot: data.tags[] with name, description, endpoint_count. Human: formatted list with "X total" in header.

## Acceptance Criteria
- [ ] `tags petstore --robot` returns JSON with data.tags array
- [ ] Each tag has name (string), description (string|null), endpoint_count (integer)
- [ ] Tags sorted by name ASC (pre-sorted in index)
- [ ] Human output shows tag name, count, and description
- [ ] Human output shows "X total" count in header line
- [ ] Tags with no description show null in robot output, empty/omitted in human output
- [ ] Empty tags list (spec with no tags defined) returns ok:true with data.tags as empty array
- [ ] Robot meta includes standard fields (schema_version, tool_version, command, duration_ms)

## Edge Cases
- **Spec with no tags:** Return ok:true, data.tags: [], meta.total: 0. Human output: "0 total" header, no rows.
- **Tags with empty descriptions:** Tag defined in spec with `description: ""` — treat as null/empty in output (same as missing description).
- **Orphaned tags:** Tags defined at root level in the OpenAPI spec but not referenced by any operation. These should still appear in output with endpoint_count: 0 (they exist in the spec, the command reports what the spec declares).

## Files
- MODIFY: src/cli/tags.rs (TagsArgs, execute)
- MODIFY: src/output/robot.rs (add output_tags)
- MODIFY: src/output/human.rs (add output_tags)

## TDD Anchor
RED: Write `test_tags_list` — fetch petstore, run tags --robot, assert data.tags has expected tag count.
RED: Write `test_tags_empty` — use a spec with no tags, assert data.tags is empty array.
RED: Write `test_tags_no_description` — use a spec with a tag that has no description, assert description is null in robot output.
GREEN: Implement tags command.
VERIFY: `cargo test test_tags_list`

## Dependency Context
Uses SpecIndex and IndexedTag types from bd-ilo (error types and core data models). Uses CacheManager.load_index from bd-3ea (cache read path).


Build CLI skeleton with clap and output formatting framework

## Background
swagger-cli needs a CLI parser (clap) that routes to subcommands and an output framework that handles both human-readable and robot JSON formatting. The CLI skeleton defines the top-level Cli struct with global flags (--robot, --pretty, --network, --config) and the Commands enum with all 11 subcommands. The output framework provides consistent formatting for all commands.

## Approach
**CLI (src/cli/mod.rs):**
- Define `Cli` struct with `#[derive(Parser)]`: command (Commands subcommand), robot (bool, global), pretty (bool, global), network (String, global, default "auto"), config (Option<PathBuf>, global, env SWAGGER_CLI_CONFIG)
- Define `Commands` enum with all 11 variants: Fetch, List, Show, Search, Schemas, Tags, Aliases, Sync, Doctor, Cache, Diff
- Create stub modules for each command (src/cli/fetch.rs, list.rs, etc.) with empty `FetchArgs` structs and `pub async fn execute()` signatures returning `Result<(), SwaggerCliError>`
- Note: ALL execute functions use `async fn` signatures (we use tokio runtime throughout). Fetch, sync, and diff perform actual async I/O; query commands (list, show, search, etc.) are async in signature but may not await internally.

**Main (src/main.rs):**
- Pre-scan argv for `--robot` before clap parsing (handles parse errors in robot JSON)
- `#[tokio::main] async fn main()` that tries Cli::try_parse_from, routes to command execute, handles errors
- `output_robot_error()` and `output_human_error()` functions per PRD

**Output (src/output/):**
- `mod.rs`: Common traits/helpers, `RobotEnvelope` struct with ok, data, meta fields
- `robot.rs`: Generic `robot_success()` and `robot_error()` functions that build the envelope with schema_version=1, tool_version from CARGO_PKG_VERSION, command name, duration_ms
- `human.rs`: Stub formatters, TTY detection for color/unicode
- `table.rs`: Table formatting helpers using `tabled` crate

## Acceptance Criteria
- [ ] `cargo build` succeeds with the full CLI skeleton
- [ ] `swagger-cli --help` shows all 11 subcommands
- [ ] `swagger-cli --version` prints version from Cargo.toml
- [ ] `swagger-cli nonexistent --robot` outputs JSON error to stderr with code USAGE_ERROR and exits 2
- [ ] `swagger-cli fetch --robot` (missing required args) outputs JSON error to stderr
- [ ] RobotEnvelope serializes with ok, data, meta.schema_version, meta.tool_version, meta.command, meta.duration_ms
- [ ] All command stubs return `Ok(())` (do nothing yet)

## Files
- CREATE: src/cli/mod.rs (Cli, Commands, pub mods)
- CREATE: src/cli/fetch.rs, list.rs, show.rs, search.rs, schemas.rs, tags.rs, aliases.rs, sync.rs, doctor.rs, cache.rs, diff.rs (stub args + execute)
- CREATE: src/output/mod.rs, src/output/robot.rs, src/output/human.rs, src/output/table.rs
- MODIFY: src/main.rs (full entry point with error handling)
- MODIFY: src/lib.rs (pub mod cli; pub mod output;)

## TDD Anchor
RED: Write test `test_robot_error_on_bad_command` using `assert_cmd` -- run `swagger-cli nonexistent --robot`, parse stderr as JSON, assert the JSON contains code="USAGE_ERROR" and the process exits with code 2.
GREEN: Implement main.rs pre-scan and robot error output.
VERIFY: `cargo test test_robot_error_on_bad_command`

## Edge Cases
- Pre-scan argv for --robot MUST happen before clap parsing, otherwise clap's error output is plaintext even when agent expects JSON
- Global --robot flag must be accessible from all subcommands (use `#[arg(long, global = true)]`)
- duration_ms in robot envelope: use `std::time::Instant::now()` at start, elapsed at output
- BTreeMap (not HashMap) for any constructed JSON objects to ensure deterministic key ordering

## Dependency Context
Uses SwaggerCliError (exit_code, code, suggestion) from bd-ilo (error types and core data models).

CLI skeleton with 11 subcommands, robot/human output, main.rs routing


Epic: Project Foundation


Implement cache read path with integrity validation

## Background
The cache read path is used by every query command (list, search, tags, schemas, aliases, doctor). It loads index.json and meta.json, validates their integrity (generation match, hash match, index_version match), and provides the data to commands. The `show` command additionally loads raw.json. Coalesced last_accessed updates reduce write amplification for hot-read bursts.

## Approach
Implement on CacheManager:
- `load_index(alias) -> Result<(SpecIndex, CacheMetadata)>`: Read meta.json first (commit marker). If missing -> AliasNotFound or CacheIntegrity. Read index.json. Validate: meta.index_version == index.index_version, meta.generation == index.generation, meta.index_hash == sha256(index.json bytes). Mismatch -> CacheIntegrity error. Update last_accessed if stale >10min (best-effort, no lock required).
- `load_raw(alias, meta: &CacheMetadata) -> Result<serde_json::Value>`: Read raw.json, parse as Value. Validate meta.raw_hash == sha256(raw.json bytes). Return Value.
- `list_aliases() -> Result<Vec<CacheMetadata>>`: Iterate alias directories, read meta.json from each. Skip broken/partial aliases (log warning).
- `delete_alias(alias) -> Result<()>`: Remove alias directory after acquiring lock.
- `default_alias() -> Option<String>`: Load config, return default_alias.
- `alias_exists(alias) -> bool`: Check if meta.json exists in alias dir.

Coalesced last_accessed: When load_index reads meta, check if meta.last_accessed is >10min old. If so, update only the last_accessed field in meta.json (best-effort write, no lock, ignore errors).

## Acceptance Criteria
- [ ] load_index succeeds for a valid cache (all 4 files present, hashes match)
- [ ] load_index returns CacheIntegrity when generation mismatches
- [ ] load_index returns CacheIntegrity when index_hash mismatches
- [ ] load_index returns AliasNotFound when alias directory doesn't exist
- [ ] load_raw validates raw_hash and returns parsed Value
- [ ] list_aliases returns metadata for all valid aliases, skips broken ones
- [ ] delete_alias removes the entire alias directory
- [ ] last_accessed is updated at most once per 10 minutes

## Files
- MODIFY: src/core/cache.rs (add load_index, load_raw, list_aliases, delete_alias, default_alias, alias_exists, coalesced last_accessed)

## TDD Anchor
RED: Write `test_load_index_integrity_check` -- create a valid cache, then tamper with index.json (change a byte). Assert load_index returns CacheIntegrity error.
GREEN: Implement hash validation in load_index().
VERIFY: `cargo test test_load_index_integrity_check`

Additional tests:
- test_load_index_success
- test_load_index_missing_meta
- test_load_raw_validates_hash
- test_list_aliases_skips_broken
- test_coalesced_last_accessed (verify not written within 10min window)

## Edge Cases
- list_aliases must not panic if an alias directory has no meta.json -- skip with warning
- Coalesced write is best-effort: if it fails (permissions, concurrent write), silently ignore
- load_raw is only called by show and schemas --show -- never by list/search/tags
- Empty cache directory (no aliases) should return empty Vec, not error

## Dependency Context
Uses CacheManager and write_cache() from bd-1ie (cache write path). Uses CacheMetadata, SpecIndex, and SwaggerCliError from bd-ilo (error types and core data models).

Cache read with integrity validation implemented - load_index, load_raw, list_aliases, alias_exists, delete_alias with hash verification


Implement sync command with change detection and index-based diffs

## Background
The sync command checks if a remote spec has changed and re-fetches if needed. Change detection uses ETag/Last-Modified headers and content hash comparison. Index-based diffs compute added/removed/modified endpoints and schemas by comparing old vs new indexes. This is async (network calls).

## Approach
Implement src/cli/sync.rs with SyncArgs and async execute():

**Single alias sync flow:**
1. Load existing meta (get ETag, Last-Modified, content_hash)
2. Fetch remote spec with conditional headers (If-None-Match, If-Modified-Since)
3. If 304 Not Modified → report "no changes"
4. If 200: compute new content_hash, compare with stored
5. If hash matches → report "no changes" (content identical despite no 304)
6. If changed: normalize, build new index, compare old vs new index
7. Compute diff: added/removed/modified endpoints and schemas (compare by path+method / name)
8. If not --dry-run: write new cache
9. Output change summary

**SyncArgs:** alias (Option<String>), all (bool), dry_run (bool), force (bool — re-fetch regardless), details (bool — include change lists), jobs (usize, default 4), per_host (usize, default 2), resume (bool), max_failures (Option<usize>).

**--details output:** Capped at 200 items per category. Include truncated:bool flag.

## Acceptance Criteria
- [ ] Sync detects "no changes" via 304 response
- [ ] Sync detects changes via content hash mismatch
- [ ] --dry-run checks without writing
- [ ] --force re-fetches regardless
- [ ] --details includes added/removed/modified endpoint/schema lists (capped at 200)
- [ ] Robot output: changed (bool), local_version, remote_version, changes.endpoints/schemas counts
- [ ] --force with --dry-run: fetches and computes diff but doesn't write

## Edge Cases
- **304 Not Modified but content actually changed:** Server returns 304 incorrectly. Content hash comparison catches this (hash mismatch despite 304 = treat as changed).
- **Huge index diff:** If thousands of endpoints changed, --details output must respect the 200-item cap with truncated:true flag.
- **Server returns different Content-Type:** e.g., returns HTML error page instead of JSON. Format detection catches this — invalid spec error.
- **ETag/Last-Modified missing on first fetch:** meta.etag and meta.last_modified will be None. Sync without conditional headers — always downloads full content.
- **--force with --dry-run:** Fetch the content but don't write. Report what would change. This combo must be supported.

## Files
- MODIFY: src/cli/sync.rs (SyncArgs, execute, single_alias_sync, compute_index_diff)
- MODIFY: src/output/robot.rs (add output_sync)
- MODIFY: src/output/human.rs (add output_sync)

## TDD Anchor
RED: Write `test_sync_no_changes` — fetch petstore fixture locally, run sync --robot (mock server returns same content), assert changed==false.
GREEN: Implement hash-based change detection.
VERIFY: `cargo test test_sync_no_changes`

## Dependency Context
Uses AsyncHttpClient from bd-3b6 for fetching. Uses indexer from bd-189 for rebuilding. Uses CacheManager from bd-3ea/bd-1ie for read/write. Requires a fetched spec in cache.


Implement list command with index-backed filtering and sorting

## Background
The list command is the most-used query command. It loads only index.json (never raw.json) and filters/sorts endpoints by method, tag, and path regex. This is the command that must hit <50ms for cached queries, even on large specs like Stripe (312 endpoints) and GitHub (800+ endpoints).

## Approach
Implement src/cli/list.rs with ListArgs and execute():

**ListArgs:** alias (Option<String>), method (Option<String>, value_parser GET/POST/PUT/DELETE/PATCH), tag (Option<String>), path (Option<String> — regex), sort (String, default "path", values: path/method/tag), limit (usize, default 50), all (bool — show all, no limit), all_aliases (bool — cross-alias search, Phase 2 bead).

**Execute flow:**
1. Resolve alias (use default if not specified)
2. CacheManager::load_index(alias) — loads index.json + meta.json only
3. Build regex from --path (fail fast with USAGE_ERROR on invalid regex)
4. Filter endpoints: method match (case-insensitive), tag contains, path regex match
5. Sort using canonical method ranking: GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, OPTIONS=5, HEAD=6, TRACE=7
6. Apply limit (unless --all)
7. Output robot JSON or human table

**Robot output format:** Per PRD — ok:true, data.endpoints[], data.total, data.filtered, data.applied_filters, meta with alias/spec_version/cached_at/duration_ms.

**Human output:** Tabular format with METHOD PATH SUMMARY, header with API title and total count, footer with "Showing X of Y endpoints (filtered)".

## Acceptance Criteria
- [ ] `swagger-cli list petstore --robot` returns all endpoints as JSON
- [ ] --method POST filters to POST-only endpoints
- [ ] --tag pet filters to pet-tagged endpoints
- [ ] --path "store.*" filters by regex
- [ ] Invalid regex returns USAGE_ERROR (exit 2)
- [ ] Combined filters work (--method POST --tag pet)
- [ ] Default limit is 50; --all shows all
- [ ] Endpoints sorted by path ASC, method_rank ASC by default
- [ ] --sort method sorts by method rank first
- [ ] No alias + no default → CONFIG_ERROR with suggestion
- [ ] Command completes in <50ms for 500+ endpoint index

## Files
- MODIFY: src/cli/list.rs (ListArgs, execute, filter_endpoints, sort_endpoints, method_rank)
- MODIFY: src/output/robot.rs (add output_list)
- MODIFY: src/output/human.rs (add output_list)

## TDD Anchor
RED: Write `test_list_filter_by_method` — set up test cache with petstore, run list --method POST --robot, parse JSON, assert all endpoints have method==POST.
GREEN: Implement filter_endpoints with method check.
VERIFY: `cargo test test_list_filter_by_method`

## Edge Cases
- Empty result set (no matches) should return ok:true with empty endpoints array, not error
- Path regex with special chars (e.g., `/pet/{petId}` — the braces are regex special) — users must escape
- Method comparison must be case-insensitive (user passes "post", spec has "POST")

## Dependency Context
Uses CacheManager.load_index from bd-3ea (cache read). Uses SpecIndex/IndexedEndpoint types from bd-ilo. Uses CLI skeleton from bd-3d2. Requires a fetched spec in cache to work (tested via test helper that calls fetch with local fixture).


Epic: Global Features


Epic: Fetch Pipeline


Add OS keychain credential backend

## What
Implement CredentialSource::Keyring variant — resolve auth tokens from macOS Keychain or Linux Secret Service at runtime.

## Acceptance Criteria
- [ ] macOS Keychain lookup works
- [ ] Linux Secret Service lookup works
- [ ] Graceful fallback when keychain unavailable

## Files
- CREATE: src/core/keyring.rs
- MODIFY: src/core/config.rs (implement Keyring resolution)


Generate curl commands from endpoints

## What
New command: swagger-cli curl <alias> <path> [--method METHOD] that generates a ready-to-run curl command with correct URL, headers, auth, and example request body.

## Acceptance Criteria
- [ ] Generates valid curl command for endpoint
- [ ] Includes auth headers from profile
- [ ] Includes example request body from schema

## Files
- CREATE: src/cli/curl.rs


Bootstrap Rust project and directory structure

## Background
swagger-cli is a greenfield Rust CLI tool for querying OpenAPI specifications. Nothing exists yet — no Cargo.toml, no src/, no VCS. This bead creates the complete project skeleton that all other beads build on.

Note: The PRD code examples show sync patterns (blocking reqwest), but we've made a conscious decision to implement async from the start (tokio + non-blocking reqwest), following feedback-2 recommendations. The PRD code examples are stale in this regard.

## Approach
1. Run `cargo init --name swagger-cli` to create the Rust project
2. Run `jj init --git` to initialize jj version control (colocated with git)
3. Populate Cargo.toml with all dependencies (async tokio instead of blocking reqwest):
   - `reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-tls"] }`
   - `tokio = { version = "1", features = ["full"] }`
   - `serde`, `serde_json`, `serde_yaml`, `clap`, `anyhow`, `thiserror`, `toml`, `directories`, `colored`, `tabled`, `chrono`, `regex`, `sha2`, `fs2`
   - dev-deps: `assert_cmd`, `predicates`, `tempfile`, `mockito`, `criterion`, `tokio` (macros+rt)
4. Create full directory structure: src/{main.rs, lib.rs, errors.rs, utils.rs}, src/cli/, src/core/, src/output/, tests/integration/, tests/fixtures/, benches/, docs/robot-schema/v1/
5. Add .gitignore, run `cargo check`

## Acceptance Criteria
- [ ] `cargo check` succeeds with zero errors
- [ ] Cargo.toml has all dependencies with correct features (async reqwest, tokio)
- [ ] src/main.rs has `#[tokio::main] async fn main()` that exits 0
- [ ] All directories from project structure exist
- [ ] src/lib.rs declares pub mods for cli, core, output, errors, utils
- [ ] jj initialized (`.jj/` exists)
- [ ] .gitignore present

## Files
- CREATE: Cargo.toml, src/main.rs, src/lib.rs, src/errors.rs, src/utils.rs, src/cli/mod.rs, src/core/mod.rs, src/output/mod.rs, .gitignore

## TDD Anchor
RED: `cargo check` fails (no modules). GREEN: Create placeholders. VERIFY: `cargo check && echo OK`

## Edge Cases
- Use `edition = "2021"` (safer for dep compat)
- Do NOT add `[[bench]]` entries yet
- main.rs should NOT do any real work yet

Completed: project bootstrapped with edition 2024, all deps, async tokio, directory structure, jj init


Implement search engine with tokenized scoring and search command

## Background
The search engine is a core piece of swagger-cli. It provides tokenized multi-term text search across endpoint paths, summaries, descriptions, and schema names. All search is index-backed (never loads raw.json). Scores are quantized to integer basis points for cross-platform determinism. Results include ranked ordering with stable tie-breaking.

## Approach
Implement src/core/search.rs with SearchEngine struct and src/cli/search.rs with SearchArgs:

**SearchEngine:** Takes a SpecIndex reference. search() method tokenizes query (whitespace-split unless --exact), scores each endpoint and schema against terms with field weights: path=10, summary=5, description=2, schema_name=8. Coverage boost: matching more terms increases score (score *= 1.0 + coverage_ratio). Quantize: (raw_float * 100.0).round() as u32.

**Tie-breaking (deterministic):** Primary: score DESC. Secondary: type ordinal (endpoint=0 before schema=1). Tertiary: path/name ASC, method_rank ASC. Assign 1-based rank after sorting.

**Unicode-safe snippets:** safe_snippet() uses char_indices() to find char-boundary-safe positions. Context window of 50 chars before/after match. Prefix/suffix with "..." when truncated.

**SearchArgs:** alias (Option<String>), query (String, positional), case_sensitive (bool), exact (bool), in_fields (Option<String> -- comma-separated: all/paths/descriptions/schemas, invalid -> USAGE_ERROR), limit (usize, default 20), all_aliases (bool -- Phase 2 cross-alias).

**SearchOptions:** search_paths, search_descriptions, search_schemas (parsed from --in), case_sensitive, exact, limit.

## Acceptance Criteria
- [ ] search("pet status") finds endpoints with "pet" and "status" in path/summary
- [ ] Scores quantized to integer (u32), deterministic across platforms
- [ ] --exact treats query as single phrase
- [ ] --case-sensitive respects case
- [ ] --in paths only searches path field
- [ ] --in invalid_field returns USAGE_ERROR
- [ ] Results sorted by score DESC with stable tie-breaking
- [ ] Unicode-safe snippets don't panic on multi-byte chars
- [ ] Robot output: results[] with type, path/name, method, summary, rank, score, matches[]
- [ ] Search never loads raw.json (index-only)

## Files
- CREATE: src/core/search.rs (SearchEngine, SearchResult, SearchResultType, Match, SearchOptions, tokenize, safe_snippet)
- MODIFY: src/cli/search.rs (SearchArgs, execute)
- MODIFY: src/output/robot.rs (add output_search)
- MODIFY: src/output/human.rs (add output_search)

## TDD Anchor
RED: Write `test_search_basic` -- create a SpecIndex with petstore endpoints, search for "pet status", assert results include /pet/findByStatus.
GREEN: Implement SearchEngine::search().
VERIFY: `cargo test test_search_basic`

RED: Write `test_search_scores_deterministic` -- search for "pet" on petstore index, record scores, search again, assert identical scores and ordering.
GREEN: Implement quantized scoring with stable sort.
VERIFY: `cargo test test_search_scores_deterministic`

## Edge Cases
- Empty query should return empty results, not error
- Single-char queries should work (common for agents: "/" to find all paths)
- Coverage boost division by zero: if terms.len() == 0, skip boost (shouldn't happen with non-empty query)
- safe_snippet on very short text: don't add "..." if text fits entirely within context

## Dependency Context
Uses SpecIndex, IndexedEndpoint, and IndexedSchema types from bd-ilo (error types and core data models). Uses CacheManager.load_index from bd-3ea (cache read path). Uses CLI skeleton from bd-3d2.


Add YAML output format (--format yaml)

## What
Add --format yaml option to list/show/search/schemas commands. Use serde_yaml for serialization with deterministic key ordering. Create YAML golden fixtures.

## Acceptance Criteria
- [ ] --format yaml outputs valid YAML for all query commands
- [ ] YAML output is deterministic (sorted keys)
- [ ] Golden YAML fixtures exist

## Files
- MODIFY: src/output/mod.rs (add yaml output mode)
- CREATE: src/output/yaml.rs


Epic: Query Commands Phase 1


Create Dockerfile and installation script

## Background
Dockerfile for minimal Alpine-based image and install.sh for curl-based binary installation with checksum/signature verification.

## Approach

**Dockerfile (multi-stage build):**
- Builder stage: `FROM rust:1.93-alpine` as builder, `apk add musl-dev`, `COPY . .`, `cargo build --release --locked --target x86_64-unknown-linux-musl`
- Runtime stage: `FROM alpine:latest`, `apk add --no-cache ca-certificates`, `COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/swagger-cli /usr/local/bin/swagger-cli`
- Pre-create XDG dirs: `mkdir -p /root/.config/swagger-cli /root/.cache/swagger-cli/aliases`
- `ENTRYPOINT ["swagger-cli"]` (no CMD — user passes subcommands directly)

**install.sh:**
- Header: `#!/usr/bin/env bash`, `set -euo pipefail`
- Secure temp directory: `mktemp -d` with cleanup trap (`trap "rm -rf $TMPDIR" EXIT`)
- OS detection: `uname -s` → Darwin or Linux (reject others with clear error)
- Arch detection: `uname -m` → arm64/aarch64 maps to aarch64, x86_64 stays x86_64 (reject others)
- Download URL: GitLab Package Registry URL pattern, constructed from OS+arch variables
- Download: `curl -fsSL` binary + SHA256SUMS file
- Checksum verification (portable): Linux uses `sha256sum --check`, macOS uses `shasum -a 256 --check` — detect which is available
- Optional minisign verification: if `minisign` is on PATH, download `.minisig` file and verify signature; if not on PATH, print info message and skip (not an error)
- Install: `chmod +x`, move to `/usr/local/bin/` (or `~/.local/bin/` if no write access to /usr/local/bin)
- PATH check: verify install dir is on PATH, print warning if not with suggested export command

## Acceptance Criteria
- [ ] Dockerfile builds successfully with `docker build .`
- [ ] Container runs `swagger-cli --version` and exits 0
- [ ] Docker image is minimal (Alpine-based runtime, no build tools in final image)
- [ ] install.sh starts with `set -euo pipefail` and creates secure temp dir with cleanup trap
- [ ] install.sh detects OS (Darwin/Linux) and architecture (arm64/x86_64) correctly
- [ ] install.sh rejects unsupported OS/arch with clear error message
- [ ] Checksum verification works on Linux (sha256sum) and macOS (shasum -a 256)
- [ ] Checksum failure aborts install with non-zero exit
- [ ] Optional minisign verification runs when minisign is available, skips gracefully when not
- [ ] Binary installed to /usr/local/bin or ~/.local/bin with executable permissions
- [ ] PATH warning printed if install directory not on PATH

## Files
- CREATE: Dockerfile
- CREATE: install.sh

## TDD Anchor
VERIFY: `docker build -t swagger-cli-test . && docker run swagger-cli-test --version`
VERIFY: `bash -n install.sh` (syntax check)

## Edge Cases
- **musl vs glibc:** Alpine uses musl. The Dockerfile must use the musl target, not the gnu target. Mixing causes runtime failures.
- **Rootless Docker:** ENTRYPOINT should work regardless of UID. Don't assume /root/ — use $HOME or a configurable path.
- **install.sh on minimal systems:** Some minimal Docker images don't have `curl`. The script should check for curl and error with a clear message.
- **Interrupted install:** The trap ensures temp dir cleanup on any exit (EXIT, not just specific signals). Verify install.sh doesn't leave artifacts on Ctrl+C.
- **Apple Silicon detection:** `uname -m` returns "arm64" on macOS but "aarch64" on Linux. Both must map to the aarch64 binary.


Epic: Config and Cache Infrastructure


Implement error types and core data models

## Background
Every command in swagger-cli returns typed errors that map to specific exit codes and robot JSON error codes. This bead defines the complete error type system and the core data models (SpecIndex, CacheMetadata, IndexedEndpoint, etc.) that all commands operate on. These types are the backbone of the entire application.

## Approach
Implement `SwaggerCliError` enum in `src/errors.rs` with variants: Usage, CacheLocked, Network, InvalidSpec, AliasNotFound, AliasExists, Cache, CacheIntegrity, Config, Auth, Io, Json, OfflineMode, PolicyBlocked. Each variant maps to an exit code (2-16), a string error code (USAGE_ERROR, CACHE_LOCKED, etc.), and an optional suggestion string. Use `thiserror::Error` for Display derivation.

Implement core data models in `src/core/spec.rs`: SpecIndex (with index_version, generation, content_hash, openapi, info, endpoints, schemas, tags), IndexInfo, IndexedEndpoint (with path, method, summary, description, operation_id, tags, deprecated, parameters, request_body_required, request_body_content_types, security_schemes, security_required, operation_ptr), IndexedParam (name, location, required, description), IndexedSchema (name, schema_ptr), IndexedTag (name, description, endpoint_count). All derive Serialize, Deserialize, Debug, Clone.

Implement CacheMetadata in `src/core/cache.rs` types section (or a separate types file): alias, url, fetched_at, last_accessed, content_hash, raw_hash, etag, last_modified, spec_version, spec_title, endpoint_count, schema_count, raw_size_bytes, source_format, index_version, generation, index_hash. Include `is_stale()` method.

## Acceptance Criteria
- [ ] SwaggerCliError has all 14 variants with correct exit_code(), code(), and suggestion() methods
- [ ] SpecIndex and all index types compile and derive Serialize + Deserialize
- [ ] CacheMetadata compiles with all fields and is_stale() works correctly
- [ ] `cargo test --lib` passes for error mapping tests
- [ ] No use of `any` type -- all fields have concrete types with explicit Rust type annotations

## Files
- CREATE: src/errors.rs (SwaggerCliError enum, exit_code/code/suggestion impls)
- CREATE: src/core/spec.rs (SpecIndex, IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag)
- CREATE: src/core/cache.rs (CacheMetadata struct and related types — CacheManager is added later by bd-1ie)
- CREATE: src/core/mod.rs (pub mod spec; pub mod cache; pub mod config; pub mod search;)
- MODIFY: src/lib.rs (add pub mod errors; ensure pub mod core;)

## TDD Anchor
RED: Write `test_error_exit_codes` that asserts each SwaggerCliError variant returns the correct exit code (Usage->2, Network->4, InvalidSpec->5, AliasExists->6, Auth->7, AliasNotFound->8, CacheLocked->9, Cache->10, Config->11, Io->12, Json->13, CacheIntegrity->14, OfflineMode->15, PolicyBlocked->16).
GREEN: Implement all variants and methods.
VERIFY: `cargo test test_error_exit_codes`

## Edge Cases
- AliasNotFound suggestion references `swagger-cli aliases --list` (not just the alias name)
- suggestion() returns Option<String> -- some variants (Io, Json) have no suggestion
- Network variant wraps reqwest::Error via #[from] -- but since we use async reqwest, ensure the Error type is the async one

Completed: SwaggerCliError with 14 variants, SpecIndex, CacheMetadata, all tests pass


Add breaking-change classification for diff command

## What
Extend diff command with heuristic-based breaking-change classification. Removed endpoint = breaking. Removed required parameter = breaking. Added optional field = non-breaking. Changed type = breaking.

## Acceptance Criteria
- [ ] Each change classified as breaking/non-breaking/unknown
- [ ] --fail-on breaking uses classification (not just structural)
- [ ] classification field in robot output

## Files
- MODIFY: src/cli/diff.rs (add classification logic)


Epic: Query Commands Phase 2