Convert show, schemas, search, diff commands to OutputMode

## Background
After the tags proof-of-concept validates the pattern, convert the 4 simple query commands. Each follows the same pattern: load index, compute result, branch on robot bool for output.

## Approach
For each of show.rs, schemas.rs, search.rs, diff.rs:
1. Change execute() signature: replace `robot: bool` with `mode: OutputMode`
2. Impl `HumanDisplay` for the command's output struct
3. Move human formatting from the `if !robot` block into `display_human()`
4. Replace `if robot { robot_success(...) } else { ... }` with `emit(&output, mode, cmd, duration)`
5. Update main.rs dispatch to pass `mode` instead of `bool` for these commands

### Per-command details:

**show.rs** (execute at line 50: `pub async fn execute(args: &Args, robot: bool)`)
- Output struct: `ShowOutput` (line 36) with fields: `path: String`, `method: String`, `summary: Option<String>`, `description: Option<String>`, `operation_id: Option<String>`, `tags: Vec<String>`, `deprecated: bool`, `parameters: Vec<ParamOutput>`, `request_body: Option<RequestBodyOutput>`, `responses: Vec<ResponseOutput>`, `security: Vec<SecurityOutput>`
- Human output in `print_human()` (lines 198-254): Prints sections (SUMMARY, PARAMETERS, etc.) line-by-line
- Impl `HumanDisplay for ShowOutput` moving `print_human()` logic into `display_human()`, using `write!(w, ...)` instead of `println!`

**schemas.rs** (execute at line 88: `pub async fn execute(args: &Args, robot: bool)`)
- Two sub-modes: list and show (delegated via `execute_list()` and `execute_show()`)
- List output: `SchemasListOutput` (line 42) with `schemas: Vec<SchemaEntry>`, `total: usize`
- Show output: `SchemaShowOutput` (line 53) with `name: String`, `schema: serde_json::Value`
- Impl `HumanDisplay for SchemasListOutput` -- table via `render_table_or_empty`
- Impl `HumanDisplay for SchemaShowOutput` -- pretty-print JSON schema
- Both sub-functions need `mode: OutputMode` propagated from execute()

**search.rs** (execute at line 174: `pub async fn execute(args: &Args, robot_mode: bool)`)
- Has two modes: single-alias and all-aliases (line 174: `if args.all_aliases`)
- Single output: `RobotOutput` (line 50) with `results: Vec<RobotResult>`, `total: usize`
- All-aliases output: `AllAliasesRobotOutput` (line 56) with `results: Vec<AliasRobotResult>`, `total: usize`, `aliases_searched: usize`, `warnings: Vec<String>`
- NOTE: Rename `RobotOutput` -> `SearchOutput` and `AllAliasesRobotOutput` -> `AllAliasesSearchOutput` since they are no longer robot-specific
- Impl `HumanDisplay for SearchOutput` -- table with rank/score/type/name/method/summary columns
- Impl `HumanDisplay for AllAliasesSearchOutput` -- table with alias column added
- `execute_all_aliases()` (around line 250) also needs `mode: OutputMode`

**diff.rs** (execute at line 61: `pub async fn execute(args: &Args, robot_mode: bool)`)
- Output struct: `DiffOutput` (line 37) with `left: String`, `right: String`, `changes: DiffResult`
- DiffResult contains: `summary: ChangeSummary`, `details: Option<ChangeDetails>`
- Human output prints change counts and optional details table
- Impl `HumanDisplay for DiffOutput`

## Acceptance Criteria
- [ ] All 4 commands accept `OutputMode` instead of `bool`
- [ ] `HumanDisplay` implemented for: `ShowOutput`, `SchemasListOutput`, `SchemaShowOutput`, `SearchOutput` (renamed from `RobotOutput`), `AllAliasesSearchOutput` (renamed from `AllAliasesRobotOutput`), `DiffOutput`
- [ ] Zero `if robot` / `if !robot` branches in show.rs, schemas.rs, search.rs, diff.rs
- [ ] search.rs structs renamed: `RobotOutput` -> `SearchOutput`, `AllAliasesRobotOutput` -> `AllAliasesSearchOutput`
- [ ] All integration tests pass unchanged
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/show.rs (accept OutputMode, impl HumanDisplay for ShowOutput)
- MODIFY: src/cli/schemas.rs (accept OutputMode, impl HumanDisplay for SchemasListOutput + SchemaShowOutput)
- MODIFY: src/cli/search.rs (accept OutputMode, rename structs, impl HumanDisplay for SearchOutput + AllAliasesSearchOutput)
- MODIFY: src/cli/diff.rs (accept OutputMode, impl HumanDisplay for DiffOutput)
- MODIFY: src/main.rs (pass mode to these 4 commands at lines 124-127, 135)

## TDD Anchor
RED: `test_show_human_display` -- create `ShowOutput` with known data, call `display_human(&mut Vec<u8>)`, assert contains path, method, summary section
GREEN: Impl `HumanDisplay for ShowOutput`
VERIFY: `cargo test show`

Additional tests per command:
- `test_schemas_list_human_display` -- assert table contains schema names
- `test_schema_show_human_display` -- assert contains pretty-printed JSON
- `test_search_human_display` -- assert table with rank/score columns
- `test_search_all_aliases_human_display` -- assert alias column present
- `test_diff_human_display` -- assert change summary line

## Edge Cases
- schemas.rs two code paths (list vs show) need SEPARATE `HumanDisplay` impls, not one enum -- each sub-function calls emit() independently
- search.rs `RobotResult` and `AliasRobotResult` structs should also be renamed (`SearchResultEntry`, `AliasSearchResultEntry`) for consistency, but these are internal -- agent discretion
- show.rs `print_human()` function (line 198) should be removed after its logic moves to `HumanDisplay` impl
- diff.rs uses `ChangeSummary` and `ChangeDetails` from `crate::cli::sync_cmd` -- these types move to `crate::cli::sync::types` in Epic 2, but that happens AFTER this bead

## Dependency Context
Uses `OutputMode`, `HumanDisplay`, `emit()` from bd-2im. Pattern validated by bd-bq8 (tags PoC).


Clean up robot:bool remnants and delete pre_scan duplication

## Background
After all 10 command handlers are converted from robot:bool to OutputMode+HumanDisplay+emit(), there will be remnants to clean up: duplicated robot-mode resolution logic, dead code, and an output/human.rs that is still only 8 lines despite HumanDisplay impls now existing across the codebase.

