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 = std::result::Result; /// 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, } /// 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>) -> 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>) -> 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 }, } fn write_skill(dir: &Path, force: bool) -> CommandResult { 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>) -> 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) }