mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-15 16:12:05 +02:00
Add a Yaak CLI skill for coding agents (#535)
This commit is contained in:
@@ -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<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
/// 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<Vec<String>>) -> 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<Vec<String>>) -> 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<u8> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<String>>) -> CommandResult<Vec<Target>> {
|
||||
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<Target> = known
|
||||
.into_iter()
|
||||
.filter(|(label, marker, _)| *label == "agents" || marker.exists())
|
||||
.map(|(label, _, skills_dir)| Target { label, skills_dir })
|
||||
.collect();
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
@@ -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<T = ()> = std::result::Result<T, String>;
|
||||
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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> = 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<String>, json_input: Option<String>) -> 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(())
|
||||
}
|
||||
|
||||
@@ -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<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
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<HttpResponse> {
|
||||
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<u64>) -> 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(())
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::cli::{TemplateFunctionArgs, TemplateFunctionCommands};
|
||||
use crate::context::CliContext;
|
||||
use yaak_plugins::events::{PluginContext, TemplateFunction};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
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<Vec<TemplateFunction>> {
|
||||
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<TemplateFunction> =
|
||||
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::<Vec<_>>().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::<Vec<_>>();
|
||||
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<String> {
|
||||
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()) }
|
||||
}
|
||||
Reference in New Issue
Block a user