diff --git a/src/cli.rs b/src/cli.rs index c891949..a2d6513 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use clap::{Parser, Subcommand}; use crate::client::CovalClient; use crate::commands; use crate::config::Config; -use crate::output::OutputFormat; +use crate::output::{OutputContext, OutputFormat}; #[derive(Parser)] #[command(name = "coval")] @@ -21,6 +21,9 @@ pub struct Cli { #[arg(long, global = true, env = "COVAL_API_URL")] pub api_url: Option, + + #[arg(long, global = true)] + pub agent: bool, } #[derive(Subcommand)] @@ -100,18 +103,68 @@ pub enum Commands { }, } -pub async fn run(cli: Cli) -> anyhow::Result<()> { +impl Commands { + pub fn resource(&self) -> &'static str { + match self { + Self::Login(_) | Self::Whoami => "auth", + Self::Config { .. } => "config", + Self::Agents { .. } => "agents", + Self::Conversations { .. } => "conversations", + Self::Runs { .. } => "runs", + Self::Simulations { .. } => "simulations", + Self::TestSets { .. } => "test-sets", + Self::TestCases { .. } => "test-cases", + Self::Personas { .. } => "personas", + Self::Metrics { .. } => "metrics", + Self::Mutations { .. } => "mutations", + Self::ApiKeys { .. } => "api-keys", + Self::RunTemplates { .. } => "run-templates", + Self::ScheduledRuns { .. } => "scheduled-runs", + Self::Dashboards { command } => match command { + commands::dashboards::DashboardCommands::Widgets { .. } => "widgets", + _ => "dashboards", + }, + Self::ReviewAnnotations { .. } => "review-annotations", + Self::ReviewProjects { .. } => "review-projects", + } + } + + pub fn operation(&self) -> &'static str { + match self { + Self::Login(_) => "login", + Self::Whoami => "whoami", + Self::Config { command } => command.operation(), + Self::Agents { command } => command.operation(), + Self::Conversations { command } => command.operation(), + Self::Runs { command } => command.operation(), + Self::Simulations { command } => command.operation(), + Self::TestSets { command } => command.operation(), + Self::TestCases { command } => command.operation(), + Self::Personas { command } => command.operation(), + Self::Metrics { command } => command.operation(), + Self::Mutations { command } => command.operation(), + Self::ApiKeys { command } => command.operation(), + Self::RunTemplates { command } => command.operation(), + Self::ScheduledRuns { command } => command.operation(), + Self::Dashboards { command } => command.operation(), + Self::ReviewAnnotations { command } => command.operation(), + Self::ReviewProjects { command } => command.operation(), + } + } +} + +pub async fn run(cli: Cli, ctx: &OutputContext) -> anyhow::Result<()> { let config = Config::load().unwrap_or_default(); let api_key = cli.api_key.or(config.api_key); let api_url = cli.api_url.or(config.api_url); match cli.command { - Commands::Login(args) => commands::auth::login(args).await, + Commands::Login(args) => commands::auth::login(args, ctx).await, Commands::Whoami => { - commands::auth::whoami(api_key.as_ref()); + commands::auth::whoami(api_key.as_ref(), ctx); Ok(()) } - Commands::Config { command } => commands::config::execute(command), + Commands::Config { command } => commands::config::execute(command, ctx), _ => { let api_key = api_key.ok_or_else(|| { anyhow::anyhow!( @@ -122,49 +175,47 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { match cli.command { Commands::Agents { command } => { - commands::agents::execute(command, &client, cli.format).await + commands::agents::execute(command, &client, ctx).await } Commands::Conversations { command } => { - commands::conversations::execute(command, &client, cli.format).await - } - Commands::Runs { command } => { - commands::runs::execute(command, &client, cli.format).await + commands::conversations::execute(command, &client, ctx).await } + Commands::Runs { command } => commands::runs::execute(command, &client, ctx).await, Commands::Simulations { command } => { - commands::simulations::execute(command, &client, cli.format).await + commands::simulations::execute(command, &client, ctx).await } Commands::TestSets { command } => { - commands::test_sets::execute(command, &client, cli.format).await + commands::test_sets::execute(command, &client, ctx).await } Commands::TestCases { command } => { - commands::test_cases::execute(command, &client, cli.format).await + commands::test_cases::execute(command, &client, ctx).await } Commands::Personas { command } => { - commands::personas::execute(command, &client, cli.format).await + commands::personas::execute(command, &client, ctx).await } Commands::Metrics { command } => { - commands::metrics::execute(command, &client, cli.format).await + commands::metrics::execute(command, &client, ctx).await } Commands::Mutations { command } => { - commands::mutations::execute(command, &client, cli.format).await + commands::mutations::execute(command, &client, ctx).await } Commands::ApiKeys { command } => { - commands::api_keys::execute(command, &client, cli.format).await + commands::api_keys::execute(command, &client, ctx).await } Commands::RunTemplates { command } => { - commands::run_templates::execute(command, &client, cli.format).await + commands::run_templates::execute(command, &client, ctx).await } Commands::ScheduledRuns { command } => { - commands::scheduled_runs::execute(command, &client, cli.format).await + commands::scheduled_runs::execute(command, &client, ctx).await } Commands::Dashboards { command } => { - commands::dashboards::execute(command, &client, cli.format).await + commands::dashboards::execute(command, &client, ctx).await } Commands::ReviewAnnotations { command } => { - commands::review_annotations::execute(command, &client, cli.format).await + commands::review_annotations::execute(command, &client, ctx).await } Commands::ReviewProjects { command } => { - commands::review_projects::execute(command, &client, cli.format).await + commands::review_projects::execute(command, &client, ctx).await } _ => unreachable!(), } diff --git a/src/client/models/conversation.rs b/src/client/models/conversation.rs index 2dddc08..9c651c5 100644 --- a/src/client/models/conversation.rs +++ b/src/client/models/conversation.rs @@ -117,7 +117,7 @@ pub struct GetConversationResponse { pub conversation: Conversation, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ConversationAudioUrlResponse { pub audio_url: String, pub conversation_id: String, diff --git a/src/client/models/simulation.rs b/src/client/models/simulation.rs index b9cd131..e22494e 100644 --- a/src/client/models/simulation.rs +++ b/src/client/models/simulation.rs @@ -85,7 +85,7 @@ pub struct GetSimulationResponse { pub simulation: Simulation, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct AudioUrlResponse { pub audio_url: String, pub simulation_id: String, diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 7b8bbbe..eb60bcd 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{AgentType, CreateAgentRequest, ListParams, UpdateAgentRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum AgentCommands { @@ -14,6 +14,18 @@ pub enum AgentCommands { Delete(DeleteArgs), } +impl AgentCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -87,7 +99,8 @@ pub struct DeleteArgs { agent_id: String, } -pub async fn execute(cmd: AgentCommands, client: &CovalClient, format: OutputFormat) -> Result<()> { +pub async fn execute(cmd: AgentCommands, client: &CovalClient, ctx: &OutputContext) -> Result<()> { + let operation = cmd.operation(); match cmd { AgentCommands::List(args) => { let params = ListParams { @@ -97,11 +110,11 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, format: OutputFor ..Default::default() }; let response = client.agents().list(params).await?; - print_list(&response.agents, format); + emit_list(ctx, "agents", operation, &response.agents); } AgentCommands::Get(args) => { let agent = client.agents().get(&args.agent_id).await?; - print_one(&agent, format); + emit_one(ctx, "agents", operation, &agent); } AgentCommands::Create(args) => { let metadata = args @@ -121,7 +134,7 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, format: OutputFor test_set_ids: args.test_set_ids, }; let agent = client.agents().create(req).await?; - print_one(&agent, format); + emit_one(ctx, "agents", operation, &agent); } AgentCommands::Update(args) => { let metadata = args @@ -141,11 +154,11 @@ pub async fn execute(cmd: AgentCommands, client: &CovalClient, format: OutputFor test_set_ids: args.test_set_ids, }; let agent = client.agents().update(&args.agent_id, req).await?; - print_one(&agent, format); + emit_one(ctx, "agents", operation, &agent); } AgentCommands::Delete(args) => { client.agents().delete(&args.agent_id).await?; - print_success("Agent deleted."); + emit_success(ctx, "agents", operation, "Agent deleted."); } } Ok(()) diff --git a/src/commands/api_keys.rs b/src/commands/api_keys.rs index 98f4b3e..46d7118 100644 --- a/src/commands/api_keys.rs +++ b/src/commands/api_keys.rs @@ -6,7 +6,9 @@ use crate::client::models::{ UpdateApiKeyRequest, }; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{ + emit_list, emit_one, emit_one_with_warnings, emit_success, AgentWarning, OutputContext, +}; #[derive(Subcommand)] pub enum ApiKeyCommands { @@ -16,6 +18,17 @@ pub enum ApiKeyCommands { Delete(DeleteArgs), } +impl ApiKeyCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -58,11 +71,8 @@ pub struct DeleteArgs { api_key_id: String, } -pub async fn execute( - cmd: ApiKeyCommands, - client: &CovalClient, - format: OutputFormat, -) -> Result<()> { +pub async fn execute(cmd: ApiKeyCommands, client: &CovalClient, ctx: &OutputContext) -> Result<()> { + let operation = cmd.operation(); match cmd { ApiKeyCommands::List(args) => { let params = ListParams { @@ -75,7 +85,7 @@ pub async fn execute( .api_keys() .list(params, args.status, args.environment) .await?; - print_list(&response.api_keys, format); + emit_list(ctx, "api-keys", operation, &response.api_keys); } ApiKeyCommands::Create(args) => { let req = CreateApiKeyRequest { @@ -87,10 +97,25 @@ pub async fn execute( }; let api_key = client.api_keys().create(req).await?; if !api_key.key.is_empty() && !api_key.key.contains("***") { - eprintln!("WARNING: Store this key now. It will not be shown again."); - eprintln!("Key: {}", api_key.key); + if ctx.agent { + emit_one_with_warnings( + ctx, + "api-keys", + operation, + &api_key, + vec![AgentWarning::new( + "store_api_key", + "Store this key now. It will not be shown again.", + )], + ); + } else { + eprintln!("WARNING: Store this key now. It will not be shown again."); + eprintln!("Key: {}", api_key.key); + emit_one(ctx, "api-keys", operation, &api_key); + } + } else { + emit_one(ctx, "api-keys", operation, &api_key); } - print_one(&api_key, format); } ApiKeyCommands::Update(args) => { let req = UpdateApiKeyRequest { @@ -98,11 +123,11 @@ pub async fn execute( reason: args.reason, }; let api_key = client.api_keys().update(&args.api_key_id, req).await?; - print_one(&api_key, format); + emit_one(ctx, "api-keys", operation, &api_key); } ApiKeyCommands::Delete(args) => { client.api_keys().delete(&args.api_key_id).await?; - print_success("API key deleted."); + emit_success(ctx, "api-keys", operation, "API key deleted."); } } Ok(()) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 9dce8e3..5220fc6 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -2,10 +2,12 @@ use std::io::{self, Write}; use anyhow::Result; use clap::Args; +use serde_json::json; use crate::client::models::ListParams; use crate::client::CovalClient; -use crate::config::Config; +use crate::config::{mask_api_key, Config}; +use crate::output::{emit_one, OutputContext}; #[derive(Args)] pub struct LoginArgs { @@ -13,9 +15,11 @@ pub struct LoginArgs { pub api_key: Option, } -pub async fn login(args: LoginArgs) -> Result<()> { +pub async fn login(args: LoginArgs, ctx: &OutputContext) -> Result<()> { let api_key = if let Some(key) = args.api_key { key + } else if ctx.agent { + anyhow::bail!("API key is required in agent mode. Pass `coval login --api-key `.") } else { print!("Enter your Coval API key: "); io::stdout().flush()?; @@ -38,8 +42,20 @@ pub async fn login(args: LoginArgs) -> Result<()> { config.save()?; let path = Config::path(); - println!("Authenticated successfully."); - println!("Credentials saved to {}", path.display()); + if ctx.agent { + emit_one( + ctx, + "auth", + "login", + &json!({ + "authenticated": true, + "config_path": path.display().to_string(), + }), + ); + } else { + println!("Authenticated successfully."); + println!("Credentials saved to {}", path.display()); + } Ok(()) } Err(e) => { @@ -48,14 +64,32 @@ pub async fn login(args: LoginArgs) -> Result<()> { } } -pub fn whoami(api_key: Option<&String>) { +pub fn whoami(api_key: Option<&String>, ctx: &OutputContext) { if let Some(key) = api_key { - let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len() - 4..]) + let masked = mask_api_key(key); + if ctx.agent { + emit_one( + ctx, + "auth", + "whoami", + &json!({ + "authenticated": true, + "api_key": masked, + }), + ); } else { - "****".to_string() - }; - println!("Authenticated with API key: {masked}"); + println!("Authenticated with API key: {masked}"); + } + } else if ctx.agent { + emit_one( + ctx, + "auth", + "whoami", + &json!({ + "authenticated": false, + "api_key": null, + }), + ); } else { println!("Not authenticated. Run `coval login` to authenticate."); } diff --git a/src/commands/config.rs b/src/commands/config.rs index 8363a20..1188e60 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,7 +1,9 @@ use anyhow::Result; use clap::Subcommand; +use serde_json::json; -use crate::config::Config; +use crate::config::{mask_api_key, Config}; +use crate::output::{emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum ConfigCommands { @@ -10,30 +12,47 @@ pub enum ConfigCommands { Set { key: String, value: String }, } -pub fn execute(cmd: ConfigCommands) -> Result<()> { +impl ConfigCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::Path => "path", + Self::Get { .. } => "get", + Self::Set { .. } => "set", + } + } +} + +pub fn execute(cmd: ConfigCommands, ctx: &OutputContext) -> Result<()> { + let operation = cmd.operation(); match cmd { ConfigCommands::Path => { - println!("{}", Config::path().display()); + let path = Config::path().display().to_string(); + if ctx.agent { + emit_one(ctx, "config", operation, &json!({ "path": path })); + } else { + println!("{path}"); + } } ConfigCommands::Get { key } => { let config = Config::load()?; - match key.as_str() { - "api_key" => { - if let Some(key) = config.api_key { - let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len() - 4..]) - } else { - "****".to_string() - }; - println!("{masked}"); - } - } - "api_url" => { - if let Some(url) = config.api_url { - println!("{url}"); - } - } + let value = match key.as_str() { + "api_key" => config.api_key.map(|key| mask_api_key(&key)), + "api_url" => config.api_url, _ => anyhow::bail!("Unknown config key: {key}"), + }; + + if ctx.agent { + emit_one( + ctx, + "config", + operation, + &json!({ + "key": key, + "value": value, + }), + ); + } else if let Some(value) = value { + println!("{value}"); } } ConfigCommands::Set { key, value } => { @@ -44,7 +63,7 @@ pub fn execute(cmd: ConfigCommands) -> Result<()> { _ => anyhow::bail!("Unknown config key: {key}"), } config.save()?; - println!("Config updated."); + emit_success(ctx, "config", operation, "Config updated."); } } Ok(()) diff --git a/src/commands/conversations.rs b/src/commands/conversations.rs index 0d0a167..95e17b0 100644 --- a/src/commands/conversations.rs +++ b/src/commands/conversations.rs @@ -11,7 +11,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use crate::client::error::ApiError; use crate::client::models::{ListParams, MetricDetailResponse, SubmitConversationRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum ConversationCommands { @@ -25,6 +25,20 @@ pub enum ConversationCommands { MetricDetail(MetricDetailArgs), } +impl ConversationCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Delete(_) => "delete", + Self::Audio(_) => "audio", + Self::Submit(_) => "submit", + Self::Metrics(_) => "metrics", + Self::MetricDetail(_) => "metric-detail", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -107,8 +121,9 @@ pub struct MetricDetailArgs { pub async fn execute( cmd: ConversationCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { ConversationCommands::List(args) => { let params = ListParams { @@ -118,14 +133,16 @@ pub async fn execute( ..Default::default() }; let response = client.conversations().list(params).await?; - print_list(&response.conversations, format); + emit_list(ctx, "conversations", operation, &response.conversations); } ConversationCommands::Get(args) => { let result = client.conversations().get(&args.conversation_id).await; match result { - Ok(conversation) => print_one(&conversation, format), + Ok(conversation) => emit_one(ctx, "conversations", operation, &conversation), Err(ApiError::NotFound { .. }) => { - print_not_found_hint(&args.conversation_id, "simulations"); + if !ctx.agent { + print_not_found_hint(&args.conversation_id, "simulations"); + } return Err(ApiError::NotFound { resource: format!("Conversation '{}'", args.conversation_id), } @@ -137,9 +154,11 @@ pub async fn execute( ConversationCommands::Delete(args) => { let result = client.conversations().delete(&args.conversation_id).await; match result { - Ok(()) => print_success("Conversation deleted."), + Ok(()) => emit_success(ctx, "conversations", operation, "Conversation deleted."), Err(ApiError::NotFound { .. }) => { - print_not_found_hint(&args.conversation_id, "simulations"); + if !ctx.agent { + print_not_found_hint(&args.conversation_id, "simulations"); + } return Err(ApiError::NotFound { resource: format!("Conversation '{}'", args.conversation_id), } @@ -153,7 +172,7 @@ pub async fn execute( .conversations() .list_metrics(&args.conversation_id) .await?; - print_list(&response.metrics, format); + emit_list(ctx, "conversations", operation, &response.metrics); } ConversationCommands::MetricDetail(args) => { let response = client @@ -161,27 +180,38 @@ pub async fn execute( .get_metric(&args.conversation_id, &args.metric_id) .await?; match response { - MetricDetailResponse::Single { metric } => print_one(&metric, format), + MetricDetailResponse::Single { metric } => { + emit_one(ctx, "conversations", operation, &metric) + } MetricDetailResponse::Collection { metric_outputs } => { - print_list(&metric_outputs, format) + emit_list(ctx, "conversations", operation, &metric_outputs) } } } ConversationCommands::Submit(args) => { let req = build_submit_request(args)?; let conversation = client.conversations().submit(req).await?; - print_one(&conversation, format); + emit_one(ctx, "conversations", operation, &conversation); } ConversationCommands::Audio(args) => { let audio = client.conversations().audio(&args.conversation_id).await?; match args.output { Some(path) => { - download_audio(&audio.audio_url, &path).await?; - print_success(&format!("Audio saved to {}", path.display())); + download_audio(&audio.audio_url, &path, !ctx.agent).await?; + emit_success( + ctx, + "conversations", + operation, + &format!("Audio saved to {}", path.display()), + ); } None => { - println!("{}", audio.audio_url); + if ctx.human() { + println!("{}", audio.audio_url); + } else { + emit_one(ctx, "conversations", operation, &audio); + } } } } @@ -265,7 +295,7 @@ fn build_submit_request(args: SubmitArgs) -> Result { }) } -async fn download_audio(url: &str, path: &Path) -> Result<()> { +async fn download_audio(url: &str, path: &Path, show_progress: bool) -> Result<()> { let client = reqwest::Client::new(); let resp = client.get(url).send().await?; @@ -274,16 +304,17 @@ async fn download_audio(url: &str, path: &Path) -> Result<()> { } let total = resp.content_length().unwrap_or(0); - let pb = ProgressBar::new(total); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes}")? - .progress_chars("=>-"), - ); - let bytes = resp.bytes().await?; - pb.set_position(bytes.len() as u64); - pb.finish_and_clear(); + if show_progress { + let pb = ProgressBar::new(total); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes}")? + .progress_chars("=>-"), + ); + pb.set_position(bytes.len() as u64); + pb.finish_and_clear(); + } std::fs::write(path, &bytes)?; Ok(()) diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index 515b500..42f849c 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -6,7 +6,7 @@ use crate::client::models::{ UpdateWidgetRequest, WidgetType, }; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum DashboardCommands { @@ -21,6 +21,19 @@ pub enum DashboardCommands { }, } +impl DashboardCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + Self::Widgets { command } => command.operation(), + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -63,6 +76,18 @@ pub enum WidgetCommands { Delete(WidgetDeleteArgs), } +impl WidgetCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "widgets.list", + Self::Get(_) => "widgets.get", + Self::Create(_) => "widgets.create", + Self::Update(_) => "widgets.update", + Self::Delete(_) => "widgets.delete", + } + } +} + #[derive(Args)] pub struct WidgetListArgs { dashboard_id: String, @@ -147,8 +172,9 @@ fn parse_config(raw: &str) -> Result { pub async fn execute( cmd: DashboardCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { DashboardCommands::List(args) => { let params = ListParams { @@ -158,31 +184,31 @@ pub async fn execute( ..Default::default() }; let response = client.dashboards().list(params).await?; - print_list(&response.dashboards, format); + emit_list(ctx, "dashboards", operation, &response.dashboards); } DashboardCommands::Get(args) => { let dashboard = client.dashboards().get(&args.dashboard_id).await?; - print_one(&dashboard, format); + emit_one(ctx, "dashboards", operation, &dashboard); } DashboardCommands::Create(args) => { let req = CreateDashboardRequest { display_name: args.name, }; let dashboard = client.dashboards().create(req).await?; - print_one(&dashboard, format); + emit_one(ctx, "dashboards", operation, &dashboard); } DashboardCommands::Update(args) => { let req = UpdateDashboardRequest { display_name: args.name, }; let dashboard = client.dashboards().update(&args.dashboard_id, req).await?; - print_one(&dashboard, format); + emit_one(ctx, "dashboards", operation, &dashboard); } DashboardCommands::Delete(args) => { client.dashboards().delete(&args.dashboard_id).await?; - print_success("Dashboard deleted."); + emit_success(ctx, "dashboards", operation, "Dashboard deleted."); } - DashboardCommands::Widgets { command } => execute_widget(command, client, format).await?, + DashboardCommands::Widgets { command } => execute_widget(command, client, ctx).await?, } Ok(()) } @@ -190,8 +216,9 @@ pub async fn execute( async fn execute_widget( cmd: WidgetCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { WidgetCommands::List(args) => { let params = ListParams { @@ -199,14 +226,14 @@ async fn execute_widget( ..Default::default() }; let response = client.widgets(&args.dashboard_id).list(params).await?; - print_list(&response.widgets, format); + emit_list(ctx, "widgets", operation, &response.widgets); } WidgetCommands::Get(args) => { let widget = client .widgets(&args.dashboard_id) .get(&args.widget_id) .await?; - print_one(&widget, format); + emit_one(ctx, "widgets", operation, &widget); } WidgetCommands::Create(args) => { validate_widget_grid(args.grid_w, args.grid_h)?; @@ -221,7 +248,7 @@ async fn execute_widget( grid_h: args.grid_h, }; let widget = client.widgets(&args.dashboard_id).create(req).await?; - print_one(&widget, format); + emit_one(ctx, "widgets", operation, &widget); } WidgetCommands::Update(args) => { validate_widget_grid(args.grid_w, args.grid_h)?; @@ -239,14 +266,14 @@ async fn execute_widget( .widgets(&args.dashboard_id) .update(&args.widget_id, req) .await?; - print_one(&widget, format); + emit_one(ctx, "widgets", operation, &widget); } WidgetCommands::Delete(args) => { client .widgets(&args.dashboard_id) .delete(&args.widget_id) .await?; - print_success("Widget deleted."); + emit_success(ctx, "widgets", operation, "Widget deleted."); } } Ok(()) diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index 25cbdee..ebd3ecb 100644 --- a/src/commands/metrics.rs +++ b/src/commands/metrics.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreateMetricRequest, ListParams, MetricType, UpdateMetricRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum MetricCommands { @@ -14,6 +14,18 @@ pub enum MetricCommands { Delete(DeleteArgs), } +impl MetricCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Filter expression (supports metric_type, metric_name, create_time) @@ -144,11 +156,8 @@ pub struct DeleteArgs { metric_id: String, } -pub async fn execute( - cmd: MetricCommands, - client: &CovalClient, - format: OutputFormat, -) -> Result<()> { +pub async fn execute(cmd: MetricCommands, client: &CovalClient, ctx: &OutputContext) -> Result<()> { + let operation = cmd.operation(); match cmd { MetricCommands::List(args) => { let params = ListParams { @@ -158,11 +167,11 @@ pub async fn execute( ..Default::default() }; let response = client.metrics().list(params, args.include_builtin).await?; - print_list(&response.metrics, format); + emit_list(ctx, "metrics", operation, &response.metrics); } MetricCommands::Get(args) => { let metric = client.metrics().get(&args.metric_id).await?; - print_one(&metric, format); + emit_one(ctx, "metrics", operation, &metric); } MetricCommands::Create(args) => { let target_condition = args @@ -190,7 +199,7 @@ pub async fn execute( target_condition, }; let metric = client.metrics().create(req).await?; - print_one(&metric, format); + emit_one(ctx, "metrics", operation, &metric); } MetricCommands::Update(args) => { let target_condition = args @@ -218,11 +227,11 @@ pub async fn execute( target_condition, }; let metric = client.metrics().update(&args.metric_id, req).await?; - print_one(&metric, format); + emit_one(ctx, "metrics", operation, &metric); } MetricCommands::Delete(args) => { client.metrics().delete(&args.metric_id).await?; - print_success("Metric deleted."); + emit_success(ctx, "metrics", operation, "Metric deleted."); } } Ok(()) diff --git a/src/commands/mutations.rs b/src/commands/mutations.rs index a7658dd..126468b 100644 --- a/src/commands/mutations.rs +++ b/src/commands/mutations.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreateMutationRequest, ListParams, UpdateMutationRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum MutationCommands { @@ -14,6 +14,18 @@ pub enum MutationCommands { Delete(DeleteArgs), } +impl MutationCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Parent agent ID (22-char ID) @@ -76,8 +88,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: MutationCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { MutationCommands::List(args) => { let params = ListParams { @@ -85,14 +98,14 @@ pub async fn execute( ..Default::default() }; let response = client.mutations(&args.agent_id).list(params).await?; - print_list(&response.mutations, format); + emit_list(ctx, "mutations", operation, &response.mutations); } MutationCommands::Get(args) => { let mutation = client .mutations(&args.agent_id) .get(&args.mutation_id) .await?; - print_one(&mutation, format); + emit_one(ctx, "mutations", operation, &mutation); } MutationCommands::Create(args) => { let config_overrides = args @@ -107,7 +120,7 @@ pub async fn execute( parameter_values: None, }; let mutation = client.mutations(&args.agent_id).create(req).await?; - print_one(&mutation, format); + emit_one(ctx, "mutations", operation, &mutation); } MutationCommands::Update(args) => { let config_overrides = args @@ -125,14 +138,14 @@ pub async fn execute( .mutations(&args.agent_id) .update(&args.mutation_id, req) .await?; - print_one(&mutation, format); + emit_one(ctx, "mutations", operation, &mutation); } MutationCommands::Delete(args) => { client .mutations(&args.agent_id) .delete(&args.mutation_id) .await?; - print_success("Mutation deleted."); + emit_success(ctx, "mutations", operation, "Mutation deleted."); } } Ok(()) diff --git a/src/commands/personas.rs b/src/commands/personas.rs index c6c479c..74fcf9d 100644 --- a/src/commands/personas.rs +++ b/src/commands/personas.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreatePersonaRequest, ListParams, UpdatePersonaRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum PersonaCommands { @@ -16,6 +16,19 @@ pub enum PersonaCommands { PhoneNumbers, } +impl PersonaCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + Self::PhoneNumbers => "phone-numbers", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Filter expression (supports name, create_time, update_time) @@ -87,8 +100,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: PersonaCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { PersonaCommands::List(args) => { let params = ListParams { @@ -98,11 +112,11 @@ pub async fn execute( ..Default::default() }; let response = client.personas().list(params).await?; - print_list(&response.personas, format); + emit_list(ctx, "personas", operation, &response.personas); } PersonaCommands::Get(args) => { let persona = client.personas().get(&args.persona_id).await?; - print_one(&persona, format); + emit_one(ctx, "personas", operation, &persona); } PersonaCommands::Create(args) => { let req = CreatePersonaRequest { @@ -115,7 +129,7 @@ pub async fn execute( conversation_initiation: None, }; let persona = client.personas().create(req).await?; - print_one(&persona, format); + emit_one(ctx, "personas", operation, &persona); } PersonaCommands::Update(args) => { let req = UpdatePersonaRequest { @@ -128,15 +142,15 @@ pub async fn execute( ..Default::default() }; let persona = client.personas().update(&args.persona_id, req).await?; - print_one(&persona, format); + emit_one(ctx, "personas", operation, &persona); } PersonaCommands::Delete(args) => { client.personas().delete(&args.persona_id).await?; - print_success("Persona deleted."); + emit_success(ctx, "personas", operation, "Persona deleted."); } PersonaCommands::PhoneNumbers => { let response = client.personas().list_phone_numbers().await?; - print_list(&response.phone_numbers, format); + emit_list(ctx, "personas", operation, &response.phone_numbers); } } Ok(()) diff --git a/src/commands/review_annotations.rs b/src/commands/review_annotations.rs index 40c9fc9..fe02467 100644 --- a/src/commands/review_annotations.rs +++ b/src/commands/review_annotations.rs @@ -6,7 +6,7 @@ use crate::client::models::{ ListParams, UpdateReviewAnnotationRequest, }; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum ReviewAnnotationCommands { @@ -17,6 +17,18 @@ pub enum ReviewAnnotationCommands { Delete(DeleteArgs), } +impl ReviewAnnotationCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -97,8 +109,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: ReviewAnnotationCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { ReviewAnnotationCommands::List(args) => { let params = ListParams { @@ -108,11 +121,16 @@ pub async fn execute( ..Default::default() }; let response = client.review_annotations().list(params).await?; - print_list(&response.review_annotations, format); + emit_list( + ctx, + "review-annotations", + operation, + &response.review_annotations, + ); } ReviewAnnotationCommands::Get(args) => { let annotation = client.review_annotations().get(&args.annotation_id).await?; - print_one(&annotation, format); + emit_one(ctx, "review-annotations", operation, &annotation); } ReviewAnnotationCommands::Create(args) => { let subvalues = args @@ -130,7 +148,7 @@ pub async fn execute( priority: args.priority, }; let annotation = client.review_annotations().create(req).await?; - print_one(&annotation, format); + emit_one(ctx, "review-annotations", operation, &annotation); } ReviewAnnotationCommands::Update(args) => { let subvalues = args @@ -151,14 +169,19 @@ pub async fn execute( .review_annotations() .update(&args.annotation_id, req) .await?; - print_one(&annotation, format); + emit_one(ctx, "review-annotations", operation, &annotation); } ReviewAnnotationCommands::Delete(args) => { client .review_annotations() .delete(&args.annotation_id) .await?; - print_success("Review annotation deleted."); + emit_success( + ctx, + "review-annotations", + operation, + "Review annotation deleted.", + ); } } Ok(()) diff --git a/src/commands/review_projects.rs b/src/commands/review_projects.rs index 676b3d6..21a8a89 100644 --- a/src/commands/review_projects.rs +++ b/src/commands/review_projects.rs @@ -5,7 +5,7 @@ use crate::client::models::{ CreateReviewProjectRequest, ListParams, ProjectType, UpdateReviewProjectRequest, }; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum ReviewProjectCommands { @@ -16,6 +16,18 @@ pub enum ReviewProjectCommands { Delete(DeleteArgs), } +impl ReviewProjectCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -90,8 +102,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: ReviewProjectCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { ReviewProjectCommands::List(args) => { let params = ListParams { @@ -101,11 +114,11 @@ pub async fn execute( ..Default::default() }; let response = client.review_projects().list(params).await?; - print_list(&response.review_projects, format); + emit_list(ctx, "review-projects", operation, &response.review_projects); } ReviewProjectCommands::Get(args) => { let project = client.review_projects().get(&args.project_id).await?; - print_one(&project, format); + emit_one(ctx, "review-projects", operation, &project); } ReviewProjectCommands::Create(args) => { let req = CreateReviewProjectRequest { @@ -118,7 +131,7 @@ pub async fn execute( notifications: args.notifications, }; let project = client.review_projects().create(req).await?; - print_one(&project, format); + emit_one(ctx, "review-projects", operation, &project); } ReviewProjectCommands::Update(args) => { let req = UpdateReviewProjectRequest { @@ -134,11 +147,11 @@ pub async fn execute( .review_projects() .update(&args.project_id, req) .await?; - print_one(&project, format); + emit_one(ctx, "review-projects", operation, &project); } ReviewProjectCommands::Delete(args) => { client.review_projects().delete(&args.project_id).await?; - print_success("Review project deleted."); + emit_success(ctx, "review-projects", operation, "Review project deleted."); } } Ok(()) diff --git a/src/commands/run_templates.rs b/src/commands/run_templates.rs index 78967d7..3e2df04 100644 --- a/src/commands/run_templates.rs +++ b/src/commands/run_templates.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreateRunTemplateRequest, ListParams, UpdateRunTemplateRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum RunTemplateCommands { @@ -14,6 +14,18 @@ pub enum RunTemplateCommands { Delete(DeleteArgs), } +impl RunTemplateCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -90,8 +102,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: RunTemplateCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { RunTemplateCommands::List(args) => { let params = ListParams { @@ -101,11 +114,11 @@ pub async fn execute( ..Default::default() }; let response = client.run_templates().list(params).await?; - print_list(&response.run_templates, format); + emit_list(ctx, "run-templates", operation, &response.run_templates); } RunTemplateCommands::Get(args) => { let template = client.run_templates().get(&args.run_template_id).await?; - print_one(&template, format); + emit_one(ctx, "run-templates", operation, &template); } RunTemplateCommands::Create(args) => { let req = CreateRunTemplateRequest { @@ -123,7 +136,7 @@ pub async fn execute( metadata: None, }; let template = client.run_templates().create(req).await?; - print_one(&template, format); + emit_one(ctx, "run-templates", operation, &template); } RunTemplateCommands::Update(args) => { let req = UpdateRunTemplateRequest { @@ -144,11 +157,11 @@ pub async fn execute( .run_templates() .update(&args.run_template_id, req) .await?; - print_one(&template, format); + emit_one(ctx, "run-templates", operation, &template); } RunTemplateCommands::Delete(args) => { client.run_templates().delete(&args.run_template_id).await?; - print_success("Run template deleted."); + emit_success(ctx, "run-templates", operation, "Run template deleted."); } } Ok(()) diff --git a/src/commands/runs.rs b/src/commands/runs.rs index 90d3ff2..fa83d41 100644 --- a/src/commands/runs.rs +++ b/src/commands/runs.rs @@ -8,7 +8,7 @@ use crate::client::models::{ LaunchMetadata, LaunchOptions, LaunchRunRequest, ListParams, RunStatus, UpdateRunRequest, }; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum RunCommands { @@ -20,6 +20,19 @@ pub enum RunCommands { Delete(DeleteArgs), } +impl RunCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Launch(_) => "launch", + Self::Update(_) => "update", + Self::Watch(_) => "watch", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Filter expression (supports status, agent_id, persona_id, test_set_id, create_time, tag) @@ -106,7 +119,8 @@ pub struct DeleteArgs { run_id: String, } -pub async fn execute(cmd: RunCommands, client: &CovalClient, format: OutputFormat) -> Result<()> { +pub async fn execute(cmd: RunCommands, client: &CovalClient, ctx: &OutputContext) -> Result<()> { + let operation = cmd.operation(); match cmd { RunCommands::List(args) => { let params = ListParams { @@ -116,11 +130,11 @@ pub async fn execute(cmd: RunCommands, client: &CovalClient, format: OutputForma ..Default::default() }; let response = client.runs().list(params).await?; - print_list(&response.runs, format); + emit_list(ctx, "runs", operation, &response.runs); } RunCommands::Get(args) => { let run = client.runs().get(&args.run_id).await?; - print_one(&run, format); + emit_one(ctx, "runs", operation, &run); } RunCommands::Launch(args) => { if let Some(ref ids) = args.test_cases { @@ -167,7 +181,7 @@ pub async fn execute(cmd: RunCommands, client: &CovalClient, format: OutputForma metadata, }; let run = client.runs().launch(req).await?; - print_one(&run, format); + emit_one(ctx, "runs", operation, &run); } RunCommands::Update(args) => { let tags = args @@ -179,21 +193,47 @@ pub async fn execute(cmd: RunCommands, client: &CovalClient, format: OutputForma .runs() .update(&args.run_id, UpdateRunRequest { tags }) .await?; - print_one(&run, format); + emit_one(ctx, "runs", operation, &run); } RunCommands::Watch(args) => { - watch_run(client, &args.run_id, args.interval).await?; + watch_run(client, &args.run_id, args.interval, ctx).await?; } RunCommands::Delete(args) => { client.runs().delete(&args.run_id).await?; - print_success("Run deleted."); + emit_success(ctx, "runs", operation, "Run deleted."); } } Ok(()) } #[allow(clippy::cast_sign_loss)] -async fn watch_run(client: &CovalClient, run_id: &str, interval_secs: u64) -> Result<()> { +async fn watch_run( + client: &CovalClient, + run_id: &str, + interval_secs: u64, + ctx: &OutputContext, +) -> Result<()> { + if ctx.agent { + loop { + let run = client.runs().get(run_id).await?; + match run.status { + RunStatus::Completed | RunStatus::Cancelled => { + emit_one(ctx, "runs", "watch", &run); + break; + } + RunStatus::Failed => { + let msg = run.error.unwrap_or_else(|| "Unknown error".to_string()); + anyhow::bail!("Run failed: {msg}"); + } + _ => { + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + } + } + } + + return Ok(()); + } + let run = client.runs().get(run_id).await?; let total = run .progress diff --git a/src/commands/scheduled_runs.rs b/src/commands/scheduled_runs.rs index 72ed2f5..2ba0eb7 100644 --- a/src/commands/scheduled_runs.rs +++ b/src/commands/scheduled_runs.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreateScheduledRunRequest, ListParams, UpdateScheduledRunRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum ScheduledRunCommands { @@ -14,6 +14,18 @@ pub enum ScheduledRunCommands { Delete(DeleteArgs), } +impl ScheduledRunCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -70,8 +82,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: ScheduledRunCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { ScheduledRunCommands::List(args) => { let params = ListParams { @@ -84,11 +97,11 @@ pub async fn execute( .scheduled_runs() .list(params, args.enabled, args.template_id.as_deref()) .await?; - print_list(&response.scheduled_runs, format); + emit_list(ctx, "scheduled-runs", operation, &response.scheduled_runs); } ScheduledRunCommands::Get(args) => { let run = client.scheduled_runs().get(&args.scheduled_run_id).await?; - print_one(&run, format); + emit_one(ctx, "scheduled-runs", operation, &run); } ScheduledRunCommands::Create(args) => { let req = CreateScheduledRunRequest { @@ -99,7 +112,7 @@ pub async fn execute( enabled: args.enabled, }; let run = client.scheduled_runs().create(req).await?; - print_one(&run, format); + emit_one(ctx, "scheduled-runs", operation, &run); } ScheduledRunCommands::Update(args) => { let req = UpdateScheduledRunRequest { @@ -113,14 +126,14 @@ pub async fn execute( .scheduled_runs() .update(&args.scheduled_run_id, req) .await?; - print_one(&run, format); + emit_one(ctx, "scheduled-runs", operation, &run); } ScheduledRunCommands::Delete(args) => { client .scheduled_runs() .delete(&args.scheduled_run_id) .await?; - print_success("Scheduled run deleted."); + emit_success(ctx, "scheduled-runs", operation, "Scheduled run deleted."); } } Ok(()) diff --git a/src/commands/simulations.rs b/src/commands/simulations.rs index a55097f..f3ff49b 100644 --- a/src/commands/simulations.rs +++ b/src/commands/simulations.rs @@ -7,7 +7,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use crate::client::error::ApiError; use crate::client::models::{ListParams, MetricDetailResponse}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum SimulationCommands { @@ -20,6 +20,19 @@ pub enum SimulationCommands { MetricDetail(MetricDetailArgs), } +impl SimulationCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Delete(_) => "delete", + Self::Audio(_) => "audio", + Self::Metrics(_) => "metrics", + Self::MetricDetail(_) => "metric-detail", + } + } +} + #[derive(Args)] pub struct ListArgs { #[arg(long)] @@ -63,8 +76,9 @@ pub struct MetricDetailArgs { pub async fn execute( cmd: SimulationCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { SimulationCommands::List(args) => { let filter = match (args.filter, args.run_id) { @@ -81,14 +95,16 @@ pub async fn execute( ..Default::default() }; let response = client.simulations().list(params).await?; - print_list(&response.simulations, format); + emit_list(ctx, "simulations", operation, &response.simulations); } SimulationCommands::Get(args) => { let result = client.simulations().get(&args.simulation_id).await; match result { - Ok(simulation) => print_one(&simulation, format), + Ok(simulation) => emit_one(ctx, "simulations", operation, &simulation), Err(ApiError::NotFound { .. }) => { - print_not_found_hint(&args.simulation_id, "conversations"); + if !ctx.agent { + print_not_found_hint(&args.simulation_id, "conversations"); + } return Err(ApiError::NotFound { resource: format!("Simulation '{}'", args.simulation_id), } @@ -100,9 +116,11 @@ pub async fn execute( SimulationCommands::Delete(args) => { let result = client.simulations().delete(&args.simulation_id).await; match result { - Ok(()) => print_success("Simulation deleted."), + Ok(()) => emit_success(ctx, "simulations", operation, "Simulation deleted."), Err(ApiError::NotFound { .. }) => { - print_not_found_hint(&args.simulation_id, "conversations"); + if !ctx.agent { + print_not_found_hint(&args.simulation_id, "conversations"); + } return Err(ApiError::NotFound { resource: format!("Simulation '{}'", args.simulation_id), } @@ -116,7 +134,7 @@ pub async fn execute( .simulations() .list_metrics(&args.simulation_id) .await?; - print_list(&response.metrics, format); + emit_list(ctx, "simulations", operation, &response.metrics); } SimulationCommands::MetricDetail(args) => { let response = client @@ -124,9 +142,11 @@ pub async fn execute( .get_metric(&args.simulation_id, &args.metric_id) .await?; match response { - MetricDetailResponse::Single { metric } => print_one(&metric, format), + MetricDetailResponse::Single { metric } => { + emit_one(ctx, "simulations", operation, &metric) + } MetricDetailResponse::Collection { metric_outputs } => { - print_list(&metric_outputs, format) + emit_list(ctx, "simulations", operation, &metric_outputs) } } } @@ -135,11 +155,20 @@ pub async fn execute( match args.output { Some(path) => { - download_audio(&audio.audio_url, &path).await?; - print_success(&format!("Audio saved to {}", path.display())); + download_audio(&audio.audio_url, &path, !ctx.agent).await?; + emit_success( + ctx, + "simulations", + operation, + &format!("Audio saved to {}", path.display()), + ); } None => { - println!("{}", audio.audio_url); + if ctx.agent { + emit_one(ctx, "simulations", operation, &audio); + } else { + println!("{}", audio.audio_url); + } } } } @@ -151,7 +180,7 @@ fn print_not_found_hint(id: &str, try_command: &str) { eprintln!("hint: not found as a simulation. Try `coval {try_command} get {id}` instead."); } -async fn download_audio(url: &str, path: &Path) -> Result<()> { +async fn download_audio(url: &str, path: &Path, show_progress: bool) -> Result<()> { let client = reqwest::Client::new(); let resp = client.get(url).send().await?; @@ -160,16 +189,17 @@ async fn download_audio(url: &str, path: &Path) -> Result<()> { } let total = resp.content_length().unwrap_or(0); - let pb = ProgressBar::new(total); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes}")? - .progress_chars("=>-"), - ); - let bytes = resp.bytes().await?; - pb.set_position(bytes.len() as u64); - pb.finish_and_clear(); + if show_progress { + let pb = ProgressBar::new(total); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes}")? + .progress_chars("=>-"), + ); + pb.set_position(bytes.len() as u64); + pb.finish_and_clear(); + } std::fs::write(path, &bytes)?; Ok(()) diff --git a/src/commands/test_cases.rs b/src/commands/test_cases.rs index efada55..0ab99da 100644 --- a/src/commands/test_cases.rs +++ b/src/commands/test_cases.rs @@ -3,10 +3,11 @@ use std::io::{self, BufRead}; use anyhow::Result; use clap::{Args, Subcommand}; use serde::Deserialize; +use serde_json::json; use crate::client::models::{CreateTestCaseRequest, ListParams, UpdateTestCaseRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum TestCaseCommands { @@ -17,6 +18,18 @@ pub enum TestCaseCommands { Delete(DeleteArgs), } +impl TestCaseCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Filter expression (e.g. test_set_id=abc12345) @@ -88,8 +101,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: TestCaseCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { TestCaseCommands::List(args) => { let filter = match (args.filter, args.test_set_id) { @@ -106,11 +120,11 @@ pub async fn execute( ..Default::default() }; let response = client.test_cases().list(params).await?; - print_list(&response.test_cases, format); + emit_list(ctx, "test-cases", operation, &response.test_cases); } TestCaseCommands::Get(args) => { let test_case = client.test_cases().get(&args.test_case_id).await?; - print_one(&test_case, format); + emit_one(ctx, "test-cases", operation, &test_case); } TestCaseCommands::Create(args) => { if args.stdin { @@ -140,13 +154,24 @@ pub async fn execute( match client.test_cases().create(req).await { Ok(_) => created += 1, Err(e) => { - eprintln!("Error: {e}"); + if !ctx.agent { + eprintln!("Error: {e}"); + } failed += 1; } } } - println!("Created {created} test cases ({failed} failed)"); + if ctx.human() { + println!("Created {created} test cases ({failed} failed)"); + } else { + emit_one( + ctx, + "test-cases", + operation, + &json!({ "created": created, "failed": failed }), + ); + } } else { let req = CreateTestCaseRequest { test_set_id: args.test_set_id, @@ -161,7 +186,7 @@ pub async fn execute( user_notes: None, }; let test_case = client.test_cases().create(req).await?; - print_one(&test_case, format); + emit_one(ctx, "test-cases", operation, &test_case); } } TestCaseCommands::Update(args) => { @@ -172,11 +197,11 @@ pub async fn execute( ..Default::default() }; let test_case = client.test_cases().update(&args.test_case_id, req).await?; - print_one(&test_case, format); + emit_one(ctx, "test-cases", operation, &test_case); } TestCaseCommands::Delete(args) => { client.test_cases().delete(&args.test_case_id).await?; - print_success("Test case deleted."); + emit_success(ctx, "test-cases", operation, "Test case deleted."); } } Ok(()) diff --git a/src/commands/test_sets.rs b/src/commands/test_sets.rs index 08ee4ae..40627ca 100644 --- a/src/commands/test_sets.rs +++ b/src/commands/test_sets.rs @@ -3,7 +3,7 @@ use clap::{Args, Subcommand}; use crate::client::models::{CreateTestSetRequest, ListParams, UpdateTestSetRequest}; use crate::client::CovalClient; -use crate::output::{print_list, print_one, print_success, OutputFormat}; +use crate::output::{emit_list, emit_one, emit_success, OutputContext}; #[derive(Subcommand)] pub enum TestSetCommands { @@ -14,6 +14,18 @@ pub enum TestSetCommands { Delete(DeleteArgs), } +impl TestSetCommands { + pub fn operation(&self) -> &'static str { + match self { + Self::List(_) => "list", + Self::Get(_) => "get", + Self::Create(_) => "create", + Self::Update(_) => "update", + Self::Delete(_) => "delete", + } + } +} + #[derive(Args)] pub struct ListArgs { /// Filter expression (e.g. test_set_type=SCENARIO) @@ -70,8 +82,9 @@ pub struct DeleteArgs { pub async fn execute( cmd: TestSetCommands, client: &CovalClient, - format: OutputFormat, + ctx: &OutputContext, ) -> Result<()> { + let operation = cmd.operation(); match cmd { TestSetCommands::List(args) => { let params = ListParams { @@ -81,11 +94,11 @@ pub async fn execute( ..Default::default() }; let response = client.test_sets().list(params).await?; - print_list(&response.test_sets, format); + emit_list(ctx, "test-sets", operation, &response.test_sets); } TestSetCommands::Get(args) => { let test_set = client.test_sets().get(&args.test_set_id).await?; - print_one(&test_set, format); + emit_one(ctx, "test-sets", operation, &test_set); } TestSetCommands::Create(args) => { let req = CreateTestSetRequest { @@ -97,7 +110,7 @@ pub async fn execute( parameters: None, }; let test_set = client.test_sets().create(req).await?; - print_one(&test_set, format); + emit_one(ctx, "test-sets", operation, &test_set); } TestSetCommands::Update(args) => { let req = UpdateTestSetRequest { @@ -107,11 +120,11 @@ pub async fn execute( ..Default::default() }; let test_set = client.test_sets().update(&args.test_set_id, req).await?; - print_one(&test_set, format); + emit_one(ctx, "test-sets", operation, &test_set); } TestSetCommands::Delete(args) => { client.test_sets().delete(&args.test_set_id).await?; - print_success("Test set deleted."); + emit_success(ctx, "test-sets", operation, "Test set deleted."); } } Ok(()) diff --git a/src/config.rs b/src/config.rs index ab0cbed..6646578 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,3 +39,18 @@ impl Config { Ok(()) } } + +pub fn mask_api_key(key: &str) -> String { + let chars = key.chars().collect::>(); + if chars.len() > 8 { + let prefix = chars.iter().take(4).copied().collect::(); + let suffix = chars + .iter() + .skip(chars.len() - 4) + .copied() + .collect::(); + format!("{prefix}...{suffix}") + } else { + "****".to_string() + } +} diff --git a/src/main.rs b/src/main.rs index 7a372ac..67c71ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,66 @@ mod commands; mod config; mod output; +use std::process::ExitCode; + +use clap::error::ErrorKind; use clap::Parser; +use crate::client::error::ApiError; +use crate::output::{AgentError, OutputContext}; + #[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = cli::Cli::parse(); - cli::run(args).await +async fn main() -> ExitCode { + let agent_requested = std::env::args_os().any(|arg| arg == "--agent"); + let args = match cli::Cli::try_parse() { + Ok(args) => args, + Err(err) => match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => err.exit(), + _ if agent_requested => { + output::emit_error( + "cli", + "parse", + AgentError::new("usage_error", err.to_string()), + Vec::new(), + Vec::new(), + ); + return exit_code(err.exit_code()); + } + _ => err.exit(), + }, + }; + + let resource = args.command.resource(); + let operation = args.command.operation(); + let ctx = OutputContext::new(args.format, args.agent); + + match cli::run(args, &ctx).await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + if ctx.agent { + output::emit_error( + resource, + operation, + AgentError::from_anyhow(&err), + Vec::new(), + Vec::new(), + ); + } else { + eprintln!("Error: {err}"); + } + exit_code_for_error(&err) + } + } +} + +fn exit_code_for_error(err: &anyhow::Error) -> ExitCode { + if let Some(api_error) = err.downcast_ref::() { + return exit_code(api_error.exit_code()); + } + + exit_code(1) +} + +fn exit_code(code: i32) -> ExitCode { + ExitCode::from(u8::try_from(code).unwrap_or(1)) } diff --git a/src/output.rs b/src/output.rs index 84d35f9..3984964 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,7 +1,12 @@ use clap::ValueEnum; use serde::Serialize; +use serde_json::json; use tabled::settings::Style; +use crate::client::error::ApiError; + +pub const ACI_VERSION: &str = "0.1"; + #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub enum OutputFormat { #[default] @@ -9,6 +14,128 @@ pub enum OutputFormat { Json, } +#[derive(Debug, Clone, Copy)] +pub struct OutputContext { + pub format: OutputFormat, + pub agent: bool, +} + +impl OutputContext { + pub fn new(format: OutputFormat, agent: bool) -> Self { + Self { format, agent } + } + + pub fn human(&self) -> bool { + !self.agent && matches!(self.format, OutputFormat::Table) + } +} + +#[derive(Debug, Serialize)] +pub struct AgentWarning { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub remedy: Option, +} + +impl AgentWarning { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + remedy: None, + } + } +} + +#[derive(Debug, Serialize)] +pub struct NextAction { + pub id: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub argv: Vec, + pub safe: bool, + pub primary: bool, + pub requires_confirmation: bool, +} + +#[derive(Debug, Serialize)] +pub struct AgentError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub remedy: Option, + pub retryable: bool, +} + +impl AgentError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + remedy: None, + retryable: false, + } + } + + pub fn with_remedy(mut self, remedy: impl Into) -> Self { + self.remedy = Some(remedy.into()); + self + } + + pub fn retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } + + pub fn from_anyhow(err: &anyhow::Error) -> Self { + if let Some(api_error) = err.downcast_ref::() { + return match api_error { + ApiError::Unauthenticated { message } => Self::new("unauthenticated", message) + .with_remedy("Run `coval login`, pass `--api-key`, or set COVAL_API_KEY."), + ApiError::NotFound { resource } => Self::new("not_found", resource), + ApiError::InvalidArgument { message, .. } => Self::new("validation_error", message), + ApiError::PermissionDenied { message } => Self::new("permission_denied", message), + ApiError::Internal { message } => Self::new("server_error", message), + ApiError::Network(network_error) => { + Self::new("network_error", network_error.to_string()).retryable(true) + } + }; + } + + let message = err.to_string(); + if message.starts_with("Not authenticated.") { + return Self::new("unauthenticated", message) + .with_remedy("Run `coval login`, pass `--api-key`, or set COVAL_API_KEY."); + } + + Self::new("cli_error", message) + } +} + +#[derive(Debug, Serialize)] +struct AgentEnvelope<'a, T: Serialize> { + aci: &'static str, + ok: bool, + resource: &'a str, + operation: &'a str, + data: T, + warnings: Vec, + next_actions: Vec, +} + +#[derive(Debug, Serialize)] +struct AgentErrorEnvelope<'a> { + aci: &'static str, + ok: bool, + resource: &'a str, + operation: &'a str, + error: AgentError, + warnings: Vec, + next_actions: Vec, +} + pub trait Tabular { fn headers() -> Vec<&'static str>; fn row(&self) -> Vec; @@ -51,3 +178,124 @@ pub fn print_id(id: &str) { pub fn print_success(message: &str) { println!("{message}"); } + +pub fn emit_list( + ctx: &OutputContext, + resource: &'static str, + operation: &'static str, + items: &[T], +) { + if ctx.agent { + emit_agent_data(resource, operation, items, Vec::new(), Vec::new()); + } else { + print_list(items, ctx.format); + } +} + +pub fn emit_one( + ctx: &OutputContext, + resource: &'static str, + operation: &'static str, + item: &T, +) { + if ctx.agent { + emit_agent_data(resource, operation, item, Vec::new(), Vec::new()); + } else { + print_one(item, ctx.format); + } +} + +pub fn emit_one_with_warnings( + ctx: &OutputContext, + resource: &'static str, + operation: &'static str, + item: &T, + warnings: Vec, +) { + if ctx.agent { + emit_agent_data(resource, operation, item, warnings, Vec::new()); + } else { + print_one(item, ctx.format); + } +} + +pub fn emit_success( + ctx: &OutputContext, + resource: &'static str, + operation: &'static str, + message: &str, +) { + if ctx.agent { + emit_agent_data( + resource, + operation, + json!({ "message": message }), + Vec::new(), + Vec::new(), + ); + } else { + print_success(message); + } +} + +pub fn emit_error( + resource: &'static str, + operation: &'static str, + error: AgentError, + warnings: Vec, + next_actions: Vec, +) { + let envelope = AgentErrorEnvelope { + aci: ACI_VERSION, + ok: false, + resource, + operation, + error, + warnings, + next_actions, + }; + print_agent_json(&envelope); +} + +fn emit_agent_data( + resource: &'static str, + operation: &'static str, + data: T, + warnings: Vec, + next_actions: Vec, +) { + let envelope = AgentEnvelope { + aci: ACI_VERSION, + ok: true, + resource, + operation, + data, + warnings, + next_actions, + }; + print_agent_json(&envelope); +} + +fn print_agent_json(value: &T) { + match serde_json::to_string_pretty(value) { + Ok(json) => println!("{json}"), + Err(error) => { + let envelope = AgentErrorEnvelope { + aci: ACI_VERSION, + ok: false, + resource: "cli", + operation: "serialize", + error: AgentError::new( + "serialization_error", + format!("Failed to serialize output: {error}"), + ), + warnings: Vec::new(), + next_actions: Vec::new(), + }; + let json = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| { + r#"{"aci":"0.1","ok":false,"resource":"cli","operation":"serialize","error":{"code":"serialization_error","message":"Failed to serialize output","retryable":false},"warnings":[],"next_actions":[]}"#.to_string() + }); + println!("{json}"); + } + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index c79535b..48e3743 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,6 +1,6 @@ use assert_cmd::Command; use predicates::prelude::*; -use serde_json::json; +use serde_json::{json, Value}; use wiremock::matchers::{body_partial_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -9,6 +9,10 @@ fn coval() -> Command { Command::cargo_bin("coval").unwrap() } +fn stdout_json(assert: assert_cmd::assert::Assert) -> Value { + serde_json::from_slice(&assert.get_output().stdout).unwrap() +} + #[test] fn test_help() { coval() @@ -41,6 +45,62 @@ fn test_missing_api_key() { .stderr(predicate::str::contains("Not authenticated")); } +#[test] +fn test_missing_api_key_agent_mode() { + let temp_dir = tempfile::tempdir().unwrap(); + let value = stdout_json( + coval() + .arg("--agent") + .arg("agents") + .arg("list") + .env_remove("COVAL_API_KEY") + .env("HOME", temp_dir.path()) + .env("XDG_CONFIG_HOME", temp_dir.path()) + .assert() + .failure() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["aci"], "0.1"); + assert_eq!(value["ok"], false); + assert_eq!(value["resource"], "agents"); + assert_eq!(value["operation"], "list"); + assert_eq!(value["error"]["code"], "unauthenticated"); +} + +#[test] +fn test_config_get_masks_unicode_api_key() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_dir = temp_dir.path().join(".config").join("coval"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + r#"api_key = "πŸ”‘πŸ”‘πŸ”‘πŸ”‘abcdEFGH""#, + ) + .unwrap(); + + coval() + .arg("config") + .arg("get") + .arg("api_key") + .env_remove("COVAL_API_KEY") + .env("HOME", temp_dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("πŸ”‘πŸ”‘πŸ”‘πŸ”‘...EFGH")); +} + +#[test] +fn test_whoami_masks_unicode_api_key() { + coval() + .arg("--api-key") + .arg("πŸ”‘πŸ”‘πŸ”‘πŸ”‘abcdEFGH") + .arg("whoami") + .assert() + .success() + .stdout(predicate::str::contains("πŸ”‘πŸ”‘πŸ”‘πŸ”‘...EFGH")); +} + #[test] fn test_agents_help() { coval() @@ -120,6 +180,86 @@ async fn test_agents_list_json() { .stdout(predicate::str::contains("\"id\": \"abc123\"")); } +#[tokio::test] +async fn test_agents_list_agent_mode() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/agents")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "agents": [ + { + "id": "abc123", + "display_name": "Test Agent", + "model_type": "MODEL_TYPE_VOICE", + "create_time": "2025-01-15T10:30:00Z" + } + ] + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("list") + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["aci"], "0.1"); + assert_eq!(value["ok"], true); + assert_eq!(value["resource"], "agents"); + assert_eq!(value["operation"], "list"); + assert_eq!(value["data"][0]["id"], "abc123"); + assert_eq!(value["warnings"].as_array().unwrap().len(), 0); + assert_eq!(value["next_actions"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_agents_list_json_is_not_agent_enveloped() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/agents")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "agents": [ + { + "id": "abc123", + "display_name": "Test Agent", + "model_type": "MODEL_TYPE_VOICE", + "create_time": "2025-01-15T10:30:00Z" + } + ] + }))) + .mount(&mock_server) + .await; + + let output = coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("--format") + .arg("json") + .arg("agents") + .arg("list") + .output() + .unwrap(); + + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(value.is_array()); +} + #[tokio::test] async fn test_agents_get() { let mock_server = MockServer::start().await; @@ -278,6 +418,305 @@ async fn test_api_error_handling() { .stderr(predicate::str::contains("not found")); } +#[tokio::test] +async fn test_api_error_handling_agent_mode() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/agents/notfound")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "error": { + "code": "NOT_FOUND", + "message": "Agent not found", + "details": [] + } + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("agents") + .arg("get") + .arg("notfound") + .assert() + .failure() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["aci"], "0.1"); + assert_eq!(value["ok"], false); + assert_eq!(value["resource"], "agents"); + assert_eq!(value["operation"], "get"); + assert_eq!(value["error"]["code"], "not_found"); + assert_eq!(value["error"]["message"], "Agent not found"); +} + +#[tokio::test] +async fn test_api_key_create_warning_agent_mode() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/api-keys")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "api_key": { + "id": "key123", + "name": "Agent Key", + "description": "", + "key_type": "SERVICE", + "environment": "DEVELOPMENT", + "status": "ACTIVE", + "permissions": [], + "api_key": "coval_secret_123", + "create_time": "2025-01-15T10:30:00Z" + } + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("api-keys") + .arg("create") + .arg("--name") + .arg("Agent Key") + .arg("--type") + .arg("service") + .arg("--environment") + .arg("development") + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["ok"], true); + assert_eq!(value["resource"], "api-keys"); + assert_eq!(value["operation"], "create"); + assert_eq!(value["warnings"][0]["code"], "store_api_key"); + assert_eq!(value["data"]["api_key"], "coval_secret_123"); +} + +#[tokio::test] +async fn test_dashboard_widgets_list_agent_mode_resource() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/dashboards/dash123/widgets")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "widgets": [ + { + "name": "dashboards/dash123/widgets/widget123", + "display_name": "Overview", + "type": "chart", + "create_time": "2025-01-15T10:30:00Z" + } + ] + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("dashboards") + .arg("widgets") + .arg("list") + .arg("dash123") + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["ok"], true); + assert_eq!(value["resource"], "widgets"); + assert_eq!(value["operation"], "widgets.list"); + assert_eq!( + value["data"][0]["name"], + "dashboards/dash123/widgets/widget123" + ); +} + +#[test] +fn test_dashboard_widgets_agent_mode_error_resource() { + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("dashboards") + .arg("widgets") + .arg("create") + .arg("dash123") + .arg("--name") + .arg("Overview") + .arg("--type") + .arg("chart") + .arg("--config") + .arg("{") + .assert() + .failure() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["ok"], false); + assert_eq!(value["resource"], "widgets"); + assert_eq!(value["operation"], "widgets.create"); + assert_eq!(value["error"]["code"], "cli_error"); +} + +#[tokio::test] +async fn test_conversations_audio_json_output() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/conversations/conv123/audio")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "audio_url": "https://storage.example.com/conversation.wav", + "conversation_id": "conv123", + "url_expires_in_seconds": 3600 + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("--format") + .arg("json") + .arg("conversations") + .arg("audio") + .arg("conv123") + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!( + value["audio_url"], + "https://storage.example.com/conversation.wav" + ); + assert_eq!(value["conversation_id"], "conv123"); +} + +#[tokio::test] +async fn test_test_cases_stdin_json_summary() { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/test-cases")) + .and(header("X-API-Key", "test_key")) + .and(body_partial_json(json!({ + "test_set_id": "ts123", + "input_str": "hello" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "test_case": { + "name": "testCases/tc123", + "id": "tc123", + "test_set_id": "ts123", + "input_str": "hello", + "create_time": "2025-01-15T10:30:00Z" + } + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("--format") + .arg("json") + .arg("test-cases") + .arg("create") + .arg("--test-set-id") + .arg("ts123") + .arg("--stdin") + .write_stdin(r#"{"input_str":"hello"}"#) + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["created"], 1); + assert_eq!(value["failed"], 0); +} + +#[tokio::test] +async fn test_runs_watch_agent_mode() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/runs/run123")) + .and(header("X-API-Key", "test_key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "run": { + "name": "Test Run", + "run_id": "run123", + "status": "COMPLETED", + "create_time": "2025-01-15T10:30:00Z", + "progress": { + "total_test_cases": 10, + "completed_test_cases": 10, + "failed_test_cases": 0, + "in_progress_test_cases": 0 + }, + "results": { + "output_ids": ["sim123"], + "metrics": {} + } + } + }))) + .mount(&mock_server) + .await; + + let value = stdout_json( + coval() + .arg("--agent") + .arg("--api-key") + .arg("test_key") + .arg("--api-url") + .arg(mock_server.uri()) + .arg("runs") + .arg("watch") + .arg("run123") + .arg("--interval") + .arg("0") + .assert() + .success() + .stderr(predicate::str::is_empty()), + ); + + assert_eq!(value["ok"], true); + assert_eq!(value["resource"], "runs"); + assert_eq!(value["operation"], "watch"); + assert_eq!(value["data"]["run_id"], "run123"); + assert_eq!(value["data"]["status"], "COMPLETED"); +} + #[tokio::test] async fn test_simulations_audio_url() { let mock_server = MockServer::start().await;