From bb79773ebafffa537f7fd9fc6a3df89d22f75619 Mon Sep 17 00:00:00 2001 From: James Dumay Date: Thu, 28 May 2026 16:09:15 +1000 Subject: [PATCH 1/3] Let plugins provide agent skills --- .github/workflows/docker-precheck.yml | 1 + Cargo.lock | 12 + Cargo.toml | 1 + .../src/cli/commands/agent_cli.rs | 7 + .../src/cli/commands/mod.rs | 3 + .../src/cli/commands/skills.rs | 180 +++++++ crates/mesh-llm-host-runtime/src/cli/mod.rs | 45 ++ crates/mesh-llm-plugin-manager/Cargo.toml | 1 + crates/mesh-llm-plugin-manager/src/lib.rs | 6 + crates/mesh-llm-plugin-manager/src/skills.rs | 158 ++++++ crates/mesh-llm-skills/Cargo.toml | 20 + crates/mesh-llm-skills/src/lib.rs | 470 ++++++++++++++++++ docker/Dockerfile.client | 4 + docs/CLI.md | 34 ++ docs/plugins/README.md | 46 +- fly/Dockerfile | 4 + scripts/affected-crates.sh | 1 + scripts/plan-clippy-batches.sh | 2 + 18 files changed, 993 insertions(+), 2 deletions(-) create mode 100644 crates/mesh-llm-host-runtime/src/cli/commands/skills.rs create mode 100644 crates/mesh-llm-plugin-manager/src/skills.rs create mode 100644 crates/mesh-llm-skills/Cargo.toml create mode 100644 crates/mesh-llm-skills/src/lib.rs diff --git a/.github/workflows/docker-precheck.yml b/.github/workflows/docker-precheck.yml index b754ff7887..62674d9072 100644 --- a/.github/workflows/docker-precheck.yml +++ b/.github/workflows/docker-precheck.yml @@ -71,6 +71,7 @@ jobs: crates/mesh-llm-config/ \ crates/mesh-llm-console-server/ \ crates/mesh-llm-plugin/ \ + crates/mesh-llm-skills/ \ crates/mesh-llm-plugin-manager/ \ crates/mesh-client/ \ crates/mesh-llm-api-client/ \ diff --git a/Cargo.lock b/Cargo.lock index d13f2f1d4c..c5b32fd373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4031,6 +4031,7 @@ dependencies = [ "dirs", "flate2", "futures-util", + "mesh-llm-skills", "reqwest 0.12.28", "serde", "serde_json", @@ -4058,6 +4059,17 @@ dependencies = [ "iroh", ] +[[package]] +name = "mesh-llm-skills" +version = "0.68.0" +dependencies = [ + "anyhow", + "dirs", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "mesh-llm-system" version = "0.68.0" diff --git a/Cargo.toml b/Cargo.toml index 1700f5cdb1..ef11b0171b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/mesh-llm-console-server", "crates/mesh-llm-ui", "crates/mesh-llm-plugin", + "crates/mesh-llm-skills", "crates/mesh-llm-plugin-manager", "crates/mesh-client", "crates/mesh-llm-api-client", diff --git a/crates/mesh-llm-host-runtime/src/cli/commands/agent_cli.rs b/crates/mesh-llm-host-runtime/src/cli/commands/agent_cli.rs index 532a679cf7..b1c06c835d 100644 --- a/crates/mesh-llm-host-runtime/src/cli/commands/agent_cli.rs +++ b/crates/mesh-llm-host-runtime/src/cli/commands/agent_cli.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; +use mesh_llm_plugin_manager::SkillAgent; use std::process::{Command, Stdio}; +use crate::cli::commands::skills::install_skills_for_agent; use crate::{cli::shell, runtime}; use url::Url; @@ -483,6 +485,7 @@ pub(crate) async fn run_goose(model: Option, port: u16) -> Result<()> { std::fs::write(&provider_path, serde_json::to_string_pretty(&provider)?)?; eprintln!("โœ… Wrote {}", provider_path.display()); write_goose_mcp_config(DEFAULT_MESH_MCP_URL)?; + install_skills_for_agent(SkillAgent::Goose); let goose_app = std::path::Path::new("/Applications/Goose.app"); if goose_app.exists() { @@ -560,6 +563,7 @@ pub(crate) async fn run_claude(model: Option, port: u16) -> Result<()> { }); let settings_json = serde_json::to_string(&settings)?; let mcp_config_json = mesh_mcp_claude_config_json(DEFAULT_MESH_MCP_URL)?; + install_skills_for_agent(SkillAgent::Claude); eprintln!("๐Ÿš€ Launching Claude Code with {chosen} โ†’ {base_url}\n"); let mut command = Command::new("claude"); @@ -801,6 +805,7 @@ fn run_pi_with_mesh( write: bool, ) -> Result<()> { write_pi_config_with_limits(model_names, base_url, context_lengths)?; + install_skills_for_agent(SkillAgent::Pi); if write { return Ok(()); @@ -843,6 +848,7 @@ pub(crate) async fn run_opencode(model: Option, host: &str, write: bool) }; let result = if write { + install_skills_for_agent(SkillAgent::Opencode); write_opencode_config(&client, &models, &chosen, &target).await } else { let spec = build_opencode_launch_spec_with_mcp( @@ -856,6 +862,7 @@ pub(crate) async fn run_opencode(model: Option, host: &str, write: bool) "๐Ÿš€ Launching OpenCode with {} โ†’ {}\n", chosen, target.api_base_url ); + install_skills_for_agent(SkillAgent::Opencode); let mut command = Command::new("opencode"); command .args(["-m", &spec.model]) diff --git a/crates/mesh-llm-host-runtime/src/cli/commands/mod.rs b/crates/mesh-llm-host-runtime/src/cli/commands/mod.rs index 6c72b36529..55c6bff06a 100644 --- a/crates/mesh-llm-host-runtime/src/cli/commands/mod.rs +++ b/crates/mesh-llm-host-runtime/src/cli/commands/mod.rs @@ -9,6 +9,7 @@ mod models; mod plugin; mod plugin_cli; mod runtime; +mod skills; mod update; use anyhow::Result; @@ -22,6 +23,7 @@ use crate::cli::commands::models::dispatch_models_command; use crate::cli::commands::plugin::run_plugin_command; use crate::cli::commands::plugin_cli::run_external_plugin_command; use crate::cli::commands::runtime::{dispatch_runtime_command, run_drop, run_load, run_status}; +use crate::cli::commands::skills::run_skills_command; use crate::cli::commands::update::run_update; use crate::cli::{AuthCommand, Cli, Command}; use crate::network::nostr; @@ -86,6 +88,7 @@ async fn dispatch_general_command(cli: &Cli, cmd: &Command) -> Result<()> { Command::Claude { model, port } => run_claude(model.clone(), *port).await, Command::Pi { model, host, write } => run_pi(model.clone(), host, *write).await, Command::Opencode { model, host, write } => run_opencode(model.clone(), host, *write).await, + Command::Skills { command } => run_skills_command(command), Command::Plugin { command } => run_plugin_command(command, cli).await, Command::Benchmark { command } => dispatch_benchmark_command(command).await, Command::ModelPrepare { .. } => dispatch_model_prepare(cmd).await, diff --git a/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs new file mode 100644 index 0000000000..8a23ff5b1e --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs @@ -0,0 +1,180 @@ +use anyhow::Result; +use mesh_llm_plugin_manager::{ + PluginSkillInstallOptions, SkillAgent, SkillInstallReport, SkillInstallStatus, + install_available_skills, +}; + +use crate::cli::{SkillAgentArg, SkillCommand}; + +pub(crate) fn run_skills_command(command: &SkillCommand) -> Result<()> { + match command { + SkillCommand::Install { + agent, + all, + dry_run, + force, + } => install(agent, *all, *dry_run, *force), + } +} + +pub(crate) fn install_skills_for_agent(agent: SkillAgent) { + match PluginSkillInstallOptions::for_agent(agent).and_then(|options| { + let report = install_available_skills(&options)?; + Ok(report) + }) { + Ok(report) => print_agent_install_summary(agent, &report), + Err(error) => eprintln!( + "โš ๏ธ Could not install mesh plugin skills for {}: {error}", + agent.as_str() + ), + } +} + +fn install(agents: &[SkillAgentArg], all: bool, dry_run: bool, force: bool) -> Result<()> { + let mut options = PluginSkillInstallOptions::from_env()?; + options.skill_options.dry_run = dry_run; + options.skill_options.force = force; + if all { + options.skill_options.detected_only = false; + } + if !agents.is_empty() { + options.skill_options.agents = agents.iter().copied().map(Into::into).collect(); + options.skill_options.detected_only = false; + } + let report = install_available_skills(&options)?; + print_install_report(&report, dry_run); + Ok(()) +} + +fn print_agent_install_summary(agent: SkillAgent, report: &SkillInstallReport) { + let changed = report + .actions + .iter() + .filter(|action| { + matches!( + action.status, + SkillInstallStatus::Installed | SkillInstallStatus::Updated + ) + }) + .count(); + if changed > 0 { + eprintln!( + "โœ… Installed {changed} mesh plugin skill(s) for {}", + agent.as_str() + ); + } +} + +fn print_install_report(report: &SkillInstallReport, dry_run: bool) { + let heading = if dry_run { + "๐Ÿงช Mesh plugin skill install preview" + } else { + "๐Ÿง  Installing mesh plugin skills" + }; + eprintln!("{heading}"); + + if report.available_skills == 0 { + eprintln!("๐Ÿ”Ž No plugin skills found in installed plugins."); + eprintln!("๐Ÿ“ฆ Plugins can expose skills with skills//SKILL.md."); + return; + } + + eprintln!( + "๐Ÿ“ฆ Found {}", + plural_count(report.available_skills, "plugin skill") + ); + + if report.targets.is_empty() { + eprintln!("๐Ÿ”Ž No supported agent skill targets detected."); + eprintln!("๐Ÿ’ก Use --agent or --all to install anyway."); + return; + } + + eprintln!( + "๐ŸŽฏ Targeting {}:", + plural_count(report.targets.len(), "agent") + ); + for target in &report.targets { + let reason = target + .detection_reason + .as_deref() + .unwrap_or("explicit target"); + eprintln!( + " โ€ข {:<8} {} ({reason})", + target.agent.as_str(), + target.root.display() + ); + } + + eprintln!("๐Ÿ› ๏ธ Applying skills:"); + for action in &report.actions { + let Some(label) = action_status_label(&action.status, dry_run) else { + continue; + }; + eprintln!( + " {label:<17} {:<28} -> {:<8} {}", + skill_display_name(action), + action.agent.as_str(), + action.destination_dir.display() + ); + } + + print_install_summary(report, dry_run); +} + +fn print_install_summary(report: &SkillInstallReport, dry_run: bool) { + let mut installed = 0; + let mut updated = 0; + let mut unchanged = 0; + let mut conflicts = 0; + for action in &report.actions { + match action.status { + SkillInstallStatus::Installed | SkillInstallStatus::WouldInstall => installed += 1, + SkillInstallStatus::Updated | SkillInstallStatus::WouldUpdate => updated += 1, + SkillInstallStatus::Unchanged => unchanged += 1, + SkillInstallStatus::SkippedConflict | SkillInstallStatus::WouldSkipConflict => { + conflicts += 1; + } + } + } + + let verb = if dry_run { "planned" } else { "complete" }; + let mut parts = vec![ + format!("{}", plural_count(installed, "install")), + format!("{}", plural_count(updated, "update")), + ]; + if unchanged > 0 { + parts.push(format!("{}", plural_count(unchanged, "unchanged"))); + } + if conflicts > 0 { + parts.push(format!("{}", plural_count(conflicts, "conflict"))); + } + eprintln!("โœ… Skill install {verb}: {}", parts.join(", ")); +} + +fn action_status_label(status: &SkillInstallStatus, dry_run: bool) -> Option<&'static str> { + match status { + SkillInstallStatus::Installed => Some("โœ… installed"), + SkillInstallStatus::Updated => Some("โ™ป๏ธ updated"), + SkillInstallStatus::Unchanged if !dry_run => None, + SkillInstallStatus::Unchanged => Some("โญ๏ธ unchanged"), + SkillInstallStatus::WouldInstall => Some("๐Ÿ“ would install"), + SkillInstallStatus::WouldUpdate => Some("๐Ÿ“ would update"), + SkillInstallStatus::WouldSkipConflict => Some("โš ๏ธ would skip"), + SkillInstallStatus::SkippedConflict => Some("โš ๏ธ skipped"), + } +} + +fn skill_display_name(action: &mesh_llm_plugin_manager::SkillInstallAction) -> String { + format!("{}/{}", action.provider_name, action.skill_name) +} + +fn plural_count(count: usize, noun: &str) -> String { + if count == 1 { + format!("{count} {noun}") + } else if noun == "unchanged" { + format!("{count} unchanged") + } else { + format!("{count} {noun}s") + } +} diff --git a/crates/mesh-llm-host-runtime/src/cli/mod.rs b/crates/mesh-llm-host-runtime/src/cli/mod.rs index f5a648a032..1ff154df46 100644 --- a/crates/mesh-llm-host-runtime/src/cli/mod.rs +++ b/crates/mesh-llm-host-runtime/src/cli/mod.rs @@ -742,6 +742,11 @@ pub(crate) enum Command { #[command(subcommand)] command: PluginCommand, }, + /// Install agent skills exposed by installed plugins. + Skills { + #[command(subcommand)] + command: SkillCommand, + }, /// Benchmark and compare model/runtime strategies. #[command(hide = true)] Benchmark { @@ -870,6 +875,46 @@ pub(crate) enum PluginCommand { List, } +#[derive(Subcommand, Debug)] +pub(crate) enum SkillCommand { + /// Install skills exposed by installed plugins into supported agent skill folders. + Install { + /// Agent to install for. Repeat to install to several agents. + #[arg(long, value_enum, conflicts_with = "all")] + agent: Vec, + /// Install to all supported agent locations, even if the agent is not detected. + #[arg(long)] + all: bool, + /// Show what would be installed without writing files. + #[arg(long)] + dry_run: bool, + /// Replace an existing non-mesh-managed skill with the same directory name. + #[arg(long)] + force: bool, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum SkillAgentArg { + Goose, + Pi, + Codex, + Opencode, + Claude, +} + +impl From for mesh_llm_plugin_manager::SkillAgent { + fn from(value: SkillAgentArg) -> Self { + match value { + SkillAgentArg::Goose => Self::Goose, + SkillAgentArg::Pi => Self::Pi, + SkillAgentArg::Codex => Self::Codex, + SkillAgentArg::Opencode => Self::Opencode, + SkillAgentArg::Claude => Self::Claude, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum RuntimeSurface { Serve, diff --git a/crates/mesh-llm-plugin-manager/Cargo.toml b/crates/mesh-llm-plugin-manager/Cargo.toml index 7ed5c00438..6dde5a7f84 100644 --- a/crates/mesh-llm-plugin-manager/Cargo.toml +++ b/crates/mesh-llm-plugin-manager/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true dirs = "6" flate2 = "1" futures-util = "0.3" +mesh-llm-skills = { path = "../mesh-llm-skills", version = "0.68.0" } reqwest = { version = "0.12", features = ["json", "stream"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-llm-plugin-manager/src/lib.rs b/crates/mesh-llm-plugin-manager/src/lib.rs index 1cef955b0b..5f93104876 100644 --- a/crates/mesh-llm-plugin-manager/src/lib.rs +++ b/crates/mesh-llm-plugin-manager/src/lib.rs @@ -3,6 +3,7 @@ pub mod asset; pub mod catalog; pub mod github; pub mod install; +pub mod skills; pub mod source_ref; pub mod store; pub mod target; @@ -14,6 +15,11 @@ pub use install::{ InstallOutcome, PluginInstallOptions, PluginProgressEvent, PluginProgressReporter, install_plugin, update_plugin, }; +pub use mesh_llm_skills::{ + SkillAgent, SkillInstallAction, SkillInstallReport, SkillInstallStatus, SkillPackage, + SkillTarget, +}; +pub use skills::{PluginSkillInstallOptions, discover_plugin_skills, install_available_skills}; pub use source_ref::{GitHubPluginSource, PluginInstallRef, PluginVersion, parse_install_ref}; pub use store::{InstalledPluginMetadata, PluginStore, default_store_root}; pub use target::{ArchiveExt, PluginTarget, UnsupportedTarget}; diff --git a/crates/mesh-llm-plugin-manager/src/skills.rs b/crates/mesh-llm-plugin-manager/src/skills.rs new file mode 100644 index 0000000000..14207fc853 --- /dev/null +++ b/crates/mesh-llm-plugin-manager/src/skills.rs @@ -0,0 +1,158 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use mesh_llm_skills::{ + SkillAgent, SkillInstallOptions, SkillInstallReport, SkillPackage, install_skills, + is_valid_skill_name, +}; + +use crate::store::{InstalledPluginMetadata, PluginStore, default_store_root}; + +#[derive(Clone, Debug)] +pub struct PluginSkillInstallOptions { + pub store_root: PathBuf, + pub skill_options: SkillInstallOptions, +} + +impl PluginSkillInstallOptions { + pub fn from_env() -> Result { + Ok(Self { + store_root: default_store_root()?, + skill_options: SkillInstallOptions::from_env()?, + }) + } + + pub fn for_agent(agent: SkillAgent) -> Result { + Ok(Self { + store_root: default_store_root()?, + skill_options: SkillInstallOptions::for_agent(agent)?, + }) + } +} + +pub fn install_available_skills(options: &PluginSkillInstallOptions) -> Result { + let store = PluginStore::new(&options.store_root); + let skills = discover_plugin_skills(&store)?; + install_skills(&skills, &options.skill_options) +} + +pub fn discover_plugin_skills(store: &PluginStore) -> Result> { + let mut skills = Vec::new(); + for plugin in store.list()? { + if !plugin.enabled { + continue; + } + append_plugin_root_skill(&mut skills, &plugin); + append_plugin_skills_dir(&mut skills, &plugin)?; + } + skills.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then(left.provider_name.cmp(&right.provider_name)) + }); + Ok(skills) +} + +fn append_plugin_root_skill(skills: &mut Vec, plugin: &InstalledPluginMetadata) { + let source_dir = plugin.install_path.clone(); + if !source_dir.join("SKILL.md").exists() { + return; + } + skills.push(SkillPackage { + provider_name: plugin.name.clone(), + provider_version: plugin.installed_version.clone(), + name: plugin.name.clone(), + source_dir, + }); +} + +fn append_plugin_skills_dir( + skills: &mut Vec, + plugin: &InstalledPluginMetadata, +) -> Result<()> { + let skills_dir = plugin.install_path.join("skills"); + if !skills_dir.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(&skills_dir) + .with_context(|| format!("read plugin skills directory {}", skills_dir.display()))? + { + let entry = + entry.with_context(|| format!("read plugin skill entry {}", skills_dir.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("read file type for {}", entry.path().display()))?; + if !file_type.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if !is_valid_skill_name(&name) { + bail!( + "plugin '{}' exposes invalid skill directory '{}'", + plugin.name, + name + ); + } + let source_dir = entry.path(); + if source_dir.join("SKILL.md").exists() { + skills.push(SkillPackage { + provider_name: plugin.name.clone(), + provider_version: plugin.installed_version.clone(), + name, + source_dir, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{fs, path::Path}; + + use tempfile::TempDir; + + use super::*; + + fn metadata(name: &str, install_path: PathBuf) -> InstalledPluginMetadata { + InstalledPluginMetadata { + name: name.to_string(), + source_repository: format!("https://github.com/mesh-llm/{name}"), + installed_version: "v1.0.0".to_string(), + target_triple: "x86_64-unknown-linux-gnu".to_string(), + downloaded_asset_name: format!("{name}-x86_64-unknown-linux-gnu.tar.gz"), + install_path, + enabled: true, + last_protocol_version: None, + last_status: None, + last_error: None, + } + } + + fn write_skill(root: &Path, name: &str) { + let skill_dir = root.join("skills").join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: Demo skill\n---\n"), + ) + .unwrap(); + } + + #[test] + fn discovers_enabled_plugin_skills() { + let temp = TempDir::new().unwrap(); + let install_path = temp.path().join("installed").join("demo"); + write_skill(&install_path, "demo-skill"); + + let store = PluginStore::new(temp.path().join("store")); + store.save(&metadata("demo", install_path)).unwrap(); + + let skills = discover_plugin_skills(&store).unwrap(); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].provider_name, "demo"); + assert_eq!(skills[0].name, "demo-skill"); + } +} diff --git a/crates/mesh-llm-skills/Cargo.toml b/crates/mesh-llm-skills/Cargo.toml new file mode 100644 index 0000000000..4d72553f31 --- /dev/null +++ b/crates/mesh-llm-skills/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mesh-llm-skills" +edition.workspace = true +license.workspace = true +version.workspace = true +description = "Agent skill data model and installer primitives for Mesh LLM" +repository = "https://github.com/Mesh-LLM/mesh-llm" +homepage = "https://github.com/Mesh-LLM/mesh-llm" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +dirs = "6" +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/mesh-llm-skills/src/lib.rs b/crates/mesh-llm-skills/src/lib.rs new file mode 100644 index 0000000000..3dd594c051 --- /dev/null +++ b/crates/mesh-llm-skills/src/lib.rs @@ -0,0 +1,470 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +const MARKER_FILE: &str = ".mesh-llm-skill.json"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SkillAgent { + Goose, + Pi, + Codex, + Opencode, + Claude, +} + +impl SkillAgent { + pub fn as_str(self) -> &'static str { + match self { + Self::Goose => "goose", + Self::Pi => "pi", + Self::Codex => "codex", + Self::Opencode => "opencode", + Self::Claude => "claude", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillTarget { + pub agent: SkillAgent, + pub root: PathBuf, + pub detected: bool, + pub detection_reason: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillPackage { + pub provider_name: String, + pub provider_version: String, + pub name: String, + pub source_dir: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct SkillInstallOptions { + pub home_dir: PathBuf, + pub agents: Vec, + pub detected_only: bool, + pub dry_run: bool, + pub force: bool, +} + +impl SkillInstallOptions { + pub fn from_env() -> Result { + let home_dir = dirs::home_dir().context("Cannot determine home directory")?; + Ok(Self { + home_dir, + agents: Vec::new(), + detected_only: true, + dry_run: false, + force: false, + }) + } + + pub fn for_agent(agent: SkillAgent) -> Result { + let mut options = Self::from_env()?; + options.agents = vec![agent]; + options.detected_only = false; + Ok(options) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillInstallReport { + pub available_skills: usize, + pub targets: Vec, + pub actions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillInstallAction { + pub agent: SkillAgent, + pub skill_name: String, + pub provider_name: String, + pub source_dir: PathBuf, + pub destination_dir: PathBuf, + pub status: SkillInstallStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SkillInstallStatus { + Installed, + Updated, + Unchanged, + WouldInstall, + WouldUpdate, + WouldSkipConflict, + SkippedConflict, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct ManagedSkillMarker { + source_provider: String, + source_skill: String, + provider_version: String, +} + +pub fn install_skills( + skills: &[SkillPackage], + options: &SkillInstallOptions, +) -> Result { + let targets = resolve_targets(options); + let mut actions = Vec::new(); + + for target in &targets { + for skill in skills { + actions.push(install_skill_to_target(skill, target, options)?); + } + } + + Ok(SkillInstallReport { + available_skills: skills.len(), + targets, + actions, + }) +} + +pub fn resolve_targets(options: &SkillInstallOptions) -> Vec { + let agents = if options.agents.is_empty() { + vec![ + SkillAgent::Goose, + SkillAgent::Pi, + SkillAgent::Codex, + SkillAgent::Opencode, + SkillAgent::Claude, + ] + } else { + options.agents.clone() + }; + + let mut targets = agents + .into_iter() + .map(|agent| skill_target(agent, &options.home_dir)) + .filter(|target| !options.detected_only || target.detected) + .collect::>(); + targets.sort_by(|left, right| left.agent.as_str().cmp(right.agent.as_str())); + targets +} + +pub fn is_valid_skill_name(value: &str) -> bool { + let mut previous_hyphen = false; + if value.is_empty() || value.len() > 64 || value.starts_with('-') || value.ends_with('-') { + return false; + } + for ch in value.chars() { + let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'; + if !valid || (ch == '-' && previous_hyphen) { + return false; + } + previous_hyphen = ch == '-'; + } + true +} + +fn install_skill_to_target( + skill: &SkillPackage, + target: &SkillTarget, + options: &SkillInstallOptions, +) -> Result { + let destination_dir = target.root.join(&skill.name); + let marker = ManagedSkillMarker { + source_provider: skill.provider_name.clone(), + source_skill: skill.name.clone(), + provider_version: skill.provider_version.clone(), + }; + let existing_marker = read_marker(&destination_dir)?; + let status = classify_install(&destination_dir, existing_marker.as_ref(), &marker, options); + + if !options.dry_run { + match status { + SkillInstallStatus::Installed | SkillInstallStatus::Updated => { + replace_skill_dir(&skill.source_dir, &destination_dir, &marker)?; + } + SkillInstallStatus::SkippedConflict | SkillInstallStatus::Unchanged => {} + SkillInstallStatus::WouldInstall + | SkillInstallStatus::WouldUpdate + | SkillInstallStatus::WouldSkipConflict => unreachable!("dry-run status in live run"), + } + } + + Ok(SkillInstallAction { + agent: target.agent, + skill_name: skill.name.clone(), + provider_name: skill.provider_name.clone(), + source_dir: skill.source_dir.clone(), + destination_dir, + status, + }) +} + +fn classify_install( + destination_dir: &Path, + existing_marker: Option<&ManagedSkillMarker>, + marker: &ManagedSkillMarker, + options: &SkillInstallOptions, +) -> SkillInstallStatus { + if !destination_dir.exists() { + return if options.dry_run { + SkillInstallStatus::WouldInstall + } else { + SkillInstallStatus::Installed + }; + } + if existing_marker == Some(marker) && !options.force { + return SkillInstallStatus::Unchanged; + } + if existing_marker + .map(|existing| { + existing.source_provider == marker.source_provider + && existing.source_skill == marker.source_skill + }) + .unwrap_or(options.force) + { + return if options.dry_run { + SkillInstallStatus::WouldUpdate + } else { + SkillInstallStatus::Updated + }; + } + if options.dry_run { + SkillInstallStatus::WouldSkipConflict + } else { + SkillInstallStatus::SkippedConflict + } +} + +fn replace_skill_dir( + source_dir: &Path, + destination_dir: &Path, + marker: &ManagedSkillMarker, +) -> Result<()> { + let parent = destination_dir.parent().with_context(|| { + format!( + "skill destination has no parent: {}", + destination_dir.display() + ) + })?; + fs::create_dir_all(parent) + .with_context(|| format!("create skills directory {}", parent.display()))?; + let temp_dir = parent.join(format!( + ".mesh-llm-skill-{}-{}", + std::process::id(), + marker.source_skill + )); + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir) + .with_context(|| format!("remove stale temporary skill dir {}", temp_dir.display()))?; + } + copy_dir(source_dir, &temp_dir)?; + write_marker(&temp_dir, marker)?; + if destination_dir.exists() { + fs::remove_dir_all(destination_dir) + .with_context(|| format!("remove previous skill {}", destination_dir.display()))?; + } + fs::rename(&temp_dir, destination_dir).with_context(|| { + format!( + "install skill {} to {}", + marker.source_skill, + destination_dir.display() + ) + })?; + Ok(()) +} + +fn copy_dir(source_dir: &Path, destination_dir: &Path) -> Result<()> { + fs::create_dir_all(destination_dir) + .with_context(|| format!("create directory {}", destination_dir.display()))?; + for entry in fs::read_dir(source_dir) + .with_context(|| format!("read source directory {}", source_dir.display()))? + { + let entry = entry.with_context(|| format!("read source entry {}", source_dir.display()))?; + let source = entry.path(); + let destination = destination_dir.join(entry.file_name()); + let file_type = entry + .file_type() + .with_context(|| format!("read file type for {}", source.display()))?; + if file_type.is_dir() { + copy_dir(&source, &destination)?; + } else if file_type.is_file() { + fs::copy(&source, &destination).with_context(|| { + format!( + "copy skill file {} to {}", + source.display(), + destination.display() + ) + })?; + } + } + Ok(()) +} + +fn read_marker(skill_dir: &Path) -> Result> { + let marker_path = skill_dir.join(MARKER_FILE); + if !marker_path.exists() { + return Ok(None); + } + let bytes = fs::read(&marker_path) + .with_context(|| format!("read skill marker {}", marker_path.display()))?; + Ok(Some(serde_json::from_slice(&bytes).with_context(|| { + format!("parse skill marker {}", marker_path.display()) + })?)) +} + +fn write_marker(skill_dir: &Path, marker: &ManagedSkillMarker) -> Result<()> { + let marker_path = skill_dir.join(MARKER_FILE); + fs::write(&marker_path, serde_json::to_vec_pretty(marker)?) + .with_context(|| format!("write skill marker {}", marker_path.display()))?; + Ok(()) +} + +fn skill_target(agent: SkillAgent, home_dir: &Path) -> SkillTarget { + let (root, command, config_dir) = match agent { + SkillAgent::Goose => (home_dir.join(".agents").join("skills"), "goose", None), + SkillAgent::Pi => ( + home_dir.join(".pi").join("agent").join("skills"), + "pi", + Some(home_dir.join(".pi").join("agent")), + ), + SkillAgent::Codex => (home_dir.join(".agents").join("skills"), "codex", None), + SkillAgent::Opencode => ( + home_dir.join(".config").join("opencode").join("skills"), + "opencode", + Some(home_dir.join(".config").join("opencode")), + ), + SkillAgent::Claude => ( + home_dir.join(".claude").join("skills"), + "claude", + Some(home_dir.join(".claude")), + ), + }; + let command_detected = command_exists(command); + let config_detected = config_dir.as_ref().is_some_and(|dir| dir.exists()); + let detected = command_detected || config_detected || root.exists(); + let detection_reason = if command_detected { + Some(format!("found '{command}' in PATH")) + } else if config_detected { + config_dir.map(|dir| format!("found {}", dir.display())) + } else if root.exists() { + Some(format!("found {}", root.display())) + } else { + None + }; + + SkillTarget { + agent, + root, + detected, + detection_reason, + } +} + +fn command_exists(command: &str) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + env::split_paths(&paths) + .any(|path| command_candidates(command).any(|candidate| path.join(candidate).is_file())) +} + +fn command_candidates(command: &str) -> impl Iterator + '_ { + let suffix = env::consts::EXE_SUFFIX; + let mut candidates = vec![command.to_string()]; + if !suffix.is_empty() { + candidates.push(format!("{command}{suffix}")); + } + if cfg!(windows) { + candidates.push(format!("{command}.cmd")); + candidates.push(format!("{command}.bat")); + } + candidates.into_iter() +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn skill(name: &str, source_dir: PathBuf) -> SkillPackage { + SkillPackage { + provider_name: "demo".to_string(), + provider_version: "v1.0.0".to_string(), + name: name.to_string(), + source_dir, + } + } + + fn write_skill(root: &Path, name: &str) -> PathBuf { + let skill_dir = root.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: Demo skill\n---\n"), + ) + .unwrap(); + skill_dir + } + + #[test] + fn validates_agent_skill_names() { + assert!(is_valid_skill_name("demo-skill-1")); + assert!(!is_valid_skill_name("Demo")); + assert!(!is_valid_skill_name("-demo")); + assert!(!is_valid_skill_name("demo--skill")); + } + + #[test] + fn installs_skills_to_requested_agent_target() { + let temp = TempDir::new().unwrap(); + let source_dir = write_skill(&temp.path().join("source"), "demo-skill"); + + let options = SkillInstallOptions { + home_dir: temp.path().join("home"), + agents: vec![SkillAgent::Pi], + detected_only: false, + dry_run: false, + force: false, + }; + let report = install_skills(&[skill("demo-skill", source_dir)], &options).unwrap(); + + assert_eq!(report.available_skills, 1); + assert_eq!(report.actions[0].status, SkillInstallStatus::Installed); + assert!( + options + .home_dir + .join(".pi/agent/skills/demo-skill/SKILL.md") + .exists() + ); + } + + #[test] + fn skips_user_owned_conflicts_without_force() { + let temp = TempDir::new().unwrap(); + let source_dir = write_skill(&temp.path().join("source"), "demo-skill"); + + let home_dir = temp.path().join("home"); + let existing = home_dir.join(".agents/skills/demo-skill"); + fs::create_dir_all(&existing).unwrap(); + fs::write(existing.join("SKILL.md"), "---\ndescription: mine\n---\n").unwrap(); + + let options = SkillInstallOptions { + home_dir, + agents: vec![SkillAgent::Codex], + detected_only: false, + dry_run: false, + force: false, + }; + let report = install_skills(&[skill("demo-skill", source_dir)], &options).unwrap(); + + assert_eq!( + report.actions[0].status, + SkillInstallStatus::SkippedConflict + ); + } +} diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client index 8acadb47ba..e299b1f719 100644 --- a/docker/Dockerfile.client +++ b/docker/Dockerfile.client @@ -52,11 +52,13 @@ COPY crates/mesh-llm/Cargo.toml crates/mesh-llm/Cargo.toml COPY crates/mesh-llm/build.rs crates/mesh-llm/build.rs COPY crates/mesh-llm-plugin/Cargo.toml crates/mesh-llm-plugin/Cargo.toml COPY crates/mesh-llm-plugin/build.rs crates/mesh-llm-plugin/build.rs +COPY crates/mesh-llm-skills/Cargo.toml crates/mesh-llm-skills/Cargo.toml COPY crates/mesh-llm-plugin-manager/Cargo.toml crates/mesh-llm-plugin-manager/Cargo.toml # Satisfy Cargo workspace member resolution COPY crates/mesh-llm-host-runtime/ crates/mesh-llm-host-runtime/ COPY crates/mesh-llm/ crates/mesh-llm/ COPY crates/mesh-llm-plugin/ crates/mesh-llm-plugin/ +COPY crates/mesh-llm-skills/ crates/mesh-llm-skills/ COPY crates/mesh-llm-plugin-manager/ crates/mesh-llm-plugin-manager/ COPY crates/mesh-client/ crates/mesh-client/ COPY crates/mesh-llm-api-client/ crates/mesh-llm-api-client/ @@ -121,11 +123,13 @@ COPY crates/mesh-llm/Cargo.toml crates/mesh-llm/Cargo.toml COPY crates/mesh-llm/build.rs crates/mesh-llm/build.rs COPY crates/mesh-llm-plugin/Cargo.toml crates/mesh-llm-plugin/Cargo.toml COPY crates/mesh-llm-plugin/build.rs crates/mesh-llm-plugin/build.rs +COPY crates/mesh-llm-skills/Cargo.toml crates/mesh-llm-skills/Cargo.toml COPY crates/mesh-llm-plugin-manager/Cargo.toml crates/mesh-llm-plugin-manager/Cargo.toml # Satisfy Cargo workspace member resolution COPY crates/mesh-llm-host-runtime/ crates/mesh-llm-host-runtime/ COPY crates/mesh-llm/ crates/mesh-llm/ COPY crates/mesh-llm-plugin/ crates/mesh-llm-plugin/ +COPY crates/mesh-llm-skills/ crates/mesh-llm-skills/ COPY crates/mesh-llm-plugin-manager/ crates/mesh-llm-plugin-manager/ COPY crates/mesh-client/ crates/mesh-client/ COPY crates/mesh-llm-api-client/ crates/mesh-llm-api-client/ diff --git a/docs/CLI.md b/docs/CLI.md index 63672a43b6..d05935595f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -428,6 +428,16 @@ Switches: - `--model `: model id from `/v1/models`. - `--port `: mesh-llm API port (default `9337`). +### `pi` + +Use this to launch Pi already wired to mesh-llmโ€™s OpenAI-compatible endpoint. + +Switches: + +- `--model `: model id from `/v1/models`. +- `--host `: Pi target host or URL (default `127.0.0.1:9337`). +- `--write`: write the mesh provider config without launching Pi. + ### `opencode` Use this to launch OpenCode already wired to mesh-llmโ€™s OpenAI-compatible endpoint. @@ -440,6 +450,30 @@ Switches: - `--host `: OpenCode target host or URL (default `127.0.0.1:9337`). Bare host forms assume `http`, default inference port `9337`, and default management port `3131`. - `--write`: write a merged `~/.config/opencode/opencode.json` that preserves unrelated root keys and sibling providers. If only `opencode.jsonc` exists, mesh-llm errors and tells you to rename or migrate it to `opencode.json` first. +### `skills` + +Use this to install Agent Skills exposed by installed mesh plugins. + +Usage: + +```bash +mesh-llm skills install +mesh-llm skills install --agent goose --agent codex +mesh-llm skills install --all --dry-run +``` + +By default, the installer writes only to detected agents. Plugin packages expose +skills by shipping `skills//SKILL.md` under the plugin archive root. +Agent launch commands (`goose`, `pi`, `opencode`, and `claude`) install +available plugin skills for that agent before starting the session. + +Switches: + +- `--agent `: install to a specific agent (`goose`, `pi`, `codex`, `opencode`, `claude`); repeatable. +- `--all`: install to every supported target location even if the agent is not detected. +- `--dry-run`: print planned writes without changing files. +- `--force`: overwrite an existing user-owned skill directory with the same name. + ### `stop` Use this to stop local `mesh-llm` instances tracked in the runtime root. diff --git a/docs/plugins/README.md b/docs/plugins/README.md index f724361934..d095d10cf3 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -147,6 +147,9 @@ cool-plugin/ cool-plugin README.md LICENSE + skills/ + cool-workflow/ + SKILL.md ``` On Windows, the executable should use `.exe`: @@ -157,8 +160,47 @@ cool-plugin/ cool-plugin.exe ``` -Only `plugin.toml` and the native executable are required. Documentation and -license files are optional but recommended. +Only `plugin.toml` and the native executable are required. Documentation, +license files, and skill folders are optional but recommended when the plugin +has agent-facing workflows. + +## Plugin Skills + +Installed plugins may expose Agent Skills by shipping skill directories under +their extracted plugin root: + +```text +cool-plugin/ + skills/ + cool-workflow/ + SKILL.md + references/ + scripts/ + assets/ +``` + +Each skill directory name must use the portable Agent Skills naming convention: +lowercase ASCII letters, numbers, and single hyphen separators. `SKILL.md` +should include `name` and `description` YAML frontmatter and should refer to +supporting files with paths relative to the skill directory. Avoid hard-coded +home directories, OS-specific absolute paths, and shell-specific commands unless +the skill documents the required platform in its `compatibility` field. + +`mesh-llm skills install` copies plugin skills into detected agent skill +directories. `mesh-llm goose`, `mesh-llm pi`, `mesh-llm opencode`, and +`mesh-llm claude` also install available plugin skills for that launched agent +before starting the session. Existing user-owned skill directories are not +overwritten unless `--force` is passed to the explicit installer. + +Current install targets: + +| Agent | Target | +|---|---| +| Goose | `~/.agents/skills` | +| Codex | `~/.agents/skills` | +| Pi | `~/.pi/agent/skills` | +| OpenCode | `~/.config/opencode/skills` | +| Claude Code | `~/.claude/skills` | Install selection should follow this order: diff --git a/fly/Dockerfile b/fly/Dockerfile index 2a5e4fbcd6..44d1bea074 100644 --- a/fly/Dockerfile +++ b/fly/Dockerfile @@ -45,9 +45,13 @@ COPY crates/mesh-llm/Cargo.toml crates/mesh-llm/Cargo.toml COPY crates/mesh-llm/build.rs crates/mesh-llm/build.rs COPY crates/mesh-llm-plugin/Cargo.toml crates/mesh-llm-plugin/Cargo.toml COPY crates/mesh-llm-plugin/build.rs crates/mesh-llm-plugin/build.rs +COPY crates/mesh-llm-skills/Cargo.toml crates/mesh-llm-skills/Cargo.toml +COPY crates/mesh-llm-plugin-manager/Cargo.toml crates/mesh-llm-plugin-manager/Cargo.toml COPY crates/mesh-llm-host-runtime/ crates/mesh-llm-host-runtime/ COPY crates/mesh-llm/ crates/mesh-llm/ COPY crates/mesh-llm-plugin/ crates/mesh-llm-plugin/ +COPY crates/mesh-llm-skills/ crates/mesh-llm-skills/ +COPY crates/mesh-llm-plugin-manager/ crates/mesh-llm-plugin-manager/ COPY crates/mesh-client/ crates/mesh-client/ COPY crates/mesh-llm-api-client/ crates/mesh-llm-api-client/ COPY crates/mesh-llm-api-server/ crates/mesh-llm-api-server/ diff --git a/scripts/affected-crates.sh b/scripts/affected-crates.sh index afc1da1680..3be7d8da88 100755 --- a/scripts/affected-crates.sh +++ b/scripts/affected-crates.sh @@ -21,6 +21,7 @@ WORKSPACE_MEMBERS=( "mesh-llm-console-server" "mesh-llm-ui" "mesh-llm-plugin" + "mesh-llm-skills" "mesh-llm-plugin-manager" "mesh-llm-client" "mesh-mixture-of-agents" diff --git a/scripts/plan-clippy-batches.sh b/scripts/plan-clippy-batches.sh index c23c452493..93c2ade61a 100644 --- a/scripts/plan-clippy-batches.sh +++ b/scripts/plan-clippy-batches.sh @@ -24,6 +24,7 @@ WORKSPACE_MEMBERS=( "mesh-llm-console-server" "mesh-llm-ui" "mesh-llm-plugin" + "mesh-llm-skills" "mesh-llm-plugin-manager" "mesh-llm-client" "mesh-llm-api-client" @@ -145,6 +146,7 @@ weights = { "mesh-llm-console-server": 2, "mesh-llm-ui": 2, "mesh-llm-plugin": 2, + "mesh-llm-skills": 1, "mesh-llm-plugin-manager": 1, "mesh-llm-node": 2, "mesh-llm-nodejs": 2, From 5cfa6f1ba2a848044c0948d1b41c107f76e5eac4 Mon Sep 17 00:00:00 2001 From: James Dumay Date: Thu, 28 May 2026 16:18:34 +1000 Subject: [PATCH 2/3] Fix skills CLI clippy warnings --- crates/mesh-llm-host-runtime/src/cli/commands/skills.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs index 8a23ff5b1e..2176ad2fab 100644 --- a/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs +++ b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs @@ -140,14 +140,14 @@ fn print_install_summary(report: &SkillInstallReport, dry_run: bool) { let verb = if dry_run { "planned" } else { "complete" }; let mut parts = vec![ - format!("{}", plural_count(installed, "install")), - format!("{}", plural_count(updated, "update")), + plural_count(installed, "install").to_string(), + plural_count(updated, "update").to_string(), ]; if unchanged > 0 { - parts.push(format!("{}", plural_count(unchanged, "unchanged"))); + parts.push(plural_count(unchanged, "unchanged").to_string()); } if conflicts > 0 { - parts.push(format!("{}", plural_count(conflicts, "conflict"))); + parts.push(plural_count(conflicts, "conflict").to_string()); } eprintln!("โœ… Skill install {verb}: {}", parts.join(", ")); } From 917f1011529bfb225b466262590017b1b5b01c4e Mon Sep 17 00:00:00 2001 From: James Dumay Date: Fri, 29 May 2026 15:59:10 +1000 Subject: [PATCH 3/3] Address plugin skill review feedback --- Cargo.toml | 1 + .../src/cli/commands/skills.rs | 48 ++++--- crates/mesh-llm-host-runtime/src/cli/mod.rs | 19 +++ crates/mesh-llm-plugin-manager/Cargo.toml | 2 +- crates/mesh-llm-skills/src/lib.rs | 123 ++++++++++++------ 5 files changed, 134 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ef11b0171b..102432873d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ version = "0.68.0" anyhow = "1" blake3 = "1" clap = { version = "4", features = ["derive"] } +mesh-llm-skills = { path = "crates/mesh-llm-skills" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs index 2176ad2fab..c5928b5ec2 100644 --- a/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs +++ b/crates/mesh-llm-host-runtime/src/cli/commands/skills.rs @@ -4,7 +4,7 @@ use mesh_llm_plugin_manager::{ install_available_skills, }; -use crate::cli::{SkillAgentArg, SkillCommand}; +use crate::cli::{SkillAgentArg, SkillCommand, output::json_mode_enabled}; pub(crate) fn run_skills_command(command: &SkillCommand) -> Result<()> { match command { @@ -23,10 +23,13 @@ pub(crate) fn install_skills_for_agent(agent: SkillAgent) { Ok(report) }) { Ok(report) => print_agent_install_summary(agent, &report), - Err(error) => eprintln!( - "โš ๏ธ Could not install mesh plugin skills for {}: {error}", - agent.as_str() - ), + Err(error) if !json_mode_enabled() => { + eprintln!( + "Could not install mesh plugin skills for {}: {error}", + agent.as_str() + ); + } + Err(_) => {} } } @@ -42,11 +45,14 @@ fn install(agents: &[SkillAgentArg], all: bool, dry_run: bool, force: bool) -> R options.skill_options.detected_only = false; } let report = install_available_skills(&options)?; - print_install_report(&report, dry_run); + print_install_report(&report, dry_run)?; Ok(()) } fn print_agent_install_summary(agent: SkillAgent, report: &SkillInstallReport) { + if json_mode_enabled() { + return; + } let changed = report .actions .iter() @@ -65,7 +71,12 @@ fn print_agent_install_summary(agent: SkillAgent, report: &SkillInstallReport) { } } -fn print_install_report(report: &SkillInstallReport, dry_run: bool) { +fn print_install_report(report: &SkillInstallReport, dry_run: bool) -> Result<()> { + if json_mode_enabled() { + println!("{}", serde_json::to_string_pretty(report)?); + return Ok(()); + } + let heading = if dry_run { "๐Ÿงช Mesh plugin skill install preview" } else { @@ -76,7 +87,7 @@ fn print_install_report(report: &SkillInstallReport, dry_run: bool) { if report.available_skills == 0 { eprintln!("๐Ÿ”Ž No plugin skills found in installed plugins."); eprintln!("๐Ÿ“ฆ Plugins can expose skills with skills//SKILL.md."); - return; + return Ok(()); } eprintln!( @@ -87,7 +98,7 @@ fn print_install_report(report: &SkillInstallReport, dry_run: bool) { if report.targets.is_empty() { eprintln!("๐Ÿ”Ž No supported agent skill targets detected."); eprintln!("๐Ÿ’ก Use --agent or --all to install anyway."); - return; + return Ok(()); } eprintln!( @@ -120,6 +131,7 @@ fn print_install_report(report: &SkillInstallReport, dry_run: bool) { } print_install_summary(report, dry_run); + Ok(()) } fn print_install_summary(report: &SkillInstallReport, dry_run: bool) { @@ -140,14 +152,14 @@ fn print_install_summary(report: &SkillInstallReport, dry_run: bool) { let verb = if dry_run { "planned" } else { "complete" }; let mut parts = vec![ - plural_count(installed, "install").to_string(), - plural_count(updated, "update").to_string(), + count_label(installed, "installed", "installed"), + count_label(updated, "updated", "updated"), ]; if unchanged > 0 { - parts.push(plural_count(unchanged, "unchanged").to_string()); + parts.push(count_label(unchanged, "unchanged", "unchanged")); } if conflicts > 0 { - parts.push(plural_count(conflicts, "conflict").to_string()); + parts.push(count_label(conflicts, "conflict", "conflicts")); } eprintln!("โœ… Skill install {verb}: {}", parts.join(", ")); } @@ -170,11 +182,13 @@ fn skill_display_name(action: &mesh_llm_plugin_manager::SkillInstallAction) -> S } fn plural_count(count: usize, noun: &str) -> String { + count_label(count, noun, &format!("{noun}s")) +} + +fn count_label(count: usize, singular: &str, plural: &str) -> String { if count == 1 { - format!("{count} {noun}") - } else if noun == "unchanged" { - format!("{count} unchanged") + format!("{count} {singular}") } else { - format!("{count} {noun}s") + format!("{count} {plural}") } } diff --git a/crates/mesh-llm-host-runtime/src/cli/mod.rs b/crates/mesh-llm-host-runtime/src/cli/mod.rs index d56338e7ab..f0929edba9 100644 --- a/crates/mesh-llm-host-runtime/src/cli/mod.rs +++ b/crates/mesh-llm-host-runtime/src/cli/mod.rs @@ -901,6 +901,7 @@ pub(crate) enum SkillCommand { #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub(crate) enum SkillAgentArg { + Global, Goose, Pi, Codex, @@ -911,6 +912,7 @@ pub(crate) enum SkillAgentArg { impl From for mesh_llm_plugin_manager::SkillAgent { fn from(value: SkillAgentArg) -> Self { match value { + SkillAgentArg::Global => Self::Global, SkillAgentArg::Goose => Self::Goose, SkillAgentArg::Pi => Self::Pi, SkillAgentArg::Codex => Self::Codex, @@ -1599,6 +1601,23 @@ mod tests { assert_eq!(cli.log_format, LogFormat::Pretty); } + #[test] + fn skills_install_accepts_global_agent_target() { + let cli = Cli::parse_from(["mesh-llm", "skills", "install", "--agent", "global"]); + + match cli.command.expect("skills command expected") { + Command::Skills { + command: + SkillCommand::Install { + agent, all: false, .. + }, + } => { + assert_eq!(agent, vec![SkillAgentArg::Global]); + } + other => panic!("unexpected command: {other:?}"), + } + } + #[test] fn cli_accepts_json_log_format() { let normalized = diff --git a/crates/mesh-llm-plugin-manager/Cargo.toml b/crates/mesh-llm-plugin-manager/Cargo.toml index 6dde5a7f84..28feda8b2e 100644 --- a/crates/mesh-llm-plugin-manager/Cargo.toml +++ b/crates/mesh-llm-plugin-manager/Cargo.toml @@ -16,7 +16,7 @@ anyhow.workspace = true dirs = "6" flate2 = "1" futures-util = "0.3" -mesh-llm-skills = { path = "../mesh-llm-skills", version = "0.68.0" } +mesh-llm-skills.workspace = true reqwest = { version = "0.12", features = ["json", "stream"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-llm-skills/src/lib.rs b/crates/mesh-llm-skills/src/lib.rs index 3dd594c051..f72ed1dab8 100644 --- a/crates/mesh-llm-skills/src/lib.rs +++ b/crates/mesh-llm-skills/src/lib.rs @@ -1,5 +1,6 @@ use std::{ - env, fs, + collections::HashSet, + fs, path::{Path, PathBuf}, }; @@ -11,6 +12,7 @@ const MARKER_FILE: &str = ".mesh-llm-skill.json"; #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SkillAgent { + Global, Goose, Pi, Codex, @@ -21,6 +23,7 @@ pub enum SkillAgent { impl SkillAgent { pub fn as_str(self) -> &'static str { match self { + Self::Global => "global", Self::Goose => "goose", Self::Pi => "pi", Self::Codex => "codex", @@ -30,7 +33,7 @@ impl SkillAgent { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct SkillTarget { pub agent: SkillAgent, pub root: PathBuf, @@ -38,7 +41,7 @@ pub struct SkillTarget { pub detection_reason: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct SkillPackage { pub provider_name: String, pub provider_version: String, @@ -70,19 +73,18 @@ impl SkillInstallOptions { pub fn for_agent(agent: SkillAgent) -> Result { let mut options = Self::from_env()?; options.agents = vec![agent]; - options.detected_only = false; Ok(options) } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct SkillInstallReport { pub available_skills: usize, pub targets: Vec, pub actions: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct SkillInstallAction { pub agent: SkillAgent, pub skill_name: String, @@ -92,7 +94,8 @@ pub struct SkillInstallAction { pub status: SkillInstallStatus, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum SkillInstallStatus { Installed, Updated, @@ -133,6 +136,7 @@ pub fn install_skills( pub fn resolve_targets(options: &SkillInstallOptions) -> Vec { let agents = if options.agents.is_empty() { vec![ + SkillAgent::Global, SkillAgent::Goose, SkillAgent::Pi, SkillAgent::Codex, @@ -148,10 +152,31 @@ pub fn resolve_targets(options: &SkillInstallOptions) -> Vec { .map(|agent| skill_target(agent, &options.home_dir)) .filter(|target| !options.detected_only || target.detected) .collect::>(); - targets.sort_by(|left, right| left.agent.as_str().cmp(right.agent.as_str())); + deduplicate_targets_by_root(&mut targets); + targets.sort_by(|left, right| { + skill_agent_sort_rank(left.agent) + .cmp(&skill_agent_sort_rank(right.agent)) + .then(left.agent.as_str().cmp(right.agent.as_str())) + }); targets } +fn deduplicate_targets_by_root(targets: &mut Vec) { + let mut seen = HashSet::new(); + targets.retain(|target| seen.insert(target.root.clone())); +} + +fn skill_agent_sort_rank(agent: SkillAgent) -> usize { + match agent { + SkillAgent::Global => 0, + SkillAgent::Claude => 1, + SkillAgent::Codex => 2, + SkillAgent::Goose => 3, + SkillAgent::Opencode => 4, + SkillAgent::Pi => 5, + } +} + pub fn is_valid_skill_name(value: &str) -> bool { let mut previous_hyphen = false; if value.is_empty() || value.len() > 64 || value.starts_with('-') || value.ends_with('-') { @@ -324,33 +349,35 @@ fn write_marker(skill_dir: &Path, marker: &ManagedSkillMarker) -> Result<()> { } fn skill_target(agent: SkillAgent, home_dir: &Path) -> SkillTarget { - let (root, command, config_dir) = match agent { - SkillAgent::Goose => (home_dir.join(".agents").join("skills"), "goose", None), + let (root, config_dir) = match agent { + SkillAgent::Global => (home_dir.join(".agents").join("skills"), None), + SkillAgent::Goose => ( + home_dir.join(".agents").join("skills"), + Some(home_dir.join(".config").join("goose")), + ), SkillAgent::Pi => ( home_dir.join(".pi").join("agent").join("skills"), - "pi", Some(home_dir.join(".pi").join("agent")), ), - SkillAgent::Codex => (home_dir.join(".agents").join("skills"), "codex", None), + SkillAgent::Codex => ( + home_dir.join(".agents").join("skills"), + Some(home_dir.join(".codex")), + ), SkillAgent::Opencode => ( home_dir.join(".config").join("opencode").join("skills"), - "opencode", Some(home_dir.join(".config").join("opencode")), ), SkillAgent::Claude => ( home_dir.join(".claude").join("skills"), - "claude", Some(home_dir.join(".claude")), ), }; - let command_detected = command_exists(command); let config_detected = config_dir.as_ref().is_some_and(|dir| dir.exists()); - let detected = command_detected || config_detected || root.exists(); - let detection_reason = if command_detected { - Some(format!("found '{command}' in PATH")) - } else if config_detected { + let root_detected = root.exists(); + let detected = config_detected || root_detected; + let detection_reason = if config_detected { config_dir.map(|dir| format!("found {}", dir.display())) - } else if root.exists() { + } else if root_detected { Some(format!("found {}", root.display())) } else { None @@ -364,27 +391,6 @@ fn skill_target(agent: SkillAgent, home_dir: &Path) -> SkillTarget { } } -fn command_exists(command: &str) -> bool { - let Some(paths) = env::var_os("PATH") else { - return false; - }; - env::split_paths(&paths) - .any(|path| command_candidates(command).any(|candidate| path.join(candidate).is_file())) -} - -fn command_candidates(command: &str) -> impl Iterator + '_ { - let suffix = env::consts::EXE_SUFFIX; - let mut candidates = vec![command.to_string()]; - if !suffix.is_empty() { - candidates.push(format!("{command}{suffix}")); - } - if cfg!(windows) { - candidates.push(format!("{command}.cmd")); - candidates.push(format!("{command}.bat")); - } - candidates.into_iter() -} - #[cfg(test)] mod tests { use tempfile::TempDir; @@ -443,6 +449,41 @@ mod tests { ); } + #[test] + fn defaults_to_global_open_skill_target_once() { + let temp = TempDir::new().unwrap(); + let home_dir = temp.path().join("home"); + fs::create_dir_all(home_dir.join(".agents/skills")).unwrap(); + fs::create_dir_all(home_dir.join(".codex")).unwrap(); + + let options = SkillInstallOptions { + home_dir: home_dir.clone(), + agents: Vec::new(), + detected_only: true, + dry_run: true, + force: false, + }; + let targets = resolve_targets(&options); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].agent, SkillAgent::Global); + assert_eq!(targets[0].root, home_dir.join(".agents/skills")); + } + + #[test] + fn launch_time_agent_options_do_not_create_missing_skill_roots() { + let temp = TempDir::new().unwrap(); + let source_dir = write_skill(&temp.path().join("source"), "demo-skill"); + let mut options = SkillInstallOptions::for_agent(SkillAgent::Goose).unwrap(); + options.home_dir = temp.path().join("home"); + + let report = install_skills(&[skill("demo-skill", source_dir)], &options).unwrap(); + + assert!(report.targets.is_empty()); + assert!(report.actions.is_empty()); + assert!(!options.home_dir.join(".agents").exists()); + } + #[test] fn skips_user_owned_conflicts_without_force() { let temp = TempDir::new().unwrap();