diff --git a/crates-cli/yaak-cli/skills/use-yaak/SKILL.md b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md new file mode 100644 index 00000000..74e12e6b --- /dev/null +++ b/crates-cli/yaak-cli/skills/use-yaak/SKILL.md @@ -0,0 +1,172 @@ +--- +name: use-yaak +description: > + Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces, + folders, environments and variables, author HTTP requests, configure + authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4), + send them individually or a whole folder/workspace at once, chain one + request's response into the next, and import existing APIs from OpenAPI, + Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a + Yaak workspace, or the `yaak` command, and also when they ask to try, hit, + call, exercise, or smoke test an HTTP or REST endpoint, to save or organize + API requests for reuse, to set up API requests for manual testing, to add auth + to a saved request, to turn an OpenAPI or Postman collection into runnable + requests, or to run a saved request suite against staging versus production. + Prefer this over one-off `curl` commands whenever the requests should be + saved, reused, shared, or run as a set. +allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*) +--- + +# Use Yaak + + + +Yaak is a desktop API client. The `yaak` CLI reads and writes the **same local +database as the desktop app**, so anything you create shows up in the app +immediately, and vice versa. There is no server and no sign-in: `yaak auth` is +only for publishing plugins to the Yaak registry. + +Two consequences worth holding onto. Requests you create are permanent user data +in an app they use, not scratch files, so name them the way the user would and +clean up anything created just to test. And because the app is right there, the +CLI is usually the wrong place to *read* a response in detail; it is the right +place to build, organize, and run requests. + +## The CLI describes itself + +**This skill deliberately does not list fields, body types, auth strategies, or +template functions.** The user's CLI version and installed plugins decide what +exists, so any list written here would eventually be wrong. Ask the CLI: + +```bash +yaak --help # commands, plus agent hints at the bottom +yaak --help # flags for one command +yaak request schema http --pretty # full request model, with guidance per field +yaak template-function list [filter] # template functions from installed plugins +yaak template-function show # one function's arguments +``` + +`request schema http` is generated from the real model and merges in the auth +strategies contributed by plugins, so it is the authoritative answer for what a +request payload may contain and what each auth strategy needs. `workspace`, +`environment`, and `folder` have `schema` subcommands too. + +Read the relevant schema before writing a JSON payload you are not certain of. +That is faster than a failed send, and it stays correct as Yaak changes. + +## Resource model + +- **Workspace** (`wk_…`) is the top-level container. +- **Folder** (`fl_…`) groups requests and can nest. Folders carry headers and + authentication that child requests inherit, which is the usual way to apply + one token to a whole group. +- **Request** (`rq_…`) is a single HTTP, gRPC, or WebSocket request. The CLI can + currently only create and send HTTP ones. +- **Environment** (`ev_…`) holds variables. Each workspace has a base + environment plus any number of sub-environments; a sub-environment overrides + base variables of the same name and is chosen per send with `-e`. +- **Cookie jar** (`cj_…`) stores cookies per workspace. The oldest is used by + default, so this normally needs no attention. + +IDs are prefix-typed, so you can always tell what an ID refers to. Commands that +take a workspace ID infer it when exactly one workspace exists. + +## Getting oriented + +```bash +yaak --version || npm install -g @yaakapp/cli +yaak workspace list +``` + +Pick the workspace matching the user's project before changing anything, and +create one only when nothing fits. + +### Skill freshness + +The CLI writes this skill, so an upgraded CLI can leave it behind. That failure +is silent: nothing errors, the skill just stops mentioning things the CLI can +now do. Check once per session, alongside the commands above: + +```bash +yaak --help 2>&1 | grep -A2 "Agent tooling:" +``` + +If it reports the skill is out of date, run `yaak agent install` and tell the +user to restart their coding tool. This session keeps running on the old copy +until they do, so finish the current request either way. Check once and do not +re-run it after acting. + +**When the CLI and this skill disagree, the CLI is right.** + +## Core workflows + +**Start from a spec when one exists.** `yaak import ` auto-detects OpenAPI, +Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests +by hand every time. + +**Make the host swappable.** Put the base URL in a base-environment variable, +reference it as `${[ base_url ]}`, then add a sub-environment per deployment +target. Now `yaak -e ev_staging send ` runs everything against staging. + +**Chain instead of shell-plumbing.** A request can read another request's +response directly, and Yaak sends the dependency first if it needs to: + +``` +${[ response.body.path(request='rq_login', path='$.token') ]} +``` + +Run `yaak template-function show response.body.path` for its arguments, +including how to control when the upstream request re-sends. Chain when a +request genuinely depends on another's response; to merely run requests in +order, `yaak send ` already does that. + +**Run a set.** `yaak send` accepts a folder or workspace ID, with `--fail-fast` +and `--parallel`. Workspace and request IDs survive an export/import, so a +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. 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 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 prefixed `*`, `>`, and `<`, with the +body after the last `<` header line: + +```bash +yaak -v request send rq_abc123 2>&1 | grep '^< HTTP' +``` + +That works, but `response show` is still better when you need to act on the +result, since it gives you fields rather than text to parse. + +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. + +## Execution rules + +1. Resolve the workspace before mutating, and prefer an existing one. +2. Read the schema rather than guessing field names, auth fields, or body shapes. +3. `update` takes a JSON merge patch keyed by `id`: send only what changes, and + note that arrays are replaced wholesale, not merged. +4. Deletes need `--yes` in a non-interactive shell. Confirm with the user first. +5. Never write a real secret into an environment variable on the user's behalf. + Reference one and let them fill in the value. +6. Verify what you built by sending it, and report the real HTTP status. diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index a1417760..b4754ac6 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -1,17 +1,17 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; +pub const AGENT_HINTS: &str = r#"Agent Hints: + - Template variable syntax is ${[ my_var ]}, not {{ ... }} + - Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]} + - View JSONSchema for models before creating or updating (eg. `yaak request schema http`) + - Deletion requires confirmation (--yes for non-interactive environments)"#; + #[derive(Parser)] #[command(name = "yaak")] #[command(about = "Yaak CLI - API client from the command line")] #[command(version = crate::version::cli_version())] #[command(disable_help_subcommand = true)] -#[command(after_help = r#"Agent Hints: - - Template variable syntax is ${[ my_var ]}, not {{ ... }} - - Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]} - - View JSONSchema for models before creating or updating (eg. `yaak request schema http`) - - Deletion requires confirmation (--yes for non-interactive environments) - "#)] pub struct Cli { /// Use a custom data directory #[arg(long, global = true)] @@ -39,6 +39,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + /// Install Yaak skills for AI coding agents + Agent(AgentArgs), + /// Authentication commands Auth(AuthArgs), @@ -82,6 +85,105 @@ pub enum Commands { /// Environment commands Environment(EnvironmentArgs), + + /// 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)] +pub struct TemplateFunctionArgs { + #[command(subcommand)] + pub command: TemplateFunctionCommands, +} + +#[derive(Subcommand)] +pub enum TemplateFunctionCommands { + /// List template functions provided by installed plugins + List { + /// Only show functions whose name contains this text + #[arg(value_name = "FILTER")] + filter: Option, + }, + + /// Show a template function's arguments as JSON + Show { + /// Template function name (for example: response.body.path) + name: String, + + /// Pretty-print JSON output + #[arg(long)] + pretty: bool, + }, +} + +#[derive(Args)] +pub struct AgentArgs { + #[command(subcommand)] + pub command: AgentCommands, +} + +#[derive(Subcommand)] +pub enum AgentCommands { + /// Install the Yaak skill so coding agents know how to drive the CLI + #[command(alias = "update", alias = "add")] + Install { + /// Install for specific agents instead of all detected ones + #[arg(long = "agent", value_name = "AGENT")] + agent: Option>, + }, + + /// Remove the Yaak skill + #[command(alias = "uninstall", alias = "rm")] + Remove { + /// Remove for specific agents instead of all detected ones + #[arg(long = "agent", value_name = "AGENT")] + agent: Option>, + }, } #[derive(Args)] @@ -328,6 +430,13 @@ pub enum FolderCommands { workspace_id: Option, }, + /// Output JSON schema for folder create/update payloads + Schema { + /// Pretty-print schema JSON output + #[arg(long)] + pretty: bool, + }, + /// Show a folder as JSON Show { /// Folder ID diff --git a/crates-cli/yaak-cli/src/commands/agent.rs b/crates-cli/yaak-cli/src/commands/agent.rs new file mode 100644 index 00000000..3bc47010 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/agent.rs @@ -0,0 +1,269 @@ +use crate::cli::{AgentArgs, AgentCommands}; +use crate::ui; +use crate::version; +use include_dir::{Dir, include_dir}; +use std::fs; +use std::path::{Path, PathBuf}; + +static SKILL_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skills/use-yaak"); + +const SKILL_NAME: &str = "use-yaak"; + +/// Substituted at install time so the installed skill records which CLI wrote it, +/// letting `--help` tell the user when an upgrade has left their copy behind. +const VERSION_PLACEHOLDER: &str = "__YAAK_CLI_VERSION__"; +const VERSION_MARKER: &str = "yaak-cli-version:"; + +type CommandResult = std::result::Result; + +/// A coding tool that reads skills from a directory in the user's home. +struct Target { + /// Display name used in output. + label: &'static str, + /// Directory holding all skills for this tool (`…/skills`). + skills_dir: PathBuf, +} + +pub fn run(args: AgentArgs) -> i32 { + let result = match args.command { + AgentCommands::Install { agent } => install(agent), + AgentCommands::Remove { agent } => remove(agent), + }; + + match result { + Ok(()) => 0, + Err(error) => { + ui::error(&error); + 1 + } + } +} + +fn install(agent: Option>) -> CommandResult { + let targets = resolve_targets(agent)?; + + let mut installed = 0usize; + for target in &targets { + let dir = target.skills_dir.join(SKILL_NAME); + match write_skill(&dir) { + Ok(()) => { + installed += 1; + ui::success(&format!("{} -> {}", target.label, dir.display())); + } + Err(error) => ui::warning_stderr(&format!("{}: {}", target.label, error)), + } + } + + if installed == 0 { + return Err("Failed to install the Yaak skill anywhere".to_string()); + } + + ui::info("Restart your coding tool to pick up the skill"); + Ok(()) +} + +fn remove(agent: Option>) -> CommandResult { + let targets = resolve_targets(agent)?; + + let mut removed = 0usize; + for target in &targets { + let dir = target.skills_dir.join(SKILL_NAME); + if !dir.exists() { + continue; + } + match fs::remove_dir_all(&dir) { + Ok(()) => { + removed += 1; + ui::success(&format!("Removed {}", dir.display())); + } + Err(error) => { + ui::warning_stderr(&format!("Failed to remove {}: {error}", dir.display())) + } + } + } + + if removed == 0 { + ui::info("No Yaak skill was installed"); + } + Ok(()) +} + +/// The skill directory belongs to the CLI, so every install overwrites what is there +/// and prunes what this version no longer ships. Preserving local edits would be worse +/// than losing them: an edited file would be skipped by every future install and go +/// stale forever, against a CLI that keeps changing. +/// +/// NOTE: atomicity is per file, not for the install as a whole. That is total today +/// because the skill is a single file, but adding a second one would mean a reader +/// could catch a mix of old and new files. Replacing the directory as a unit is not an +/// option (`rename` refuses a non-empty destination), so a multi-file skill would need +/// the published path to become a symlink swapped between versioned directories. +fn write_skill(dir: &Path) -> CommandResult { + fs::create_dir_all(dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?; + + let mut written = Vec::new(); + for file in walk(&SKILL_DIR) { + let relative = file.path().to_path_buf(); + let destination = dir.join(&relative); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + + let contents = stamp_version(file.contents()); + write_atomic(&destination, &contents)?; + written.push(relative); + } + + prune_unshipped(dir, &written); + Ok(()) +} + +/// Write via a sibling temp file and rename over the destination. `rename` replaces an +/// existing file atomically, so a reader sees either the old copy or the new one, and a +/// crash part way through cannot leave a half-written skill behind. +fn write_atomic(destination: &Path, contents: &[u8]) -> CommandResult { + let parent = destination + .parent() + .ok_or_else(|| format!("Invalid skill path {}", destination.display()))?; + let name = destination + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Invalid skill path {}", destination.display()))?; + let temp = parent.join(format!(".{name}.tmp-{}", std::process::id())); + + fs::write(&temp, contents) + .map_err(|e| format!("Failed to write {}: {e}", destination.display()))?; + + match fs::rename(&temp, destination) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temp); + Err(format!("Failed to write {}: {error}", destination.display())) + } + } +} + +/// Stamp the running version into the skill so it carries its own provenance. +fn stamp_version(contents: &[u8]) -> Vec { + match std::str::from_utf8(contents) { + Ok(text) => text.replace(VERSION_PLACEHOLDER, version::cli_version()).into_bytes(), + Err(_) => contents.to_vec(), + } +} + +/// Drop anything this version does not ship, so files an older version wrote (or a +/// leftover temp file) cannot linger beside the refreshed skill. +fn prune_unshipped(dir: &Path, written: &[PathBuf]) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Ok(relative) = path.strip_prefix(dir) else { + continue; + }; + if written.iter().any(|w| w == relative || w.starts_with(relative)) { + continue; + } + if path.is_dir() { + let _ = fs::remove_dir_all(&path); + } else { + let _ = fs::remove_file(&path); + } + } +} + +/// Read the version an installed skill was stamped with. +fn installed_version(dir: &Path) -> Option { + let text = fs::read_to_string(dir.join("SKILL.md")).ok()?; + text.lines() + .find_map(|line| line.trim().strip_prefix(VERSION_MARKER)) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty() && v != VERSION_PLACEHOLDER) +} + +/// Health summary appended to root `--help`, so an agent can notice in one command +/// that an upgraded CLI has left the installed skill behind. Silent when nothing is +/// installed anywhere, to avoid nagging users who do not use agent tooling. +pub fn help_section() -> Option { + let targets = resolve_targets(None).ok()?; + let current = version::cli_version(); + + let mut stale = Vec::new(); + let mut installed = 0usize; + for target in &targets { + let dir = target.skills_dir.join(SKILL_NAME); + if !dir.join("SKILL.md").exists() { + continue; + } + installed += 1; + + let found = installed_version(&dir).unwrap_or_default(); + if found != current { + let found = if found.is_empty() { "unknown".to_string() } else { found }; + stale.push(format!("{} ({found})", target.label)); + } + } + + if installed == 0 { + return None; + } + + if stale.is_empty() { + return Some(format!( + "Agent tooling:\n Yaak skill is installed and up to date ({current})" + )); + } + + Some(format!( + "Agent tooling:\n Yaak skill is out of date for {} — CLI is {current}\n Run `yaak agent install`, then restart your coding tool", + stale.join(", ") + )) +} + +/// Flatten the embedded skill directory into its files, recursing into subdirectories. +fn walk<'a>(dir: &'a Dir<'a>) -> Vec<&'a include_dir::File<'a>> { + let mut files: Vec<_> = dir.files().collect(); + for child in dir.dirs() { + files.extend(walk(child)); + } + files +} + +/// `~/.agents/skills` is the cross-tool location and is always written. Tool-specific +/// directories are written only when that tool is already set up on this machine, so +/// installing never creates a config directory for a tool the user does not use. +fn resolve_targets(requested: Option>) -> CommandResult> { + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + + let known: Vec<(&str, PathBuf, PathBuf)> = vec![ + ("agents", home.join(".agents"), home.join(".agents").join("skills")), + ("claude-code", home.join(".claude"), home.join(".claude").join("skills")), + ("cursor", home.join(".cursor"), home.join(".cursor").join("skills")), + ("codex", home.join(".codex"), home.join(".codex").join("skills")), + ("opencode", home.join(".opencode"), home.join(".opencode").join("skills")), + ]; + + if let Some(requested) = requested { + let mut targets = Vec::new(); + for name in requested { + let found = + known.iter().find(|(label, _, _)| *label == name.as_str()).ok_or_else(|| { + let names: Vec<_> = known.iter().map(|(l, _, _)| *l).collect(); + format!("Unknown agent '{name}'. Known agents: {}", names.join(", ")) + })?; + targets.push(Target { label: found.0, skills_dir: found.2.clone() }); + } + return Ok(targets); + } + + let targets: Vec = known + .into_iter() + .filter(|(label, marker, _)| *label == "agents" || marker.exists()) + .map(|(label, _, skills_dir)| Target { label, skills_dir }) + .collect(); + + Ok(targets) +} diff --git a/crates-cli/yaak-cli/src/commands/folder.rs b/crates-cli/yaak-cli/src/commands/folder.rs index 926c76b8..71eab72c 100644 --- a/crates-cli/yaak-cli/src/commands/folder.rs +++ b/crates-cli/yaak-cli/src/commands/folder.rs @@ -5,7 +5,9 @@ use crate::utils::json::{ apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json, parse_required_json, require_id, validate_create_id, }; +use crate::utils::schema::append_agent_hints; use crate::utils::workspace::resolve_workspace_id; +use schemars::schema_for; use yaak_models::models::Folder; use yaak_models::util::UpdateSource; @@ -14,6 +16,7 @@ type CommandResult = std::result::Result; pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 { let result = match args.command { FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), + FolderCommands::Schema { pretty } => schema(pretty), FolderCommands::Show { folder_id } => show(ctx, &folder_id), FolderCommands::Create { workspace_id, name, json } => { create(ctx, workspace_id, name, json) @@ -31,6 +34,18 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 { } } +fn schema(pretty: bool) -> CommandResult { + let mut schema = serde_json::to_value(schema_for!(Folder)) + .map_err(|e| format!("Failed to serialize folder schema: {e}"))?; + append_agent_hints(&mut schema); + + let output = + if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) } + .map_err(|e| format!("Failed to format folder schema JSON: {e}"))?; + println!("{output}"); + Ok(()) +} + fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?; let folders = diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index d19d9c9e..901371f1 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod auth; pub mod cookie_jar; pub mod environment; @@ -5,5 +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 a14e42f0..66b731fd 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; @@ -119,7 +160,21 @@ fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) { if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) { append_description( url_schema, - "For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).", + "For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names that keep the leading `:` (for example `:id`, `:commentId`). A name without the `:` is sent as a query string parameter instead, leaving the placeholder in the path.", + ); + } + + if let Some(body_type_schema) = properties.get_mut("bodyType").and_then(Value::as_object_mut) { + append_description( + body_type_schema, + "Known values: `application/json`, `text/xml`, `application/x-www-form-urlencoded`, `multipart/form-data`, `graphql`, `binary`, `other`, or null for no body. This selects how `body` is encoded; it does NOT add a `Content-Type` header. Add that header yourself, matching the body type (`other` pairs with `text/plain` and `graphql` with `application/json`). Multipart is the exception: its header is generated at send time to carry the boundary.", + ); + } + + if let Some(body_schema) = properties.get_mut("body").and_then(Value::as_object_mut) { + append_description( + body_schema, + "Shape depends on `bodyType`. Text-ish types (`application/json`, `text/xml`, `other`) use `{\"text\": \"...\"}` where the value is a string, so JSON bodies are a JSON string containing JSON. Form types use `{\"form\": [{\"name\": \"a\", \"value\": \"1\", \"enabled\": true}]}`, and a multipart entry may use `file` (an absolute path) and `contentType` instead of `value`. `binary` uses `{\"filePath\": \"/abs/path\"}`. `graphql` uses `{\"query\": \"...\", \"variables\": \"{}\", \"operationName\": \"\"}` where `variables` is a string of JSON.", ); } } @@ -353,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() @@ -401,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() @@ -493,14 +550,24 @@ async fn send_http_request_by_id( } } }); + // Verbose mode has a second writer on stdout (the event task above), and the two + // race: the body could land in the middle of the headers, or run onto the same + // line as the status. So buffer the body while verbose and write it once the + // events are done. Without -v nothing else writes, so stream it straight through. let body_handle = tokio::task::spawn_blocking(move || { + let mut buffered = Vec::new(); let mut stdout = std::io::stdout(); while let Some(chunk) = body_chunk_rx.blocking_recv() { + if verbose { + buffered.extend_from_slice(&chunk); + continue; + } if stdout.write_all(&chunk).is_err() { break; } let _ = stdout.flush(); } + buffered }); let response_dir = ctx.data_dir().join("responses"); @@ -522,8 +589,16 @@ async fn send_http_request_by_id( }) .await; + // Await the events first so every `*`, `>`, and `<` line is out before the body. let _ = event_handle.await; - let _ = body_handle.await; + if let Ok(buffered) = body_handle.await + && !buffered.is_empty() + { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(&buffered); + let _ = stdout.flush(); + } + result.map_err(|e| e.to_string())?; Ok(()) } 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..3a8b3ba8 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/response.rs @@ -0,0 +1,139 @@ +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(()); + } + let count = 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() + .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/commands/template_function.rs b/crates-cli/yaak-cli/src/commands/template_function.rs new file mode 100644 index 00000000..c2666e1a --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/template_function.rs @@ -0,0 +1,111 @@ +use crate::cli::{TemplateFunctionArgs, TemplateFunctionCommands}; +use crate::context::CliContext; +use yaak_plugins::events::{PluginContext, TemplateFunction}; + +type CommandResult = std::result::Result; + +pub async fn run(ctx: &CliContext, args: TemplateFunctionArgs) -> i32 { + let result = match args.command { + TemplateFunctionCommands::List { filter } => list(ctx, filter.as_deref()).await, + TemplateFunctionCommands::Show { name, pretty } => show(ctx, &name, pretty).await, + }; + + match result { + Ok(()) => 0, + Err(error) => { + eprintln!("Error: {error}"); + 1 + } + } +} + +/// Template functions come from plugins, so the only accurate list is the one the +/// installed plugins report right now. +async fn all(ctx: &CliContext) -> CommandResult> { + let plugin_context = PluginContext::new_empty(); + let summaries = ctx + .plugin_manager() + .get_template_function_summaries(&plugin_context) + .await + .map_err(|e| format!("Failed to list template functions: {e}"))?; + + let mut functions: Vec = + summaries.into_iter().flat_map(|summary| summary.functions).collect(); + functions.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(functions) +} + +async fn list(ctx: &CliContext, filter: Option<&str>) -> CommandResult { + let mut functions = all(ctx).await?; + + if let Some(filter) = filter { + let needle = filter.to_lowercase(); + functions.retain(|f| f.name.to_lowercase().contains(&needle)); + } + + if functions.is_empty() { + match filter { + Some(filter) => println!("No template functions matching '{filter}'"), + None => println!("No template functions found"), + } + return Ok(()); + } + + for function in functions { + let args = function.args.iter().filter_map(arg_name).collect::>().join(", "); + match function.description { + Some(description) if !description.is_empty() => { + println!("{}({}) - {}", function.name, args, description) + } + _ => println!("{}({})", function.name, args), + } + } + + Ok(()) +} + +async fn show(ctx: &CliContext, name: &str, pretty: bool) -> CommandResult { + let functions = all(ctx).await?; + let function = functions + .iter() + .find(|f| { + f.name == name + || f.aliases.as_ref().is_some_and(|aliases| aliases.iter().any(|a| a == name)) + }) + .ok_or_else(|| { + let names = functions.iter().map(|f| f.name.as_str()).collect::>(); + format!("No template function named '{name}'. Available: {}", names.join(", ")) + })?; + + let output = if pretty { + serde_json::to_string_pretty(function) + } else { + serde_json::to_string(function) + } + .map_err(|e| format!("Failed to serialize template function: {e}"))?; + + println!("{output}"); + Ok(()) +} + +fn arg_name(arg: &yaak_plugins::events::TemplateFunctionArg) -> Option { + use yaak_plugins::events::{FormInput, TemplateFunctionArg}; + + let TemplateFunctionArg::FormInput(input) = arg; + let base = match input { + FormInput::Text(v) => &v.base, + FormInput::Editor(v) => &v.base, + FormInput::Select(v) => &v.base, + FormInput::Checkbox(v) => &v.base, + FormInput::File(v) => &v.base, + FormInput::HttpRequest(v) => &v.base, + FormInput::KeyValue(v) => &v.base, + // Layout-only inputs have no value of their own + FormInput::Accordion(_) + | FormInput::HStack(_) + | FormInput::Banner(_) + | FormInput::Markdown(_) => return None, + }; + + if base.name.trim().is_empty() { None } else { Some(base.name.clone()) } +} diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index d2630e11..b6020378 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -7,15 +7,31 @@ mod utils; mod version; mod version_check; -use clap::Parser; -use cli::{Cli, Commands, PluginCommands, RequestCommands}; +use clap::{CommandFactory, FromArgMatches}; +use cli::{AGENT_HINTS, Cli, Commands, PluginCommands, RequestCommands}; use context::{CliContext, CliExecutionContext}; use std::path::PathBuf; use yaak_models::queries::any_request::AnyRequest; +/// Built at runtime so root `--help` can report whether the installed agent skill is +/// still in step with this CLI version. +fn help_footer() -> String { + match commands::agent::help_section() { + Some(section) => format!("{AGENT_HINTS}\n\n{section}"), + None => format!( + "{AGENT_HINTS}\n - Run `yaak agent install` to install the Yaak skill for AI coding agents" + ), + } +} + #[tokio::main] async fn main() { - let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse(); + let matches = Cli::command().after_help(help_footer()).get_matches(); + let Cli { data_dir, environment, cookie_jar, verbose, log, command } = + match Cli::from_arg_matches(&matches) { + Ok(cli) => cli, + Err(error) => error.exit(), + }; if let Some(log_level) = log { match log_level { @@ -36,6 +52,20 @@ async fn main() { version_check::maybe_check_for_updates().await; 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; + let exit_code = commands::template_function::run(&context, args).await; + context.shutdown().await; + exit_code + } Commands::Auth(args) => commands::auth::run(args).await, Commands::Import(args) => { let mut context = CliContext::new(data_dir.clone(), app_id); diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index fc9f4301..957d00a5 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -1630,10 +1630,11 @@ async fn cmd_delete_all_http_responses( app_handle: AppHandle, window: WebviewWindow, ) -> YaakResult<()> { - Ok(app_handle.db().delete_all_http_responses_for_request( + app_handle.db().delete_all_http_responses_for_request( request_id, &UpdateSource::from_window_label(window.label()), - )?) + )?; + Ok(()) } #[tauri::command] diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 2148650e..74a02020 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -949,7 +949,7 @@ pub struct ParentHeaders { pub headers: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "folders")] diff --git a/crates/yaak-models/src/queries/http_responses.rs b/crates/yaak-models/src/queries/http_responses.rs index d1282418..3530929f 100644 --- a/crates/yaak-models/src/queries/http_responses.rs +++ b/crates/yaak-models/src/queries/http_responses.rs @@ -30,29 +30,33 @@ impl<'a> ClientDb<'a> { self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit) } + /// Returns the number of responses deleted. pub fn delete_all_http_responses_for_request( &self, request_id: &str, source: &UpdateSource, - ) -> Result<()> { + ) -> Result { let responses = self.list_http_responses_for_request(request_id, None)?; + let count = responses.len(); for m in responses { self.delete(&m, source)?; } - Ok(()) + Ok(count) } + /// Returns the number of responses deleted. pub fn delete_all_http_responses_for_workspace( &self, workspace_id: &str, source: &UpdateSource, - ) -> Result<()> { + ) -> Result { let responses = self.find_many::(HttpResponseIden::WorkspaceId, workspace_id, None)?; + let count = responses.len(); for m in responses { self.delete(&m, source)?; } - Ok(()) + Ok(count) } pub fn delete_http_response(