## Approach

### 1. Consolidate robot-mode resolution (src/main.rs)

**Current state**: Two functions duplicate the same logic:
- `pre_scan_robot()` (lines 15-36): Runs before clap parse for error formatting. Reads raw args + env var + TTY.
- `resolve_robot_mode()` (lines 43-58): Runs after clap parse. Reads Cli fields + env var + TTY.

**Target**: Rename `resolve_robot_mode` to `resolve_output_mode` returning `OutputMode`. Keep `pre_scan_robot()` but make it call a shared inner function:

```rust
fn robot_from_env_and_tty() -> bool {
    if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) {
        return true;
    }
    !std::io::stdout().is_terminal()
}

fn pre_scan_robot() -> bool {
    let args: Vec<String> = std::env::args().collect();
    if args.iter().any(|a| a == "--no-robot") { return false; }
    if args.iter().any(|a| a == "--robot" || a == "--json") { return true; }
    robot_from_env_and_tty()
}

fn resolve_output_mode(cli: &Cli) -> OutputMode {
    if cli.no_robot { return OutputMode::Human; }
    if cli.robot { return OutputMode::Robot { pretty: cli.pretty }; }
    if robot_from_env_and_tty() {
        OutputMode::Robot { pretty: cli.pretty }
    } else {
        OutputMode::Human
    }
}
```

### 2. Move shared formatting helpers to output/human.rs

After all HumanDisplay impls are written, identify formatting functions used by 3+ impls and move them to `output/human.rs`. Based on existing patterns in the codebase, these are:

- `format_duration(duration: Duration) -> String` -- "123ms" or "1.2s" pattern used by tags, list, show, search, diff, sync. Currently each command formats inline.
- `format_header(title: &str, version: &str, count: usize, label: &str) -> String` -- "PetStore v3.0 -- 42 endpoints" pattern used by tags, list, show, schemas. Currently duplicated.
- Re-export `print_error()` (already there)

Do NOT move command-specific formatting (e.g., show's section headers, doctor's check results) -- those stay in HumanDisplay impls.

### 3. Dead code removal (cargo clippy -D warnings)

Run `cargo clippy --all-targets -- -D warnings` and fix:
- Remove unused `robot: bool` parameters from any internal helper functions
- Remove any `#[allow(dead_code)]` added during transition
- Remove old `output_no_changes()` and `output_changes()` if not already deleted in bd-2x7
- Clean up unused imports in main.rs and command files

### 4. Verify output/human.rs grew beyond 8 lines

Final output/human.rs should contain: `print_error()`, `format_duration()`, `format_header()`, and any other shared helpers. Expected ~30-50 lines.

## Acceptance Criteria
- [ ] `pre_scan_robot()` and `resolve_output_mode()` share a `robot_from_env_and_tty()` helper -- zero duplicated env/TTY logic
- [ ] `resolve_output_mode()` returns `OutputMode` (not bool)
- [ ] `output/human.rs` contains `print_error`, `format_duration`, `format_header` (3+ functions, 30+ lines)
- [ ] Zero `dead_code` warnings from `cargo clippy --all-targets -- -D warnings`
- [ ] Zero unused imports in main.rs
- [ ] All tests pass (`cargo test`)
- [ ] `cargo fmt --check` passes

## Files
- MODIFY: src/main.rs (consolidate resolution, add `robot_from_env_and_tty()`, rename `resolve_robot_mode` -> `resolve_output_mode`)
- MODIFY: src/output/human.rs (add `format_duration()`, `format_header()`)
- MODIFY: src/output/mod.rs (re-export shared helpers if needed)
- POSSIBLY MODIFY: Any command files with remaining dead code

## TDD Anchor
RED: `test_format_duration_ms` in output/human.rs tests -- `format_duration(Duration::from_millis(123))` returns `"123ms"`
GREEN: Implement `format_duration`
VERIFY: `cargo test output::human && cargo clippy --all-targets -- -D warnings`

Additional tests:
- `test_format_duration_seconds` -- `format_duration(Duration::from_millis(1234))` returns `"1.2s"`
- `test_format_header` -- `format_header("PetStore", "3.0", 42, "endpoints")` returns `"PetStore 3.0 -- 42 endpoints"`
- `test_robot_from_env_and_tty` -- unit test for shared helper

## Edge Cases
- `pre_scan_robot()` MUST remain as a separate function -- it runs before clap parse, so it cannot use `Cli` fields. The shared helper only covers env+TTY; arg scanning stays in `pre_scan_robot`.
- `format_duration` must handle Duration::ZERO gracefully (return "0ms")
- If no shared helpers emerge from the conversion (unlikely given the patterns), just add `format_duration` -- it is used by every command.

## Dependency Context
Depends on ALL command conversion beads being complete: bd-bq8 (tags), bd-2wp (query commands), bd-29k (management commands), bd-1nm (list), bd-71g (fetch), bd-2x7 (sync). After all are done, the cleanup can identify actual shared patterns.


Convert sync command to OutputMode

## Background
sync_cmd.rs (1843 lines) is the most complex command with both single-alias and batch (--all) modes. It has extensive robot/human branching in `output_no_changes()`, `output_changes()`, and `sync_all_inner()`. This is the final output sink conversion before the sync split epic.

Current execute signature (line 1073):
```rust
pub async fn execute(
    args: &Args,
    robot: bool,
    network_flag: &str,
    config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError>
```

## Approach

### 1. Change execute() to accept OutputMode
Replace `robot: bool` with `mode: OutputMode`. Propagate `mode` to:
- `sync_inner()` (~line 557)
- `sync_one_alias_inner()` (~line 396)
- `sync_all_inner()` (~line 786)
- `output_no_changes()` and `output_changes()` helper functions

### 2. Impl HumanDisplay for SyncOutput (single-alias result)
`SyncOutput` struct (line 118):
```rust
pub struct SyncOutput {
    pub alias: String,
    pub changed: bool,
    pub reason: Option<String>,     // "no changes" or "updated"
    pub local_version: Option<String>,
    pub remote_version: Option<String>,
    pub changes: Option<ChangeSummary>,
    pub details: Option<ChangeDetails>,
    pub dry_run: bool,
}
```
Move logic from `output_no_changes()` and `output_changes()` into `HumanDisplay`:
- No changes: "'{alias}' is up to date (v{version})"
- Changes: "'{alias}' updated: {added} added, {removed} removed, {modified} modified" with optional details table
- Dry-run: prefix with "[dry-run] "

