apcore-cli 0.10.0

Command-line interface for apcore modules
# Task: cli-integration

**ID**: cli-integration
**Status**: pending
**Type**: RED-GREEN-REFACTOR
**Estimate**: ~45 min
**Depends on**: tty-prompt-timeout

---

## Objective

Wire `check_approval` into the CLI execution path so that any module with `annotations.requires_approval: true` passes through the approval gate before execution. Map all `ApprovalError` variants to exit code 46 (`EXIT_APPROVAL_DENIED`).

This task also updates the public API surface in `src/lib.rs` to reflect the removal of `ApprovalTimeoutError` and the new `check_approval` signature.

---

## RED — Write failing tests first

Integration tests live in `tests/approval_integration.rs`. They test the end-to-end flow: approval gate → exit code mapping.

```rust
// tests/approval_integration.rs

use apcore_cli::{ApprovalError, EXIT_APPROVAL_DENIED};
use serde_json::json;

/// Helper: map ApprovalError to exit code (mirrors main.rs logic).
fn exit_code_for(e: &ApprovalError) -> i32 {
    match e {
        ApprovalError::Denied { .. }
        | ApprovalError::NonInteractive { .. }
        | ApprovalError::Timeout { .. } => EXIT_APPROVAL_DENIED,
    }
}

#[tokio::test]
async fn all_approval_errors_map_to_exit_46() {
    let denied = ApprovalError::Denied { module_id: "m".into() };
    let non_interactive = ApprovalError::NonInteractive { module_id: "m".into() };
    let timeout = ApprovalError::Timeout { module_id: "m".into(), seconds: 60 };

    assert_eq!(exit_code_for(&denied), 46);
    assert_eq!(exit_code_for(&non_interactive), 46);
    assert_eq!(exit_code_for(&timeout), 46);
    assert_eq!(EXIT_APPROVAL_DENIED, 46);
}

#[tokio::test]
async fn module_without_requires_approval_skips_gate() {
    // No annotations field → gate must return Ok immediately.
    let module_def = json!({"module_id": "open-module"});
    let result = apcore_cli::approval::check_approval(&module_def, false).await;
    assert!(result.is_ok());
}

#[tokio::test]
async fn module_with_requires_approval_false_skips_gate() {
    let module_def = json!({
        "module_id": "open-module",
        "annotations": {"requires_approval": false}
    });
    let result = apcore_cli::approval::check_approval(&module_def, false).await;
    assert!(result.is_ok());
}

#[tokio::test]
async fn module_with_auto_approve_true_skips_gate() {
    let module_def = json!({
        "module_id": "guarded-module",
        "annotations": {"requires_approval": true}
    });
    let result = apcore_cli::approval::check_approval(&module_def, true).await;
    assert!(result.is_ok());
}

#[tokio::test]
async fn module_non_tty_no_bypass_returns_approval_error() {
    // Uses the internal check_approval_with_tty for non-TTY simulation.
    // In the integration context, we verify via the public check_approval
    // but cannot force is_terminal()=false portably here.
    // This test validates the error type returned when running in CI (non-TTY).
    //
    // Note: this test is environment-sensitive. In a real TTY environment it
    // will invoke the interactive prompt. Run with CI=true or in a pipe to
    // guarantee non-TTY behavior:
    //   echo "" | cargo test approval_integration
    //
    // This test is skipped if stdin is a TTY.
    use std::io::IsTerminal;
    if std::io::stdin().is_terminal() {
        eprintln!("Skipping non-TTY integration test (stdin is a TTY).");
        return;
    }
    let module_def = json!({
        "module_id": "guarded-module",
        "annotations": {"requires_approval": true}
    });
    let result = apcore_cli::approval::check_approval(&module_def, false).await;
    assert!(matches!(result, Err(ApprovalError::NonInteractive { .. })));
}
```

Run: `cargo test --test approval_integration` — tests will fail (module visibility or missing public `check_approval`).

---

## GREEN — Implement

### 1. Confirm public API in `src/lib.rs`

The `pub use` line must export `check_approval` and `ApprovalError` but NOT `ApprovalTimeoutError` (removed in `error-types` task):

```rust
pub use approval::{check_approval, ApprovalError};
```

### 2. Make `check_approval` pub in `src/approval.rs`

Already declared `pub` in the existing stub. Confirm the signature matches:

```rust
pub async fn check_approval(
    module_def: &serde_json::Value,
    auto_approve: bool,
) -> Result<(), ApprovalError>
```

### 3. Wire into `main.rs` / CLI dispatcher

Locate where module execution is triggered (likely in `src/cli.rs` exec callback or `src/main.rs`). Add the approval check before calling execute:

```rust
// In the exec callback, after collecting module_def and auto_approve flag:
if let Err(e) = check_approval(&module_def, auto_approve).await {
    // All ApprovalError variants → exit 46
    eprintln!("{}", e);  // thiserror Display message
    std::process::exit(EXIT_APPROVAL_DENIED);
}
```

The exact insertion point depends on `src/cli.rs` structure. Find the `build_module_command` exec closure and add the `check_approval` call at the top of module execution, before the actual module invoke.

### 4. Update `src/lib.rs` re-export

Remove `ApprovalTimeoutError` from the `pub use` line. Final line:

```rust
pub use approval::{check_approval, ApprovalError};
```

Run: `cargo test --test approval_integration` — all integration tests must pass.
Run: `cargo test --lib approval` — all unit tests must still pass.

---

## REFACTOR

- Confirm `eprintln!("{}", e)` uses the `thiserror` `Display` impl, not `Debug`.
- Confirm `EXIT_APPROVAL_DENIED` is used (not the literal `46`) for the `std::process::exit` call.
- Run `cargo clippy -- -D warnings` — zero warnings.

---

## Verification

```
cargo test --lib approval
cargo test --test approval_integration
cargo build
cargo clippy -- -D warnings
```

Full acceptance criteria checklist (from plan.md) must all be green.

---

## Files Modified

- `src/approval.rs` — confirm public signature
- `src/lib.rs` — update `pub use approval::{...}` (remove `ApprovalTimeoutError`)
- `src/main.rs` or `src/cli.rs` — wire `check_approval` into module exec path
- `tests/approval_integration.rs` — new integration test file