Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/docker-precheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/ \
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -59,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"
Expand Down
7 changes: 7 additions & 0 deletions crates/mesh-llm-host-runtime/src/cli/commands/agent_cli.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -495,6 +497,7 @@ pub(crate) async fn run_goose(model: Option<String>, 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() {
Expand Down Expand Up @@ -572,6 +575,7 @@ pub(crate) async fn run_claude(model: Option<String>, 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");
Expand Down Expand Up @@ -813,6 +817,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(());
Expand Down Expand Up @@ -855,6 +860,7 @@ pub(crate) async fn run_opencode(model: Option<String>, host: &str, write: bool)
};

let result = if write {
install_skills_for_agent(SkillAgent::Opencode);
write_opencode_config(&client, &models, &chosen, &target).await
} else {
let context_lengths =
Expand All @@ -873,6 +879,7 @@ pub(crate) async fn run_opencode(model: Option<String>, 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");
configure_opencode_launch_command(&mut command, &spec);
let status = command.status();
Expand Down
3 changes: 3 additions & 0 deletions crates/mesh-llm-host-runtime/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod models;
mod plugin;
mod plugin_cli;
mod runtime;
mod skills;
mod update;

use anyhow::Result;
Expand All @@ -24,6 +25,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;
Expand Down Expand Up @@ -89,6 +91,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,
Expand Down
194 changes: 194 additions & 0 deletions crates/mesh-llm-host-runtime/src/cli/commands/skills.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use anyhow::Result;
use mesh_llm_plugin_manager::{
PluginSkillInstallOptions, SkillAgent, SkillInstallReport, SkillInstallStatus,
install_available_skills,
};

use crate::cli::{SkillAgentArg, SkillCommand, output::json_mode_enabled};

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) if !json_mode_enabled() => {
eprintln!(
"Could not install mesh plugin skills for {}: {error}",
agent.as_str()
);
}
Err(_) => {}
}
}

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) {
if json_mode_enabled() {
return;
}
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) -> 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 {
"🧠 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/<name>/SKILL.md.");
return Ok(());
}

eprintln!(
"📦 Found {}",
plural_count(report.available_skills, "plugin skill")
);

if report.targets.is_empty() {
eprintln!("🔎 No supported agent skill targets detected.");
eprintln!("💡 Use --agent <agent> or --all to install anyway.");
return Ok(());
}

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);
Ok(())
}
Comment thread
i386 marked this conversation as resolved.

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![
count_label(installed, "installed", "installed"),
count_label(updated, "updated", "updated"),
];
if unchanged > 0 {
parts.push(count_label(unchanged, "unchanged", "unchanged"));
}
if conflicts > 0 {
parts.push(count_label(conflicts, "conflict", "conflicts"));
}
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 {
count_label(count, noun, &format!("{noun}s"))
}

fn count_label(count: usize, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {plural}")
}
}
Loading
Loading