### 3. Impl HumanDisplay for SyncAllOutput (batch result)
`SyncAllOutput` struct (line 152):
```rust
pub struct SyncAllOutput {
    pub total: usize,
    pub succeeded: usize,
    pub failed: usize,
    pub skipped: usize,
    pub aborted: bool,
    pub results: Vec<AliasSyncResult>,
    pub dry_run: bool,
}
```
`AliasSyncResult` struct (line 132):
```rust
pub struct AliasSyncResult {
    pub alias: String,
    pub status: String,              // "ok", "failed", "skipped"
    pub changed: bool,
    pub reason: Option<String>,
    pub error: Option<String>,
    pub local_version: Option<String>,
    pub remote_version: Option<String>,
    pub changes: Option<ChangeSummary>,
    pub duration_ms: u64,
}
```
Move batch output logic from `sync_all_inner()` (lines 1023-1059) into `HumanDisplay`:
- Per-alias status line: "[ok] alias (v1 -> v2, 3 changes) 123ms" or "[FAIL] alias: error message"
- Summary: "Sync complete: X ok, Y failed, Z skipped"
- Aborted: "(aborted early: failure budget exceeded)"

### 4. Delete output helpers
After HumanDisplay impls, delete `output_no_changes()` and `output_changes()` functions.

### 5. Replace branches with emit()
Replace all `if robot { robot_success(...) } else { output_*(...) }` with `emit(&output, mode, "sync", duration)`.

## Acceptance Criteria
- [ ] sync `execute()` accepts `OutputMode` instead of `bool`
- [ ] `HumanDisplay for SyncOutput` produces identical output to current `output_no_changes`/`output_changes`
- [ ] `HumanDisplay for SyncAllOutput` produces identical per-alias status lines and summary
- [ ] `output_no_changes()` and `output_changes()` helper functions deleted
- [ ] Zero `if robot` / `if !robot` branches in sync_cmd.rs
- [ ] Dry-run prefix "[dry-run]" appears in human output for both single and batch
- [ ] Integration tests pass (sync single, sync --all)
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/sync_cmd.rs (accept OutputMode, impl HumanDisplay, delete helpers, use emit())
- MODIFY: src/main.rs (pass mode to sync at line 130-132)

## TDD Anchor
RED: `test_sync_output_no_changes_human_display` -- create `SyncOutput { changed: false, alias: "petstore", .. }`, call `display_human`, assert contains "up to date"
GREEN: Impl `HumanDisplay for SyncOutput`
VERIFY: `cargo test sync`

Additional tests:
- `test_sync_output_changes_human_display` -- assert contains "updated" and change counts
- `test_sync_output_dry_run_prefix` -- assert starts with "[dry-run]"
- `test_sync_all_output_human_display` -- assert per-alias lines and "Sync complete" summary
- `test_sync_all_aborted_display` -- assert "(aborted early" footer

## Edge Cases
- `SyncAllOutput` human display must handle interleaved [ok]/[FAIL]/[skip] statuses per alias
- Aborted sync must print "(aborted early: failure budget exceeded)" footer
- Dry-run prefix "[dry-run]" must appear in BOTH single and batch human output
- Per-alias version display uses "?" when version is `None`
- `ChangeSummary` fields: `endpoints_added`, `endpoints_removed`, `endpoints_modified`, `schemas_added`, `schemas_removed` -- all used in human display

## Dependency Context
Uses `OutputMode`, `HumanDisplay`, `emit()` from bd-2im (output foundation). This is the last command conversion and unblocks bd-2x6 (cleanup) and the sync split epic (bd-10e, starting with bd-1re).


Convert all commands to use CommandContext

## Background
After CommandContext exists and tags is converted as proof of concept (bd-1iz), convert all remaining commands to accept `&CommandContext` instead of individual `mode`/`cache`/`timing` parameters. This eliminates ~15-20 lines of identical scaffolding per command.

## Approach
For each command handler:
1. Change `execute()` signature to accept `&CommandContext`
2. Replace `AsyncCache::new(cache_dir())` with `ctx.cache.clone()` (AsyncCache derives Clone)
3. Replace `Instant::now()` / timing with `ctx.elapsed()` for duration
4. Replace `mode` parameter with `ctx.mode`
5. For commands needing network_policy: use `ctx.network_policy`
6. For commands needing config_path: use `ctx.config_path.as_deref()`

### Per-command current signatures and changes:

**Simple query commands:**
| Command | Current Signature | Changes |
|---------|------------------|---------|
| `show.rs:50` | `execute(args: &Args, robot: bool)` | `execute(args: &Args, ctx: &CommandContext)` -- use `ctx.cache`, `ctx.mode`, `ctx.elapsed()` |
| `schemas.rs:88` | `execute(args: &Args, robot: bool)` | Same pattern. Also update `execute_list` and `execute_show` to receive cache/mode from ctx. |
| `search.rs:174` | `execute(args: &Args, robot_mode: bool)` | Same. `execute_all_aliases` uses `ctx.cache.list_aliases()` instead of creating its own. |
| `diff.rs:61` | `execute(args: &Args, robot_mode: bool)` | Same. Two load_index calls use `ctx.cache`. |
| `tags.rs:58` | Already converted in bd-1iz | No change needed. |

**List command:**
| Command | Current Signature | Changes |
|---------|------------------|---------|
| `list.rs:135` | `execute(args: &Args, robot_mode: bool)` | `execute(args: &Args, ctx: &CommandContext)`. All-aliases variant at line 307 uses `ctx.cache.list_aliases()`. |

**Management commands:**
| Command | Current Signature | Changes |
|---------|------------------|---------|
| `aliases.rs:123` | `execute(args: &Args, robot: bool)` | Same pattern. Sub-functions (`cmd_list`, etc.) receive `&ctx.cache` and `ctx.mode`. |
| `doctor.rs:344` | `execute(args: &Args, robot_mode: bool)` | Same. Uses `ctx.cache` for health checks. |
| `cache_cmd.rs:151` | `execute(args: &Args, robot: bool)` | Same. 4 sub-functions each previously created their own CacheManager -- all use `ctx.cache`. |

