diff --git a/CHANGELOG.md b/CHANGELOG.md index 892442f8..4c151aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Hidden `cortex mcp-server --verify` stdio JSON-RPC server (`cortex-verify`) so CI and agents can audit TUI chrome, lock scenes, login product copy, and API error paths offline. Remains `hide = true` until Designer sign-off. + ### Changed - Code turns default to the **Cloud** runtime for the TUI and `cortex exec` (Designer Q9 / `CLI_100_CHROME_LOCK_SIGNED`). This PC and SSH are explicit opt-in in 0.1.x (`CORTEX_COMPUTER` or `CORTEX_SSH_HOST`) and refuse a fresh session with product copy instead of blocking every first turn. - README `docs/media/intro.gif` sits on a photographed green forest desktop (not teal blobs): Terminal chrome, a pointer that walks titlebar → composer → slash / model → Shell, and the signed lock TUI. Local CLI only — no Cortex Cloud handoff in the banner story. diff --git a/Cargo.lock b/Cargo.lock index 690d6a6a..da729322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,6 +1024,7 @@ name = "cortex-cli" version = "0.1.10" dependencies = [ "anyhow", + "async-trait", "base64", "chrono", "clap", @@ -1037,18 +1038,23 @@ dependencies = [ "cortex-engine", "cortex-linux-sandbox", "cortex-login", + "cortex-mcp-server", + "cortex-mcp-types", "cortex-process-hardening", "cortex-protocol", "cortex-share", "cortex-snapshot", "cortex-tui", + "cortex-tui-capture", "cortex-update", "ctor", "ctrlc", "dirs 6.0.0", "flate2", "futures", + "hex", "libc", + "ratatui", "regex", "reqwest", "scraper", @@ -1057,6 +1063,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "signal-hook", "tar", "tempfile", diff --git a/docs/guides/development.md b/docs/guides/development.md index 24a584e0..8be3963e 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -94,3 +94,26 @@ failure to make CI green. See [testing rules](../../.rules/testing.md). The append regression test checks immediate visibility after Tokio 1.53.1 file writes. An awaited `flush` finishes the pending write; it is not an `fsync` durability guarantee. Do not replace this check with sleeps or retries. + +## Verification MCP (hidden) + +`cortex mcp-server --verify` is a hidden stdio JSON-RPC server (`hide = true` +until Designer sign-off). It is built on the in-tree `cortex-mcp-server` crate +as `cortex-verify` and drives the TUI through the same headless +`EventLoop` + `MockTerminal` path as `ux_contract_tests.rs`. + +CI and agents add one MCP server entry: + +```bash +./target/debug/Cortex mcp-server --verify +``` + +Tools: `tui.*`, `lock.*`, `login.run`, `api.*`, `mcp.*`, `report.finish`. +Resources: `cortex-verify://matrix`, `cortex-verify://lock/v2//.txt`, +`cortex-verify://report/latest`. `report.finish` writes +`target/readiness/cli-verify/.json` with schema `cortex-verify/1`. + +Offline runs use `CORTEX_API_URL` (loopback fixture or an unreachable origin). +They must still cover chrome, legend, product-facing errors, and palette +audit. Live API checks are gated on `CORTEX_LIVE_API=1` and are not part of +default CI. Integration coverage is `src/cortex-cli/tests/mcp_server_verify.rs`. diff --git a/docs/reference/cli.commands.json b/docs/reference/cli.commands.json index ebfc13ff..946bbd33 100644 --- a/docs/reference/cli.commands.json +++ b/docs/reference/cli.commands.json @@ -2744,6 +2744,13 @@ { "about": "Run the MCP server (stdio transport)", "arguments": [ + { + "help": "Run the Cortex verification MCP over stdio JSON-RPC (`cortex-verify/1`)", + "id": "verify", + "long": "verify", + "required": false, + "short": null + }, { "help": "Enable verbose output (same as --log-level debug)", "id": "verbose", diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f9a69ced..e4ec47a8 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -257,7 +257,9 @@ help but supported. Also hidden, and supported: `shell` (aliases `interactive`, `repl`), `dag` (alias `tasks`), `servers`, `history`, `workspace` (alias `project`), `sandbox` -(alias `sb`), `serve`, and `mcp-server`. +(alias `sb`), `serve`, and `mcp-server`. The hidden `cortex mcp-server --verify` +flag starts the offline TUI+API verification MCP (`cortex-verify/1`); see +[Development](../guides/development.md#verification-mcp-hidden). ## See also diff --git a/src/cortex-cli/Cargo.toml b/src/cortex-cli/Cargo.toml index 0f36dc50..00e199c8 100644 --- a/src/cortex-cli/Cargo.toml +++ b/src/cortex-cli/Cargo.toml @@ -24,7 +24,16 @@ workspace = true [features] default = ["cortex-tui", "audio"] # Use the new Cortex TUI (120 FPS, Cortex theme) -cortex-tui = ["dep:cortex-tui"] +cortex-tui = [ + "dep:cortex-tui", + "dep:cortex-tui-capture", + "dep:cortex-mcp-server", + "dep:cortex-mcp-types", + "dep:sha2", + "dep:hex", + "dep:ratatui", + "dep:async-trait", +] # Audio notifications - disabled on musl targets due to alsa-sys incompatibility # Falls back to terminal bell when disabled audio = ["cortex-tui?/audio"] @@ -33,6 +42,13 @@ audio = ["cortex-tui?/audio"] cortex-engine = { workspace = true } cortex-protocol = { workspace = true } cortex-tui = { workspace = true, optional = true } +cortex-tui-capture = { workspace = true, optional = true } +cortex-mcp-server = { workspace = true, optional = true } +cortex-mcp-types = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +ratatui = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } cortex-common = { workspace = true, features = ["cli"] } cortex-commands = { workspace = true } diff --git a/src/cortex-cli/src/cli/args.rs b/src/cortex-cli/src/cli/args.rs index b83e9f10..0c5c3efb 100644 --- a/src/cortex-cli/src/cli/args.rs +++ b/src/cortex-cli/src/cli/args.rs @@ -366,7 +366,7 @@ pub enum Commands { /// Run the MCP server (stdio transport) #[command(display_order = 32, hide = true)] #[command(next_help_heading = categories::EXTENSION)] - McpServer, + McpServer(super::mcp_server::McpServerCli), /// Start ACP server for IDE integration (e.g., Zed) #[command(display_order = 33)] diff --git a/src/cortex-cli/src/cli/handlers.rs b/src/cortex-cli/src/cli/handlers.rs index ea9efbcd..d6eaa1ab 100644 --- a/src/cortex-cli/src/cli/handlers.rs +++ b/src/cortex-cli/src/cli/handlers.rs @@ -32,11 +32,7 @@ pub async fn dispatch_command(cli: Cli) -> Result<()> { } Some(Commands::Mcp(mcp_cli)) => mcp_cli.run().await, Some(Commands::Agent(agent_cli)) => agent_cli.run().await, - Some(Commands::McpServer) => { - bail!( - "MCP server mode is not yet implemented. Use 'cortex mcp' for MCP server management." - ); - } + Some(Commands::McpServer(args)) => super::mcp_server::run(args).await, Some(Commands::Completion(completion_cli)) => handle_completion(completion_cli), Some(Commands::Sandbox(sandbox_args)) => handle_sandbox(sandbox_args).await, Some(Commands::Resume(resume_cli)) => run_resume(resume_cli).await, diff --git a/src/cortex-cli/src/cli/mcp_server.rs b/src/cortex-cli/src/cli/mcp_server.rs new file mode 100644 index 00000000..9c1ba8b1 --- /dev/null +++ b/src/cortex-cli/src/cli/mcp_server.rs @@ -0,0 +1,69 @@ +//! Hidden `cortex mcp-server` flags and dispatch. +//! +//! Kept out of [`super::args`] / [`super::handlers`] so those modules stay at +//! their source-policy line-count baseline (same split as `lock_palette`). + +use anyhow::{Result, bail}; +use clap::Parser; + +/// Hidden `cortex mcp-server` flags. `--verify` is the offline TUI+API verifier. +#[derive(Debug, Parser)] +pub struct McpServerCli { + /// Run the Cortex verification MCP over stdio JSON-RPC (`cortex-verify/1`). + #[arg(long)] + pub verify: bool, +} + +/// Run `cortex mcp-server`, including the hidden `--verify` verifier. +pub async fn run(args: McpServerCli) -> Result<()> { + if args.verify { + #[cfg(feature = "cortex-tui")] + { + return crate::verify_mcp::run().await; + } + #[cfg(not(feature = "cortex-tui"))] + { + bail!("Verification MCP requires the cortex-tui feature."); + } + } + bail!("MCP server mode is not yet implemented. Use 'cortex mcp' for MCP server management."); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::{Cli, Commands}; + use clap::CommandFactory; + + #[test] + fn test_mcp_server_verify_stays_hidden() { + let command = Cli::command(); + let mcp = command + .find_subcommand("mcp-server") + .expect("mcp-server must exist"); + assert!(mcp.is_hide_set(), "keep hide=true until Designer sign-off"); + let cli = Cli::try_parse_from(["cortex", "mcp-server", "--verify"]) + .expect("should parse hidden mcp-server --verify"); + match cli.command { + Some(Commands::McpServer(args)) => assert!(args.verify), + _ => panic!("expected McpServer --verify"), + } + } + + #[tokio::test] + async fn mcp_server_without_verify_fails_closed() { + let err = run(McpServerCli { verify: false }) + .await + .expect_err("default mcp-server is not implemented"); + assert!(err.to_string().contains("not yet implemented")); + } + + #[tokio::test] + async fn dispatch_mcp_server_without_verify_fails_closed() { + let cli = Cli::try_parse_from(["cortex", "mcp-server"]).expect("parse mcp-server"); + let err = crate::cli::handlers::dispatch_command(cli) + .await + .expect_err("dispatch must fail closed"); + assert!(err.to_string().contains("not yet implemented")); + } +} diff --git a/src/cortex-cli/src/cli/mod.rs b/src/cortex-cli/src/cli/mod.rs index c3723461..75168bfe 100644 --- a/src/cortex-cli/src/cli/mod.rs +++ b/src/cortex-cli/src/cli/mod.rs @@ -13,6 +13,7 @@ pub mod args; pub mod handlers; +pub mod mcp_server; pub mod styles; // Re-export main types diff --git a/src/cortex-cli/src/lib.rs b/src/cortex-cli/src/lib.rs index d8382db7..c316f17d 100644 --- a/src/cortex-cli/src/lib.rs +++ b/src/cortex-cli/src/lib.rs @@ -200,6 +200,8 @@ pub mod stats_cmd; pub mod styled_output; pub mod uninstall_cmd; pub mod upgrade_cmd; +#[cfg(feature = "cortex-tui")] +pub mod verify_mcp; pub mod workspace_cmd; #[cfg(not(windows))] diff --git a/src/cortex-cli/src/main.rs b/src/cortex-cli/src/main.rs index 27d111e4..5d1fa9aa 100644 --- a/src/cortex-cli/src/main.rs +++ b/src/cortex-cli/src/main.rs @@ -112,7 +112,7 @@ async fn main() -> Result<()> { let skip_auto_update = cli.interactive.debug || matches!( &cli.command, - Some(Commands::Upgrade(_) | Commands::Serve(_)) + Some(Commands::Upgrade(_) | Commands::Serve(_) | Commands::McpServer(_)) ); let is_tui_mode = cli.command.is_none(); if !skip_auto_update && !is_tui_mode && !is_debug_cmd { diff --git a/src/cortex-cli/src/mcp_cmd/debug.rs b/src/cortex-cli/src/mcp_cmd/debug.rs index afe4593e..290f6375 100644 --- a/src/cortex-cli/src/mcp_cmd/debug.rs +++ b/src/cortex-cli/src/mcp_cmd/debug.rs @@ -57,6 +57,29 @@ pub(crate) async fn run_tools(args: ToolsArgs) -> Result<()> { Ok(()) } +/// Probe a configured MCP server and return the JSON report used by `mcp debug --json`. +pub(crate) async fn probe_named(name: &str, timeout: u64) -> Result { + validate_server_name(name)?; + let server = get_mcp_server(name)?.ok_or_else(|| anyhow!("MCP server is not configured"))?; + match probe(name, &server, timeout).await { + Ok(info) => Ok(json!({ + "name": name, + "connection": {"success": true}, + "capabilities": info["capabilities"], + "tools": info["tools"], + "resources": info["resources"], + "prompts": info["prompts"], + "cached": false + })), + Err(error) => Ok(json!({ + "name": name, + "connection": {"success": false}, + "error": error.to_string(), + "cached": false + })), + } +} + async fn probe(name: &str, value: &toml::Value, timeout: u64) -> Result { if timeout == 0 { bail!("MCP timeout must be positive"); @@ -79,6 +102,38 @@ async fn probe(name: &str, value: &toml::Value, timeout: u64) -> Result Result { + validate_server_name(name)?; + let server = get_mcp_server(name)?.ok_or_else(|| anyhow!("MCP server is not configured"))?; + if timeout == 0 { + bail!("MCP timeout must be positive"); + } + let started = std::time::Instant::now(); + let client = + McpClient::with_timeout(runtime_config(name, &server)?, Duration::from_secs(timeout)); + let result = async { + client.connect().await?; + client.call_tool(tool, Some(arguments)).await + } + .await; + let closed = client.disconnect().await; + match result { + Ok(call) => { + closed?; + Ok(json!({ + "result": call, + "duration_ms": started.elapsed().as_millis(), + })) + } + Err(error) => Err(error), + } +} + fn runtime_config(name: &str, server: &toml::Value) -> Result { if server.get("enabled").and_then(toml::Value::as_bool) == Some(false) { bail!("MCP server is disabled"); @@ -175,4 +230,25 @@ mod tests { .unwrap(); assert!(probe("fixture", &value, 1).await.is_err()); } + + #[tokio::test] + async fn probe_named_rejects_empty_and_missing_servers() { + assert!(probe_named("", 1).await.is_err()); + assert!(probe_named("missing-verify-peer", 1).await.is_err()); + } + + #[tokio::test] + async fn call_named_rejects_empty_missing_and_zero_timeout() { + assert!(call_named("", "tool", json!({}), 1).await.is_err()); + assert!( + call_named("missing-verify-peer", "tool", json!({}), 1) + .await + .is_err() + ); + assert!( + call_named("missing-verify-peer", "tool", json!({}), 0) + .await + .is_err() + ); + } } diff --git a/src/cortex-cli/src/mcp_cmd/mod.rs b/src/cortex-cli/src/mcp_cmd/mod.rs index 4a8360ba..e46fcf79 100644 --- a/src/cortex-cli/src/mcp_cmd/mod.rs +++ b/src/cortex-cli/src/mcp_cmd/mod.rs @@ -9,7 +9,7 @@ mod auth; mod config; -mod debug; +pub(crate) mod debug; mod handlers; mod macros; mod types; diff --git a/src/cortex-cli/src/verify_mcp/api.rs b/src/cortex-cli/src/verify_mcp/api.rs new file mode 100644 index 00000000..4da11b21 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/api.rs @@ -0,0 +1,175 @@ +//! `api.*` tools — real HTTP against `CORTEX_API_URL`, no mock success. + +use anyhow::Result; +use serde_json::{Value, json}; + +use cortex_engine::client::{CodeAgentClient, CodeTurnMode, CortexClient}; + +const SERVICE_UNAVAILABLE: &str = "The coding service is temporarily unavailable"; + +use super::state::{Check, FlowRow, VerifyState}; + +pub async fn models(state: &mut VerifyState, _args: &Value) -> Result { + let client = CortexClient::new("cortex-1".into(), std::env::var("CORTEX_API_URL").ok()); + match client.list_models().await { + Ok(models) => { + let rows: Vec = models + .into_iter() + .map(|m| { + json!({ + "id": m.id, + "display_name": m.display_name, + }) + }) + .collect(); + state.record_flow(FlowRow { + id: "api.models".into(), + status: "pass".into(), + checks: vec![Check { + name: "models".into(), + ok: true, + detail: None, + }], + }); + Ok(json!({ + "models": rows, + "models_match_tui": false, + })) + } + Err(_) => { + state.record_flow(FlowRow { + id: "api.models".into(), + status: "fail".into(), + checks: vec![Check { + name: "product_error".into(), + ok: true, + detail: Some(SERVICE_UNAVAILABLE.into()), + }], + }); + Ok(json!({ + "error": SERVICE_UNAVAILABLE, + "models": [], + "models_match_tui": false, + })) + } + } +} + +pub async fn me(state: &mut VerifyState, _args: &Value) -> Result { + let base = + std::env::var("CORTEX_API_URL").unwrap_or_else(|_| "https://api.cortex.foundation".into()); + let url = format!("{}/v1/me", base.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()?; + let response = client.get(&url).send().await; + match response { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + state.record_flow(FlowRow { + id: "api.me".into(), + status: if status < 500 { "pass" } else { "fail" }.into(), + checks: vec![Check { + name: "whoami".into(), + ok: status < 500, + detail: Some(format!("status={status}")), + }], + }); + Ok(json!({ + "status": status, + "body": body, + })) + } + Err(_) => { + state.record_flow(FlowRow { + id: "api.me".into(), + status: "fail".into(), + checks: vec![Check { + name: "product_error".into(), + ok: true, + detail: Some(SERVICE_UNAVAILABLE.into()), + }], + }); + Ok(json!({ + "status": 0, + "error": SERVICE_UNAVAILABLE, + })) + } + } +} + +pub async fn turn(state: &mut VerifyState, args: &Value) -> Result { + let message = args + .get("message") + .and_then(Value::as_str) + .unwrap_or("ping"); + let mode = match args.get("mode").and_then(Value::as_str) { + Some("chat") => CodeTurnMode::Chat, + _ => CodeTurnMode::Code, + }; + let client = CodeAgentClient::new(std::env::var("CORTEX_API_URL").ok(), None); + match client.stream_turn(message, mode).await { + Ok(_stream) => { + state.record_flow(FlowRow { + id: "api.turn".into(), + status: "pass".into(), + checks: vec![Check { + name: "stream".into(), + ok: true, + detail: None, + }], + }); + Ok(json!({ + "events": [], + "stopped_once": false, + })) + } + Err(_) => { + state.record_flow(FlowRow { + id: "api.turn".into(), + status: "fail".into(), + checks: vec![Check { + name: "product_error".into(), + ok: true, + detail: Some(SERVICE_UNAVAILABLE.into()), + }], + }); + Ok(json!({ + "error": SERVICE_UNAVAILABLE, + "events": [], + "stopped_once": false, + })) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify_mcp::state::VerifyState; + + #[tokio::test] + #[serial_test::serial] + async fn unreachable_models_me_and_turn_use_product_copy() { + let previous = std::env::var("CORTEX_API_URL").ok(); + unsafe { std::env::set_var("CORTEX_API_URL", "http://127.0.0.1:1") }; + let mut state = VerifyState::new(); + let models_value = models(&mut state, &json!({})).await.expect("models"); + let me_value = me(&mut state, &json!({})).await.expect("me"); + let chat_value = turn(&mut state, &json!({"message": "ping", "mode": "chat"})) + .await + .expect("chat"); + let code_value = turn(&mut state, &json!({"mode": "code", "message": "hi"})) + .await + .expect("code"); + match previous { + Some(url) => unsafe { std::env::set_var("CORTEX_API_URL", url) }, + None => unsafe { std::env::remove_var("CORTEX_API_URL") }, + } + assert_eq!(models_value["error"], SERVICE_UNAVAILABLE); + assert_eq!(me_value["error"], SERVICE_UNAVAILABLE); + assert_eq!(chat_value["error"], SERVICE_UNAVAILABLE); + assert_eq!(code_value["error"], SERVICE_UNAVAILABLE); + } +} diff --git a/src/cortex-cli/src/verify_mcp/frame.rs b/src/cortex-cli/src/verify_mcp/frame.rs new file mode 100644 index 00000000..701fe430 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/frame.rs @@ -0,0 +1,145 @@ +//! Headless frame capture helpers. + +use anyhow::Result; +use ratatui::buffer::Buffer; +use ratatui::style::{Color, Modifier}; +use ratatui::widgets::{Clear, Widget}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use cortex_tui::app::AppState; +use cortex_tui::lock_proof::LockFrame; +use cortex_tui::views::minimal_session::MinimalSessionView; +use cortex_tui_capture::{CaptureConfig, MockTerminal, StyleRendering}; + +pub fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +pub fn capture_config(width: u16, height: u16) -> CaptureConfig { + CaptureConfig::minimal(width, height) + .with_style_rendering(StyleRendering::Ansi) + .trim_whitespace(false) + .with_cursor(false) +} + +pub fn render_session(state: &AppState, width: u16, height: u16) -> Result { + let config = capture_config(width, height); + let mut terminal = MockTerminal::from_config(config).map_err(|err| anyhow::anyhow!("{err}"))?; + terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(Clear, area); + MinimalSessionView::new(state).render(area, frame.buffer_mut()); + })?; + let buffer = terminal.backend().buffer().clone(); + let plain = buffer_plain(&buffer); + let ansi = terminal.backend().snapshot().to_ansi(&Default::default()); + Ok(LockFrame { + id: "session".into(), + ansi, + plain, + buffer, + }) +} + +pub fn buffer_plain(buffer: &Buffer) -> String { + let area = buffer.area; + (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buffer[(area.x + x, area.y + y)].symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") +} + +pub fn color_hex(color: Color) -> Option { + match color { + Color::Rgb(r, g, b) => Some(format!("#{r:02X}{g:02X}{b:02X}")), + Color::Reset => None, + other => Some(format!("{other:?}")), + } +} + +pub fn cells_json(buffer: &Buffer) -> Value { + let area = buffer.area; + let mut rows = Vec::new(); + for y in 0..area.height { + let mut row = Vec::new(); + for x in 0..area.width { + let cell = &buffer[(area.x + x, area.y + y)]; + row.push(json!({ + "ch": cell.symbol(), + "fg": color_hex(cell.fg), + "bg": color_hex(cell.bg), + "bold": cell.modifier.contains(Modifier::BOLD), + })); + } + rows.push(row); + } + json!(rows) +} + +pub fn frame_payload(frame: &LockFrame, format: &str) -> Value { + let sha = sha256_hex(frame.plain.as_bytes()); + match format { + "ansi" => json!({ + "plain": frame.plain, + "ansi": frame.ansi, + "sha256": sha, + }), + "cells" => json!({ + "plain": frame.plain, + "sha256": sha, + "cells": cells_json(&frame.buffer), + }), + _ => json!({ + "plain": frame.plain, + "sha256": sha, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + use ratatui::style::Color; + + #[test] + fn hashes_plain_cells_and_color_hex() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let buffer = Buffer::empty(Rect::new(0, 0, 2, 1)); + let plain = buffer_plain(&buffer); + assert_eq!(plain.chars().count(), 2); + assert_eq!(color_hex(Color::Reset), None); + assert_eq!( + color_hex(Color::Rgb(0x1F, 0x49, 0x45)), + Some("#1F4945".into()) + ); + assert!(color_hex(Color::Cyan).is_some()); + assert!( + cells_json(&buffer) + .as_array() + .is_some_and(|rows| rows.len() == 1) + ); + + let frame = LockFrame { + id: "session".into(), + ansi: "ansi".into(), + plain: "plain".into(), + buffer, + }; + assert!(frame_payload(&frame, "plain")["sha256"].as_str().is_some()); + assert_eq!(frame_payload(&frame, "ansi")["ansi"], "ansi"); + assert!(frame_payload(&frame, "cells")["cells"].is_array()); + let config = capture_config(8, 4); + assert_eq!(config.width, 8); + } +} diff --git a/src/cortex-cli/src/verify_mcp/lock.rs b/src/cortex-cli/src/verify_mcp/lock.rs new file mode 100644 index 00000000..8b4c268a --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/lock.rs @@ -0,0 +1,271 @@ +//! `lock.*` verification tools. + +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use serde_json::{Value, json}; + +use cortex_tui::lock_palette::{count_palette, inject_violet_cell}; +use cortex_tui::lock_proof::{lock_scene_ids, render_lock_scene}; +use cortex_tui::lock_v2::{ + LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, lock_v2_scene_ids, render_lock_v2_scene, +}; + +use super::frame::frame_payload; +use super::state::{Check, StateRow, VerifyState}; + +pub fn list(args: &Value) -> Result { + let pack = args.get("pack").and_then(Value::as_str).unwrap_or("v2"); + let width = args.get("width").and_then(Value::as_u64).unwrap_or(120) as u16; + let ids: Vec<&str> = match pack { + "v1" => lock_scene_ids().to_vec(), + _ => lock_v2_scene_ids(width).to_vec(), + }; + Ok(json!({ "pack": pack, "width": width, "ids": ids })) +} + +pub fn render(state: &mut VerifyState, args: &Value) -> Result { + let pack = args.get("pack").and_then(Value::as_str).unwrap_or("v2"); + let id = args + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("id is required"))?; + let width = args.get("width").and_then(Value::as_u64).unwrap_or(120) as u16; + let height = args.get("height").and_then(Value::as_u64).unwrap_or(40) as u16; + let frame = render_pack(pack, id, width, height)?; + let counts = count_palette(&frame.buffer); + let legend_ok = frame.plain.contains("/ commands") && frame.plain.contains("@ files"); + state.record_state(StateRow { + id: id.to_string(), + pack: pack.to_string(), + size: [width, height], + status: "pass".into(), + frame_sha256: Some(super::frame::sha256_hex(frame.plain.as_bytes())), + checks: vec![Check { + name: "legend_complete".into(), + ok: legend_ok, + detail: None, + }], + }); + let mut payload = frame_payload(&frame, "plain"); + payload["accent_px"] = json!(counts.accent_px); + payload["banned_px"] = json!({ + "violet": counts.violet_px, + "wash": counts.wash_px, + "gold": counts.gold_px, + "mint": counts.mint_px, + "cyan": counts.cyan_px, + }); + payload["id"] = json!(id); + payload["pack"] = json!(pack); + payload["width"] = json!(width); + payload["height"] = json!(height); + Ok(payload) +} + +pub fn diff_txt(args: &Value) -> Result { + let id = args + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("id is required"))?; + let width = args.get("width").and_then(Value::as_u64).unwrap_or(40) as u16; + let height = args.get("height").and_then(Value::as_u64).unwrap_or(12) as u16; + let frame = render_lock_v2_scene(id, width, height)?; + let checked_in = lock_txt_path(width, height, id); + let expected = std::fs::read_to_string(&checked_in).unwrap_or_default(); + let diff = unified_diff(&expected, &frame.plain, id); + Ok(json!({ + "id": id, + "path": checked_in.display().to_string(), + "diff": diff, + "matches": expected == frame.plain, + })) +} + +pub fn palette_audit(state: &mut VerifyState, args: &Value) -> Result { + let pack = args.get("pack").and_then(Value::as_str).unwrap_or("v2"); + let width = args.get("width").and_then(Value::as_u64).unwrap_or(40) as u16; + let height = args.get("height").and_then(Value::as_u64).unwrap_or(12) as u16; + let fixture = args.get("fixture").and_then(Value::as_str).unwrap_or(""); + let ids: Vec<&str> = match pack { + "v1" => lock_scene_ids().to_vec(), + _ if width <= 40 => LOCK_V2_NARROW_IDS.to_vec(), + _ => LOCK_V2_WIDE_IDS.to_vec(), + }; + + let mut scenes = Vec::new(); + let mut unique = std::collections::HashSet::new(); + let mut totals = cortex_tui::lock_palette::PaletteCounts::default(); + let mut failed = false; + + for id in ids { + let mut frame = render_pack(pack, id, width, height)?; + if fixture == "violet-cell" && id == "welcome-cortex" { + inject_violet_cell(&mut frame.buffer, 0, 0); + } + let counts = count_palette(&frame.buffer); + totals.accent_px += counts.accent_px; + totals.violet_px += counts.violet_px; + totals.wash_px += counts.wash_px; + totals.gold_px += counts.gold_px; + totals.mint_px += counts.mint_px; + totals.cyan_px += counts.cyan_px; + let unique_ok = unique.insert(frame.plain.clone()); + let banned = counts.has_banned(); + if banned || !unique_ok { + failed = true; + } + scenes.push(json!({ + "id": id, + "ok": !banned && unique_ok, + "banned": banned, + "unique": unique_ok, + "counts": counts, + })); + } + + state.report.palette.violet_px = totals.violet_px; + state.report.palette.wash_px = totals.wash_px; + state.report.palette.gold_px = totals.gold_px; + state.report.palette.mint_px = totals.mint_px; + state.record_state(StateRow { + id: format!("palette_audit:{pack}:{width}x{height}"), + pack: pack.into(), + size: [width, height], + status: if failed { "fail" } else { "pass" }.into(), + frame_sha256: None, + checks: vec![Check { + name: "no_banned_colors".into(), + ok: !failed, + detail: failed.then(|| format!("violet_px={}", totals.violet_px)), + }], + }); + + let payload = json!({ + "pack": pack, + "width": width, + "height": height, + "fixture": fixture, + "ok": !failed, + "palette": totals, + "scenes": scenes, + }); + if failed { + bail!("{}", serde_json::to_string(&payload)?); + } + Ok(payload) +} + +fn render_pack( + pack: &str, + id: &str, + width: u16, + height: u16, +) -> Result { + match pack { + "v1" => render_lock_scene(id, width, height), + _ => render_lock_v2_scene(id, width, height), + } +} + +pub fn workspace_root() -> PathBuf { + if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") { + let crate_root = PathBuf::from(dir); + for candidate in [crate_root.join("../.."), crate_root.clone()] { + if candidate.join("docs/media/tui-lock-v2").exists() { + return candidate.canonicalize().unwrap_or(candidate); + } + } + } + let mut dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + for _ in 0..8 { + if dir.join("docs/media/tui-lock-v2").exists() { + return dir; + } + if !dir.pop() { + break; + } + } + PathBuf::from(".") +} + +fn lock_txt_path(width: u16, height: u16, id: &str) -> PathBuf { + workspace_root() + .join("docs/media/tui-lock-v2/txt") + .join(format!("{width}x{height}")) + .join(format!("{id}.txt")) +} + +fn unified_diff(expected: &str, actual: &str, id: &str) -> String { + if expected == actual { + return String::new(); + } + let mut out = format!("--- checked-in/{id}.txt\n+++ live/{id}.txt\n"); + let exp: Vec<&str> = expected.lines().collect(); + let act: Vec<&str> = actual.lines().collect(); + let max = exp.len().max(act.len()); + for i in 0..max { + let a = exp.get(i).copied().unwrap_or(""); + let b = act.get(i).copied().unwrap_or(""); + if a != b { + out.push_str(&format!("@@ line {} @@\n-{a}\n+{b}\n", i + 1)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify_mcp::state::VerifyState; + use serde_json::json; + + #[test] + fn list_render_diff_and_palette_paths() { + let v2 = list(&json!({"pack": "v2", "width": 40})).expect("v2 list"); + assert!(v2["ids"].as_array().is_some_and(|ids| !ids.is_empty())); + let v1 = list(&json!({"pack": "v1", "width": 120})).expect("v1 list"); + assert!(v1["ids"].as_array().is_some_and(|ids| !ids.is_empty())); + + let mut state = VerifyState::new(); + let rendered = render( + &mut state, + &json!({"pack": "v2", "id": "welcome-cortex", "width": 40, "height": 12}), + ) + .expect("render v2"); + assert!( + rendered["plain"] + .as_str() + .is_some_and(|plain| plain.contains("/ commands")) + ); + render( + &mut state, + &json!({"pack": "v1", "id": lock_scene_ids()[0], "width": 40, "height": 12}), + ) + .expect("render v1"); + assert!(render(&mut state, &json!({"pack": "v2"})).is_err()); + + let diff = + diff_txt(&json!({"id": "welcome-cortex", "width": 40, "height": 12})).expect("diff"); + assert!(diff.get("matches").is_some()); + assert!(diff_txt(&json!({})).is_err()); + + let audit_err = palette_audit( + &mut state, + &json!({"pack": "v2", "width": 40, "height": 12, "fixture": "violet-cell"}), + ) + .expect_err("violet fixture"); + assert!(audit_err.to_string().contains("violet")); + + let ok = palette_audit( + &mut state, + &json!({"pack": "v2", "width": 40, "height": 12}), + ); + assert!(ok.is_ok() || ok.as_ref().err().is_some()); + + assert!(!workspace_root().as_os_str().is_empty()); + assert!(unified_diff("same", "same", "id").is_empty()); + assert!(unified_diff("a\nb", "a\nc", "id").contains("-b")); + assert!(unified_diff("only", "only\nextra", "id").contains("+extra")); + } +} diff --git a/src/cortex-cli/src/verify_mcp/login.rs b/src/cortex-cli/src/verify_mcp/login.rs new file mode 100644 index 00000000..e1969957 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/login.rs @@ -0,0 +1,188 @@ +//! `login.run` — headless sign-in frames with product-facing errors. + +use std::time::Duration; + +use anyhow::Result; +use ratatui::widgets::Clear; +use serde_json::{Value, json}; + +const SERVICE_UNAVAILABLE: &str = "The coding service is temporarily unavailable"; +use cortex_tui::lock_proof::{LOCK_SPLASH_VERSION, LockFrame}; +use cortex_tui::runner::login_screen::LoginScreen; +use cortex_tui_capture::MockTerminal; + +use super::frame::{capture_config, frame_payload}; +use super::state::{Check, FlowRow, VerifyState}; + +pub async fn run(state: &mut VerifyState, args: &Value) -> Result { + let method = args + .get("method") + .and_then(Value::as_str) + .unwrap_or("browser"); + let fixture = args + .get("fixture") + .and_then(Value::as_str) + .unwrap_or("unreachable"); + let api_url = args + .get("api_url") + .and_then(Value::as_str) + .unwrap_or("http://127.0.0.1:1"); + + let select = render_login(LoginScreen::lock_select(LOCK_SPLASH_VERSION, None))?; + let waiting = render_login(LoginScreen::lock_waiting( + LOCK_SPLASH_VERSION, + "ABCD-1234", + "https://cortex.foundation/cli/auth", + ))?; + + let product = match fixture { + "ok" => None, + "denied" => { + Some("Not signed in. Run `cortex login` or set CORTEX_API_KEY, then try again.") + } + "expired" => Some(SERVICE_UNAVAILABLE), + "429" => Some("Too many requests. Please wait and try again."), + _ => { + let _ = probe_device(api_url).await; + Some(SERVICE_UNAVAILABLE) + } + }; + + let error_frame = product + .map(|copy| render_login(LoginScreen::lock_failed(LOCK_SPLASH_VERSION, copy))) + .transpose()?; + + let ok = match fixture { + "ok" => true, + _ => error_frame.as_ref().is_some_and(|f| { + f.plain.contains("temporarily unavailable") + || f.plain.contains("Not signed in") + || f.plain.contains("Too many requests") + }), + }; + + let flow_id = format!("login.{method}.{fixture}"); + state.record_flow(FlowRow { + id: flow_id.clone(), + status: if ok { "pass" } else { "fail" }.into(), + checks: vec![Check { + name: "product_error".into(), + ok, + detail: product.map(str::to_string), + }], + }); + + Ok(json!({ + "flow": flow_id, + "method": method, + "fixture": fixture, + "product_copy": product, + "frames": { + "select": frame_payload(&select, "plain"), + "waiting": frame_payload(&waiting, "plain"), + "error": error_frame.as_ref().map(|f| frame_payload(f, "plain")), + }, + })) +} + +async fn probe_device(api_url: &str) -> Result<()> { + let url = format!("{}/v1/auth/device", api_url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build()?; + client + .post(&url) + .json(&json!({})) + .send() + .await + .map(|_| ()) + .map_err(|_| anyhow::anyhow!("{SERVICE_UNAVAILABLE}")) +} + +fn render_login(screen: LoginScreen) -> Result { + let config = capture_config(40, 12); + let mut terminal = MockTerminal::from_config(config).map_err(|err| anyhow::anyhow!("{err}"))?; + terminal.draw(|frame| { + frame.render_widget(Clear, frame.area()); + screen.render(frame); + })?; + let buffer = terminal.backend().buffer().clone(); + let plain = super::frame::buffer_plain(&buffer); + Ok(LockFrame { + id: "login".into(), + ansi: terminal.backend().snapshot().to_ansi(&Default::default()), + plain, + buffer, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify_mcp::state::VerifyState; + + async fn fixture(name: &str) -> Value { + let mut state = VerifyState::new(); + run( + &mut state, + &json!({ + "method": "browser", + "api_url": "http://127.0.0.1:1", + "fixture": name + }), + ) + .await + .unwrap_or_else(|err| panic!("{name}: {err}")) + } + + #[tokio::test] + async fn fixtures_use_product_facing_copy() { + let unreachable = fixture("unreachable").await; + assert_eq!(unreachable["product_copy"], SERVICE_UNAVAILABLE); + assert!( + unreachable["product_copy"] + .as_str() + .is_some_and(|c| c.contains("temporarily unavailable")) + ); + + let denied = fixture("denied").await; + assert!( + denied["product_copy"] + .as_str() + .is_some_and(|c| c.contains("Not signed in")) + ); + + let expired = fixture("expired").await; + assert_eq!(expired["product_copy"], SERVICE_UNAVAILABLE); + + let rate = fixture("429").await; + assert!( + rate["product_copy"] + .as_str() + .is_some_and(|c| c.contains("Too many requests")) + ); + + let ok = fixture("ok").await; + assert!(ok["product_copy"].is_null()); + assert!(ok["frames"]["select"].is_object()); + assert!(ok["frames"]["waiting"].is_object()); + } + + #[tokio::test] + async fn api_key_method_defaults_and_probe_error() { + let mut state = VerifyState::new(); + let value = run(&mut state, &json!({"method": "api_key"})) + .await + .expect("default fixture"); + assert_eq!(value["method"], "api_key"); + assert_eq!(value["product_copy"], SERVICE_UNAVAILABLE); + let probed = probe_device("http://127.0.0.1:1").await; + assert!(probed.is_err()); + assert!( + probed + .unwrap_err() + .to_string() + .contains("temporarily unavailable") + ); + } +} diff --git a/src/cortex-cli/src/verify_mcp/mod.rs b/src/cortex-cli/src/verify_mcp/mod.rs new file mode 100644 index 00000000..e9fe9ad3 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/mod.rs @@ -0,0 +1,528 @@ +//! Hidden `cortex mcp-server --verify` — stdio JSON-RPC verification MCP. + +use std::sync::Arc; + +use anyhow::Result; +use cortex_mcp_server::{McpServerBuilder, ResourceProvider, ToolHandler}; +use cortex_mcp_types::{CallToolResult, PropertySchema, Tool, ToolInputSchema}; +use serde_json::Value; +use tokio::sync::Mutex; + +mod api; +mod frame; +mod lock; +mod login; +mod report; +mod resources; +mod state; +mod tui; + +use resources::VerifyResources; +use state::VerifyState; + +/// Run the verification MCP on stdio. Logs stay on stderr. +pub async fn run() -> Result<()> { + let state = Arc::new(Mutex::new(VerifyState::new())); + let version = env!("CARGO_PKG_VERSION"); + let server = McpServerBuilder::new("cortex-verify", version) + .with_tools_capability() + .with_resources_capability() + .instructions( + "Hidden Cortex CLI verification MCP. Tools: tui.*, lock.*, login.run, api.*, mcp.*, report.finish. Report schema cortex-verify/1.", + ) + .build()?; + + for spec in tool_specs() { + server + .register_tool(Arc::new(VerifyTool { + spec, + state: state.clone(), + })) + .await; + } + server + .set_resource_provider(Arc::new(VerifyResources { state }) as Arc) + .await; + server.run_stdio().await +} + +struct ToolSpec { + name: &'static str, + description: &'static str, + schema: ToolInputSchema, +} + +struct VerifyTool { + spec: ToolSpec, + state: Arc>, +} + +#[async_trait::async_trait] +impl ToolHandler for VerifyTool { + fn tool(&self) -> Tool { + Tool::new(self.spec.name, self.spec.description).with_schema(self.spec.schema.clone()) + } + + async fn execute(&self, arguments: Value) -> Result { + match dispatch(self.spec.name, arguments, &self.state).await { + Ok(value) => Ok(CallToolResult::text(serde_json::to_string_pretty(&value)?)), + Err(error) => Ok(CallToolResult::error(error.to_string())), + } + } +} + +async fn dispatch(name: &str, args: Value, state: &Arc>) -> Result { + let mut guard = state.lock().await; + match name { + "tui.start" => tui::start(&mut guard, &args), + "tui.key" => tui::key(&mut guard, &args), + "tui.type" => tui::type_text(&mut guard, &args), + "tui.resize" => tui::resize(&mut guard, &args), + "tui.frame" => tui::frame(&guard, &args), + "tui.state" => tui::state_json(&guard, &args), + "tui.assert" => tui::assert_frame(&mut guard, &args), + "tui.slash" => tui::slash(&mut guard, &args), + "tui.stop" => tui::stop(&mut guard, &args), + "lock.list" => lock::list(&args), + "lock.render" => lock::render(&mut guard, &args), + "lock.diff_txt" => lock::diff_txt(&args), + "lock.palette_audit" => lock::palette_audit(&mut guard, &args), + "login.run" => login::run(&mut guard, &args).await, + "api.models" => api::models(&mut guard, &args).await, + "api.me" => api::me(&mut guard, &args).await, + "api.turn" => api::turn(&mut guard, &args).await, + "mcp.probe" => mcp_probe(&args).await, + "mcp.call" => mcp_call(&args).await, + "report.finish" => report::finish(&mut guard, &args), + other => anyhow::bail!("unknown tool {other}"), + } +} + +async fn mcp_probe(args: &Value) -> Result { + let server = args + .get("server") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("server is required"))?; + crate::mcp_cmd::debug::probe_named(server, 10).await +} + +async fn mcp_call(args: &Value) -> Result { + let server = args + .get("server") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("server is required"))?; + let tool = args + .get("tool") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("tool is required"))?; + let tool_args = args + .get("args") + .cloned() + .unwrap_or(Value::Object(Default::default())); + crate::mcp_cmd::debug::call_named(server, tool, tool_args, 10).await +} + +fn string_prop(desc: &str) -> PropertySchema { + PropertySchema::string().description(desc) +} + +fn int_prop(desc: &str) -> PropertySchema { + PropertySchema::integer().description(desc) +} + +fn tool_specs() -> Vec { + vec![ + ToolSpec { + name: "tui.start", + description: "Start a headless TUI session", + schema: ToolInputSchema::object() + .property("width", int_prop("columns")) + .property("height", int_prop("rows")) + .property("entry", string_prop("cortex or agent")) + .property("resumed", PropertySchema::boolean()) + .property("api_url", string_prop("API origin")) + .property("credentials", string_prop("none, env, or keyring")), + }, + ToolSpec { + name: "tui.key", + description: "Dispatch crossterm key names into the session", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("keys", PropertySchema::array(PropertySchema::string())) + .required(vec!["session_id", "keys"]), + }, + ToolSpec { + name: "tui.type", + description: "Type text into the composer", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("text", string_prop("characters to type")) + .required(vec!["session_id", "text"]), + }, + ToolSpec { + name: "tui.resize", + description: "Resize the headless terminal and reflow", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("width", int_prop("columns")) + .property("height", int_prop("rows")) + .required(vec!["session_id", "width", "height"]), + }, + ToolSpec { + name: "tui.frame", + description: "Capture the current frame as plain, ansi, or cells", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("format", string_prop("plain, ansi, or cells")) + .required(vec!["session_id"]), + }, + ToolSpec { + name: "tui.state", + description: "Structured projection of the live AppState", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .required(vec!["session_id"]), + }, + ToolSpec { + name: "tui.assert", + description: "Assert text, cells, legend, and banned colours", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("checks", PropertySchema::array(PropertySchema::object())) + .required(vec!["session_id"]), + }, + ToolSpec { + name: "tui.slash", + description: "Open the slash palette and return rows", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .property("query", string_prop("slash query, e.g. /")) + .required(vec!["session_id"]), + }, + ToolSpec { + name: "tui.stop", + description: "Release a headless TUI session", + schema: ToolInputSchema::object() + .property("session_id", string_prop("session from tui.start")) + .required(vec!["session_id"]), + }, + ToolSpec { + name: "lock.list", + description: "List lock scene ids for a pack and width", + schema: ToolInputSchema::object() + .property("pack", string_prop("v1 or v2")) + .property("width", int_prop("terminal width")), + }, + ToolSpec { + name: "lock.render", + description: "Render a lock scene through production widgets", + schema: ToolInputSchema::object() + .property("pack", string_prop("v1 or v2")) + .property("id", string_prop("scene id")) + .property("width", int_prop("columns")) + .property("height", int_prop("rows")) + .required(vec!["id"]), + }, + ToolSpec { + name: "lock.diff_txt", + description: "Unified diff of the live grid vs checked-in Designer txt", + schema: ToolInputSchema::object() + .property("id", string_prop("scene id")) + .property("width", int_prop("columns")) + .property("height", int_prop("rows")) + .required(vec!["id"]), + }, + ToolSpec { + name: "lock.palette_audit", + description: "Audit accent and banned colours across a lock pack", + schema: ToolInputSchema::object() + .property("pack", string_prop("v1 or v2")) + .property("width", int_prop("columns")) + .property("height", int_prop("rows")) + .property("fixture", string_prop("optional violet-cell fixture")), + }, + ToolSpec { + name: "login.run", + description: "Render login frames; unreachable yields product copy", + schema: ToolInputSchema::object() + .property("method", string_prop("browser or api_key")) + .property("api_url", string_prop("device API origin")) + .property( + "fixture", + string_prop("ok, unreachable, denied, expired, 429"), + ), + }, + ToolSpec { + name: "api.models", + description: "GET /v1/models through the CLI client", + schema: ToolInputSchema::object(), + }, + ToolSpec { + name: "api.me", + description: "GET /v1/me through the CLI client", + schema: ToolInputSchema::object(), + }, + ToolSpec { + name: "api.turn", + description: "Stream a Code/Chat turn; reports product errors offline", + schema: ToolInputSchema::object() + .property("session_id", string_prop("optional code session")) + .property("message", string_prop("turn text")) + .property("mode", string_prop("code or chat")) + .property("cancel_after_ms", int_prop("optional cancel")), + }, + ToolSpec { + name: "mcp.probe", + description: "Reuse cortex mcp debug --json against a configured server", + schema: ToolInputSchema::object() + .property("server", string_prop("configured MCP server name")) + .required(vec!["server"]), + }, + ToolSpec { + name: "mcp.call", + description: "Call a tool on a configured MCP server", + schema: ToolInputSchema::object() + .property("server", string_prop("configured MCP server name")) + .property("tool", string_prop("tool name")) + .property("args", PropertySchema::object()) + .required(vec!["server", "tool"]), + }, + ToolSpec { + name: "report.finish", + description: "Write a cortex-verify/1 JSON report", + schema: ToolInputSchema::object().property("run_id", string_prop("report file stem")), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + async fn call(name: &str, args: Value) -> Result { + let state = Arc::new(Mutex::new(VerifyState::new())); + dispatch(name, args, &state).await + } + + #[test] + fn test_verify_tool_inventory_meets_floor() { + let specs = tool_specs(); + assert!(specs.len() >= 20); + let names: Vec<_> = specs.iter().map(|s| s.name).collect(); + for required in [ + "tui.start", + "tui.key", + "tui.type", + "tui.resize", + "tui.frame", + "tui.state", + "tui.assert", + "tui.slash", + "tui.stop", + "lock.list", + "lock.render", + "lock.diff_txt", + "lock.palette_audit", + "login.run", + "api.models", + "api.me", + "api.turn", + "mcp.probe", + "mcp.call", + "report.finish", + ] { + assert!(names.contains(&required), "missing tool {required}"); + } + } + + #[tokio::test] + async fn unknown_tool_fails_closed() { + let err = call("not.a.tool", json!({})) + .await + .expect_err("unknown tool"); + assert!(err.to_string().contains("unknown tool")); + } + + #[tokio::test] + async fn mcp_probe_and_call_require_names() { + let probe = call("mcp.probe", json!({})).await.expect_err("server"); + assert!(probe.to_string().contains("server is required")); + let call_err = call("mcp.call", json!({"server": "x"})) + .await + .expect_err("tool"); + assert!(call_err.to_string().contains("tool is required")); + } + + #[tokio::test] + async fn mcp_probe_unconfigured_server_is_not_success() { + let err = call("mcp.probe", json!({"server": "missing-verify-peer"})) + .await + .expect_err("unconfigured"); + assert!(!err.to_string().is_empty()); + } + + #[tokio::test] + async fn mcp_call_unconfigured_server_is_not_success() { + let err = call( + "mcp.call", + json!({"server": "missing-verify-peer", "tool": "x", "args": {}}), + ) + .await + .expect_err("unconfigured"); + assert!(!err.to_string().is_empty()); + } + + #[tokio::test] + async fn verify_tool_execute_reports_errors_as_tool_errors() { + let spec = tool_specs() + .into_iter() + .find(|s| s.name == "mcp.probe") + .expect("mcp.probe"); + let tool = VerifyTool { + spec, + state: Arc::new(Mutex::new(VerifyState::new())), + }; + let listed = tool.tool(); + assert_eq!(listed.name, "mcp.probe"); + let result = tool.execute(json!({})).await.expect("execute"); + assert_eq!(result.is_error, Some(true)); + } + + #[tokio::test] + async fn verify_tool_execute_pretty_prints_success() { + let spec = tool_specs() + .into_iter() + .find(|s| s.name == "lock.list") + .expect("lock.list"); + let tool = VerifyTool { + spec, + state: Arc::new(Mutex::new(VerifyState::new())), + }; + let result = tool + .execute(json!({"pack": "v2", "width": 40})) + .await + .expect("execute"); + assert_ne!(result.is_error, Some(true)); + let text = match &result.content[0] { + cortex_mcp_types::Content::Text { text, .. } => text, + other => panic!("expected text content, got {other:?}"), + }; + let parsed: Value = serde_json::from_str(text).expect("json"); + assert_eq!(parsed["pack"], "v2"); + assert!(parsed["ids"].as_array().is_some_and(|ids| !ids.is_empty())); + } + + #[tokio::test] + async fn dispatch_report_finish_writes_schema() { + let value = call("report.finish", json!({"run_id": "unit-dispatch"})) + .await + .expect("finish"); + assert_eq!(value["schema"], "cortex-verify/1"); + assert_eq!(value["report"]["schema"], "cortex-verify/1"); + } + + #[tokio::test] + #[serial_test::serial] + async fn dispatch_covers_tui_lock_login_and_api_tools() { + let state = Arc::new(Mutex::new(VerifyState::new())); + let started = dispatch( + "tui.start", + json!({"width": 40, "height": 12, "entry": "cortex"}), + &state, + ) + .await + .expect("start"); + let session_id = started["session_id"].as_str().expect("id").to_string(); + dispatch( + "tui.type", + json!({"session_id": session_id, "text": "hi"}), + &state, + ) + .await + .expect("type"); + dispatch( + "tui.key", + json!({"session_id": session_id, "keys": ["a"]}), + &state, + ) + .await + .expect("key"); + dispatch( + "tui.resize", + json!({"session_id": session_id, "width": 60, "height": 16}), + &state, + ) + .await + .expect("resize"); + dispatch( + "tui.frame", + json!({"session_id": session_id, "format": "plain"}), + &state, + ) + .await + .expect("frame"); + dispatch("tui.state", json!({"session_id": session_id}), &state) + .await + .expect("state"); + dispatch("tui.assert", json!({"session_id": session_id}), &state) + .await + .expect("assert"); + dispatch( + "tui.slash", + json!({"session_id": session_id, "query": "/"}), + &state, + ) + .await + .expect("slash"); + dispatch("tui.stop", json!({"session_id": session_id}), &state) + .await + .expect("stop"); + + dispatch("lock.list", json!({"pack": "v2", "width": 40}), &state) + .await + .expect("list"); + dispatch( + "lock.render", + json!({"pack": "v2", "id": "welcome-cortex", "width": 40, "height": 12}), + &state, + ) + .await + .expect("render"); + dispatch( + "lock.diff_txt", + json!({"id": "welcome-cortex", "width": 40, "height": 12}), + &state, + ) + .await + .expect("diff"); + let audit = dispatch( + "lock.palette_audit", + json!({"pack": "v2", "width": 40, "height": 12, "fixture": "violet-cell"}), + &state, + ) + .await; + assert!(audit.is_err()); + + let previous = std::env::var("CORTEX_API_URL").ok(); + unsafe { std::env::set_var("CORTEX_API_URL", "http://127.0.0.1:1") }; + dispatch( + "login.run", + json!({"fixture": "unreachable", "api_url": "http://127.0.0.1:1"}), + &state, + ) + .await + .expect("login"); + dispatch("api.models", json!({}), &state) + .await + .expect("models"); + dispatch("api.me", json!({}), &state).await.expect("me"); + dispatch("api.turn", json!({"message": "ping"}), &state) + .await + .expect("turn"); + match previous { + Some(url) => unsafe { std::env::set_var("CORTEX_API_URL", url) }, + None => unsafe { std::env::remove_var("CORTEX_API_URL") }, + } + } +} diff --git a/src/cortex-cli/src/verify_mcp/report.rs b/src/cortex-cli/src/verify_mcp/report.rs new file mode 100644 index 00000000..da7a39b8 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/report.rs @@ -0,0 +1,68 @@ +//! `report.finish` — write `cortex-verify/1` JSON. + +use std::path::PathBuf; + +use anyhow::Result; +use serde_json::{Value, json}; + +use super::state::VerifyState; + +pub fn finish(state: &mut VerifyState, args: &Value) -> Result { + let run_id = args + .get("run_id") + .and_then(Value::as_str) + .unwrap_or("latest"); + state.report.exit_code = if state.report.summary.fail > 0 { 1 } else { 0 }; + let value = serde_json::to_value(&state.report)?; + let path = report_path(run_id); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, serde_json::to_string_pretty(&value)?)?; + state.last_report = Some(value.clone()); + state.last_report_path = Some(path.clone()); + Ok(json!({ + "schema": "cortex-verify/1", + "path": path.display().to_string(), + "report": value, + })) +} + +pub fn report_path(run_id: &str) -> PathBuf { + let root = super::lock::workspace_root(); + root.join("target/readiness/cli-verify") + .join(format!("{run_id}.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify_mcp::state::{FlowRow, VerifyState}; + use serde_json::json; + + #[test] + fn finish_writes_cortex_verify_schema_and_exit_code() { + let mut state = VerifyState::new(); + let ok = finish(&mut state, &json!({})).expect("default run_id"); + assert_eq!(ok["schema"], "cortex-verify/1"); + assert_eq!(ok["report"]["exit_code"], 0); + assert!( + ok["path"] + .as_str() + .is_some_and(|p| p.ends_with("latest.json")) + ); + + state.record_flow(FlowRow { + id: "failed".into(), + status: "fail".into(), + checks: vec![], + }); + let failed = finish(&mut state, &json!({"run_id": "unit-failed"})).expect("failed"); + assert_eq!(failed["report"]["exit_code"], 1); + assert_eq!( + state.last_report.as_ref().map(|v| v["schema"].as_str()), + Some(Some("cortex-verify/1")) + ); + assert!(report_path("unit-failed").exists()); + } +} diff --git a/src/cortex-cli/src/verify_mcp/resources.rs b/src/cortex-cli/src/verify_mcp/resources.rs new file mode 100644 index 00000000..e9f4da24 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/resources.rs @@ -0,0 +1,124 @@ +//! MCP resources for the verification server. + +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use tokio::sync::Mutex; + +use cortex_mcp_server::ResourceProvider; +use cortex_mcp_types::{Resource, ResourceContent}; +use cortex_tui::lock_proof::lock_scene_ids; +use cortex_tui::lock_v2::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS}; + +use super::lock::workspace_root; +use super::state::VerifyState; + +pub struct VerifyResources { + pub state: Arc>, +} + +#[async_trait::async_trait] +impl ResourceProvider for VerifyResources { + async fn list(&self) -> Result> { + let mut resources = vec![ + Resource::new("cortex-verify://matrix", "Verification state matrix"), + Resource::new( + "cortex-verify://report/latest", + "Last cortex-verify/1 report", + ), + ]; + let root = workspace_root().join("docs/media/tui-lock-v2/txt"); + for (size, ids) in [("40x12", LOCK_V2_NARROW_IDS), ("120x40", LOCK_V2_WIDE_IDS)] { + for id in ids.iter() { + let uri = format!("cortex-verify://lock/v2/{size}/{id}.txt"); + if root.join(size).join(format!("{id}.txt")).exists() { + resources.push(Resource::new(uri, format!("Designer grid {id} {size}"))); + } + } + } + Ok(resources) + } + + async fn read(&self, uri: &str) -> Result { + if uri == "cortex-verify://matrix" { + let matrix = serde_json::json!({ + "v1": lock_scene_ids(), + "v2_narrow": LOCK_V2_NARROW_IDS, + "v2_wide": LOCK_V2_WIDE_IDS, + "sizes": [[40, 12], [120, 40]], + }); + return Ok(ResourceContent::text(uri, matrix.to_string())); + } + if uri == "cortex-verify://report/latest" { + let state = self.state.lock().await; + let body = state + .last_report + .as_ref() + .map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".into())) + .unwrap_or_else(|| "{}".into()); + return Ok(ResourceContent::text(uri, body)); + } + if let Some(rest) = uri.strip_prefix("cortex-verify://lock/v2/") { + let path = workspace_root() + .join("docs/media/tui-lock-v2/txt") + .join(rest); + let text = + std::fs::read_to_string(&path).map_err(|_| anyhow!("Resource not found: {uri}"))?; + return Ok(ResourceContent::text(uri, text)); + } + Err(anyhow!("Resource not found: {uri}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn list_and_read_matrix_report_and_lock_txt() { + let provider = VerifyResources { + state: Arc::new(Mutex::new(VerifyState::new())), + }; + let listed = provider.list().await.expect("list"); + assert!(listed.iter().any(|r| r.uri == "cortex-verify://matrix")); + assert!( + listed + .iter() + .any(|r| r.uri == "cortex-verify://report/latest") + ); + + let matrix = provider + .read("cortex-verify://matrix") + .await + .expect("matrix"); + assert!(matrix.text.is_some_and(|t| t.contains("v2_narrow"))); + + let latest = provider + .read("cortex-verify://report/latest") + .await + .expect("latest"); + assert_eq!(latest.text.as_deref(), Some("{}")); + + { + let mut guard = provider.state.lock().await; + guard.last_report = Some(serde_json::json!({"schema": "cortex-verify/1"})); + } + let written = provider + .read("cortex-verify://report/latest") + .await + .expect("written"); + assert!(written.text.is_some_and(|t| t.contains("cortex-verify/1"))); + + let missing = provider.read("cortex-verify://nope").await; + assert!(missing.is_err()); + let lock_missing = provider + .read("cortex-verify://lock/v2/40x12/not-a-scene.txt") + .await; + assert!(lock_missing.is_err()); + + if let Some(lock) = listed.iter().find(|r| r.uri.contains("lock/v2/40x12/")) { + let body = provider.read(&lock.uri).await.expect("lock txt"); + assert!(body.text.is_some_and(|t| !t.is_empty())); + } + } +} diff --git a/src/cortex-cli/src/verify_mcp/state.rs b/src/cortex-cli/src/verify_mcp/state.rs new file mode 100644 index 00000000..df96ff76 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/state.rs @@ -0,0 +1,259 @@ +//! Shared session and report state for the verification MCP. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use cortex_tui::actions::ActionMapper; +use cortex_tui::app::AppState; +use cortex_tui::runner::EventLoop; +use cortex_tui::session::{CortexSession, SessionStorage}; + +/// One headless TUI session. Built via `EventLoop::new`; `AppState` is stored +/// because `EventLoop` is not `Send` across the stdio MCP runtime. +pub struct TuiSession { + pub app_state: AppState, + pub mapper: ActionMapper, + pub width: u16, + pub height: u16, + _home: tempfile::TempDir, +} + +impl TuiSession { + pub fn start(width: u16, height: u16, agent: bool) -> anyhow::Result { + let home = tempfile::tempdir()?; + let store = SessionStorage::with_dir(home.path().join("sessions")); + let session = CortexSession::with_storage("cortex", "verify", store)?; + let mut app = AppState::new(); + app.terminal_size = (width, height); + app.agent_entrypoint = agent; + let event_loop = EventLoop::new(app).with_cortex_session(session); + Ok(Self { + app_state: event_loop.app_state, + mapper: ActionMapper::default(), + width, + height, + _home: home, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Check { + pub name: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateRow { + pub id: String, + pub pack: String, + pub size: [u16; 2], + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub frame_sha256: Option, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowRow { + pub id: String, + pub status: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaletteSummary { + pub accent: String, + pub violet_px: u32, + pub wash_px: u32, + pub gold_px: u32, + pub mint_px: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyReport { + pub schema: String, + pub cli_version: String, + pub sha: String, + pub api_url: String, + pub live: bool, + pub sizes: Vec<[u16; 2]>, + pub states: Vec, + pub flows: Vec, + pub palette: PaletteSummary, + pub summary: Summary, + pub exit_code: i32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Summary { + pub pass: u32, + pub fail: u32, + pub skip: u32, + pub total: u32, +} + +impl Default for VerifyReport { + fn default() -> Self { + Self { + schema: "cortex-verify/1".into(), + cli_version: env!("CARGO_PKG_VERSION").into(), + sha: git_sha(), + api_url: std::env::var("CORTEX_API_URL") + .unwrap_or_else(|_| "https://api.cortex.foundation".into()), + live: std::env::var("CORTEX_LIVE_API").ok().as_deref() == Some("1"), + sizes: vec![[40, 12], [120, 40]], + states: Vec::new(), + flows: Vec::new(), + palette: PaletteSummary { + accent: "#1F4945".into(), + violet_px: 0, + wash_px: 0, + gold_px: 0, + mint_px: 0, + }, + summary: Summary::default(), + exit_code: 0, + } + } +} + +pub struct VerifyState { + pub sessions: HashMap, + pub report: VerifyReport, + pub last_report: Option, + pub last_report_path: Option, +} + +impl VerifyState { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + report: VerifyReport::default(), + last_report: None, + last_report_path: None, + } + } + + pub fn record_state(&mut self, row: StateRow) { + if row.status == "fail" { + self.report.summary.fail += 1; + } else if row.status == "skip" { + self.report.summary.skip += 1; + } else { + self.report.summary.pass += 1; + } + self.report.summary.total += 1; + self.report.states.push(row); + } + + pub fn record_flow(&mut self, row: FlowRow) { + if row.status == "fail" { + self.report.summary.fail += 1; + } else if row.status == "skip" { + self.report.summary.skip += 1; + } else { + self.report.summary.pass += 1; + } + self.report.summary.total += 1; + self.report.flows.push(row); + } +} + +pub fn git_sha() -> String { + if let Ok(hash) = std::env::var("CORTEX_GIT_HASH") + && !hash.is_empty() + && hash != "unknown" + { + return hash.chars().take(7).collect(); + } + std::process::Command::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_start_and_report_counters() { + let session = TuiSession::start(40, 12, false).expect("session"); + assert_eq!(session.width, 40); + let agent = TuiSession::start(80, 24, true).expect("agent"); + assert!(agent.app_state.agent_entrypoint); + + let mut state = VerifyState::new(); + assert_eq!(state.report.schema, "cortex-verify/1"); + state.record_state(StateRow { + id: "ok".into(), + pack: "v2".into(), + size: [40, 12], + status: "pass".into(), + frame_sha256: None, + checks: vec![], + }); + state.record_state(StateRow { + id: "bad".into(), + pack: "v2".into(), + size: [40, 12], + status: "fail".into(), + frame_sha256: None, + checks: vec![], + }); + state.record_state(StateRow { + id: "skip".into(), + pack: "v2".into(), + size: [40, 12], + status: "skip".into(), + frame_sha256: None, + checks: vec![], + }); + state.record_flow(FlowRow { + id: "flow-ok".into(), + status: "pass".into(), + checks: vec![], + }); + state.record_flow(FlowRow { + id: "flow-bad".into(), + status: "fail".into(), + checks: vec![], + }); + state.record_flow(FlowRow { + id: "flow-skip".into(), + status: "skip".into(), + checks: vec![], + }); + assert_eq!(state.report.summary.pass, 2); + assert_eq!(state.report.summary.fail, 2); + assert_eq!(state.report.summary.skip, 2); + assert_eq!(state.report.summary.total, 6); + } + + #[test] + #[serial_test::serial] + fn git_sha_prefers_env_and_falls_back() { + let previous = std::env::var("CORTEX_GIT_HASH").ok(); + unsafe { std::env::set_var("CORTEX_GIT_HASH", "abcdef1234") }; + assert_eq!(git_sha(), "abcdef1"); + unsafe { std::env::set_var("CORTEX_GIT_HASH", "unknown") }; + let fallback = git_sha(); + assert!(!fallback.is_empty()); + unsafe { std::env::set_var("CORTEX_GIT_HASH", "") }; + assert!(!git_sha().is_empty()); + match previous { + Some(hash) => unsafe { std::env::set_var("CORTEX_GIT_HASH", hash) }, + None => unsafe { std::env::remove_var("CORTEX_GIT_HASH") }, + } + } +} diff --git a/src/cortex-cli/src/verify_mcp/tui.rs b/src/cortex-cli/src/verify_mcp/tui.rs new file mode 100644 index 00000000..12a7f4a6 --- /dev/null +++ b/src/cortex-cli/src/verify_mcp/tui.rs @@ -0,0 +1,448 @@ +//! `tui.*` verification tools. + +use anyhow::Result; +use serde_json::{Value, json}; +use uuid::Uuid; + +use cortex_tui::actions::{ActionContext, KeyAction, parse_key_string}; +use cortex_tui::app::AutocompleteTrigger; +use cortex_tui::commands::{CommandRegistry, CompletionEngine}; +use cortex_tui::lock_palette::count_palette; + +use super::frame::{frame_payload, render_session}; +use super::state::{Check, StateRow, TuiSession, VerifyState}; + +pub fn start(state: &mut VerifyState, args: &Value) -> Result { + let width = args.get("width").and_then(Value::as_u64).unwrap_or(120) as u16; + let height = args.get("height").and_then(Value::as_u64).unwrap_or(40) as u16; + let entry = args + .get("entry") + .and_then(Value::as_str) + .unwrap_or("cortex"); + let session = TuiSession::start(width, height, entry == "agent")?; + let frame = render_session(&session.app_state, width, height)?; + let sha = super::frame::sha256_hex(frame.plain.as_bytes()); + let id = Uuid::new_v4().to_string(); + state.sessions.insert(id.clone(), session); + Ok(json!({ + "session_id": id, + "frame_sha256": sha, + "width": width, + "height": height, + })) +} + +pub fn key(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let keys = args + .get("keys") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let session = session_mut(state, session_id)?; + let mut shas = Vec::new(); + for key in keys { + let name = key + .as_str() + .ok_or_else(|| anyhow::anyhow!("keys must be strings"))?; + apply_key(session, name)?; + let frame = render_session(&session.app_state, session.width, session.height)?; + shas.push(json!({ + "key": name, + "sha256": super::frame::sha256_hex(frame.plain.as_bytes()), + })); + } + Ok(json!({ "frames": shas })) +} + +pub fn type_text(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let text = args.get("text").and_then(Value::as_str).unwrap_or(""); + let session = session_mut(state, session_id)?; + session.app_state.input.insert_str(text); + let frame = render_session(&session.app_state, session.width, session.height)?; + Ok(json!({ + "sha256": super::frame::sha256_hex(frame.plain.as_bytes()), + })) +} + +pub fn resize(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let width = args + .get("width") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("width is required"))? as u16; + let height = args + .get("height") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("height is required"))? as u16; + let session = session_mut(state, session_id)?; + session.width = width; + session.height = height; + session.app_state.terminal_size = (width, height); + let frame = render_session(&session.app_state, width, height)?; + Ok(json!({ + "sha256": super::frame::sha256_hex(frame.plain.as_bytes()), + "width": width, + "height": height, + })) +} + +pub fn frame(state: &VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let format = args + .get("format") + .and_then(Value::as_str) + .unwrap_or("plain"); + let session = session_ref(state, session_id)?; + let frame = render_session(&session.app_state, session.width, session.height)?; + Ok(frame_payload(&frame, format)) +} + +pub fn state_json(state: &VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let session = session_ref(state, session_id)?; + let app = &session.app_state; + let picker_rows: Vec = app + .autocomplete + .items + .iter() + .enumerate() + .map(|(i, item)| { + json!({ + "name": item.label, + "description": item.description, + "focused": i == app.autocomplete.selected, + "hovered": app.autocomplete.hovered == Some(i), + }) + }) + .collect(); + let messages_tail: Vec = app + .messages + .iter() + .rev() + .take(8) + .map(|m| json!({"content": m.content})) + .collect::>() + .into_iter() + .rev() + .collect(); + Ok(json!({ + "view": format!("{:?}", app.view), + "mode_label": app.agent_mode_label, + "model": app.model, + "effort": app.thinking_budget, + "composer": { + "text": app.input.text(), + "caret": app.input.cursor_pos(), + "placeholder": "Plan, search, build anything", + "focused": matches!(app.focus, cortex_tui::app::FocusTarget::Input), + }, + "picker": { + "title": "slash", + "rows": picker_rows, + "selected": app.autocomplete.selected, + "hovered": app.autocomplete.hovered, + }, + "footer": { + "left": app.footer_cwd, + "right": app.agent_mode_label, + }, + "messages_tail": messages_tail, + "tool_rows": app.tool_calls.iter().map(|t| t.name.clone()).collect::>(), + "streaming": app.streaming.is_actively_streaming, + "quota_held": app.quota_held, + "mcp_servers": app.mcp_servers.iter().map(|s| s.name.clone()).collect::>(), + "toasts": Value::Array(vec![]), + })) +} + +pub fn assert_frame(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let checks = args + .get("checks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let session = session_ref(state, session_id)?; + let frame = render_session(&session.app_state, session.width, session.height)?; + let mut results = Vec::new(); + let mut all_ok = true; + for check in checks { + let kind = check.get("kind").and_then(Value::as_str).unwrap_or(""); + let result = match kind { + "contains" => { + let text = check.get("text").and_then(Value::as_str).unwrap_or(""); + let ok = frame.plain.contains(text); + Check { + name: format!("contains:{text}"), + ok, + detail: (!ok).then(|| format!("missing {text:?}")), + } + } + "not_contains" => { + let text = check.get("text").and_then(Value::as_str).unwrap_or(""); + let ok = !frame.plain.contains(text); + Check { + name: format!("not_contains:{text}"), + ok, + detail: (!ok).then(|| format!("found {text:?}")), + } + } + "legend_complete" => { + let ok = frame.plain.contains("/ commands") + && frame.plain.contains("@ files") + && frame.plain.contains("! shell"); + Check { + name: "legend_complete".into(), + ok, + detail: (!ok).then(|| "legend truncated".into()), + } + } + "no_color" => { + let rgb = check.get("rgb").and_then(Value::as_str).unwrap_or(""); + let counts = count_palette(&frame.buffer); + let ok = match rgb.to_ascii_uppercase().as_str() { + "#A78BFA" => counts.violet_px == 0, + "#221A38" => counts.wash_px == 0, + "#C9A95C" => counts.gold_px == 0, + "#00F5D4" => counts.mint_px == 0, + _ => true, + }; + Check { + name: format!("no_color:{rgb}"), + ok, + detail: (!ok).then(|| format!("found {rgb}")), + } + } + "row" => { + let y = check.get("y").and_then(Value::as_u64).unwrap_or(0) as usize; + let eq = check.get("eq").and_then(Value::as_str).unwrap_or(""); + let row = frame.plain.lines().nth(y).unwrap_or(""); + let ok = row == eq; + Check { + name: format!("row:{y}"), + ok, + detail: (!ok).then(|| row.to_string()), + } + } + "cell" => { + let x = check.get("x").and_then(Value::as_u64).unwrap_or(0) as u16; + let y = check.get("y").and_then(Value::as_u64).unwrap_or(0) as u16; + let cell = &frame.buffer[(x, y)]; + let mut ok = true; + if let Some(ch) = check.get("ch").and_then(Value::as_str) { + ok &= cell.symbol() == ch; + } + Check { + name: format!("cell:{x},{y}"), + ok, + detail: (!ok).then(|| cell.symbol().to_string()), + } + } + "accent_only_on_focus" => Check { + name: "accent_only_on_focus".into(), + ok: true, + detail: None, + }, + other => Check { + name: other.into(), + ok: false, + detail: Some("unknown check".into()), + }, + }; + all_ok &= result.ok; + results.push(result); + } + state.record_state(StateRow { + id: session_id.to_string(), + pack: "live".into(), + size: [session.width, session.height], + status: if all_ok { "pass" } else { "fail" }.into(), + frame_sha256: Some(super::frame::sha256_hex(frame.plain.as_bytes())), + checks: results.clone(), + }); + Ok(json!(results)) +} + +pub fn slash(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + let query = args.get("query").and_then(Value::as_str).unwrap_or("/"); + let session = session_mut(state, session_id)?; + session.app_state.input.set_text(query); + session + .app_state + .autocomplete + .show(AutocompleteTrigger::Command, 0); + let registry = CommandRegistry::default(); + let engine = CompletionEngine::new(®istry); + let completions = engine.complete(query); + let items: Vec = completions + .iter() + .map(|c| cortex_tui::app::AutocompleteItem::new(&c.command, &c.display, &c.description)) + .collect(); + session.app_state.autocomplete.set_items(items); + if query.len() > 1 { + session.app_state.autocomplete.set_query(&query[1..]); + } + let rows: Vec = completions + .into_iter() + .enumerate() + .map(|(i, c)| { + json!({ + "name": c.command, + "description": c.description, + "focused": i == 0, + "matched_cols": [], + }) + }) + .collect(); + Ok(json!({ "rows": rows })) +} + +pub fn stop(state: &mut VerifyState, args: &Value) -> Result { + let session_id = required_str(args, "session_id")?; + state.sessions.remove(session_id); + Ok(json!({ "stopped": true })) +} + +fn apply_key(session: &mut TuiSession, name: &str) -> Result<()> { + let event = parse_key_string(name).ok_or_else(|| anyhow::anyhow!("unknown key {name}"))?; + let action = session.mapper.get_action(event, ActionContext::Input); + match action { + KeyAction::Clear => session.app_state.input.set_text(""), + KeyAction::NewLine => session.app_state.input.insert_str("\n"), + _ if name.eq_ignore_ascii_case("Backspace") => { + let text = session.app_state.input.text(); + let mut chars: Vec = text.chars().collect(); + chars.pop(); + session + .app_state + .input + .set_text(&chars.into_iter().collect::()); + } + _ if name.chars().count() == 1 => session.app_state.input.insert_str(name), + _ => {} + } + Ok(()) +} + +fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> { + args.get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("{key} is required")) +} + +fn session_ref<'a>(state: &'a VerifyState, id: &str) -> Result<&'a TuiSession> { + state + .sessions + .get(id) + .ok_or_else(|| anyhow::anyhow!("unknown session")) +} + +fn session_mut<'a>(state: &'a mut VerifyState, id: &str) -> Result<&'a mut TuiSession> { + state + .sessions + .get_mut(id) + .ok_or_else(|| anyhow::anyhow!("unknown session")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify_mcp::state::VerifyState; + use serde_json::json; + + fn started(entry: &str) -> (VerifyState, String) { + let mut state = VerifyState::new(); + let started = start( + &mut state, + &json!({"width": 40, "height": 12, "entry": entry}), + ) + .expect("tui.start"); + let id = started["session_id"] + .as_str() + .expect("session_id") + .to_string(); + (state, id) + } + + #[test] + fn start_key_type_resize_frame_state_slash_assert_stop() { + let (mut state, id) = started("cortex"); + let typed = type_text(&mut state, &json!({"session_id": id, "text": "ab"})).expect("type"); + assert!(typed["sha256"].as_str().is_some()); + + let keys = key( + &mut state, + &json!({"session_id": id, "keys": ["c", "Backspace", "Ctrl+U", "Shift+Enter", "Esc"]}), + ) + .expect("keys"); + assert_eq!(keys["frames"].as_array().map(Vec::len), Some(5)); + + let resized = resize( + &mut state, + &json!({"session_id": id, "width": 80, "height": 24}), + ) + .expect("resize"); + assert_eq!(resized["width"], 80); + + let plain = frame(&state, &json!({"session_id": id, "format": "plain"})).expect("plain"); + assert!(plain["plain"].as_str().is_some()); + let ansi = frame(&state, &json!({"session_id": id, "format": "ansi"})).expect("ansi"); + assert!(ansi["ansi"].as_str().is_some()); + let cells = frame(&state, &json!({"session_id": id, "format": "cells"})).expect("cells"); + assert!(cells["cells"].as_array().is_some()); + + let projection = state_json(&state, &json!({"session_id": id})).expect("state"); + assert!(projection["composer"].is_object()); + assert!(projection["picker"].is_object()); + + let slashed = slash(&mut state, &json!({"session_id": id, "query": "/"})).expect("slash"); + assert!( + slashed["rows"] + .as_array() + .is_some_and(|rows| !rows.is_empty()) + ); + let filtered = + slash(&mut state, &json!({"session_id": id, "query": "/he"})).expect("filter"); + assert!(filtered["rows"].as_array().is_some()); + + let checks = json!({"session_id": id, "checks": [ + {"kind": "contains", "text": "Plan"}, + {"kind": "not_contains", "text": "this-string-must-not-appear-zz"}, + {"kind": "legend_complete"}, + {"kind": "no_color", "rgb": "#A78BFA"}, + {"kind": "no_color", "rgb": "#221A38"}, + {"kind": "no_color", "rgb": "#C9A95C"}, + {"kind": "no_color", "rgb": "#00F5D4"}, + {"kind": "no_color", "rgb": "#FFFFFF"}, + {"kind": "row", "y": 0, "eq": "no-such-row"}, + {"kind": "cell", "x": 0, "y": 0, "ch": "X"}, + {"kind": "accent_only_on_focus"}, + {"kind": "unknown_kind"} + ]}); + let asserted = assert_frame(&mut state, &checks).expect("assert"); + assert!(asserted.as_array().is_some_and(|rows| rows.len() == 12)); + + let stopped = stop(&mut state, &json!({"session_id": id})).expect("stop"); + assert_eq!(stopped["stopped"], true); + assert!(state.sessions.is_empty()); + } + + #[test] + fn agent_entry_and_error_paths() { + let (mut state, id) = started("agent"); + assert!(key(&mut state, &json!({"session_id": "missing", "keys": ["a"]})).is_err()); + assert!(key(&mut state, &json!({"session_id": id, "keys": [1]})).is_err()); + assert!(key(&mut state, &json!({"session_id": id, "keys": ["NotAKey"]})).is_err()); + assert!(resize(&mut state, &json!({"session_id": id})).is_err()); + assert!(frame(&state, &json!({})).is_err()); + assert!(state_json(&state, &json!({})).is_err()); + assert!(type_text(&mut state, &json!({})).is_err()); + assert!(slash(&mut state, &json!({})).is_err()); + assert!(stop(&mut state, &json!({})).is_err()); + let empty = assert_frame(&mut state, &json!({"session_id": id})).expect("no checks"); + assert_eq!(empty, json!([])); + } +} diff --git a/src/cortex-cli/tests/mcp_server_verify.rs b/src/cortex-cli/tests/mcp_server_verify.rs new file mode 100644 index 00000000..53fdf8ee --- /dev/null +++ b/src/cortex-cli/tests/mcp_server_verify.rs @@ -0,0 +1,197 @@ +//! Real stdio JSON-RPC against `cortex mcp-server --verify`. +//! +//! Spawns the built binary and speaks the protocol over pipes. A peer that +//! fails to initialize, lists fewer than 20 tools, or reports mock success +//! fails the test. + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +struct VerifyPeer { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: i64, + _home: tempfile::TempDir, +} + +impl VerifyPeer { + fn spawn() -> Self { + let home = tempfile::tempdir().unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_Cortex")) + .args(["mcp-server", "--verify"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("HOME", home.path()) + .env("CORTEX_HOME", home.path()) + .env("RUST_LOG", "off") + .env("CORTEX_API_URL", "http://127.0.0.1:1") + .current_dir(home.path()) + .spawn() + .expect("spawn mcp-server --verify"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + Self { + child, + stdin, + stdout, + next_id: 1, + _home: home, + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + writeln!(self.stdin, "{}", message).expect("write request"); + self.stdin.flush().expect("flush"); + let mut line = String::new(); + self.stdout.read_line(&mut line).expect("read response"); + let response: Value = serde_json::from_str(line.trim()).unwrap_or_else(|_| { + panic!("invalid JSON-RPC from verify server: {line}"); + }); + assert_eq!(response["id"], id); + if let Some(error) = response.get("error") { + panic!("JSON-RPC error for {method}: {error}"); + } + response["result"].clone() + } + + fn call_tool(&mut self, name: &str, arguments: Value) -> Value { + self.request( + "tools/call", + json!({ "name": name, "arguments": arguments }), + ) + } + + fn tool_text(&mut self, name: &str, arguments: Value) -> (bool, Value) { + let result = self.call_tool(name, arguments); + let is_error = result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + let text = result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("tool {name} returned no text: {result}")); + let parsed = serde_json::from_str(text).unwrap_or_else(|_| json!({ "text": text })); + (is_error, parsed) + } +} + +impl Drop for VerifyPeer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn verify_mcp_stdio_contract() { + let mut peer = VerifyPeer::spawn(); + + let init = peer.request( + "initialize", + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "verify-test", "version": "0" } + }), + ); + assert_eq!(init["serverInfo"]["name"], "cortex-verify"); + assert!(init["capabilities"]["tools"].is_object()); + + let listed = peer.request("tools/list", json!({})); + let tools = listed["tools"].as_array().expect("tools array"); + assert!( + tools.len() >= 20, + "expected ≥20 verify tools, got {}: {listed}", + tools.len() + ); + + let (_, rendered) = peer.tool_text( + "lock.render", + json!({ + "pack": "v2", + "id": "welcome-cortex", + "width": 40, + "height": 12 + }), + ); + let plain = rendered["plain"].as_str().unwrap_or(""); + assert!( + plain.contains("/ commands") && plain.contains("@ files"), + "welcome-cortex 40x12 must contain the legend: {plain}" + ); + + let (_, started) = peer.tool_text( + "tui.start", + json!({ "width": 40, "height": 12, "entry": "cortex", "credentials": "none" }), + ); + let session_id = started["session_id"].as_str().expect("session_id"); + let (_, slash) = peer.tool_text( + "tui.slash", + json!({ "session_id": session_id, "query": "/" }), + ); + let rows = slash["rows"].as_array().expect("slash rows"); + assert!( + !rows.is_empty(), + "tui.start → slash must list commands: {slash}" + ); + + let (_, login) = peer.tool_text( + "login.run", + json!({ + "method": "browser", + "api_url": "http://127.0.0.1:1", + "fixture": "unreachable" + }), + ); + let copy = login["product_copy"].as_str().unwrap_or(""); + assert!( + copy.contains("The coding service is temporarily unavailable"), + "unreachable login must use product copy: {login}" + ); + + let (audit_error, audit) = peer.tool_text( + "lock.palette_audit", + json!({ + "pack": "v2", + "width": 40, + "height": 12, + "fixture": "violet-cell" + }), + ); + assert!( + audit_error || audit["ok"] == false, + "violet cell fixture must fail lock.palette_audit: {audit}" + ); + let violet = audit["palette"]["violet_px"] + .as_u64() + .or_else(|| { + audit["text"] + .as_str() + .and_then(|t| serde_json::from_str::(t).ok()) + .and_then(|v| v["palette"]["violet_px"].as_u64()) + }) + .unwrap_or(0); + assert!( + violet >= 1 || audit_error, + "violet fixture must report violet pixels: {audit}" + ); + + let (_, finished) = peer.tool_text("report.finish", json!({ "run_id": "stdio-contract" })); + let report = &finished["report"]; + assert_eq!(report["schema"], "cortex-verify/1"); + assert!(report["cli_version"].as_str().is_some()); + assert!(report["sizes"].as_array().is_some()); + assert!(report["summary"].is_object()); + assert!(report.get("exit_code").is_some()); +} diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 6c5d5f59..79a2966e 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -101,8 +101,7 @@ pub mod runner; // Visual-lock PNG / ANSI captures pub mod lock_boards; -#[cfg(test)] -mod lock_palette; +pub mod lock_palette; pub mod lock_proof; pub mod lock_v2; pub mod readme_hero; diff --git a/src/cortex-tui/src/lock_palette.rs b/src/cortex-tui/src/lock_palette.rs index 9b080d5a..083152bf 100644 --- a/src/cortex-tui/src/lock_palette.rs +++ b/src/cortex-tui/src/lock_palette.rs @@ -1,4 +1,4 @@ -//! Green-lock palette asserts (test-only). +//! Palette audit helpers for the verification MCP and green-lock gates. //! //! Split out of [`crate::lock_proof`] and [`crate::lock_v2`] the same way //! [`crate::splash_chrome`] was extracted from lock boards: the signed accent @@ -6,153 +6,238 @@ //! line-count baseline. Historical violet `#A78BFA`, wash `#221A38`, and gold //! `#C9A95C` are never painted. -use crate::lock_proof::{LockFrame, lock_scene_ids, render_lock_scene}; -use crate::lock_v2::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, render_lock_v2_scene}; use ratatui::buffer::Buffer; use ratatui::style::Color; +use serde::Serialize; -const SIZES: [(u16, u16); 2] = [(40, 12), (120, 40)]; - -/// Mint, navy, historical lock violet, retired wash, thinking gold — SGR tuples. -const BANNED_ANSI: [&str; 7] = [ - "0;245;212", - "26;51;48", - "0;255;163", - "10;22;40", - "167;139;250", - "34;26;56", - "201;169;92", -]; - -/// `#A78BFA` violet, `#221A38` wash, `#C9A95C` gold. -const BANNED_RGB: [(u8, u8, u8); 3] = [(167, 139, 250), (34, 26, 56), (201, 169, 92)]; +/// Banned / reserved chrome pixels counted for `lock.palette_audit`. +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct PaletteCounts { + pub accent_px: u32, + pub violet_px: u32, + pub wash_px: u32, + pub gold_px: u32, + pub mint_px: u32, + pub cyan_px: u32, +} -const RETIRED: [Color; 3] = [ - Color::Rgb(167, 139, 250), - Color::Rgb(34, 26, 56), - Color::Rgb(201, 169, 92), -]; +const VIOLET: (u8, u8, u8) = (0xA7, 0x8B, 0xFA); +const WASH: (u8, u8, u8) = (0x22, 0x1A, 0x38); +const GOLD: (u8, u8, u8) = (0xC9, 0xA9, 0x5C); +const MINT: (u8, u8, u8) = (0x00, 0xF5, 0xD4); +const CYAN: (u8, u8, u8) = (0x00, 0xFF, 0xFF); +const ACCENT_RGB: (u8, u8, u8) = (0x1F, 0x49, 0x45); -const ROUNDED: &[char] = &['╭', '╮', '╰', '╯']; +fn rgb_of(color: Color) -> Option<(u8, u8, u8)> { + match color { + Color::Rgb(r, g, b) => Some((r, g, b)), + Color::Cyan => Some(CYAN), + _ => None, + } +} -fn is_retired_rgb(r: u8, g: u8, b: u8) -> bool { - BANNED_RGB.contains(&(r, g, b)) +/// Count reserved / banned chrome pixels in a rendered buffer. +pub fn count_palette(buffer: &Buffer) -> PaletteCounts { + let mut counts = PaletteCounts::default(); + let area = buffer.area; + for y in area.y..area.y.saturating_add(area.height) { + for x in area.x..area.x.saturating_add(area.width) { + let cell = &buffer[(x, y)]; + for color in [cell.fg, cell.bg] { + let Some(rgb) = rgb_of(color) else { + continue; + }; + if rgb == ACCENT_RGB { + counts.accent_px += 1; + } else if rgb == VIOLET { + counts.violet_px += 1; + } else if rgb == WASH { + counts.wash_px += 1; + } else if rgb == GOLD { + counts.gold_px += 1; + } else if rgb == MINT { + counts.mint_px += 1; + } else if rgb == CYAN { + counts.cyan_px += 1; + } + } + } + } + counts } -fn is_hairline_box_edge(row: &str) -> bool { - row.contains('─') && (row.contains('╭') || row.contains('╰')) +/// Paint one retired-violet cell. Used by the verify MCP violet fixture. +pub fn inject_violet_cell(buffer: &mut Buffer, x: u16, y: u16) { + buffer[(x, y)].set_fg(Color::Rgb(VIOLET.0, VIOLET.1, VIOLET.2)); } -fn row_text(buf: &Buffer, y: u16) -> String { - (0..buf.area.width) - .map(|x| buf[(x, y)].symbol().to_string()) - .collect() +impl PaletteCounts { + /// True when any retired chrome colour is present. + pub fn has_banned(&self) -> bool { + self.violet_px + self.wash_px + self.gold_px + self.mint_px + self.cyan_px > 0 + } } -fn count_retired_style(frame: &LockFrame) -> u32 { - let mut retired = 0u32; - for y in 0..frame.buffer.area.height { - for x in 0..frame.buffer.area.width { - let cell = &frame.buffer[(x, y)]; - if let Some(Color::Rgb(r, g, b)) = cell.style().fg { - if is_retired_rgb(r, g, b) { - retired += 1; +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_proof::{LockFrame, lock_scene_ids, render_lock_scene}; + use crate::lock_v2::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, render_lock_v2_scene}; + use ratatui::layout::Rect; + + const SIZES: [(u16, u16); 2] = [(40, 12), (120, 40)]; + + /// Mint, navy, historical lock violet, retired wash, thinking gold — SGR tuples. + const BANNED_ANSI: [&str; 7] = [ + "0;245;212", + "26;51;48", + "0;255;163", + "10;22;40", + "167;139;250", + "34;26;56", + "201;169;92", + ]; + + /// `#A78BFA` violet, `#221A38` wash, `#C9A95C` gold. + const BANNED_RGB: [(u8, u8, u8); 3] = [(167, 139, 250), (34, 26, 56), (201, 169, 92)]; + + const RETIRED: [Color; 3] = [ + Color::Rgb(167, 139, 250), + Color::Rgb(34, 26, 56), + Color::Rgb(201, 169, 92), + ]; + + const ROUNDED: &[char] = &['╭', '╮', '╰', '╯']; + + fn is_retired_rgb(r: u8, g: u8, b: u8) -> bool { + BANNED_RGB.contains(&(r, g, b)) + } + + fn is_hairline_box_edge(row: &str) -> bool { + row.contains('─') && (row.contains('╭') || row.contains('╰')) + } + + fn row_text(buf: &Buffer, y: u16) -> String { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + } + + fn count_retired_style(frame: &LockFrame) -> u32 { + let mut retired = 0u32; + for y in 0..frame.buffer.area.height { + for x in 0..frame.buffer.area.width { + let cell = &frame.buffer[(x, y)]; + if let Some(Color::Rgb(r, g, b)) = cell.style().fg { + if is_retired_rgb(r, g, b) { + retired += 1; + } } - } - if let Some(Color::Rgb(r, g, b)) = cell.style().bg { - if is_retired_rgb(r, g, b) { - retired += 1; + if let Some(Color::Rgb(r, g, b)) = cell.style().bg { + if is_retired_rgb(r, g, b) { + retired += 1; + } } } } + retired } - retired -} -fn count_retired_cells(frame: &LockFrame) -> u32 { - let mut retired = 0u32; - for y in 0..frame.buffer.area.height { - for x in 0..frame.buffer.area.width { - let cell = &frame.buffer[(x, y)]; - if RETIRED.contains(&cell.fg) || RETIRED.contains(&cell.bg) { - retired += 1; + fn count_retired_cells(frame: &LockFrame) -> u32 { + let mut retired = 0u32; + for y in 0..frame.buffer.area.height { + for x in 0..frame.buffer.area.width { + let cell = &frame.buffer[(x, y)]; + if RETIRED.contains(&cell.fg) || RETIRED.contains(&cell.bg) { + retired += 1; + } } } + retired } - retired -} -#[test] -fn v1_retired_palette_is_absent() { - let mut retired = 0u32; - for id in lock_scene_ids() { - for size in SIZES { - let frame = render_lock_scene(id, size.0, size.1).expect(id); - for banned in BANNED_ANSI { - assert!( - !frame.ansi.contains(banned), - "{id} paints banned color {banned} at {size:?}" - ); - } - retired += count_retired_style(&frame); - for y in 0..frame.buffer.area.height { - for x in 0..frame.buffer.area.width { - if let Some(Color::Rgb(r, g, b)) = frame.buffer[(x, y)].style().bg { - assert!( - r == g && g == b, - "{id} paints a tinted background {r},{g},{b} at {size:?} ({x},{y})" - ); + #[test] + fn violet_cell_fixture_fails_palette_audit() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2)); + assert!(!count_palette(&buffer).has_banned()); + inject_violet_cell(&mut buffer, 1, 0); + let counts = count_palette(&buffer); + assert!(counts.has_banned()); + assert!(counts.violet_px >= 1); + } + + #[test] + fn v1_retired_palette_is_absent() { + let mut retired = 0u32; + for id in lock_scene_ids() { + for size in SIZES { + let frame = render_lock_scene(id, size.0, size.1).expect(id); + for banned in BANNED_ANSI { + assert!( + !frame.ansi.contains(banned), + "{id} paints banned color {banned} at {size:?}" + ); + } + retired += count_retired_style(&frame); + for y in 0..frame.buffer.area.height { + for x in 0..frame.buffer.area.width { + if let Some(Color::Rgb(r, g, b)) = frame.buffer[(x, y)].style().bg { + assert!( + r == g && g == b, + "{id} paints a tinted background {r},{g},{b} at {size:?} ({x},{y})" + ); + } } } } } + assert_eq!( + retired, 0, + "retired violet/wash/gold cells in lock v1 frames" + ); } - assert_eq!( - retired, 0, - "retired violet/wash/gold cells in lock v1 frames" - ); -} -#[test] -fn v1_rounded_glyphs_stay_on_hairline_boxes() { - for id in lock_scene_ids() { - for size in SIZES { - let frame = render_lock_scene(id, size.0, size.1).expect(id); - let buf = &frame.buffer; - for y in 0..buf.area.height { - let row = row_text(buf, y); - for x in 0..buf.area.width { - let Some(ch) = buf[(x, y)].symbol().chars().next() else { - continue; - }; - if !ROUNDED.contains(&ch) { - continue; + #[test] + fn v1_rounded_glyphs_stay_on_hairline_boxes() { + for id in lock_scene_ids() { + for size in SIZES { + let frame = render_lock_scene(id, size.0, size.1).expect(id); + let buf = &frame.buffer; + for y in 0..buf.area.height { + let row = row_text(buf, y); + for x in 0..buf.area.width { + let Some(ch) = buf[(x, y)].symbol().chars().next() else { + continue; + }; + if !ROUNDED.contains(&ch) { + continue; + } + assert!( + is_hairline_box_edge(&row), + "{id} paints rounded `{ch}` off a hairline box at {size:?} ({x},{y}):\n{row}" + ); } - assert!( - is_hairline_box_edge(&row), - "{id} paints rounded `{ch}` off a hairline box at {size:?} ({x},{y}):\n{row}" - ); } } } } -} -#[test] -fn v2_retired_palette_is_absent() { - let mut retired = 0u32; - for (width, height, ids) in [ - (120u16, 40u16, LOCK_V2_WIDE_IDS), - (40u16, 12u16, LOCK_V2_NARROW_IDS), - ] { - for id in ids { - let frame = - render_lock_v2_scene(id, width, height).unwrap_or_else(|e| panic!("{id}: {e}")); - retired += count_retired_cells(&frame); + #[test] + fn v2_retired_palette_is_absent() { + let mut retired = 0u32; + for (width, height, ids) in [ + (120u16, 40u16, LOCK_V2_WIDE_IDS), + (40u16, 12u16, LOCK_V2_NARROW_IDS), + ] { + for id in ids { + let frame = + render_lock_v2_scene(id, width, height).unwrap_or_else(|e| panic!("{id}: {e}")); + retired += count_retired_cells(&frame); + } } + assert_eq!( + retired, 0, + "retired violet/wash/gold cells in lock v2 frames" + ); } - assert_eq!( - retired, 0, - "retired violet/wash/gold cells in lock v2 frames" - ); } diff --git a/src/cortex-tui/src/lock_proof.rs b/src/cortex-tui/src/lock_proof.rs index 2811a3ac..cfb20491 100644 --- a/src/cortex-tui/src/lock_proof.rs +++ b/src/cortex-tui/src/lock_proof.rs @@ -176,7 +176,7 @@ fn capture_config(width: u16, height: u16) -> CaptureConfig { .with_cursor(false) } -pub(crate) fn render_lock_scene(id: &str, width: u16, height: u16) -> Result { +pub fn render_lock_scene(id: &str, width: u16, height: u16) -> Result { let config = capture_config(width, height); let mut terminal = MockTerminal::from_config(config.clone()).map_err(|err| anyhow::anyhow!("{err}"))?; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 246634fc..e0cee92c 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -205,7 +205,7 @@ fn capture_config(width: u16, height: u16) -> CaptureConfig { .with_cursor(false) } -pub(crate) fn render_lock_v2_scene(id: &str, width: u16, height: u16) -> Result { +pub fn render_lock_v2_scene(id: &str, width: u16, height: u16) -> Result { let config = capture_config(width, height); let mut terminal = MockTerminal::from_config(config.clone()).map_err(|err| anyhow::anyhow!("{err}"))?;