Files
yaak-mountain-loop/crates-cli/yaak-cli/src/commands/agent.rs
T
Gregory Schier 314f08545c 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.
2026-08-13 19:26:44 -07:00

205 lines
6.8 KiB
Rust

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)
}