diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 732cd0d..327b5e8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -8,17 +8,4 @@ # See: https://github.com/RustSec/rustsec/tree/main/cargo-audit [advisories] -# `ailoop-core` currently depends on `reqwest 0.11` (rustls 0.21), which pulls -# `rustls-webpki 0.101.x`. RustSec advisories RUSTSEC-2026-0098/0099/0104 are -# patched in `rustls-webpki >=0.103.12` and do not have a compatible fix for -# the 0.101.x line. `cli-framework` does not use the `reqwest`-based code paths -# in `ailoop-core` (it uses WebSocket HITL calls), but the crate is still in the -# dependency graph. -# -# Track removal of these ignores in issue #51 by upgrading `ailoop-core` to a -# revision which no longer pulls `rustls-webpki 0.101.x`. -ignore = [ - "RUSTSEC-2026-0098", - "RUSTSEC-2026-0099", - "RUSTSEC-2026-0104", -] +ignore = [] diff --git a/AGENTS.md b/AGENTS.md index f9cdf96..8846b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Entries use [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) normative keywords. -CFW-0001 - Automated or human-assisted changes MUST keep this repository scoped to [**cli-framework**](https://github.com/aroff/cli-framework): a Rust library for CLI apps (command registry, optional `ask` / LLM, plugins, ailoop, security helpers). Agents MUST NOT treat this project as an unrelated codebase (for example generic TUI or widget frameworks). +CFW-0001 - Automated or human-assisted changes MUST keep this repository scoped to [**cli-framework**](https://github.com/aroff/cli-framework): a Rust library for CLI apps (command registry, optional `ask` / LLM, plugins, security helpers). Agents MUST NOT treat this project as an unrelated codebase (for example generic TUI or widget frameworks). CFW-0002 - Work that affects builds, merges, or shared workflow MUST align with [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index ca37dd6..f90f69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +### Breaking + +- The ailoop integration is removed, together with the mandatory + `ailoop-core` git dependency, whose upstream repository is no longer + publicly fetchable (every consumer build failed to resolve it). Removed: + the `cli_framework::ailoop` module (`AiloopClient`, `AiloopConfig`, + `AiloopContext`, `normalize_ws_url`), `AppBuilder::with_ailoop_channel`, + `AppBuilder::with_ailoop_config`, `App::ailoop_client`, + `ChatToolCallOptions::ailoop_client`, `ConfirmationMode::Ailoop`, the + `with_ailoop` example and the `AILOOP_CHANNEL` / `AILOOP_SERVER` + environment variables. + +- `RiskEnforcer::enforce_preflight` drops its fourth `ailoop_available` + argument. A Sensitive command now proceeds when the terminal is interactive + or `--yes` is given; a Destructive command needs + `ALLOW_DESTRUCTIVE_COMMANDS=1` and an interactive terminal. Callers remove + the last argument. + +### Removed + +- The `.cargo/audit.toml` ignores for RUSTSEC-2026-0098/0099/0104: they + existed only for the `rustls-webpki 0.101` that `ailoop-core` pulled in + (#51). + ## [0.6.0] — 2026-10-21 ### Breaking diff --git a/CONTEXT.md b/CONTEXT.md index 5c14e2a..baf8e4d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,7 +2,7 @@ Domain glossary for the `cli-framework` Rust library: a CLI application framework with a central command registry, optional LLM-assisted resolution -(`ask`, `chat`), ailoop-backed human-in-the-loop prompts, plugin loading, +(`ask`, `chat`), interactive confirmation for risky commands, plugin loading, optional MCP exposure, and optional authentication. This file is a glossary, not a spec. Implementation details belong in @@ -65,9 +65,8 @@ Risk gate or trigger a Confirmation; operators must wire an MCP tool gate explicitly if they want allowlisting or confirmation for MCP. **Confirmation**: -A single y/N-style **HITL** interaction requested before Dispatch when the -Risk gate requires one. Not specific to risk — any command may request one -ad-hoc via `AiloopClient`. +A single y/N-style **HITL** interaction on the interactive terminal, +requested before Dispatch when the Risk gate requires one. _Avoid_: prompt, approval (overloaded). **CommandSpec**: @@ -129,14 +128,12 @@ One deviation worth noting: `opt_registry` / `opt_global_args` return a borrowed `Option<&T>`, but `opt_token_provider` returns an owned `Option>` — the cheap `Arc` clone lets a handler move the provider into a client that outlives the `&mut dyn AppContext` borrow. -_Avoid_ extension traits on the wrapper (e.g. `AiloopContext`) as the handler -access path: they are unreachable from `&dyn AppContext` (a handler must build -its own client, as the `ailoop` example does). +_Avoid_ extension traits on the wrapper as the handler access path: they are +unreachable from `&dyn AppContext`. **DispatchEnv**: The **framework-internal** struct (`src/app/dispatch.rs`) carrying services -the framework owns during a dispatch: the Command registry, ailoop client, -stdout capture, the **TokenProvider** when wired via +the framework owns during a dispatch: the Command registry, stdout capture, the **TokenProvider** when wired via `AppBuilder::with_token_provider` (`auth` feature), etc. The provider lives here, not in user `AppContext` state; the wrapper surfaces it through `opt_token_provider`, exactly as it surfaces the registry through @@ -147,10 +144,6 @@ code. _Avoid_ stuffing framework-owned services into user `AppContext`, or user state into `DispatchEnv`. -**AiloopContext**: -A narrow trait the wrapper implements to hand the ailoop client to code -that needs HITL. Conceptually a slice of `DispatchEnv`, not of `AppContext`. - **Plugin**: A third-party bundle of declarative command metadata loaded from a **Plugin manifest**. Today plugins are **metadata-only** — registering a @@ -191,10 +184,10 @@ long-term replacement for the Ask LLM stack. > "LLM" alone is ambiguous in this repo — always qualify which stack. **HITL** (human-in-the-loop): -Umbrella term for any user interaction routed through the paired -`ailoop serve` process (Confirmation, questions, notifications, -authorization). The framework has no stdin fallback — ailoop is the HITL -channel. +Umbrella term for a user interaction during a command. The framework's +only built-in HITL is the **Confirmation** prompt on an interactive +terminal; in a non-interactive context `--yes` answers Sensitive +confirmations and Destructive commands are blocked. **auth feature** _(ADR 0069; shipped in 0.5.8)_: The Cargo feature flag in `cli-framework` that enables the **TokenProvider** @@ -520,8 +513,7 @@ _Avoid_: "bootstrap script", "installer" for the binary's own commands. The one line a **Self-install**ed application may print after a successful command saying a newer release exists. Opt-in per application and passive: it never updates anything, and only the person running `self update` does. -_Avoid_: "auto-update" (nothing updates by itself), "notification" (taken by -ailoop). +_Avoid_: "auto-update" (nothing updates by itself). **Telemetry level** _(spec 025)_: How much telemetry leaves the process: `off`, `usage` (what was used — @@ -706,7 +698,7 @@ _Avoid_: "event log", "audit trail" as the canonical term, "event store". > pair like any other Resolution. But before Dispatch, the Risk gate looks > up `deploy` in the Risk policy — `deployment` is Destructive by default, > so the gate blocks unless `ALLOW_DESTRUCTIVE_COMMANDS=1`, and even then -> it requires a Confirmation routed through ailoop (HITL). Only then does +> it requires a Confirmation on an interactive terminal (HITL). Only then does > Dispatch invoke `execute`." > > **Dev:** "And if the same command is called through MCP?" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 389313f..e7c51aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,6 @@ chmod +x .git/hooks/pre-commit | `command_surface`, `command_surface::tool_bridge` | Command→tool schemas + shared tool invocation bridge (chat / MCP) | | `parser`, `spec` | argv → args; `CommandPath`, `CommandSpec` | | `plugin` | registry TOML / manifests | -| `ailoop` | ailoop-core client | | `security` | output sanitize, command risk policy, handle-validated private-file creation/open/atomic replacement | | `http_retry`, `retry` | HTTP retry, `secure_reqwest_client` | | `cli_output`, `cli_mode`, `message`, `environment` | help, environment-variable registry, tables, JSON, modes | @@ -58,7 +57,7 @@ Also: `auth` (feature `auth`) — `TokenProvider` trait, `AccessToken`, `AuthErr **Flow:** `AppBuilder` registers commands, groups, help ordering, and built-ins into one registry → `run` resolves id + `CommandArgs` → `await` `execute` on `AppContext`. Help, completion, command-spec export, chat, and MCP read the same frozen registry. Tool surfaces (chat / MCP) adapt inputs into `command_surface::tool_bridge` for shared parsing/validation/gating/dispatch. -**Externals (summary):** `Cargo.toml` — e.g. `tokio`, `reqwest`, `clap`, `serde`, `ailoop-core`, `aikit-agent`; optional `comfy-table`, `indicatif`. +**Externals (summary):** `Cargo.toml` — e.g. `tokio`, `reqwest`, `clap`, `serde`, `aikit-agent`; optional `comfy-table`, `indicatif`. **Security (summary):** sanitize untrusted terminal output; command risk tier policy; plugin paths rooted (no traversal). diff --git a/Cargo.toml b/Cargo.toml index c279074..a4bb587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,6 @@ toml = "1" dirs = "5.0" uuid.workspace = true crossterm = "0.28" -ailoop-core = { git = "https://github.com/goailoop/ailoop.git", rev = "c9b70bcc0bd659823dc8ddf71d8571a8e011113b", default-features = false } # Optional dependencies for CLI output utilities comfy-table = { version = "7.0", optional = true } @@ -385,10 +384,6 @@ required-features = ["chat"] name = "with_plugins" path = "skill/examples/with_plugins/src/main.rs" -[[example]] -name = "with_ailoop" -path = "skill/examples/with_ailoop/src/main.rs" - [[example]] name = "with_auth" path = "skill/examples/with_auth/src/main.rs" @@ -417,10 +412,6 @@ path = "tests/integration/tls_native_roots.rs" name = "integration_app_builder" path = "tests/integration/app_builder.rs" -[[test]] -name = "integration_ailoop_websocket" -path = "tests/integration/ailoop_websocket.rs" - [[test]] name = "integration_chat_loop" path = "tests/integration/chat_loop.rs" diff --git a/README.md b/README.md index de1c507..0181b58 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@ [![Repository](https://img.shields.io/badge/GitHub-aroff%2Fcli--framework-informational)](https://github.com/aroff/cli-framework) -A Rust library for building CLIs with optional AI-assisted command resolution (**chat**), a plugin registry, ailoop-backed human-in-the-loop prompts, structured command metadata, and async-first dispatch on Tokio. +A Rust library for building CLIs with optional AI-assisted command resolution (**chat**), a plugin registry, interactive confirmation for risky commands, structured command metadata, and async-first dispatch on Tokio. ## Features - **Chat**: Multi-turn agentic command resolution via `aikit-agent` (default feature) - **Plugins**: Manifest-driven third-party commands with path validation -- **Human-in-the-loop**: ailoop-core for confirmations - **Command registry**: Central registration, optional typed `CommandSpec`, and grouping metadata - **CLI output helpers**: Tables, JSON, progress (behind Cargo features where applicable) - **Security defaults**: Output sanitization, risk tiers, hardened HTTP helpers, and handle-validated user-private files on Unix and Windows @@ -817,8 +816,6 @@ fn main() { | `with_self_install(options)` | Register the `self` group and the `install.*` doctor checks for `EndUser` apps, and the update notice when `update_notice(true)`; invalid options (including a malformed `public_key`) fail `build()` (requires `self-install` feature) | disabled | | `with_help_section_order(sections)` | Set categorized root-help section order | alphabetical, then `Other` | | `suggest_corrections(bool)` | Enable or disable `"Did you mean?"` suggestions for unknown subcommands and flags (E001, E002, E012). When `true`, the `hint:` line shows the closest match clap identified; when `false`, the generic `"Use --help"` hint is always used. | `true` | -| `with_ailoop_channel(channel)` | Configure the ailoop channel name for HITL interactions | — | -| `with_ailoop_config(config)` | Configure ailoop with a full `AiloopConfig` | — | | `with_risk_policy(policy)` | Override the default command risk tier policy | — | | `with_token_provider(provider)` | Supply a `TokenProvider`; auto-registers four `auth` commands (requires `auth` feature) | disabled | | `with_deployment(deployment)` | Declare whether this app runs on someone's machine (`EndUser`) or on a server (`Service`); selects the default telemetry level, the end-user clamp, and whether the `telemetry` command group is registered (requires `telemetry` feature) | `EndUser` | @@ -966,40 +963,6 @@ manifest_path = "/path/to/plugin.json" enabled = true ``` -### ailoop Integration - -ailoop-core provides human-in-the-loop (HITL) interactions for commands that need confirmation, authorization, or notification: all interactions are routed over WebSocket to an `ailoop serve` process. There is no fallback to stdin. - -**Pairing requirement:** Start `ailoop serve` before using HITL methods: - -```bash -export AILOOP_SERVER=ws://localhost:8080 -ailoop serve --port 8080 -``` - -Configure via `AppBuilder::with_ailoop_channel()` or `AppBuilder::with_ailoop_config()`: - -```rust -let mut builder = AppBuilder::new() - .with_ailoop_channel("my-app-channel"); -``` - -Use `cli_framework::ailoop::AiloopClient` inside a command closure for ad-hoc HITL calls: - -```rust -use cli_framework::ailoop::AiloopClient; -// Inside execute: -let ailoop = AiloopClient::new()?; -let confirmed = ailoop - .request_confirmation("Delete all user data?", Some("This action cannot be undone")) - .await?; -if confirmed { - println!("Deleting..."); -} -``` - -See `skill/examples/with_ailoop` for a full program. - ## Examples Run the included examples to see the framework in action: @@ -1007,7 +970,6 @@ Run the included examples to see the framework in action: - `cargo run --example basic_cli` — Minimal CLI application with commands - `cargo run --example with_chat` — CLI with AI-backed natural language (`chat`) - `cargo run --example with_plugins` — CLI with registry-based plugins -- `cargo run --example with_ailoop` — ailoop confirmations and prompts - `cargo run --example http_retry_demo` — `http_retry` and secure client defaults - `cargo run -p cli-framework-oidc --example keycloak_cli --features client` — real OIDC/Keycloak login (env-configured `OidcClient`, `auth` commands, `whoami` via userinfo) @@ -1026,8 +988,8 @@ The `chat` command classifies every AI-resolved command into one of three risk t | Tier | Default categories | Behavior | |---|---|---| | `Safe` | All others | Proceeds normally | -| `Sensitive` | `data`, `config` | Requires interactive confirmation; ailoop acts as the interactive channel (no TTY needed when ailoop configured) | -| `Destructive` | `deployment`, `admin`, `destructive` | Blocked unless `ALLOW_DESTRUCTIVE_COMMANDS=1`; when set, requires TTY or ailoop for confirmation | +| `Sensitive` | `data`, `config` | Requires interactive confirmation, or `--yes` in a non-interactive context | +| `Destructive` | `deployment`, `admin`, `destructive` | Blocked unless `ALLOW_DESTRUCTIVE_COMMANDS=1`; when set, still requires an interactive terminal for confirmation (`--yes` does not bypass it) | Configure a custom policy via `AppBuilder::with_risk_policy()`: @@ -1044,7 +1006,7 @@ let app = AppBuilder::new() ### `ALLOW_DESTRUCTIVE_COMMANDS` Environment Variable -Setting `ALLOW_DESTRUCTIVE_COMMANDS=1` permits destructive-tier commands to proceed when combined with interactive confirmation. When ailoop is configured, the ailoop channel acts as the interactive channel (no TTY required). Without ailoop, an interactive terminal is always required. +Setting `ALLOW_DESTRUCTIVE_COMMANDS=1` permits destructive-tier commands to proceed when combined with interactive confirmation. An interactive terminal is always required. ### Plugin path confinement @@ -1092,13 +1054,6 @@ with different descriptions fails registration or build. | `AIKIT_LLM_URL` | OpenAI-compatible endpoint URL | | `AIKIT_MODEL` | Model name override | -### ailoop Configuration - -| Variable | Role | -|---------|------| -| `AILOOP_CHANNEL` | Channel name (default: `"cli-framework"`) | -| `AILOOP_SERVER` | WebSocket URL of the paired `ailoop serve` process (default: `ws://localhost:8080`); `http://` and `https://` URLs are normalized to `ws://`/`wss://` automatically | - ### Telemetry Configuration (`telemetry` feature) Read by the framework as the environment layer of the telemetry resolution. diff --git a/skill/SKILL.md b/skill/SKILL.md index ac31237..03ed5ee 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -2,25 +2,25 @@ name: tools-cli-framework description: >- Guides building and refactoring Rust CLIs with the cli-framework crate (tokio/async - dispatch, Arc command handlers, CommandSpec, plugins, ailoop, chat, OpenTelemetry). + dispatch, Arc command handlers, CommandSpec, plugins, chat, OpenTelemetry). Use when the user or codebase mentions cli-framework, AppBuilder, Command registration, natural language chat, plugin registry, operational command layout for Rust binaries, or telemetry/tracing/metrics/OTLP export on a cli-framework-based service. license: Apache-2.0 metadata: - version: "0.4.0" + version: "0.5.0" --- # cli-framework integration -Implement CLIs using [`cli-framework`](https://github.com/aroff/cli-framework): `AppBuilder`, `Command` (with `Arc` async executors), optional `chat`, plugins, and ailoop. Prefer repository docs over duplicating them here. +Implement CLIs using [`cli-framework`](https://github.com/aroff/cli-framework): `AppBuilder`, `Command` (with `Arc` async executors), optional `chat`, and plugins. Prefer repository docs over duplicating them here. ## 1. When to use Load this skill when: - Building a new or refactored Rust CLI on the cli-framework stack - Registering command trees, filling `CommandSpec` metadata -- Implementing risk-aware `chat` flows, plugin manifests, ailoop confirmations, or HTTP retry usage +- Implementing risk-aware `chat` flows, plugin manifests, or HTTP retry usage - The codebase imports `cli_framework`, references `AppBuilder`, or uses `register_command` ## 2. Dependency baseline @@ -252,23 +252,9 @@ Set `ALLOW_DESTRUCTIVE_COMMANDS=1` in controlled environments to permit destruct See `skill/references/cli-creation-scenarios.md` for domain-specific layouts. -## 11. Plugins and ailoop +## 11. Plugins -Plugin manifests are TOML files loaded by `PluginRegistryManager`. Each plugin declares commands that are registered alongside built-ins. See `skill/references/plugins-and-ailoop.md`. - -ailoop confirmation pattern: - -```rust -use cli_framework::ailoop::AiloopClient; - -let ailoop = AiloopClient::new()?; -let confirmed = ailoop - .request_confirmation("Delete all user data?", Some("This action cannot be undone")) - .await?; -if confirmed { /* proceed */ } -``` - -Enable via `AppBuilder::with_ailoop_channel`. See `skill/examples/with_ailoop` and `skill/examples/with_plugins`. +Plugin manifests are TOML files loaded by `PluginRegistryManager`. Each plugin declares commands that are registered alongside built-ins. See `skill/references/plugins.md` and `skill/examples/with_plugins`. ## 12. MCP auto-serve mode @@ -490,7 +476,7 @@ Full detail — Keycloak client config, all three flows, cache file schema, runt | `skill/references/features-and-cargo-flags.md` | All features + `cli-framework-oidc`, defaults, TOML snippets | | `skill/references/auth-and-oidc.md` | `TokenProvider`, `OidcClient`, Keycloak setup, cache schema, `AuthenticatedHttpClient`, JWT server layer, testing | | `skill/references/mcp-streamable-http.md` | Full MCP reference: flags, error codes, concurrency | -| `skill/references/plugins-and-ailoop.md` | Plugin manifests, ailoop confirmations | +| `skill/references/plugins.md` | Plugin manifests | | `skill/references/http-retry.md` | `RetryableHttpClient`, circuit breaker | | `skill/references/telemetry.md` | OpenTelemetry: subscriber trap, auto spans/metrics, W3C propagation, OTLP auth headers, testing | | `skill/references/testing-with-testkit.md` | `CliTestHarness`, in-process test pattern | @@ -503,7 +489,6 @@ Full detail — Keycloak client config, all three flows, cache file schema, runt |---------|-----------|---------------| | `skill/examples/basic_cli` | `cargo run --example basic_cli` | none | | `skill/examples/with_plugins` | `cargo run --example with_plugins` | none | -| `skill/examples/with_ailoop` | `cargo run --example with_ailoop` | none | | `skill/examples/with_mcp` | `cargo run --example with_mcp --features mcp-server` | `mcp-server` | | `skill/examples/with_doctor` | `cargo run --example with_doctor --features doctor` | `doctor` | | `skill/examples/with_auth` | `cargo run --example with_auth --features auth` | `auth` | diff --git a/skill/examples/with_ailoop/Cargo.toml b/skill/examples/with_ailoop/Cargo.toml deleted file mode 100644 index 260cdcc..0000000 --- a/skill/examples/with_ailoop/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "with_ailoop" -version = "0.1.0" -edition = "2021" - -[dependencies] -cli-framework = { path = "../../../" } -anyhow = "1.0" -tokio = { version = "1.0", features = ["full"] } -once_cell = "1.0" \ No newline at end of file diff --git a/skill/examples/with_ailoop/README.md b/skill/examples/with_ailoop/README.md deleted file mode 100644 index 631272f..0000000 --- a/skill/examples/with_ailoop/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# `with_ailoop` - -Uses **ailoop-core** (via **cli-framework**'s `AiloopClient`) for confirmations and choice prompts inside command handlers. - -## Prerequisites: start `ailoop serve` - -All HITL operations route over WebSocket to a paired `ailoop serve` process. Start one before running the example: - -```bash -# Default: listens on ws://localhost:8080 -ailoop serve --port 8080 -``` - -Or point to an existing server via environment variable: - -```bash -export AILOOP_SERVER=ws://your-ailoop-host:8080 -``` - -The framework will use `ws://localhost:8080` if `AILOOP_SERVER` is unset. - -## Run - -From the repository root (in a second terminal, after `ailoop serve` is running): - -```bash -cargo run --example with_ailoop -``` - -Configure **`AILOOP_SERVER`** / **`AILOOP_CHANNEL`** if your ailoop setup differs from defaults (see crate README environment section). - -## See also - -README [ailoop integration](../../README.md#ailoop-integration) and `examples/with_ailoop/src/main.rs`. diff --git a/skill/examples/with_ailoop/src/main.rs b/skill/examples/with_ailoop/src/main.rs deleted file mode 100644 index adbf611..0000000 --- a/skill/examples/with_ailoop/src/main.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! CLI with ailoop Integration Example -//! -//! Demonstrates how to create a CLI application with ailoop-core integration -//! for human-in-the-loop confirmations and interactions. - -use cli_framework::prelude::*; -use std::collections::HashMap; -use std::io::{self, Write}; -use std::sync::Arc; - -// Custom application context with ailoop support -struct MyApp; - -impl AppContext for MyApp {} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Create a command that requires confirmation - let deploy_command = Command { - id: Arc::from("deploy"), - spec: Arc::new(CommandSpec { - summary: "Deploy application (requires confirmation)", - syntax: Some("deploy --env "), - category: Some("deployment"), - ..Default::default() - }), - validator: None, - expose_mcp: false, - expose_chat: true, - meta: None, - visibility: None, - execute: Arc::new(|_ctx, args| { - Box::pin(async move { - let env = args - .get("env") - .and_then(|v| { - if let ArgValue::Str(s) = v { - Some(s.as_str()) - } else { - None - } - }) - .unwrap_or("staging"); - - println!("🚀 Preparing to deploy to {} environment...", env); - - let ailoop = cli_framework::ailoop::AiloopClient::new()?; - let confirmed = ailoop - .request_confirmation( - &format!("Deploy application to {} environment", env), - Some("This will update live systems and may cause downtime"), - ) - .await?; - - if !confirmed { - println!("❌ Deployment cancelled by user"); - return Ok(()); - } - - println!("✅ Deployment confirmed and started!"); - // Simulate deployment - tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; - println!("🎉 Deployment completed successfully!"); - Ok(()) - }) - }), - }; - - // Create a command that asks questions - let configure_command = Command { - id: Arc::from("configure"), - spec: Arc::new(CommandSpec { - summary: "Configure application settings", - syntax: Some("configure"), - category: Some("setup"), - ..Default::default() - }), - validator: None, - expose_mcp: false, - expose_chat: true, - meta: None, - visibility: None, - execute: Arc::new(|_ctx, _args| { - Box::pin(async move { - println!("⚙️ Configuring application..."); - - let ailoop = cli_framework::ailoop::AiloopClient::new()?; - let db_type = ailoop - .ask_question( - "Which database type would you like to use?", - Some(vec![ - "PostgreSQL".to_string(), - "MySQL".to_string(), - "SQLite".to_string(), - ]), - ) - .await?; - - let cache_enabled = ailoop - .request_confirmation( - "Enable Redis caching?", - Some("Caching improves performance but requires Redis server"), - ) - .await?; - - println!("📝 Configuration:"); - println!(" Database: {}", db_type); - println!( - " Caching: {}", - if cache_enabled { "Enabled" } else { "Disabled" } - ); - - ailoop - .send_notification("Application configuration completed", Some("normal")) - .await?; - - println!("✅ Configuration completed!"); - Ok(()) - }) - }), - }; - - // Build the CLI application with ailoop integration - let mut builder = AppBuilder::new(); - - // Configure ailoop (this would connect to ailoop server in production) - builder = builder.with_ailoop_channel("cli-framework-demo"); - - builder = builder - .with_version(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) - .register_command(deploy_command)? - .register_command(configure_command)?; - - let mut app = builder.build(MyApp)?; - - // Interactive CLI loop - println!("CLI Framework - ailoop Integration Example"); - println!("Available commands: deploy, configure"); - println!(); - println!("This example demonstrates ailoop integration for:"); - println!(" - Human confirmation for critical operations"); - println!(" - Interactive configuration with questions"); - println!(" - Status notifications"); - println!(); - println!("Note: This is a demo - ailoop confirmations are simulated."); - println!("In production, this would connect to a real ailoop server."); - println!(); - println!("Type 'quit' to exit"); - println!(); - - let stdin = io::stdin(); - let mut stdout = io::stdout(); - - loop { - print!("> "); - stdout.flush()?; - - let mut input = String::new(); - stdin.read_line(&mut input)?; - let input = input.trim(); - - if input == "quit" { - break; - } - - if input.is_empty() { - continue; - } - - // Parse command (basic parsing for demo) - let parts: Vec<&str> = input.split_whitespace().collect(); - if let Some(command_id) = parts.first() { - let mut args: HashMap = HashMap::new(); - // Parse --key value pairs from remaining parts - let rest = &parts[1..]; - let mut i = 0; - while i < rest.len() { - if let Some(key) = rest[i].strip_prefix("--") { - if i + 1 < rest.len() { - args.insert(key.to_string(), ArgValue::Str(rest[i + 1].to_string())); - i += 2; - } else { - args.insert(key.to_string(), ArgValue::Bool(true)); - i += 1; - } - } else { - i += 1; - } - } - - match app.execute_command(command_id, args).await { - Ok(()) => {} - Err(e) => println!("Error: {}", e), - } - } - } - - Ok(()) -} diff --git a/skill/references/architecture-and-sources.md b/skill/references/architecture-and-sources.md index c42fd06..2ca4a49 100644 --- a/skill/references/architecture-and-sources.md +++ b/skill/references/architecture-and-sources.md @@ -10,7 +10,6 @@ Module map for `cli-framework`. Read alongside [`CONTRIBUTING.md`](../../CONTRIB | `command`, `command::chat` | `Command` struct, registry, `chat` agentic resolution | | `parser`, `spec` | argv → `CommandArgs`; `CommandPath`, `CommandSpec`, `ArgSpec` | | `plugin` | Plugin registry TOML / manifests, `PluginRegistryManager` | -| `ailoop` | `ailoop-core` client, confirmation flow | | `security` | Output sanitization, command risk policy (`CommandRiskPolicy`) | | `http_retry`, `retry` | `RetryableHttpClient`, circuit breaker, `secure_reqwest_client` | | `cli_output`, `cli_mode`, `message` | Help rendering, tables, JSON output modes | diff --git a/skill/references/plugins-and-ailoop.md b/skill/references/plugins-and-ailoop.md deleted file mode 100644 index e0e2acb..0000000 --- a/skill/references/plugins-and-ailoop.md +++ /dev/null @@ -1,70 +0,0 @@ -# Plugins and ailoop - -Plugin registry and ailoop-core confirmation patterns. See [`skill/examples/with_plugins`](../examples/with_plugins/) and [`skill/examples/with_ailoop`](../examples/with_ailoop/). - -## Plugin manifest format - -Plugins are defined in TOML manifest files. Each plugin declares commands that are registered alongside built-ins when `PluginRegistryManager` loads the manifests: - -```toml -# ~/.config/mytool/plugins/myplugin.toml -[plugin] -name = "myplugin" -version = "1.0.0" - -[[commands]] -id = "greet" -summary = "Greet a user" -category = "misc" -``` - -## `PluginRegistryManager` - -```rust -use cli_framework::plugin::PluginRegistryManager; - -// In main, after builder setup: -let plugin_dir = dirs::config_dir() - .map(|d| d.join("mytool").join("plugins")) - .unwrap_or_default(); - -let mut builder = AppBuilder::new(); -let manager = PluginRegistryManager::new(plugin_dir); -builder = manager.register_plugins(builder)?; -``` - -Plugin load failures for one plugin do not affect other registered plugins or built-in commands. - -## ailoop confirmation pattern - -`ailoop-core` provides human-in-the-loop confirmation before risky operations: - -```rust -use cli_framework::ailoop::AiloopClient; - -// Inside an execute closure: -execute: Arc::new(|_ctx, args| Box::pin(async move { - let target = args.get("target").unwrap_or("unknown"); - let ailoop = AiloopClient::new()?; - let confirmed = ailoop - .request_confirmation( - &format!("Delete all data for {target}?"), - Some("This action cannot be undone"), - ) - .await?; - if !confirmed { - println!("Aborted."); - return Ok(()); - } - println!("Deleting {target}..."); - Ok(()) -})), -``` - -## `with_ailoop` builder method - -```rust -let builder = AppBuilder::new() - .with_ailoop_channel("my-channel-id")?; - // or: .with_ailoop_from_env()? — reads AILOOP_CHANNEL from environment -``` diff --git a/skill/references/plugins.md b/skill/references/plugins.md new file mode 100644 index 0000000..745ac0e --- /dev/null +++ b/skill/references/plugins.md @@ -0,0 +1,36 @@ +# Plugins + +Plugin registry patterns. See [`skill/examples/with_plugins`](../examples/with_plugins/). + +## Plugin manifest format + +Plugins are defined in TOML manifest files. Each plugin declares commands that are registered alongside built-ins when `PluginRegistryManager` loads the manifests: + +```toml +# ~/.config/mytool/plugins/myplugin.toml +[plugin] +name = "myplugin" +version = "1.0.0" + +[[commands]] +id = "greet" +summary = "Greet a user" +category = "misc" +``` + +## `PluginRegistryManager` + +```rust +use cli_framework::plugin::PluginRegistryManager; + +// In main, after builder setup: +let plugin_dir = dirs::config_dir() + .map(|d| d.join("mytool").join("plugins")) + .unwrap_or_default(); + +let mut builder = AppBuilder::new(); +let manager = PluginRegistryManager::new(plugin_dir); +builder = manager.register_plugins(builder)?; +``` + +Plugin load failures for one plugin do not affect other registered plugins or built-in commands. diff --git a/skill/skill-project.toml b/skill/skill-project.toml index 72109b5..4c56aea 100644 --- a/skill/skill-project.toml +++ b/skill/skill-project.toml @@ -1,7 +1,7 @@ [metadata] name = "tools-cli-framework" -version = "0.4.0" -description = "Rust CLI guidance for cli-framework: AppBuilder, Arc Command handlers, CommandPath, optional chat/plugins/ailoop; links to repo docs and examples." +version = "0.5.0" +description = "Rust CLI guidance for cli-framework: AppBuilder, Arc Command handlers, CommandPath, optional chat/plugins; links to repo docs and examples." author = "aroff" repository = "https://github.com/aroff/cli-framework" tags = ["cli", "rust", "cli-framework", "tokio", "commands", "chat", "plugins", "telemetry", "opentelemetry"] diff --git a/src/ailoop/mod.rs b/src/ailoop/mod.rs deleted file mode 100644 index fef6d9b..0000000 --- a/src/ailoop/mod.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! ailoop-core integration module -//! -//! Provides human-in-the-loop interactions for CLI applications using ailoop-core -//! WebSocket client APIs. -//! -//! ## Pairing Requirement -//! -//! This framework depends on a running `ailoop serve` process at the configured -//! `AILOOP_SERVER` URL (default: `ws://localhost:8080`). All HITL operations -//! (`request_confirmation`, `ask_question`, `send_notification`) establish a -//! per-call WebSocket connection to that server. If the server is unreachable, -//! all methods return `Err`; no silent fallback to stdin is permitted. -//! -//! Start a paired server before using HITL features: -//! ```bash -//! ailoop serve --port 8080 -//! # or -//! export AILOOP_SERVER=ws://localhost:8080 -//! ``` -//! -//! ## Configuration -//! -//! ```rust,no_run -//! use cli_framework::prelude::*; -//! -//! # fn main() -> anyhow::Result<()> { -//! let mut builder = AppBuilder::new(); -//! builder = builder.with_ailoop_channel("my-app-channel"); -//! -//! struct MyContext; -//! impl AppContext for MyContext {} -//! -//! let mut app = builder.build(MyContext)?; -//! # Ok(()) -//! # } -//! ``` -//! -//! ## Environment Variables -//! -//! - `AILOOP_SERVER`: WebSocket URL of the paired ailoop server (defaults to `ws://localhost:8080`) -//! - `AILOOP_CHANNEL`: Default channel name (optional, defaults to "cli-framework") -//! -//! ## Error Semantics -//! -//! All HITL methods return `Err` if the ailoop server is unreachable, times out, -//! or returns an unexpected response. No silent fallback to stdin is permitted. - -use anyhow::{anyhow, Result}; - -/// Configuration for ailoop integration -#[derive(Debug, Clone)] -pub struct AiloopConfig { - /// Channel name for interactions - pub channel: String, - /// Server URL (WebSocket URL; http:// and https:// are normalized to ws:// and wss://) - pub server_url: Option, - /// Default timeout for interactions in seconds - pub default_timeout_seconds: u32, -} - -impl Default for AiloopConfig { - fn default() -> Self { - Self { - channel: std::env::var("AILOOP_CHANNEL") - .unwrap_or_else(|_| "cli-framework".to_string()), - server_url: std::env::var("AILOOP_SERVER").ok(), - default_timeout_seconds: 300, // 5 minutes - } - } -} - -/// Normalize http(s):// URLs to ws(s):// for WebSocket use. -pub fn normalize_ws_url(url: &str) -> String { - if url.starts_with("http://") { - url.replacen("http://", "ws://", 1) - } else if url.starts_with("https://") { - url.replacen("https://", "wss://", 1) - } else { - url.to_string() - } -} - -/// Client for ailoop-core interactions via WebSocket. -/// -/// All HITL methods delegate to a paired `ailoop serve` process via -/// `ailoop_core::client` WebSocket APIs. There is no in-process channel state; -/// each method call establishes a new WebSocket connection. -/// -/// # Pairing Requirement -/// -/// A running `ailoop serve` process at `AILOOP_SERVER` (default: `ws://localhost:8080`) -/// is required for all HITL operations. If the server is unreachable or returns an -/// error, all methods return `Err`. -#[derive(Clone, Debug)] -pub struct AiloopClient { - config: AiloopConfig, -} - -impl AiloopClient { - /// Create a new ailoop client with default configuration. - pub fn new() -> Result { - Self::with_config(AiloopConfig::default()) - } - - /// Create a new ailoop client with custom configuration. - /// - /// Validates that `server_url`, if provided, normalizes to a `ws://` or `wss://` URL. - /// Returns `Err` with `AILOOP_INVALID_URL` context if the URL cannot be normalized. - pub fn with_config(config: AiloopConfig) -> Result { - if let Some(ref url) = config.server_url { - let normalized = normalize_ws_url(url); - if !normalized.starts_with("ws://") && !normalized.starts_with("wss://") { - return Err(anyhow!("Invalid WebSocket URL: {}", url)); - } - } - Ok(Self { config }) - } - - /// Compute the effective server URL: config → AILOOP_SERVER env → default. - fn effective_server_url(&self) -> String { - let raw = self.config.server_url.clone().unwrap_or_else(|| { - std::env::var("AILOOP_SERVER").unwrap_or_else(|_| "ws://localhost:8080".to_string()) - }); - normalize_ws_url(&raw) - } - - fn timeout_seconds(&self) -> u32 { - self.config.default_timeout_seconds - } - - /// Request user confirmation for an action via ailoop WebSocket `authorize`. - /// - /// Returns `Ok(true)` if approved, `Ok(false)` if denied, or `Err` on failure. - /// No fallback to stdin. Requires a paired `ailoop serve` process. - /// - /// # Arguments - /// - /// * `action` - Description of the action requiring confirmation - /// * `context` - Optional additional context appended to the action string - pub async fn request_confirmation(&self, action: &str, context: Option<&str>) -> Result { - use ailoop_core::models::{MessageContent, ResponseType}; - - let server = self.effective_server_url(); - let action_str = if let Some(ctx) = context { - format!("{} ({})", action, ctx) - } else { - action.to_string() - }; - - let response = ailoop_core::client::authorize( - &server, - &self.config.channel, - &action_str, - self.timeout_seconds(), - ) - .await - .map_err(|e| anyhow!("Ailoop authorization failed: {}", e))?; - - match response { - None => Err(anyhow!("Ailoop authorization failed: request timed out")), - Some(msg) => match msg.content { - MessageContent::Response { response_type, .. } => match response_type { - ResponseType::AuthorizationApproved => Ok(true), - ResponseType::AuthorizationDenied => Ok(false), - ResponseType::Timeout => Err(anyhow!("Ailoop authorization failed: timed out")), - ResponseType::Cancelled => { - Err(anyhow!("Ailoop authorization failed: cancelled")) - } - other => Err(anyhow!( - "Ailoop authorization failed: unexpected response type {:?}", - other - )), - }, - _ => Err(anyhow!( - "Ailoop authorization failed: unexpected message content" - )), - }, - } - } - - /// Ask a structured multi-option question via ailoop WebSocket `ask_decision` - /// (`MessageContent::Decision`). - /// - /// Returns the selected option as a `String` (canonical option id from the - /// server, matching the provided choice when ids are the choice text). - /// No fallback to stdin. Requires a paired `ailoop serve` process. - /// - /// **Requires** `choices` with **at least two** non-empty options. Open-ended - /// prompts without fixed choices are not supported; use `request_confirmation` - /// or another flow. - pub async fn ask_question( - &self, - question: &str, - choices: Option>, - ) -> Result { - use ailoop_core::models::{DecisionOption, MessageContent, ResponseType}; - - let Some(choice_list) = choices else { - return Err(anyhow!( - "Ailoop question failed: at least two choices are required; \ - open-ended questions are not supported with the current ailoop protocol" - )); - }; - let trimmed: Vec = choice_list - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if trimmed.len() < 2 { - return Err(anyhow!( - "Ailoop question failed: at least two non-empty choices are required; got {}", - trimmed.len() - )); - } - let mut seen = std::collections::HashSet::new(); - for c in &trimmed { - if !seen.insert(c.as_str()) { - return Err(anyhow!("Ailoop question failed: duplicate choice {:?}", c)); - } - } - - let options: Vec = trimmed - .iter() - .map(|c| DecisionOption { - id: c.clone(), - label: c.clone(), - detail_markdown: None, - }) - .collect(); - - const MAX_SUMMARY: usize = 500; - let q = question.trim(); - let (summary, context_markdown) = if q.len() <= MAX_SUMMARY { - (q.to_string(), None) - } else { - let summary: String = q.chars().take(MAX_SUMMARY).collect(); - (summary, Some(q.to_string())) - }; - - let decision_id = format!("cli-framework-{}", uuid::Uuid::new_v4()); - let server = self.effective_server_url(); - let timeout = self.timeout_seconds(); - - let response = ailoop_core::client::ask_decision( - &server, - &self.config.channel, - decision_id, - summary, - context_markdown, - options, - None, - timeout, - ) - .await - .map_err(|e| anyhow!("Ailoop question failed: {}", e))?; - - match response { - None => Err(anyhow!("Ailoop question failed: request timed out")), - Some(msg) => match msg.content { - MessageContent::Response { - answer, - response_type, - } => match response_type { - ResponseType::Text => { - let a = answer - .ok_or_else(|| anyhow!("Ailoop question failed: empty answer"))?; - Ok(a) - } - ResponseType::Timeout => Err(anyhow!("Ailoop question failed: timed out")), - ResponseType::Cancelled => Err(anyhow!("Ailoop question failed: cancelled")), - other => Err(anyhow!( - "Ailoop question failed: unexpected response type {:?}", - other - )), - }, - _ => Err(anyhow!( - "Ailoop question failed: unexpected message content" - )), - }, - } - } - - /// Send a notification via ailoop WebSocket `say`. - /// - /// Fire-and-forget: does not wait for a response. Returns `Err` if the - /// server is unreachable. Requires a paired `ailoop serve` process. - /// - /// Note: `priority` is forwarded to the server. If `None`, defaults to "normal". - pub async fn send_notification(&self, message: &str, priority: Option<&str>) -> Result<()> { - let server = self.effective_server_url(); - let priority_str = priority.unwrap_or("normal"); - - ailoop_core::client::say(&server, &self.config.channel, message, priority_str) - .await - .map_err(|e| anyhow!("Ailoop notification failed: {}", e)) - } - - /// Get the configured channel name. - pub fn channel(&self) -> &str { - &self.config.channel - } - - /// Get the raw server URL if explicitly configured (not normalized). - pub fn server_url(&self) -> Option<&str> { - self.config.server_url.as_deref() - } -} - -/// Extension trait for AppContext to provide ailoop client access. -/// -/// Applications can implement this trait on their AppContext to provide -/// ailoop client access to commands. -pub trait AiloopContext { - /// Get access to the ailoop client for interactions. - fn ailoop_client(&self) -> Option<&AiloopClient>; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_normalize_ws_url_http() { - assert_eq!( - normalize_ws_url("http://localhost:8080"), - "ws://localhost:8080" - ); - } - - #[test] - fn test_normalize_ws_url_https() { - assert_eq!(normalize_ws_url("https://example.com"), "wss://example.com"); - } - - #[test] - fn test_normalize_ws_url_ws_passthrough() { - assert_eq!( - normalize_ws_url("ws://localhost:8080"), - "ws://localhost:8080" - ); - } - - #[test] - fn test_normalize_ws_url_wss_passthrough() { - assert_eq!(normalize_ws_url("wss://example.com"), "wss://example.com"); - } - - #[test] - fn test_ailoop_client_creation() { - let client = AiloopClient::new().unwrap(); - assert_eq!(client.channel(), "cli-framework"); - } - - #[test] - fn test_ailoop_client_with_valid_ws_url() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("ws://localhost:9000".to_string()), - default_timeout_seconds: 30, - }; - assert!(AiloopClient::with_config(config).is_ok()); - } - - #[test] - fn test_ailoop_client_with_http_url_normalized() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("http://localhost:9000".to_string()), - default_timeout_seconds: 30, - }; - assert!(AiloopClient::with_config(config).is_ok()); - } - - #[test] - fn test_ailoop_client_with_invalid_url() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("ftp://localhost:9000".to_string()), - default_timeout_seconds: 30, - }; - let result = AiloopClient::with_config(config); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid WebSocket URL")); - } - - #[test] - fn test_effective_server_url_from_config() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("http://192.168.1.100:9000".to_string()), - default_timeout_seconds: 30, - }; - let client = AiloopClient::with_config(config).unwrap(); - assert_eq!(client.effective_server_url(), "ws://192.168.1.100:9000"); - } - - #[test] - fn test_effective_server_url_default() { - // Without AILOOP_SERVER set, should default to ws://localhost:8080 - let saved = std::env::var("AILOOP_SERVER").ok(); - std::env::remove_var("AILOOP_SERVER"); - - let config = AiloopConfig { - channel: "test".to_string(), - server_url: None, - default_timeout_seconds: 30, - }; - let client = AiloopClient::with_config(config).unwrap(); - assert_eq!(client.effective_server_url(), "ws://localhost:8080"); - - if let Some(v) = saved { - std::env::set_var("AILOOP_SERVER", v); - } - } -} diff --git a/src/app/builder.rs b/src/app/builder.rs index 4df117a..2c5c69d 100644 --- a/src/app/builder.rs +++ b/src/app/builder.rs @@ -1,4 +1,3 @@ -use crate::ailoop::{AiloopClient, AiloopConfig}; use crate::app::context::AppContext; use crate::app::dispatch::InvocationSurface; use crate::app::module::Module; @@ -38,7 +37,6 @@ use std::sync::Mutex; pub struct AppBuilder { command_registry: CommandRegistry, plugin_registry_manager: Option, - ailoop_config: Option, plugin_registry_path: Option, meta: Option, app_name: &'static str, @@ -160,7 +158,6 @@ impl AppBuilder { Self { command_registry: CommandRegistry::new(), plugin_registry_manager: None, - ailoop_config: None, plugin_registry_path: None, meta: None, app_name: "unknown", @@ -840,20 +837,6 @@ impl AppBuilder { Ok(self) } - pub fn with_ailoop_config(mut self, config: AiloopConfig) -> Self { - self.ailoop_config = Some(config); - self - } - - pub fn with_ailoop_channel(self, channel: &str) -> Self { - let config = AiloopConfig { - channel: channel.to_string(), - server_url: None, - default_timeout_seconds: 300, - }; - self.with_ailoop_config(config) - } - pub fn with_plugin_registry_path(mut self, path: PathBuf) -> Self { self.plugin_registry_path = Some(path.clone()); self.plugin_registry_manager = Some(PluginRegistryManager::new(path)); @@ -1260,7 +1243,7 @@ impl AppBuilder { // discover and wire. This is the only place they are registered, and // it has to be *here* specifically, wedged between two constraints: // `push_doctor_checks` borrows all of `self`, so it must precede the - // first partial move out of `self` (`self.ailoop_config`, immediately + // first partial move out of `self` (`self.plugin_registry_manager`, // below); and `build` later moves `doctor_checks` wholesale into the // `doctor` command, so it must precede that too. // @@ -1319,12 +1302,6 @@ impl AppBuilder { _ => None, }; - let ailoop_client = if let Some(config) = self.ailoop_config { - Some(AiloopClient::with_config(config)?) - } else { - None - }; - let plugin_registry_manager = self.plugin_registry_manager; #[cfg(feature = "chat")] @@ -1334,7 +1311,6 @@ impl AppBuilder { let chat_command = crate::command::create_chat_command( registry_snapshot, self.risk_policy.clone(), - ailoop_client.clone().map(Arc::new), self.app_name, self.chat_tool_policy.clone(), ); @@ -1678,7 +1654,6 @@ impl AppBuilder { Ok(App { command_registry: registry_arc, - ailoop_client, plugin_registry_manager, ctx, meta: self.meta, @@ -1801,7 +1776,6 @@ impl Default for AppBuilder { pub struct App { command_registry: Arc, - ailoop_client: Option, plugin_registry_manager: Option, ctx: C, meta: Option, @@ -2299,7 +2273,6 @@ impl App { ) -> Result<()> { let env = crate::app::dispatch::DispatchEnv { command_registry: self.command_registry.as_ref(), - ailoop_client: &self.ailoop_client, global_args: &global_args, stdout_capture: self.stdout_capture.clone(), telemetry: self.active_telemetry.clone(), @@ -2551,10 +2524,6 @@ impl App { &self.deployment } - pub fn ailoop_client(&self) -> Option<&AiloopClient> { - self.ailoop_client.as_ref() - } - pub fn has_plugins(&self) -> bool { self.plugin_registry_manager.is_some() } diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 0195d39..38351a4 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -1,4 +1,3 @@ -use crate::ailoop::AiloopClient; use crate::app::context::AppContext; use crate::command::Command; use crate::parser::diagnostic::Diagnostic; @@ -29,7 +28,6 @@ impl InvocationSurface { pub(crate) struct DispatchEnv<'a> { pub(crate) command_registry: &'a crate::command::CommandRegistry, - pub(crate) ailoop_client: &'a Option, pub(crate) global_args: &'a HashMap, pub(crate) stdout_capture: Option>>>, pub(crate) telemetry: Option>, @@ -183,12 +181,6 @@ impl<'a> crate::app::context::CommandRegistryContext for CliAppContextWrapper<'a } } -impl<'a> crate::ailoop::AiloopContext for CliAppContextWrapper<'a> { - fn ailoop_client(&self) -> Option<&AiloopClient> { - self.env.ailoop_client.as_ref() - } -} - #[cfg(all(test, feature = "testkit"))] mod tests { use super::*; @@ -202,7 +194,6 @@ mod tests { /// content written via framework_println is returned, not silently lost. #[tokio::test(flavor = "multi_thread")] async fn cli_app_context_wrapper_drain_output_returns_captured_content() { - use crate::ailoop::AiloopContext; use crate::app::context::CommandRegistryContext; let mut registry = CommandRegistry::new(); let mut nested = Command { @@ -234,14 +225,12 @@ mod tests { }] })); assert_eq!(validate_typed_args(&nested, &HashMap::new()).len(), 1); - let ailoop_client: Option = None; let buf = Arc::new(Mutex::new(Vec::::new())); let global_args_map: HashMap = HashMap::new(); #[cfg(feature = "telemetry")] let probe_registry = crate::telemetry::ProbeRegistry::with_builtins(); let env = DispatchEnv { command_registry: ®istry, - ailoop_client: &ailoop_client, global_args: &global_args_map, stdout_capture: Some(buf.clone()), telemetry: None, @@ -272,7 +261,6 @@ mod tests { assert_eq!(wrapper.drain_output(), "fallible 世界\n"); assert!(wrapper.opt_registry().is_some()); assert!(wrapper.opt_global_args().unwrap().is_empty()); - assert!(wrapper.ailoop_client().is_none()); assert!(wrapper.opt_telemetry_arc().is_none()); wrapper.telemetry().event("contract", &[]); wrapper.env.telemetry = Some(Arc::new(crate::telemetry::NoopTelemetry)); diff --git a/src/command/chat.rs b/src/command/chat.rs index 4ab7d79..3a7fd75 100644 --- a/src/command/chat.rs +++ b/src/command/chat.rs @@ -1,4 +1,3 @@ -use crate::ailoop::AiloopClient; use crate::command::{Command, CommandRegistry, CommandResult}; use crate::security::command_risk::CommandRiskPolicy; use crate::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality}; @@ -51,13 +50,11 @@ pub const CHAT_TOOL_REGISTRY_COLLISION: &str = "CHAT_TOOL_REGISTRY_COLLISION"; pub struct ChatToolCallOptions { pub yolo: bool, pub interactive: bool, - pub ailoop_client: Option>, } pub fn create_chat_command( registry: Arc, risk_policy: CommandRiskPolicy, - ailoop_client: Option>, app_name: &'static str, chat_tool_policy: ChatToolPolicy, ) -> Command { @@ -70,12 +67,11 @@ pub fn create_chat_command( meta: None, visibility: None, execute: Arc::new(move |ctx, args| { - let client = ailoop_client.clone(); let registry = Arc::clone(®istry); let risk_policy = risk_policy.clone(); let policy = chat_tool_policy.clone(); Box::pin(async move { - execute_chat(ctx, registry, risk_policy, client, app_name, policy, args).await + execute_chat(ctx, registry, risk_policy, app_name, policy, args).await }) }), } @@ -211,7 +207,6 @@ async fn execute_chat( ctx: &mut dyn crate::app::context::AppContext, registry_fallback: Arc, risk_policy: CommandRiskPolicy, - ailoop_client: Option>, app_name: &'static str, chat_tool_policy: ChatToolPolicy, args: HashMap, @@ -220,7 +215,6 @@ async fn execute_chat( ctx, registry_fallback, risk_policy, - ailoop_client, app_name, chat_tool_policy, args, diff --git a/src/command/chat/host_tool_adapter.rs b/src/command/chat/host_tool_adapter.rs index 12e3565..cf6076c 100644 --- a/src/command/chat/host_tool_adapter.rs +++ b/src/command/chat/host_tool_adapter.rs @@ -86,8 +86,6 @@ impl HostToolProvider for McpHostToolAdapter { let confirmation = if self.opts.yolo { ConfirmationMode::AssumeYes - } else if let Some(ref client) = self.opts.ailoop_client { - ConfirmationMode::Ailoop(Arc::clone(client)) } else if self.opts.interactive { ConfirmationMode::InteractiveStdin } else { diff --git a/src/command/chat/runtime.rs b/src/command/chat/runtime.rs index 59777fa..e888cdb 100644 --- a/src/command/chat/runtime.rs +++ b/src/command/chat/runtime.rs @@ -51,7 +51,6 @@ pub(super) async fn execute_chat( ctx: &mut dyn AppContext, registry_fallback: Arc, risk_policy: CommandRiskPolicy, - ailoop_client: Option>, app_name: &'static str, chat_tool_policy: ChatToolPolicy, args: std::collections::HashMap, @@ -106,7 +105,6 @@ pub(super) async fn execute_chat( ChatToolCallOptions { yolo, interactive: crate::cli_mode::is_interactive(), - ailoop_client: ailoop_client.clone(), }, )); config.host_tool_provider = Some(adapter); @@ -142,7 +140,6 @@ pub(super) async fn execute_chat( } repl_loop(AgentRunOpts { - ailoop_client, tool_exec, yolo, stream, @@ -161,7 +158,6 @@ async fn read_stdin_all() -> anyhow::Result { #[derive(Clone)] pub(crate) struct AgentRunOpts { - pub ailoop_client: Option>, pub tool_exec: Arc, pub yolo: bool, pub stream: bool, @@ -211,7 +207,6 @@ async fn repl_loop(opts: AgentRunOpts) -> CommandResult { ChatToolCallOptions { yolo: opts.yolo, interactive: crate::cli_mode::is_interactive(), - ailoop_client: opts.ailoop_client.clone(), }, )); config.host_tool_provider = Some(adapter); diff --git a/src/command_surface/tool_bridge.rs b/src/command_surface/tool_bridge.rs index 8cfb7da..80f7dcf 100644 --- a/src/command_surface/tool_bridge.rs +++ b/src/command_surface/tool_bridge.rs @@ -1,4 +1,3 @@ -use crate::ailoop::AiloopClient; use crate::app::context::AppContext; use crate::command::Command; use crate::mcp::schema::McpToolDescriptor; @@ -18,7 +17,6 @@ pub enum BridgeInput { #[derive(Clone)] pub enum ConfirmationMode { AssumeYes, - Ailoop(Arc), InteractiveStdin, NonInteractive, } @@ -37,9 +35,9 @@ pub enum BridgeMode { /// Why a confirmation or preflight gate was blocked. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BlockReason { - /// User was prompted (ailoop / interactive stdin) and declined. + /// User was prompted (interactive stdin) and declined. UserDeclined, - /// Non-interactive context with no ailoop available; could not prompt. + /// Non-interactive context; could not prompt. NeedsInteractive, /// `ALLOW_DESTRUCTIVE_COMMANDS` is not set. EnvGated, @@ -176,8 +174,7 @@ impl CommandAsToolBridge { let tier = enforcer.classify(&cmd.id, cmd.category()); let assume_yes = matches!(invocation.confirmation, ConfirmationMode::AssumeYes); - let ailoop_available = matches!(invocation.confirmation, ConfirmationMode::Ailoop(_)); - match enforcer.enforce_preflight(&cmd.id, cmd.category(), assume_yes, ailoop_available) { + match enforcer.enforce_preflight(&cmd.id, cmd.category(), assume_yes) { Ok(()) => {} Err(PrefightError::SensitiveNeedsConfirmation) => { return Err(BridgeError::SensitiveRequiresConfirmation( @@ -265,38 +262,13 @@ async fn request_confirmation( cmd: &Command, destructive: bool, ) -> Result<(), BridgeError> { - let (action, prompt) = if destructive { - ( - format!("Execute DESTRUCTIVE command '{}'", cmd.id), - format!("Execute DESTRUCTIVE command '{}'? [y/N] ", cmd.id), - ) + let prompt = if destructive { + format!("Execute DESTRUCTIVE command '{}'? [y/N] ", cmd.id) } else { - ( - format!("Execute command '{}'", cmd.id), - format!("Execute command '{}'? [y/N] ", cmd.id), - ) + format!("Execute command '{}'? [y/N] ", cmd.id) }; match mode { ConfirmationMode::AssumeYes => Ok(()), - ConfirmationMode::Ailoop(client) => { - let confirmed = client - .request_confirmation(&action, None) - .await - .map_err(BridgeError::Execution)?; - if confirmed { - Ok(()) - } else if destructive { - Err(BridgeError::DestructiveBlocked( - cmd.id.to_string(), - BlockReason::UserDeclined, - )) - } else { - Err(BridgeError::SensitiveRequiresConfirmation( - cmd.id.to_string(), - BlockReason::UserDeclined, - )) - } - } ConfirmationMode::InteractiveStdin => { let confirmed = prompt_confirm_blocking(prompt) .await diff --git a/src/lib.rs b/src/lib.rs index 6b762f7..ce325df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,16 +2,14 @@ //! //! A pure CLI framework with AI-powered command resolution and plugin system. //! -//! This framework provides command execution, plugin system, ailoop-core integration -//! for human-in-the-loop interactions, and CLI output utilities (tables, JSON, messages, -//! progress indicators) so application authors can focus on implementing commands +//! This framework provides command execution, a plugin system, and CLI output +//! utilities (tables, JSON, messages, progress indicators) so application authors can focus on implementing commands //! rather than CLI infrastructure. //! //! ## Features //! //! - **Chat Command**: Multi-turn agentic command resolution via aikit-agent (default feature) //! - **Plugin System**: Registry-based plugin loading with manifest files -//! - **ailoop-core Integration**: Human-in-the-loop confirmations and interactions //! - **Command Registry**: Centralized command management //! - **CLI Output**: Rich formatting for tables, JSON, progress, and interactive prompts //! @@ -46,7 +44,6 @@ pub mod environment; pub mod message; // New modules for CLI framework -pub mod ailoop; pub mod plugin; // Optional modules diff --git a/src/security/risk_enforcer.rs b/src/security/risk_enforcer.rs index 7ceb8dd..8fde61c 100644 --- a/src/security/risk_enforcer.rs +++ b/src/security/risk_enforcer.rs @@ -4,11 +4,11 @@ use std::fmt; /// Typed reason a preflight check was blocked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PrefightError { - /// Sensitive command requires interactive confirmation (non-interactive context, no ailoop). + /// Sensitive command requires interactive confirmation (non-interactive context without `--yes`). SensitiveNeedsConfirmation, /// Destructive command blocked because `ALLOW_DESTRUCTIVE_COMMANDS` is not set. DestructiveEnvGated, - /// Destructive command blocked because the terminal is non-interactive and no ailoop is available. + /// Destructive command blocked because the terminal is non-interactive. DestructiveNeedsInteractive, } @@ -28,7 +28,7 @@ impl fmt::Display for PrefightError { PrefightError::DestructiveNeedsInteractive => write!( f, "DESTRUCTIVE_COMMAND_BLOCKED: command requires an interactive \ - terminal or ailoop when ALLOW_DESTRUCTIVE_COMMANDS=1" + terminal when ALLOW_DESTRUCTIVE_COMMANDS=1" ), } } @@ -56,6 +56,10 @@ impl RiskEnforcer { /// Preflight gate used by `chat`. /// + /// - Sensitive: allowed when the terminal is interactive or `assume_yes` (`--yes`). + /// - Destructive: allowed only when `ALLOW_DESTRUCTIVE_COMMANDS` is set *and* + /// the terminal is interactive. + /// /// Returns a typed [`PrefightError`] rather than an opaque error so callers /// can match on the specific block reason without string parsing. The /// `Display` impl preserves the legacy error message prefixes that golden @@ -65,13 +69,12 @@ impl RiskEnforcer { command_id: &str, command_category: Option<&str>, assume_yes: bool, - ailoop_available: bool, ) -> Result<(), PrefightError> { let tier = self.policy.classify(command_id, command_category); match tier { CommandRiskTier::Safe => Ok(()), CommandRiskTier::Sensitive => { - if !ailoop_available && !crate::cli_mode::is_interactive() && !assume_yes { + if !crate::cli_mode::is_interactive() && !assume_yes { tracing::warn!( "Sensitive command '{}' blocked in non-interactive mode without --yes", command_id @@ -91,7 +94,7 @@ impl RiskEnforcer { ); return Err(PrefightError::DestructiveEnvGated); } - if !ailoop_available && !crate::cli_mode::is_interactive() { + if !crate::cli_mode::is_interactive() { tracing::warn!( "Destructive command '{}' blocked: non-interactive terminal", command_id diff --git a/tests/integration/ailoop_websocket.rs b/tests/integration/ailoop_websocket.rs deleted file mode 100644 index 72b781f..0000000 --- a/tests/integration/ailoop_websocket.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Integration tests for ailoop WebSocket client integration. -//! -//! Tests that verify build-time checks, error propagation, and risk gate -//! behavior for the ailoop-backed HITL system. - -use std::sync::{Mutex, OnceLock}; - -use cli_framework::ailoop::{AiloopClient, AiloopConfig}; -use cli_framework::app::{AppBuilder, AppContext}; -use cli_framework::security::{CommandRiskPolicy, RiskEnforcer}; - -struct DummyCtx; -impl AppContext for DummyCtx {} - -fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - -// AppBuilder::build succeeds when ailoop is configured -#[test] -fn test_build_succeeds_with_ailoop() { - let result = AppBuilder::new() - .with_ailoop_channel("test-channel") - .build(DummyCtx); - - assert!( - result.is_ok(), - "build should succeed with ailoop configured" - ); -} - -// AppBuilder::build succeeds when nothing is configured -#[test] -fn test_build_succeeds_without_ailoop() { - let result = AppBuilder::new().build(DummyCtx); - assert!(result.is_ok(), "build should succeed without ailoop"); -} - -// AC8: normalize_ws_url converts http:// to ws:// -#[test] -fn test_normalize_ws_url_http_to_ws() { - use cli_framework::ailoop::normalize_ws_url; - assert_eq!( - normalize_ws_url("http://localhost:8080"), - "ws://localhost:8080" - ); - assert_eq!(normalize_ws_url("https://example.com"), "wss://example.com"); - assert_eq!( - normalize_ws_url("ws://localhost:9000"), - "ws://localhost:9000" - ); - assert_eq!( - normalize_ws_url("wss://secure.example.com"), - "wss://secure.example.com" - ); -} - -// AiloopClient::with_config fails for invalid URL (after normalization) -#[test] -fn test_ailoop_client_invalid_url_returns_err() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("ftp://invalid-scheme.example.com".to_string()), - default_timeout_seconds: 30, - }; - let result = AiloopClient::with_config(config); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("Invalid WebSocket URL"), "got: {}", msg); -} - -// AiloopClient::with_config succeeds for http:// (normalized to ws://) -#[test] -fn test_ailoop_client_http_url_accepted() { - let config = AiloopConfig { - channel: "test".to_string(), - server_url: Some("http://localhost:8080".to_string()), - default_timeout_seconds: 30, - }; - assert!(AiloopClient::with_config(config).is_ok()); -} - -// AC9: If AILOOP_SERVER env not set, default to ws://localhost:8080 -#[test] -fn test_default_server_url_when_env_unset() { - let _guard = env_lock().lock().unwrap(); - let saved = std::env::var("AILOOP_SERVER").ok(); - std::env::remove_var("AILOOP_SERVER"); - - let config = AiloopConfig { - channel: "test".to_string(), - server_url: None, - default_timeout_seconds: 30, - }; - let client = AiloopClient::with_config(config).unwrap(); - - assert_eq!(client.channel(), "test"); - - if let Some(v) = saved { - std::env::set_var("AILOOP_SERVER", v); - } -} - -// Risk gate: Sensitive command allowed when ailoop_available=true (non-interactive) -#[test] -fn test_risk_gate_sensitive_allowed_with_ailoop() { - let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); - let result = enforcer.enforce_preflight("config-update", Some("config"), false, true); - assert!( - result.is_ok(), - "Sensitive command should be allowed when ailoop is configured" - ); -} - -// Risk gate: Sensitive command blocked without ailoop in non-interactive mode -#[test] -fn test_risk_gate_sensitive_blocked_without_ailoop() { - let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); - let result = enforcer.enforce_preflight("config-update", Some("config"), false, false); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("SENSITIVE_COMMAND_REQUIRES_CONFIRMATION")); -} - -// Risk gate: Destructive command allowed with ailoop + ALLOW_DESTRUCTIVE_COMMANDS=1 -#[test] -fn test_risk_gate_destructive_allowed_with_ailoop_and_env() { - let _guard = env_lock().lock().unwrap(); - std::env::set_var("ALLOW_DESTRUCTIVE_COMMANDS", "1"); - let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false, true); - assert!( - result.is_ok(), - "Destructive command should be allowed with ailoop + ALLOW_DESTRUCTIVE_COMMANDS=1" - ); - - std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); -} - -// Risk gate: Destructive command still blocked without ALLOW_DESTRUCTIVE_COMMANDS even with ailoop -#[test] -fn test_risk_gate_destructive_still_blocked_without_env() { - let _guard = env_lock().lock().unwrap(); - std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); - let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false, true); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("DESTRUCTIVE_COMMAND_BLOCKED")); -} diff --git a/tests/integration/chat_loop.rs b/tests/integration/chat_loop.rs index 88c127e..6fbae53 100644 --- a/tests/integration/chat_loop.rs +++ b/tests/integration/chat_loop.rs @@ -233,7 +233,6 @@ async fn i4_tool_call_result_forwarded_in_events() { ChatToolCallOptions { yolo: true, interactive: false, - ailoop_client: None, }, )); diff --git a/tests/unit/command/chat_command.rs b/tests/unit/command/chat_command.rs index f1be8e3..276721d 100644 --- a/tests/unit/command/chat_command.rs +++ b/tests/unit/command/chat_command.rs @@ -87,7 +87,6 @@ fn default_opts() -> ChatToolCallOptions { ChatToolCallOptions { yolo: true, interactive: false, - ailoop_client: None, } } diff --git a/tests/unit/security_command_risk.rs b/tests/unit/security_command_risk.rs index 67e341d..cefe204 100644 --- a/tests/unit/security_command_risk.rs +++ b/tests/unit/security_command_risk.rs @@ -12,7 +12,7 @@ fn env_lock() -> &'static Mutex<()> { fn test_safe_tier_always_ok_false() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("list", None, false, false); + let result = enforcer.enforce_preflight("list", None, false); assert!(result.is_ok()); } @@ -20,7 +20,7 @@ fn test_safe_tier_always_ok_false() { fn test_safe_tier_always_ok_true() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("list", None, true, false); + let result = enforcer.enforce_preflight("list", None, true); assert!(result.is_ok()); } @@ -32,7 +32,7 @@ fn test_destructive_blocked_without_env() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), true, false); + let result = enforcer.enforce_preflight("drop-db", Some("destructive"), true); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( @@ -50,7 +50,7 @@ fn test_destructive_blocked_assume_yes_true() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("rm-all", Some("destructive"), true, false); + let result = enforcer.enforce_preflight("rm-all", Some("destructive"), true); assert!(result.is_err()); assert!(result .unwrap_err() @@ -65,7 +65,7 @@ fn test_destructive_blocked_assume_yes_false() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("rm-all", Some("destructive"), false, false); + let result = enforcer.enforce_preflight("rm-all", Some("destructive"), false); assert!(result.is_err()); assert!(result .unwrap_err() @@ -73,13 +73,13 @@ fn test_destructive_blocked_assume_yes_false() { .contains("DESTRUCTIVE_COMMAND_BLOCKED")); } -// AC8: Sensitive command blocked in non-interactive mode without assume_yes (no ailoop) +// AC8: Sensitive command blocked in non-interactive mode without assume_yes #[test] fn test_sensitive_blocked_non_interactive_no_assume_yes() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("config-update", Some("config"), false, false); + let result = enforcer.enforce_preflight("config-update", Some("config"), false); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( @@ -89,50 +89,40 @@ fn test_sensitive_blocked_non_interactive_no_assume_yes() { ); } -// Sensitive command allowed when ailoop is available (non-interactive) +// Sensitive command allowed with assume_yes (--yes) in non-interactive mode #[test] -fn test_sensitive_allowed_with_ailoop() { +fn test_sensitive_allowed_with_assume_yes() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("config-update", Some("config"), false, true); + let result = enforcer.enforce_preflight("config-update", Some("config"), true); assert!( result.is_ok(), - "Sensitive command should be allowed when ailoop is available" + "Sensitive command should be allowed with assume_yes" ); } -// Destructive command allowed when ailoop + ALLOW_DESTRUCTIVE_COMMANDS=1 +// assume_yes does not stand in for an interactive terminal on destructive commands #[test] -fn test_destructive_allowed_with_ailoop_and_env() { +fn test_destructive_assume_yes_does_not_replace_interactive() { let _guard = env_lock().lock().unwrap(); std::env::set_var("ALLOW_DESTRUCTIVE_COMMANDS", "1"); let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false, true); - assert!( - result.is_ok(), - "Destructive command should be allowed when ailoop is available and env var set" - ); - + let result = enforcer.enforce_preflight("drop-db", Some("destructive"), true); std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); -} -// Destructive command still blocked without ALLOW_DESTRUCTIVE_COMMANDS even with ailoop -#[test] -fn test_destructive_still_blocked_without_env_even_with_ailoop() { - let _guard = env_lock().lock().unwrap(); - std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); - let policy = CommandRiskPolicy::default(); - let enforcer = RiskEnforcer::new(policy); - - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false, true); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("DESTRUCTIVE_COMMAND_BLOCKED")); + if cli_framework::cli_mode::is_interactive() { + assert!(result.is_ok()); + } else { + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("DESTRUCTIVE_COMMAND_BLOCKED"), + "Expected DESTRUCTIVE_COMMAND_BLOCKED in non-interactive mode, got: {}", + msg + ); + } } // AC10: RiskEnforcer is callable without App/AppBuilder @@ -143,7 +133,7 @@ fn test_risk_enforcer_standalone() { .tiers .insert("my-cmd".to_string(), CommandRiskTier::Safe); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("my-cmd", None, false, false); + let result = enforcer.enforce_preflight("my-cmd", None, false); assert!(result.is_ok()); } @@ -219,8 +209,8 @@ fn test_per_command_override_wins() { ); } -// AC7: Destructive-tier with ALLOW_DESTRUCTIVE_COMMANDS=1 returns Ok when interactive or ailoop, -// or DESTRUCTIVE_COMMAND_BLOCKED when non-interactive without ailoop (as in CI/test environments). +// AC7: Destructive-tier with ALLOW_DESTRUCTIVE_COMMANDS=1 returns Ok when interactive, +// or DESTRUCTIVE_COMMAND_BLOCKED when non-interactive (as in CI/test environments). #[test] fn test_ac7_destructive_allowed_with_env_and_interactive() { let _guard = env_lock().lock().unwrap(); @@ -228,7 +218,7 @@ fn test_ac7_destructive_allowed_with_env_and_interactive() { let policy = CommandRiskPolicy::default(); let enforcer = RiskEnforcer::new(policy); - let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false, false); + let result = enforcer.enforce_preflight("drop-db", Some("destructive"), false); if cli_framework::cli_mode::is_interactive() { assert!( diff --git a/tests/unit/security_risk_enforcer.rs b/tests/unit/security_risk_enforcer.rs index cceaeee..8a25a82 100644 --- a/tests/unit/security_risk_enforcer.rs +++ b/tests/unit/security_risk_enforcer.rs @@ -29,15 +29,15 @@ fn test_classify_matches_policy() { #[test] fn test_preflight_safe_always_ok() { let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); - assert!(enforcer.enforce_preflight("list", None, false, false).is_ok()); - assert!(enforcer.enforce_preflight("list", None, true, false).is_ok()); + assert!(enforcer.enforce_preflight("list", None, false).is_ok()); + assert!(enforcer.enforce_preflight("list", None, true).is_ok()); } #[test] -fn test_preflight_sensitive_blocks_non_interactive_without_assume_yes_or_ailoop() { +fn test_preflight_sensitive_blocks_non_interactive_without_assume_yes() { let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("config-update", Some("config"), false, false) + .enforce_preflight("config-update", Some("config"), false) .unwrap_err(); assert_eq!(err, PrefightError::SensitiveNeedsConfirmation); assert!(err.to_string().contains("SENSITIVE_COMMAND_REQUIRES_CONFIRMATION")); @@ -47,7 +47,7 @@ fn test_preflight_sensitive_blocks_non_interactive_without_assume_yes_or_ailoop( fn test_preflight_sensitive_error_message_contains_required_prefix() { let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("config-update", Some("config"), false, false) + .enforce_preflight("config-update", Some("config"), false) .unwrap_err(); assert!( err.to_string().starts_with("SENSITIVE_COMMAND_REQUIRES_CONFIRMATION:"), @@ -57,10 +57,10 @@ fn test_preflight_sensitive_error_message_contains_required_prefix() { } #[test] -fn test_preflight_sensitive_allows_with_ailoop() { +fn test_preflight_sensitive_allows_with_assume_yes() { let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); assert!(enforcer - .enforce_preflight("config-update", Some("config"), false, true) + .enforce_preflight("config-update", Some("config"), true) .is_ok()); } @@ -70,7 +70,7 @@ fn test_preflight_destructive_blocks_without_env() { std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("drop-db", Some("destructive"), true, true) + .enforce_preflight("drop-db", Some("destructive"), true) .unwrap_err(); assert_eq!(err, PrefightError::DestructiveEnvGated); assert!(err.to_string().contains("DESTRUCTIVE_COMMAND_BLOCKED")); @@ -82,19 +82,19 @@ fn test_preflight_destructive_env_gated_error_message_prefix() { std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("drop-db", Some("destructive"), true, true) + .enforce_preflight("drop-db", Some("destructive"), true) .unwrap_err(); assert!(err.to_string().starts_with("DESTRUCTIVE_COMMAND_BLOCKED:"), "got: {}", err); assert!(err.to_string().contains("ALLOW_DESTRUCTIVE_COMMANDS=1"), "got: {}", err); } #[test] -fn test_preflight_destructive_blocks_non_interactive_without_ailoop_even_with_env() { +fn test_preflight_destructive_blocks_non_interactive_even_with_env() { let _guard = env_lock().lock().unwrap(); std::env::set_var("ALLOW_DESTRUCTIVE_COMMANDS", "1"); let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("drop-db", Some("destructive"), false, false) + .enforce_preflight("drop-db", Some("destructive"), false) .unwrap_err(); assert_eq!(err, PrefightError::DestructiveNeedsInteractive); assert!(err.to_string().contains("DESTRUCTIVE_COMMAND_BLOCKED")); @@ -107,7 +107,7 @@ fn test_preflight_destructive_needs_interactive_error_message_prefix() { std::env::set_var("ALLOW_DESTRUCTIVE_COMMANDS", "1"); let enforcer = RiskEnforcer::new(CommandRiskPolicy::default()); let err = enforcer - .enforce_preflight("drop-db", Some("destructive"), false, false) + .enforce_preflight("drop-db", Some("destructive"), false) .unwrap_err(); std::env::remove_var("ALLOW_DESTRUCTIVE_COMMANDS"); assert!(err.to_string().starts_with("DESTRUCTIVE_COMMAND_BLOCKED:"), "got: {}", err);