Skip to main content

synaps_cli/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", "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    let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
73    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
74
75    // Filter disabled plugins from commands, keybinds, and help too — not just skills
76    if !config.disabled_plugins.is_empty() {
77        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
78    }
79
80    tracing::info!(
81        plugins = plugins.len(),
82        skills = skills.len(),
83        "loaded plugins and skills"
84    );
85
86    // Build keybind registry from plugin manifests
87    let mut kb_registry = keybinds::KeybindRegistry::new();
88    for plugin in &plugins {
89        if let Some(ref manifest) = plugin.manifest {
90            if !manifest.keybinds.is_empty() {
91                kb_registry.register_plugin(&manifest.name, &manifest.keybinds, &plugin.root);
92                tracing::info!(
93                    plugin = manifest.name.as_str(),
94                    count = manifest.keybinds.len(),
95                    "registered plugin keybinds"
96                );
97            }
98        }
99    }
100
101    // Apply user keybind overrides from config
102    if !config.keybinds.is_empty() {
103        kb_registry.register_user(&config.keybinds);
104    }
105
106    // Synthesize the sidecar toggle keybind. The selected key in
107    // `sidecar_toggle_key` is the *only* active sidecar toggle binding —
108    // there's no plugin-level F8 anymore, so picking a value in
109    // /settings → Sidecar fully replaces the previous chord. Defaults to
110    // F8 when no value has been chosen.
111    let sidecar_key = crate::config::read_config_value("sidecar_toggle_key")
112        .map(|v| v.trim().to_string())
113        .filter(|v| !v.is_empty())
114        .unwrap_or_else(|| "F8".to_string());
115    let mut overrides = std::collections::HashMap::new();
116    overrides.insert(sidecar_key, "/sidecar toggle".to_string());
117    kb_registry.register_user(&overrides);
118
119    let registry = Arc::new(CommandRegistry::new_with_plugins(BUILTIN_COMMANDS, skills, plugins));
120    let tool = LoadSkillTool::new(registry.clone());
121    tools.write().await.register(Arc::new(tool));
122    (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
123}
124
125/// Re-walks discovery roots and swaps in the new skill set atomically.
126/// Built-ins and the existing `load_skill` tool registration are unchanged.
127pub fn reload_registry(registry: &CommandRegistry, config: &crate::SynapsConfig) {
128    let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
129    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
130    if !config.disabled_plugins.is_empty() {
131        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
132    }
133    tracing::info!(skills = skills.len(), "reloaded skills");
134    registry.rebuild_with_plugins(skills, plugins);
135}