mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 12:54:09 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod cookie_jar;
|
||||
pub mod environment;
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user