Add a Yaak CLI skill for coding agents, installed by yaak agent install

Teaches agents to drive the CLI: workspaces, environments, requests,
sending, response chaining, and importing. SKILL.md stays lean with
four references loaded on demand.

The skill is embedded in the binary and written to ~/.agents/skills plus
any detected tool directory, so it ships with the CLI and refreshes on
update. Reinstalls keep files edited locally unless --force.

Also fixes the `request schema http` hint for URL path parameters, which
said to omit the leading colon. Names without the colon are sent as query
string parameters instead, leaving the placeholder literal in the path.
This commit is contained in:
Gregory Schier
2026-08-13 19:26:44 -07:00
parent 7e2db7799b
commit 314f08545c
10 changed files with 982 additions and 1 deletions
+33
View File
@@ -11,6 +11,7 @@ use std::path::PathBuf;
- 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)
- Run `yaak agent install` to install the Yaak skill for AI coding agents
"#)]
pub struct Cli {
/// Use a custom data directory
@@ -39,6 +40,9 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Install Yaak skills for AI coding agents
Agent(AgentArgs),
/// Authentication commands
Auth(AuthArgs),
@@ -84,6 +88,35 @@ pub enum Commands {
Environment(EnvironmentArgs),
}
#[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 {
/// Overwrite skill files you have edited locally
#[arg(long)]
force: bool,
/// Install for specific agents instead of all detected ones
#[arg(long = "agent", value_name = "AGENT")]
agent: Option<Vec<String>>,
},
/// 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<Vec<String>>,
},
}
#[derive(Args)]
pub struct SendArgs {
/// Request, folder, or workspace ID
+204
View File
@@ -0,0 +1,204 @@
use crate::cli::{AgentArgs, AgentCommands};
use crate::ui;
use crate::version;
use include_dir::{Dir, include_dir};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
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";
const MANIFEST_NAME: &str = ".yaak-skill.json";
type CommandResult<T = ()> = std::result::Result<T, String>;
/// Records what this CLI wrote, so a later install can tell its own output apart
/// from edits the user made by hand.
#[derive(Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct SkillManifest {
cli_version: String,
/// Relative file path -> SHA-256 of the contents this CLI wrote.
files: BTreeMap<String, 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 { force, agent } => install(force, agent),
AgentCommands::Remove { agent } => remove(agent),
};
match result {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
fn install(force: bool, 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, force) {
Ok(WriteOutcome::Written { skipped }) => {
installed += 1;
ui::success(&format!("{} -> {}", target.label, dir.display()));
for path in skipped {
ui::warning(&format!(" kept your edited {path} (use --force to overwrite)"));
}
}
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(())
}
enum WriteOutcome {
Written { skipped: Vec<String> },
}
fn write_skill(dir: &Path, force: bool) -> CommandResult<WriteOutcome> {
let previous = read_manifest(dir);
let mut manifest =
SkillManifest { cli_version: version::cli_version().to_string(), ..Default::default() };
let mut skipped = Vec::new();
for file in walk(&SKILL_DIR) {
let relative = file.path().to_string_lossy().to_string();
let destination = dir.join(file.path());
let contents = file.contents();
let digest = sha256(contents);
// Leave a file alone when the user has changed it since we wrote it.
if !force
&& destination.exists()
&& let Ok(on_disk) = fs::read(&destination)
&& let Some(written) = previous.files.get(&relative)
&& sha256(&on_disk) != *written
{
skipped.push(relative.clone());
manifest.files.insert(relative, written.clone());
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
fs::write(&destination, contents)
.map_err(|e| format!("Failed to write {}: {e}", destination.display()))?;
manifest.files.insert(relative, digest);
}
let manifest_json = serde_json::to_string_pretty(&manifest)
.map_err(|e| format!("Failed to serialize skill manifest: {e}"))?;
fs::write(dir.join(MANIFEST_NAME), manifest_json)
.map_err(|e| format!("Failed to write skill manifest: {e}"))?;
Ok(WriteOutcome::Written { skipped })
}
fn read_manifest(dir: &Path) -> SkillManifest {
fs::read_to_string(dir.join(MANIFEST_NAME))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
/// 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)
}
+1
View File
@@ -1,3 +1,4 @@
pub mod agent;
pub mod auth;
pub mod cookie_jar;
pub mod environment;
+1 -1
View File
@@ -119,7 +119,7 @@ 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.",
);
}
}
+1
View File
@@ -36,6 +36,7 @@ async fn main() {
version_check::maybe_check_for_updates().await;
let exit_code = match command {
Commands::Agent(args) => commands::agent::run(args),
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Import(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);