**Network-aware commands:**
| Command | Current Signature | Changes |
|---------|------------------|---------|
| `fetch.rs:390` | `execute(args: &Args, robot_mode: bool, network_flag: &str, config_override: Option<&Path>)` | `execute(args: &Args, ctx: &CommandContext)`. Uses `ctx.network_policy` (already resolved), `ctx.config_path.as_deref()`, `ctx.cache`. Removes `resolve_policy(network_flag)?` call. |
| `sync_cmd.rs:1073` | `execute(args: &Args, robot: bool, network_flag: &str, config_override: Option<&Path>)` | Same as fetch. |

**Sync command (robot_docs):**
| Command | Current Signature | Changes |
|---------|------------------|---------|
| `robot_docs.rs:484` | `pub fn execute(args: &Args, pretty: bool)` | `pub fn execute(args: &Args, ctx: &CommandContext)`. Uses `matches!(ctx.mode, OutputMode::Robot { pretty: true })`. Note: this is the only sync (non-async) command. `ctx.cache` is unused. |

### main.rs dispatch update:
```rust
let ctx = CommandContext::new(mode, cli.network.as_str(), cli.config.as_deref())?;
let result = match &cli.command {
    Commands::Fetch(args) => fetch::execute(args, &ctx).await,
    Commands::List(args) => list::execute(args, &ctx).await,
    Commands::Show(args) => show::execute(args, &ctx).await,
    Commands::Search(args) => search::execute(args, &ctx).await,
    Commands::Schemas(args) => schemas::execute(args, &ctx).await,
    Commands::Tags(args) => tags::execute(args, &ctx).await,
    Commands::Aliases(args) => aliases::execute(args, &ctx).await,
    Commands::Sync(args) => sync_cmd::execute(args, &ctx).await,
    Commands::Doctor(args) => doctor::execute(args, &ctx).await,
    Commands::Cache(args) => cache_cmd::execute(args, &ctx).await,
    Commands::Diff(args) => diff::execute(args, &ctx).await,
    Commands::RobotDocs(args) => robot_docs::execute(args, &ctx),
};
```

Remove `network_flag`, `config_override`, and local `robot`/`pretty` variables from main.

