diff --git a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md index d36bf11f..3c280387 100644 --- a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md +++ b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md @@ -110,18 +110,26 @@ committed `yaak export` plus `--data-dir ./.yaak` gives a runnable suite in CI. ## Reading results -A plain send writes only the response body to stdout. Add `-v` for the request -and response metadata, where lines are prefixed `*`, `>`, and `<`: +A plain send writes only the response body to stdout. Yaak also stores every +response, so the reliable way to see what happened is to ask afterwards rather +than to parse the send output: ```bash -yaak -v request send rq_abc123 2>&1 | grep '^< HTTP' +yaak response show rq_abc123 # latest response for a request, as JSON +yaak response list rq_abc123 # its history, newest first +yaak response body rq_abc123 # just the body ``` +`response show` gives status, reason, timing, headers, the final URL, and any +transport error. Pass a response ID for a specific one. `-v` on a send prints +the same information live, prefixed `*`, `>`, and `<`, but interleaves it with +the body on stdout, so prefer `response show` when you need to act on the result. + Exit code 1 means the send did not complete: an unresolved template variable, an unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like `curl`, a 404 or 500 exits 0, and a folder of requests that all return 500 reports success. Never tell the user an API is healthy based on a clean exit; -check the status yourself with `-v`. +check the status. ## Execution rules diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index c80b34b9..aaf221b2 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -90,6 +90,50 @@ pub enum Commands { /// Template function commands #[command(alias = "func")] TemplateFunction(TemplateFunctionArgs), + + /// Response commands + Response(ResponseArgs), +} + +#[derive(Args)] +pub struct ResponseArgs { + #[command(subcommand)] + pub command: ResponseCommands, +} + +#[derive(Subcommand)] +pub enum ResponseCommands { + /// List stored responses for a request or workspace + List { + /// Request or workspace ID (optional when exactly one workspace exists) + id: Option, + + /// Maximum number of responses to return, newest first + #[arg(long)] + limit: Option, + }, + + /// Show a response as JSON, including status, timing, and headers + Show { + /// Response ID, or a request ID to use its most recent response + id: String, + }, + + /// Write a stored response body to stdout + Body { + /// Response ID, or a request ID to use its most recent response + id: String, + }, + + /// Delete stored responses for a request or workspace + Delete { + /// Request or workspace ID + id: String, + + /// Skip confirmation prompt + #[arg(short, long)] + yes: bool, + }, } #[derive(Args)] diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index 65ed8f57..901371f1 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod folder; pub mod import_export; pub mod plugin; pub mod request; +pub mod response; pub mod send; pub mod template_function; pub mod workspace; diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index 57a98aaa..6604f07a 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) Ok(()) } +/// `request create` only builds HTTP requests, but `request schema` will happily hand +/// out gRPC and WebSocket schemas. Without this, a gRPC payload deserializes into an +/// HttpRequest with its unknown fields dropped, quietly producing a broken request +/// (the gRPC method name lands in `method`, and `service` disappears). +fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult { + let Some(object) = payload.as_object() else { + return Ok(()); + }; + + if let Some(model) = object.get("model").and_then(Value::as_str) + && !model.is_empty() + && model != "http_request" + { + return Err(format!( + "{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot work with {model} requests yet; use the Yaak app instead." + )); + } + + let known: std::collections::BTreeSet = serde_json::to_value(schema_for!(HttpRequest)) + .ok() + .and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned()) + .map(|properties| properties.keys().cloned().collect()) + .unwrap_or_default(); + + if known.is_empty() { + return Ok(()); + } + + let unknown: Vec<&str> = + object.keys().filter(|key| !known.contains(*key)).map(String::as_str).collect(); + + if !unknown.is_empty() { + return Err(format!( + "{context} got fields that are not part of an HTTP request: {}. If this is a gRPC or WebSocket request, the CLI cannot work with it yet. Run `yaak request schema http` for the valid fields.", + unknown.join(", ") + )); + } + + Ok(()) +} + fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) { if !matches!(request_type, RequestSchemaType::Http) { return; @@ -367,6 +408,7 @@ fn create( } validate_create_id(&payload, "request")?; + reject_non_http_payload(&payload, "request create")?; let mut request: HttpRequest = serde_json::from_value(payload) .map_err(|e| format!("Failed to parse request create JSON: {e}"))?; let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty() @@ -415,6 +457,7 @@ fn create( fn update(ctx: &CliContext, json: Option, json_input: Option) -> CommandResult { let patch = parse_required_json(json, json_input, "request update")?; let id = require_id(&patch, "request update")?; + reject_non_http_payload(&patch, "request update")?; let existing = ctx .db() diff --git a/crates-cli/yaak-cli/src/commands/response.rs b/crates-cli/yaak-cli/src/commands/response.rs new file mode 100644 index 00000000..63770a7d --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/response.rs @@ -0,0 +1,148 @@ +use crate::cli::{ResponseArgs, ResponseCommands}; +use crate::context::CliContext; +use crate::utils::confirm::confirm_delete; +use crate::utils::workspace::resolve_workspace_id; +use std::io::Write; +use yaak_models::models::HttpResponse; +use yaak_models::util::UpdateSource; + +type CommandResult = std::result::Result; + +pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 { + let result = match args.command { + ResponseCommands::List { id, limit } => list(ctx, id.as_deref(), limit), + ResponseCommands::Show { id } => show(ctx, &id), + ResponseCommands::Body { id } => body(ctx, &id), + ResponseCommands::Delete { id, yes } => delete(ctx, &id, yes), + }; + + match result { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +/// Accepts a response ID, or a request ID meaning "the latest response for that request". +fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult { + if let Ok(response) = ctx.db().get_http_response(id) { + return Ok(response); + } + + if ctx.db().get_http_request(id).is_ok() { + return ctx + .db() + .list_http_responses_for_request(id, Some(1)) + .map_err(|e| format!("Failed to list responses: {e}"))? + .into_iter() + .next() + .ok_or_else(|| format!("Request {id} has no stored responses yet")); + } + + Err(format!("Could not resolve ID '{id}' as a response or request")) +} + +fn list(ctx: &CliContext, id: Option<&str>, limit: Option) -> CommandResult { + // An explicit request ID scopes to that request; anything else lists the workspace. + let responses = match id { + Some(id) if ctx.db().get_http_request(id).is_ok() => ctx + .db() + .list_http_responses_for_request(id, limit) + .map_err(|e| format!("Failed to list responses: {e}"))?, + other => { + let workspace_id = resolve_workspace_id(ctx, other, "response list")?; + ctx.db() + .list_http_responses(&workspace_id, limit) + .map_err(|e| format!("Failed to list responses: {e}"))? + } + }; + + if responses.is_empty() { + println!("No responses found"); + return Ok(()); + } + + for response in responses { + let status = match response.error { + // Errors can be a paragraph of nested debug output; `show` has the full text. + Some(error) => { + let first_line = error.lines().next().unwrap_or_default(); + let summary = match first_line.char_indices().nth(80) { + Some((cut, _)) => format!("{}...", &first_line[..cut]), + None => first_line.to_string(), + }; + format!("ERROR {summary}") + } + None => { + let reason = response.status_reason.unwrap_or_default(); + format!("{} {}", response.status, reason).trim_end().to_string() + } + }; + println!("{} - {} {} ({}ms)", response.id, status, response.url, response.elapsed); + } + + Ok(()) +} + +fn show(ctx: &CliContext, id: &str) -> CommandResult { + let response = resolve_response(ctx, id)?; + let output = serde_json::to_string_pretty(&response) + .map_err(|e| format!("Failed to serialize response: {e}"))?; + println!("{output}"); + Ok(()) +} + +fn body(ctx: &CliContext, id: &str) -> CommandResult { + let response = resolve_response(ctx, id)?; + let Some(body_path) = response.body_path else { + return Err(format!("Response {} has no stored body", response.id)); + }; + + let bytes = std::fs::read(&body_path) + .map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?; + + let mut stdout = std::io::stdout(); + stdout.write_all(&bytes).map_err(|e| format!("Failed to write response body: {e}"))?; + stdout.flush().map_err(|e| format!("Failed to flush response body: {e}"))?; + Ok(()) +} + +fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult { + // Deleting is scoped to a whole request or workspace, since a single stored + // response is rarely the thing someone wants to remove. + if ctx.db().get_http_request(id).is_ok() { + if !yes && !confirm_delete("responses for request", id) { + println!("Aborted"); + return Ok(()); + } + // The delete helpers return no count, so take one first to report honestly. + let count = ctx + .db() + .list_http_responses_for_request(id, None) + .map_err(|e| format!("Failed to list responses: {e}"))? + .len(); + ctx.db() + .delete_all_http_responses_for_request(id, &UpdateSource::Sync) + .map_err(|e| format!("Failed to delete responses: {e}"))?; + println!("Deleted {count} responses for request {id}"); + return Ok(()); + } + + let workspace_id = resolve_workspace_id(ctx, Some(id), "response delete")?; + if !yes && !confirm_delete("responses for workspace", &workspace_id) { + println!("Aborted"); + return Ok(()); + } + let count = ctx + .db() + .list_http_responses(&workspace_id, None) + .map_err(|e| format!("Failed to list responses: {e}"))? + .len(); + ctx.db() + .delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync) + .map_err(|e| format!("Failed to delete responses: {e}"))?; + println!("Deleted {count} responses for workspace {workspace_id}"); + Ok(()) +} diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index 133d7bf9..fb82ff83 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -37,6 +37,12 @@ async fn main() { let exit_code = match command { Commands::Agent(args) => commands::agent::run(args), + Commands::Response(args) => { + let context = CliContext::new(data_dir.clone(), app_id); + let exit_code = commands::response::run(&context, args); + context.shutdown().await; + exit_code + } Commands::TemplateFunction(args) => { let mut context = CliContext::new(data_dir.clone(), app_id); context.init_plugins(CliExecutionContext::default()).await;