Skip to main content

agent_engine/skills/
mod.rs

1//! Skills and plugins subsystem.
2//!
3//! Discovers plugins under `.synaps-cli/plugins/` (project-local) and
4//! `~/.synaps-cli/plugins/` (global), registers each skill as a dynamic
5//! slash command, and exposes the same skills to the model via the
6//! `load_skill` tool. Submodules: `manifest` (plugin/marketplace JSON
7//! parsing), `loader` (discovery walk + frontmatter parsing), `config`
8//! (disable-list filtering), `registry` (command registry with collision
9//! handling), `tool` (the `load_skill` tool implementation).
10
11pub mod manifest;
12pub mod loader;
13pub mod config;
14pub mod registry;
15pub mod tool;
16pub mod state;
17pub mod marketplace;
18pub mod plugin_index;
19pub mod update_diff;
20pub mod install;
21pub mod keybinds;
22pub mod commands;
23pub mod trust;
24pub mod post_install;
25
26use std::path::PathBuf;
27use std::sync::Arc;
28
29use crate::skills::registry::CommandRegistry;
30use crate::skills::tool::LoadSkillTool;
31use crate::extensions::manifest::ExtensionManifest;
32
33/// A plugin discovered during skill loading.
34#[derive(Debug, Clone)]
35pub struct Plugin {
36    pub name: String,
37    pub root: PathBuf,
38    pub marketplace: Option<String>,
39    pub version: Option<String>,
40    pub description: Option<String>,
41    pub extension: Option<ExtensionManifest>,
42    pub manifest: Option<manifest::PluginManifest>,
43}
44
45/// A skill discovered during loading.
46#[derive(Debug, Clone)]
47pub struct LoadedSkill {
48    pub name: String,
49    pub description: String,
50    pub body: String,           // post-{baseDir} substitution
51    pub plugin: Option<String>, // None for loose skills
52    pub base_dir: PathBuf,      // absolute
53    pub source_path: PathBuf,   // absolute path to SKILL.md
54}
55
56/// Built-in command names. Keep in sync with the match in
57/// `src/chatui/commands.rs::handle_command`.
58pub const BUILTIN_COMMANDS: &[&str] = &[
59    "clear", "compact", "chain", "model", "models", "system", "thinking", "sessions",
60    "resume", "saveas", "theme", "gamba", "help", "quit", "exit",
61    "settings", "plugins", "extensions", "status", "stats", "ping", "keybinds",
62    "sidecar",
63];
64
65/// Load all skills, apply disable filters, build the command registry,
66/// build the keybind registry, and register the `load_skill` tool.
67/// Returns (command_registry, keybind_registry).
68pub async fn register(
69    tools: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
70    config: &crate::SynapsConfig,
71) -> (Arc<CommandRegistry>, Arc<std::sync::RwLock<keybinds::KeybindRegistry>>) {
72    // The fs-walk (read_dir + read_to_string + canonicalize across multiple roots)
73    // is fully synchronous std::fs; do it on a blocking pool so we don't park a
74    // tokio worker during boot. Behavior is identical — same inputs, same output.
75    let (mut plugins, mut skills) = tokio::task::spawn_blocking(|| {
76        loader::load_all(&loader::default_roots())
77    })
78    .await
79    .expect("skills::loader::load_all panicked");
80    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
81
82    // Filter disabled plugins from commands, keybinds, and help too — not just skills
83    if !config.disabled_plugins.is_empty() {
84        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
85    }
86
87    tracing::info!(
88        plugins = plugins.len(),
89        skills = skills.len(),
90        "loaded plugins and skills"
91    );
92
93    // Build keybind registry from plugin manifests
94    let mut kb_registry = keybinds::KeybindRegistry::new();
95    for plugin in &plugins {
96        if let Some(ref manifest) = plugin.manifest {
97            if !manifest.keybinds.is_empty() {
98                kb_registry.register_plugin(&manifest.name, &manifest.keybinds, &plugin.root);
99                tracing::info!(
100                    plugin = manifest.name.as_str(),
101                    count = manifest.keybinds.len(),
102                    "registered plugin keybinds"
103                );
104            }
105        }
106    }
107
108    // Apply user keybind overrides from config
109    if !config.keybinds.is_empty() {
110        kb_registry.register_user(&config.keybinds);
111    }
112
113    // Synthesize the sidecar toggle keybind. The selected key in
114    // `sidecar_toggle_key` is the *only* active sidecar toggle binding —
115    // there's no plugin-level F8 anymore, so picking a value in
116    // /settings → Sidecar fully replaces the previous chord. Defaults to
117    // F8 when no value has been chosen.
118    let sidecar_key = crate::config::read_config_value("sidecar_toggle_key")
119        .map(|v| v.trim().to_string())
120        .filter(|v| !v.is_empty())
121        .unwrap_or_else(|| "F8".to_string());
122    let mut overrides = std::collections::HashMap::new();
123    overrides.insert(sidecar_key, "/sidecar toggle".to_string());
124    kb_registry.register_user(&overrides);
125
126    let registry = Arc::new(CommandRegistry::new_with_plugins(BUILTIN_COMMANDS, skills, plugins));
127    let tool = LoadSkillTool::new(registry.clone());
128    tools.write().await.register(Arc::new(tool));
129    (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
130}
131
132/// Re-walks discovery roots and swaps in the new skill set atomically.
133/// Built-ins and the existing `load_skill` tool registration are unchanged.
134pub fn reload_registry(registry: &CommandRegistry, config: &crate::SynapsConfig) {
135    let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
136    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
137    if !config.disabled_plugins.is_empty() {
138        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
139    }
140    tracing::info!(skills = skills.len(), "reloaded skills");
141    registry.rebuild_with_plugins(skills, plugins);
142}