Add a Yaak CLI skill for coding agents (#535)

This commit is contained in:
Gregory Schier
2026-08-14 06:16:30 -07:00
committed by GitHub
parent c3fc3fb68a
commit e032c4c8d6
12 changed files with 946 additions and 18 deletions
+269
View File
@@ -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 =
+3
View File
@@ -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;
+77 -2
View File
@@ -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()) }
}