## Acceptance Criteria
- [ ] All 12 commands accept `&CommandContext` (not individual parameters)
- [ ] Zero `AsyncCache::new(cache_dir())` in command handlers (all use `ctx.cache`)
- [ ] Zero `Instant::now()` in command handlers (all use `ctx.elapsed()`)
- [ ] main.rs creates ONE `CommandContext` and passes `&ctx` to all commands
- [ ] fetch and sync use `ctx.network_policy` and `ctx.config_path` (no more `resolve_policy()` calls in commands)
- [ ] `robot_docs.rs` receives `&CommandContext` but only uses `ctx.mode`
- [ ] All integration tests pass
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/main.rs (single CommandContext, pass to all 12 commands)
- MODIFY: src/cli/show.rs, schemas.rs, search.rs, diff.rs (query commands)
- MODIFY: src/cli/list.rs
- MODIFY: src/cli/aliases.rs, doctor.rs, cache_cmd.rs
- MODIFY: src/cli/fetch.rs (remove network_flag/config_override params)
- MODIFY: src/cli/sync_cmd.rs or src/cli/sync/*.rs (remove network_flag/config_override params)
- MODIFY: src/cli/robot_docs.rs (accept &CommandContext, extract mode)

## TDD Anchor
No new tests needed -- this is a mechanical signature change. Existing tests verify behavior.
VERIFY: `cargo test` (all tests)
VERIFY: `cargo clippy --all-targets -- -D warnings`

## Edge Cases
- `robot_docs.rs` is the only sync (non-async) command -- it receives `&CommandContext` but `ctx.cache` is unused (it generates static documentation)
- fetch and sync previously took `network_flag: &str` and resolved policy internally via `resolve_policy()` -- now `CommandContext::new()` resolves upfront, meaning invalid network flag errors happen BEFORE command dispatch (this is better UX: fail fast)
- search.rs and list.rs all-aliases variants created their own CacheManager for iteration -- convert to use `ctx.cache.clone()` and `ctx.cache.list_aliases().await`
- `CommandContext` is not `Clone` by default (contains `Instant`) -- commands receive `&CommandContext`, sub-functions receive `&ctx.cache` or `ctx.mode`

## Dependency Context
Depends on bd-1iz (CommandContext created and tags converted as proof of concept). All Epic 1 beads must also be done (commands already accept OutputMode -- this bead changes the parameter from `mode: OutputMode` to `ctx: &CommandContext` and accesses `ctx.mode`). Also depends on bd-2x6 (cleanup complete, resolve_output_mode returns OutputMode) and bd-3aa (commands already use AsyncCache).


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)


Create AsyncCache wrapper with spawn_blocking

## Background
CacheManager uses std::fs and fs2::FileExt (flock) -- blocking I/O on the tokio async runtime. Under sync --all --jobs=4, concurrent async tasks hit blocking cache I/O, potentially starving tokio worker threads. The acquire_lock() method does a busy-wait loop with thread::sleep(50ms) polling.

## Approach
1. Add #[derive(Clone)] to CacheManager (it wraps PathBuf -- trivially cloneable)
2. Create AsyncCache struct in src/core/cache.rs:
   ```rust
   #[derive(Clone)]
   pub struct AsyncCache {
       inner: CacheManager,
   }

   impl AsyncCache {
       pub fn new(cache_dir: PathBuf) -> Self {
           Self { inner: CacheManager::new(cache_dir) }
       }

       pub fn cache_dir(&self) -> &Path {
           self.inner.cache_dir()
       }
   }
   ```
3. Add async wrappers for each public CacheManager method using spawn_blocking:
   - load_index(alias) -> Result<(SpecIndex, CacheMetadata)>
   - load_raw(alias, meta) -> Result<serde_json::Value>
   - write_cache(alias, ...) -> Result<CacheMetadata>
   - list_aliases() -> Result<Vec<CacheMetadata>>
   - ensure_dirs(alias) -> Result<()>
   - alias_dir(alias) -> PathBuf (sync -- just path computation, no I/O)
   - delete_alias_dir(alias) -> Result<()>
   Each wrapper: clones self.inner and alias string, moves into spawn_blocking closure, unwraps JoinError.
4. Handle JoinError from spawn_blocking by mapping to SwaggerCliError::Cache("task join error: ...")

## Acceptance Criteria
- CacheManager derives Clone
- AsyncCache struct exists with async wrappers for all public CacheManager methods
- Each async method uses tokio::task::spawn_blocking internally
- AsyncCache::load_index returns identical results to CacheManager::load_index
- AsyncCache::write_cache produces identical on-disk state to CacheManager::write_cache
- cargo test passes
- cargo clippy --all-targets -- -D warnings passes

## Files
- MODIFY: src/core/cache.rs (add Clone derive, add AsyncCache)

## TDD Anchor
RED: test_async_cache_load_index_matches_sync -- write spec via sync CacheManager, load via AsyncCache, assert identical SpecIndex
GREEN: Implement AsyncCache with spawn_blocking wrappers
VERIFY: cargo test cache

Additional tests:
- test_async_cache_write_then_load_roundtrip
- test_async_cache_concurrent_reads -- spawn 10 concurrent load_index tasks, all succeed
- test_async_cache_list_aliases_matches_sync

## Edge Cases
- spawn_blocking JoinError (e.g. runtime shutdown) -- map to SwaggerCliError::Cache with descriptive message
- CacheManager::write_cache has #[allow(clippy::too_many_arguments)] -- AsyncCache wrapper mirrors the same signature
- load_raw needs CacheMetadata by value (not reference) since it moves into spawn_blocking -- clone the metadata
- alias_dir() is pure computation (no I/O) -- keep it sync on AsyncCache, no spawn_blocking needed

## Dependency Context
No dependencies on other beads. Independent of Epic 1. CacheManager already exists and is unchanged.


Convert command handlers to use AsyncCache

## Background
After AsyncCache exists (bd-39e), convert all command handlers from `CacheManager::new(cache_dir())` to `AsyncCache::new(cache_dir())`. This ensures all cache I/O goes through `spawn_blocking`, preventing tokio runtime starvation under concurrent load.

## Approach
For each command handler, replace `CacheManager::new(cache_dir())` with `AsyncCache::new(cache_dir())` and add `.await` to cache method calls. All command handlers are already `async fn`, so adding `.await` is mechanical.

### Per-command changes:

**Simple query commands (load_index only):**
- `tags.rs` (line 60): `CacheManager::new(cache_dir())` -> `AsyncCache::new(cache_dir())`; `.load_index(alias).await`
- `show.rs` (line 53): Same pattern; also `.load_raw(alias, &meta).await` for raw spec access
- `schemas.rs` (line 93 region): Same; both `execute_list` and `execute_show` use `load_index`
- `search.rs`: `load_index` in single-alias path; `list_aliases` + loop of `load_index` in all-aliases path
- `diff.rs`: Two `load_index` calls (left and right alias)

**List command:**
- `list.rs` (line 157): `load_index` in single-alias; `list_aliases` + loop of `load_index` in all-aliases path

**Management commands:**
- `aliases.rs` (line 124): `list_aliases` in cmd_list; `load_index` in cmd_show; `ensure_dirs`+`alias_dir` in cmd_rename; `delete_alias` in cmd_delete
- `doctor.rs`: `list_aliases`, `load_index` per alias, `alias_dir` for disk size checks, `ensure_dirs` for repair
- `cache_cmd.rs` (4 instances at lines 151, 192, 240, 308): `list_aliases` in stats; `alias_dir().to_string()` in path (sync, no .await needed); `delete_alias` in prune; `delete_alias` in evict

**Fetch and sync:**
- `fetch.rs`: `ensure_dirs`, `write_cache`
- `sync_cmd.rs` or `sync/*.rs` (if split done): `load_index`, `load_raw`, `write_cache`, `ensure_dirs`, `list_aliases`

### Method mapping (CacheManager -> AsyncCache):
| CacheManager (sync) | AsyncCache (async) | Needs .await? |
|---------------------|-------------------|---------------|
| `.load_index(alias)` | `.load_index(alias)` | Yes |
| `.load_raw(alias, &meta)` | `.load_raw(alias, meta.clone())` | Yes (meta cloned for spawn_blocking) |
| `.write_cache(alias, ...)` | `.write_cache(alias, ...)` | Yes |
| `.list_aliases()` | `.list_aliases()` | Yes |
| `.ensure_dirs(alias)` | `.ensure_dirs(alias)` | Yes |
| `.delete_alias(alias)` | `.delete_alias(alias)` | Yes |
| `.alias_dir(alias)` | `.alias_dir(alias)` | NO (pure path computation, stays sync) |
| `.cache_dir()` | `.cache_dir()` | NO (returns &Path) |

### Import changes:
Replace `use crate::core::cache::CacheManager;` with `use crate::core::cache::AsyncCache;` in each file.

## Acceptance Criteria
- [ ] Zero `CacheManager::new()` in any command handler (only `AsyncCache::new()`)
- [ ] All `.load_index()`, `.load_raw()`, `.write_cache()`, `.list_aliases()`, `.ensure_dirs()`, `.delete_alias()` calls have `.await`
- [ ] `.alias_dir()` and `.cache_dir()` calls do NOT have `.await` (sync methods)
- [ ] Sync `CacheManager` only used in: tests, `src/core/cache.rs` internals, and within `AsyncCache` itself
- [ ] All integration tests pass
- [ ] Lock contention tests pass (`tests/lock_contention_test.rs`)
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/tags.rs, show.rs, schemas.rs, search.rs, diff.rs (query commands)
- MODIFY: src/cli/list.rs (query + all-aliases)
- MODIFY: src/cli/aliases.rs, doctor.rs, cache_cmd.rs (management)
- MODIFY: src/cli/fetch.rs (write path)
- MODIFY: src/cli/sync_cmd.rs or src/cli/sync/*.rs (read + write paths)

## TDD Anchor
No new tests needed -- existing integration tests verify correct behavior. The change is mechanical (CacheManager -> AsyncCache, add .await).
VERIFY: `cargo test` (all tests)
VERIFY: `cargo clippy --all-targets -- -D warnings`

## Edge Cases
- `cache_cmd.rs` `execute_path()` uses `cm.alias_dir(alias).to_string_lossy()` -- alias_dir is sync on AsyncCache, no .await needed
- `doctor.rs` uses CacheManager for both reading and repair -- both become AsyncCache calls
- `load_raw` takes `&CacheMetadata` on CacheManager but AsyncCache needs owned data for spawn_blocking -- AsyncCache's `load_raw` takes `meta: CacheMetadata` (cloned). Callers: `show.rs` and `sync_cmd.rs` -- add `.clone()` at call site.
- Tests in command modules may create `CacheManager` directly for unit tests -- these STAY sync (test convenience, no tokio runtime needed)
- If Epic 2 (sync split) is done first, modify `sync/single.rs` and `sync/batch.rs`. If not, modify `sync_cmd.rs`.

## Dependency Context
Uses `AsyncCache` from bd-39e (the wrapper struct with spawn_blocking methods). AsyncCache has the same public API as CacheManager but with async method signatures.


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

All 3 child beads closed: project bootstrap, error types, CLI skeleton


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.

Implemented sync command with conditional fetch (ETag/Last-Modified), content hash change detection, index-based diffing, dry-run support, force mode.


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

All 3 child beads closed: HTTP client, fetch command, spec indexer


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)


Epic: Property Tests for Search Engine and Indexer

Add 14+ proptest property tests covering search scoring monotonicity, deterministic ordering, Unicode safety, snippet bounds, format detection idempotency, normalize roundtrips, and build_index invariants. Current property tests (tests/property_test.rs) only cover 4 trivial properties (hash determinism, index ordering, JSON roundtrip, hash format).

## Child Beads (parallel, no deps between them)
1. **bd-1x1** -- Add 8 property tests for search engine (tests/property_search_test.rs)
2. **bd-2vu** -- Add 6 property tests for indexer and format detection (tests/property_indexer_test.rs)

## Independence
This epic is fully independent of all other epics. Both beads can start immediately.


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


Convert fetch command to OutputMode

## Background
fetch.rs (933 lines) is a DOCUMENTED EXCEPTION to the HumanDisplay pattern. It prints progressive output during execution ("Fetching URL...", "Format: JSON", "Normalizing...", "Building index...", "Cached as {alias}") that does not fit the produce-data-then-emit-once model.

Current execute signature (line 390):
```rust
pub async fn execute(
    args: &Args,
    robot_mode: bool,
    network_flag: &str,
    config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError>
```

Output struct (line 90):
```rust
pub struct FetchOutput {
    pub alias: String,
    pub url: Option<String>,
    pub title: String,
    pub version: String,
    pub endpoint_count: usize,
    pub schema_count: usize,
    pub cached_at: String,
    pub source_format: String,
    pub content_hash: String,
}
```

## Approach
1. Change execute() signature: replace `robot_mode: bool` with `mode: OutputMode`
2. Replace `if robot_mode` checks with `matches!(mode, OutputMode::Robot { .. })`
3. For Robot mode: keep existing `robot_success(output, "fetch", start.elapsed())` call at end (or convert to emit for consistency)
4. For Human mode: keep progressive `println!` calls inline (documented exception -- NO HumanDisplay impl)
5. Keep `network_flag` and `config_override` params (they get absorbed into CommandContext in Epic 5, not here)

This is primarily a signature change to make the interface consistent. Minimal code changes beyond the parameter type.

### Specific if-branches to update:
- Line ~430 region: `if robot_mode` for quiet progressive output -- change to `if matches!(mode, OutputMode::Robot { .. })`
- Line ~530 region: `if robot_mode` for suppressing progress messages
- Line ~580 region: `if !robot_mode` for human fetch output
- Final output: `if robot_mode { robot_success(output, ...) }` -> `if matches!(mode, OutputMode::Robot { .. }) { let pretty = matches!(mode, OutputMode::Robot { pretty: true }); if pretty { robot_success_pretty(...) } else { robot_success(...) } }`

## Acceptance Criteria
- [ ] fetch::execute() accepts `OutputMode` instead of `bool`
- [ ] Robot output unchanged (valid RobotEnvelope JSON)
- [ ] Robot pretty mode produces indented JSON (new behavior from OutputMode::Robot { pretty: true })
- [ ] Human output unchanged (progressive messages)
- [ ] No HumanDisplay impl for fetch (documented exception)
- [ ] Integration tests pass (`test_fetch_and_list`, etc.)
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/fetch.rs (accept OutputMode, replace bool checks)
- MODIFY: src/main.rs (pass mode to fetch at line 121-123)

## TDD Anchor
No new tests needed -- this is a signature change. Existing integration tests verify behavior.
VERIFY: `cargo test integration`
VERIFY: `cargo test fetch`

## Edge Cases
- fetch is async with streaming downloads -- OutputMode is only checked at output decision points, not during download
- Error output still uses `robot::robot_error()` through main.rs error handler (unchanged)
- The `--resolve-external-refs` flag adds additional output messages in human mode -- must work with both modes
- Pretty printing: fetch currently never calls `robot_success_pretty` -- with OutputMode, it can now produce pretty robot JSON via `OutputMode::Robot { pretty: true }`

## Dependency Context
Uses `OutputMode` from bd-2im (type only, not emit/HumanDisplay). This is an interface-only change. Pattern context from bd-bq8 (tags PoC).


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


Convert tags command and main.rs to OutputMode (proof of concept)

## Background
tags.rs is the simplest command handler (168 lines) with a clean if/else robot branch at line 64. Converting it first validates the OutputMode+HumanDisplay+emit() pattern before mass rollout to more complex commands.

Current execute signature (line 58):
```rust
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError>
```

Current output struct (line 22, NOT pub -- needs to become pub for emit):
```rust
struct TagsOutput {
    tags: Vec<TagEntry>,
    total: usize,
}
struct TagEntry {
    name: String,
    description: Option<String>,
    endpoint_count: usize,
}
```

Current robot/human branch (lines 64-85):
- Robot: `robot::robot_success(output, "tags", start.elapsed())`
- Human: converts to TagRow, prints header "{title} {version} -- {count} tags", then renders table

## Approach

### 1. Update main.rs
- Add `resolve_output_mode(cli: &Cli) -> OutputMode`:
  ```rust
  fn resolve_output_mode(cli: &Cli) -> OutputMode {
      if cli.no_robot { return OutputMode::Human; }
      if cli.robot { return OutputMode::Robot { pretty: cli.pretty }; }
      if std::env::var("SWAGGER_CLI_ROBOT")
          .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) {
          return OutputMode::Robot { pretty: cli.pretty };
      }
      if std::io::stdout().is_terminal() { OutputMode::Human }
      else { OutputMode::Robot { pretty: cli.pretty } }
  }
  ```
- Replace `let robot = resolve_robot_mode(&cli);` (line 114) with `let mode = resolve_output_mode(&cli);`
- Pass `mode` to tags: `tags::execute(args, mode).await`
- Keep `pre_scan_robot()` unchanged (for parse error handling)
- Temporarily coerce mode to bool for OTHER commands: `let robot = matches!(mode, OutputMode::Robot { .. });`
- Keep `let pretty = cli.pretty;` for robot_docs until it's converted

### 2. Update tags.rs
- Change signature: `pub async fn execute(args: &Args, mode: OutputMode) -> Result<(), SwaggerCliError>`
- Make `TagsOutput` and `TagEntry` pub (needed for emit's Serialize bound, and for test assertions)
- Impl `HumanDisplay for TagsOutput`:
  ```rust
  impl HumanDisplay for TagsOutput {
      fn display_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
          // Note: header with spec title/version requires CacheMetadata
          // which TagsOutput doesn't have. Options:
          // a) Add meta fields to TagsOutput
          // b) Print header before emit() call
          // Going with (a): add spec_title and spec_version to TagsOutput
          writeln!(w, "{} {} -- {} tags", self.spec_title, self.spec_version, self.total)?;
          let rows: Vec<TagRow> = self.tags.iter().map(|t| TagRow { ... }).collect();
          write!(w, "{}", render_table_or_empty(&rows, "No tags defined in this spec."))
      }
  }
  ```
- Add `spec_title: String` and `spec_version: String` fields to `TagsOutput`
- Update `build_output()` to accept &CacheMetadata and populate these fields
- Replace lines 64-85 with: `emit(&output, mode, "tags", start.elapsed());`

### 3. Import updates
- Add `use crate::output::{OutputMode, HumanDisplay, emit};` to tags.rs
- Remove `use crate::output::robot;` from tags.rs

## Acceptance Criteria
- [ ] tags::execute() accepts `OutputMode` instead of `bool`
- [ ] `HumanDisplay` implemented for `TagsOutput`, producing identical human output: "{title} {version} -- {count} tags\n{table}"
- [ ] `TagsOutput` includes `spec_title` and `spec_version` fields for human display
- [ ] main.rs has `resolve_output_mode()` returning `OutputMode`
- [ ] main.rs passes `mode` to tags, coerces to `bool` for all other commands (temporary)
- [ ] `pre_scan_robot()` unchanged (still used for parse error formatting)
- [ ] Integration test `test_tags_list` passes unchanged
- [ ] Golden test output for tags is byte-identical
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/main.rs (add `resolve_output_mode`, pass mode to tags, coerce for others)
- MODIFY: src/cli/tags.rs (accept OutputMode, add meta fields to TagsOutput, impl HumanDisplay, use emit())

## TDD Anchor
RED: `test_tags_human_display` in tags.rs tests -- create TagsOutput with spec_title="PetStore", spec_version="3.0", 2 tags, call `display_human(&mut Vec<u8>)`, assert output contains "PetStore 3.0 -- 2 tags" and table headers "NAME" and "ENDPOINTS"
GREEN: Implement HumanDisplay for TagsOutput
VERIFY: `cargo test tags`

Additional tests:
- `test_tags_empty_human_display` -- 0 tags produces "No tags defined in this spec."
- `test_resolve_output_mode_robot_flag` -- Cli with robot=true returns OutputMode::Robot
- `test_resolve_output_mode_no_robot_flag` -- Cli with no_robot=true returns OutputMode::Human

## Edge Cases
- `TagsOutput` needs spec_title/spec_version for the human header line. Without these fields, `display_human()` can't print the header. The simplest fix is adding these fields to TagsOutput and populating from CacheMetadata.
- `build_output()` signature changes from `(&SpecIndex)` to `(&SpecIndex, &CacheMetadata)` to capture title/version.
- Other commands still work with bool coercion: `let robot = matches!(mode, OutputMode::Robot { .. });` -- this is a transition bridge, removed in bd-2x6.
- `robot_docs.rs` still takes `pretty: bool` directly (line 136 in main.rs) -- unaffected by this change.

## Dependency Context
Uses `OutputMode`, `HumanDisplay`, and `emit()` from bd-2im (output foundation bead). This is the proof-of-concept that validates the pattern for all subsequent conversions.


Epic: Query Commands Phase 1

All 2 child beads closed: list and show commands


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

All 3 child beads closed: config system, cache write, cache read


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

All 3 child beads closed: search, schemas, tags commands


Create test fixtures and integration test helpers

## Background
All integration tests need test fixtures (OpenAPI spec files) and helper functions to set up hermetic test environments. This bead creates the fixtures and test infrastructure that all other test beads depend on.

## Approach
1. Create tests/fixtures/petstore.json — download the standard Petstore v3 spec (JSON format, ~50KB, 19 endpoints)
2. Create tests/fixtures/petstore.yaml — same spec in YAML format (for format normalization tests)
3. Create tests/fixtures/minimal.json — minimal valid OpenAPI 3.0 spec (3 endpoints, for fast tests)
4. Create tests/helpers/mod.rs — shared test utilities:
   - setup_test_env() → creates tempdir, sets SWAGGER_CLI_HOME, returns TestEnv struct with paths
   - fetch_fixture(env, fixture_name, alias) → runs swagger-cli fetch with local fixture file
   - run_cmd(args) → creates assert_cmd Command with SWAGGER_CLI_HOME set
   - parse_robot_json(output) → parses stdout as serde_json::Value

## Acceptance Criteria
- [ ] petstore.json is a valid OpenAPI 3.0 spec with 19+ endpoints
- [ ] petstore.yaml is equivalent YAML version
- [ ] minimal.json is valid OpenAPI 3.0 with 3 endpoints
- [ ] setup_test_env() creates isolated tempdir with SWAGGER_CLI_HOME
- [ ] fetch_fixture() successfully caches a fixture spec
- [ ] All test helpers compile and can be used from integration tests

## Files
- CREATE: tests/fixtures/petstore.json (Petstore v3 spec)
- CREATE: tests/fixtures/petstore.yaml (same in YAML)
- CREATE: tests/fixtures/minimal.json (minimal 3-endpoint spec)
- CREATE: tests/helpers/mod.rs (TestEnv, setup_test_env, fetch_fixture, run_cmd, parse_robot_json)

## TDD Anchor
RED: Write `test_fixture_is_valid_json` — parse petstore.json, assert it has "openapi" and "paths" keys.
GREEN: Create the fixture file.
VERIFY: `cargo test test_fixture_is_valid`

## Edge Cases
- Fixtures must use absolute paths (canonicalize) for file:// URLs
- petstore.json should be a real Petstore spec, not a minimal stub
- SWAGGER_CLI_HOME must be set BEFORE any command runs (use env() on assert_cmd)
- Test helpers should clean up tempdirs (use TempDir which auto-cleans on drop)

Created test fixtures (petstore.json, petstore.yaml, minimal.json) and test helpers (TestEnv, run_cmd, fetch_fixture, parse_robot_json). 13 integration tests passing.


Epic: Testing


Write integration tests for fetch and query commands

## Background
Integration tests validate the full command pipeline end-to-end: fetch → list → show → search → schemas → tags. These tests use assert_cmd to run the binary, mockito for HTTP mocking, and the test fixtures/helpers from the previous bead.

## Approach
Create tests/integration/ test files:

**fetch_test.rs:** test_fetch_success (local file), test_fetch_invalid_json, test_fetch_network_error, test_fetch_alias_exists, test_fetch_force_overwrites, test_fetch_yaml_file, test_fetch_stdin

**list_test.rs:** test_list_all_endpoints, test_list_filter_by_method, test_list_filter_by_tag, test_list_path_regex, test_list_invalid_regex_error, test_list_combined_filters, test_list_default_limit, test_list_all_flag

**show_test.rs:** test_show_endpoint_details, test_show_multiple_methods_error, test_show_expand_refs

**search_test.rs:** test_search_basic, test_search_exact, test_search_case_sensitive, test_search_in_paths_only, test_search_invalid_field_error

**schemas_test.rs:** test_schemas_list, test_schemas_show, test_schemas_name_filter

**aliases_test.rs:** test_aliases_list, test_aliases_rename, test_aliases_delete, test_aliases_set_default

## Acceptance Criteria
- [ ] All fetch tests pass (success, errors, force, YAML, stdin)
- [ ] All list tests pass (filters, sorting, limits)
- [ ] All show tests pass (details, ref expansion, method disambiguation)
- [ ] All search tests pass (basic, exact, case-sensitive, field scoping)
- [ ] Schema and alias tests pass
- [ ] All tests are hermetic (SWAGGER_CLI_HOME in tempdir)
- [ ] No real network calls (local fixtures or mockito)

## Edge Cases
- **Test isolation:** Each test MUST use its own tempdir for SWAGGER_CLI_HOME. Tests running in parallel must not share cache state.
- **CI binary not built:** Tests use assert_cmd which builds the binary. Ensure Cargo.toml has the right [[bin]] target.
- **Mockito port conflicts:** Each test needing a mock server should use mockito::Server::new() which picks a random port, not a hardcoded port.
- **Fixture file paths:** Use canonicalized absolute paths for local file fetch tests. Relative paths may break depending on test runner CWD.

## Files
- CREATE: tests/integration/fetch_test.rs
- CREATE: tests/integration/list_test.rs
- CREATE: tests/integration/show_test.rs
- CREATE: tests/integration/search_test.rs
- CREATE: tests/integration/schemas_test.rs
- CREATE: tests/integration/aliases_test.rs

## TDD Anchor
Write all tests listed above. Many will already exist as unit tests in individual command beads — this bead consolidates them as proper integration tests using assert_cmd binary invocation.
VERIFY: `cargo test --test '*'`

## Dependency Context
Uses test fixtures and helpers from bd-lx6 (Create test fixtures and integration test helpers). Tests the commands implemented in all query/management beads.

252 tests passing: 36 integration tests, 14 golden tests, 11 index invariant tests, 191 unit tests


Implement schemas command with list and show modes

## Background
The schemas command lets users browse and inspect OpenAPI component schemas. Listing is index-backed (fast). Showing a specific schema loads raw.json via schema_ptr (same pattern as show command). Supports --name regex filter and --expand-refs with cycle detection.

## Approach
Implement src/cli/schemas.rs with SchemasArgs and execute():

**SchemasArgs:** alias (Option<String>), name (Option<String> — regex filter, invalid → USAGE_ERROR), list (bool, default action), show (Option<String> — schema name), expand_refs (bool), max_depth (u32, default 3).

**List mode (default):** Load index, filter schemas by --name regex, output names. Robot: data.schemas[] with name. Human: bulleted list with title.

**Show mode (--show Name):** Find schema in index by exact name match. Load raw via schema_ptr. If --expand-refs, use the same ref expansion from show command (src/core/refs.rs). Output full schema JSON.

## Acceptance Criteria
- [ ] `schemas petstore` lists all schema names (sorted by name)
- [ ] `schemas petstore --name ".*Pet.*"` filters by regex
- [ ] Invalid --name regex returns USAGE_ERROR
- [ ] `schemas petstore --show Pet --robot` returns full Pet schema JSON
- [ ] --expand-refs works on schema details
- [ ] Schema not found returns clear error
- [ ] List mode never loads raw.json
- [ ] Show mode validates raw_hash

## Edge Cases
- **Empty schemas (spec with no components/schemas):** Return ok:true with data.schemas as empty array.
- **Schema name with special characters:** Some schema names contain dots or hyphens (e.g., "Pet.Response"). Regex --name filter must handle these.
- **--show with non-existent schema name:** Return clear error with suggestion listing available schema names.
- **schema_ptr doesn't resolve in raw.json:** Return CacheIntegrity error (corrupted index). Should not happen if index was built correctly, but defend against it.

## Files
- MODIFY: src/cli/schemas.rs (SchemasArgs, execute)
- MODIFY: src/output/robot.rs (add output_schemas_list, output_schemas_show)
- MODIFY: src/output/human.rs (add output_schemas_list, output_schemas_show)

## TDD Anchor
RED: Write `test_schemas_list` — fetch petstore, run schemas --robot, assert data.schemas is array with correct count.
GREEN: Implement list mode.
VERIFY: `cargo test test_schemas_list`

## Dependency Context
Uses ref expansion from bd-1dj (show command, src/core/refs.rs). Uses CacheManager from bd-3ea.

Implemented schemas command with list/show modes, regex filtering, ref expansion with cycle detection, robot/human output.