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 commands;
12pub mod config;
13pub mod install;
14pub mod keybinds;
15pub mod loader;
16pub mod manifest;
17pub mod marketplace;
18pub mod plugin_index;
19pub mod post_install;
20pub mod registry;
21pub mod state;
22pub mod tool;
23pub mod trust;
24pub mod update_diff;
25
26use std::path::PathBuf;
27use std::sync::Arc;
28
29use crate::extensions::manifest::ExtensionManifest;
30use crate::skills::registry::CommandRegistry;
31use crate::skills::tool::LoadSkillTool;
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. Boot discovery holds ONLY bounded
46/// metadata — the body stays on disk behind an immutable fingerprint and
47/// is read, verified, substituted, and returned exclusively by
48/// [`LoadedSkill::load_body`] at exact selection time.
49#[derive(Clone)]
50pub struct LoadedSkill {
51    pub name: String,
52    pub description: String,
53    /// Body source — NEVER the body itself at boot. Private: all access
54    /// flows through [`Self::load_body`].
55    pub(crate) source: SkillSource,
56    pub plugin: Option<String>, // None for loose skills
57    pub base_dir: PathBuf,      // absolute
58    pub source_path: PathBuf,   // absolute path to SKILL.md
59}
60
61/// Redacted Debug (Task 21 security review): bounded identity only — no
62/// source paths, no base dir, no body, no fingerprint material — so a
63/// stray debug/log rendering of boot metadata can never leak filesystem
64/// layout or content.
65impl std::fmt::Debug for LoadedSkill {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("LoadedSkill")
68            .field("name", &agent_core::BoundedText::new(&self.name, 128).text)
69            .field(
70                "plugin",
71                &self
72                    .plugin
73                    .as_deref()
74                    .map(|p| agent_core::BoundedText::new(p, 128).text),
75            )
76            .field("description_len", &self.description.len())
77            .field("source", &self.source)
78            .finish_non_exhaustive()
79    }
80}
81
82/// Where a skill body comes from. Debug is redacted: no body content, no
83/// fingerprint material.
84#[derive(Clone)]
85pub(crate) enum SkillSource {
86    /// Body held in memory (test constructors; no verification needed).
87    Inline(String),
88    /// Lazy on-disk body: re-read + fingerprint-verified at selection.
89    Lazy {
90        fingerprint: loader::SkillFingerprint,
91        /// Plugin root for ${CLAUDE_PLUGIN_ROOT} substitution.
92        plugin_root: Option<PathBuf>,
93    },
94}
95
96impl std::fmt::Debug for SkillSource {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::Inline(_) => f.debug_struct("Inline").finish_non_exhaustive(),
100            Self::Lazy { .. } => f.debug_struct("Lazy").finish_non_exhaustive(),
101        }
102    }
103}
104
105impl LoadedSkill {
106    /// Test/compat constructor with an in-memory body.
107    pub fn new_inline(
108        name: &str,
109        description: &str,
110        body: &str,
111        plugin: Option<&str>,
112        base_dir: PathBuf,
113        source_path: PathBuf,
114    ) -> Self {
115        Self {
116            name: name.to_string(),
117            description: description.to_string(),
118            source: SkillSource::Inline(body.to_string()),
119            plugin: plugin.map(str::to_string),
120            base_dir,
121            source_path,
122        }
123    }
124}
125
126// ── Stable skill identities (Task 17, spec §7.2/§7.6 boundary) ──────────────
127
128/// Byte budget for one encoded skill-id segment.
129const SKILL_ID_SEGMENT_MAX_BYTES: usize = 64;
130/// Reserved marker for hex-encoded non-canonical segments.
131const SKILL_ID_ENCODED_PREFIX: &str = "enc-";
132/// Reserved marker for digest-compressed oversized segments.
133const SKILL_ID_DIGEST_PREFIX: &str = "sha-";
134/// Hex characters kept from the SHA-256 of an oversized segment (160 bits).
135const SKILL_ID_DIGEST_HEX_LEN: usize = 40;
136
137fn skill_segment_is_canonical(segment: &str) -> bool {
138    if segment.is_empty() || segment.len() > SKILL_ID_SEGMENT_MAX_BYTES {
139        return false;
140    }
141    let mut chars = segment.chars();
142    let first_ok = chars
143        .next()
144        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
145    first_ok
146        && chars
147            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.'))
148}
149
150/// Deterministic, injective, bounded encoding of one raw identity segment
151/// (mirrors the `ToolId` segment strategy): canonical lowercase segments
152/// pass through verbatim; anything else becomes `enc-<hex>`; oversized
153/// encodings compress to `sha-<truncated sha256>`. Two distinct raw
154/// spellings can never collapse into one encoded segment.
155fn encode_skill_segment(raw: &str) -> String {
156    use sha2::{Digest as _, Sha256};
157    if skill_segment_is_canonical(raw)
158        && !raw.starts_with(SKILL_ID_ENCODED_PREFIX)
159        && !raw.starts_with(SKILL_ID_DIGEST_PREFIX)
160    {
161        return raw.to_string();
162    }
163    let mut hex = String::with_capacity(raw.len() * 2);
164    for byte in raw.as_bytes() {
165        use std::fmt::Write as _;
166        let _ = write!(hex, "{byte:02x}");
167    }
168    let encoded = format!("{SKILL_ID_ENCODED_PREFIX}{hex}");
169    if encoded.len() <= SKILL_ID_SEGMENT_MAX_BYTES {
170        return encoded;
171    }
172    let digest = Sha256::digest(raw.as_bytes());
173    let mut digest_hex = String::with_capacity(SKILL_ID_DIGEST_HEX_LEN);
174    for byte in digest.iter().take(SKILL_ID_DIGEST_HEX_LEN / 2) {
175        use std::fmt::Write as _;
176        let _ = write!(digest_hex, "{byte:02x}");
177    }
178    format!("{SKILL_ID_DIGEST_PREFIX}{digest_hex}")
179}
180
181/// Stable model-facing identity of one skill (Task 17): deterministic and
182/// alias-safe per exact (plugin, name) pair, bounded, and free of source
183/// paths. Plugin skills use the `skill.<plugin>:<name>` namespace; loose
184/// skills use `skill:<name>` — the namespaces cannot collide, so a loose
185/// skill spelling a qualified name can never impersonate a plugin skill.
186pub fn stable_skill_id(skill: &LoadedSkill) -> String {
187    match &skill.plugin {
188        Some(plugin) => format!(
189            "skill.{}:{}",
190            encode_skill_segment(plugin),
191            encode_skill_segment(&skill.name)
192        ),
193        None => format!("skill:{}", encode_skill_segment(&skill.name)),
194    }
195}
196
197/// True when a `load_skill` input is spelled in the stable skill-id
198/// namespace (and should therefore resolve by exact id, not legacy names).
199pub(crate) fn looks_like_stable_skill_id(raw: &str) -> bool {
200    raw.strip_prefix("skill")
201        .is_some_and(|rest| rest.starts_with(':') || rest.starts_with('.'))
202}
203
204/// Built-in command names. Keep in sync with the match in
205/// `src/chatui/commands.rs::handle_command`.
206pub const BUILTIN_COMMANDS: &[&str] = &[
207    "clear",
208    "compact",
209    "chain",
210    "model",
211    "models",
212    "system",
213    "thinking",
214    "effort",
215    "sessions",
216    "resume",
217    "saveas",
218    "theme",
219    "gamba",
220    "help",
221    "quit",
222    "exit",
223    "settings",
224    "plugins",
225    "extensions",
226    "status",
227    "stats",
228    "context",
229    "budget",
230    "trace",
231    "memory",
232    "ping",
233    "keybinds",
234    "sidecar",
235];
236
237/// Load all skills, apply disable filters, build the command registry,
238/// build the keybind registry, and register the `load_skill` tool.
239/// Returns (command_registry, keybind_registry).
240pub async fn register(
241    tools: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
242    config: &crate::SynapsConfig,
243) -> (
244    Arc<CommandRegistry>,
245    Arc<std::sync::RwLock<keybinds::KeybindRegistry>>,
246) {
247    // The fs-walk (read_dir + read_to_string + canonicalize across multiple roots)
248    // is fully synchronous std::fs; do it on a blocking pool so we don't park a
249    // tokio worker during boot. Behavior is identical — same inputs, same output.
250    let (mut plugins, mut skills) =
251        tokio::task::spawn_blocking(|| loader::load_all(&loader::default_roots()))
252            .await
253            .expect("skills::loader::load_all panicked");
254    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
255
256    // Filter disabled plugins from commands, keybinds, and help too — not just skills
257    if !config.disabled_plugins.is_empty() {
258        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
259    }
260
261    tracing::info!(
262        plugins = plugins.len(),
263        skills = skills.len(),
264        "loaded plugins and skills"
265    );
266
267    // Build keybind registry from plugin manifests
268    let mut kb_registry = keybinds::KeybindRegistry::new();
269    for plugin in &plugins {
270        if let Some(ref manifest) = plugin.manifest {
271            if !manifest.keybinds.is_empty() {
272                kb_registry.register_plugin(&manifest.name, &manifest.keybinds, &plugin.root);
273                tracing::info!(
274                    plugin = manifest.name.as_str(),
275                    count = manifest.keybinds.len(),
276                    "registered plugin keybinds"
277                );
278            }
279        }
280    }
281
282    // Apply user keybind overrides from config
283    if !config.keybinds.is_empty() {
284        kb_registry.register_user(&config.keybinds);
285    }
286
287    // Synthesize the sidecar toggle keybind. The selected key in
288    // `sidecar_toggle_key` is the *only* active sidecar toggle binding —
289    // there's no plugin-level F8 anymore, so picking a value in
290    // /settings → Sidecar fully replaces the previous chord. Defaults to
291    // F8 when no value has been chosen.
292    let sidecar_key = crate::config::read_config_value("sidecar_toggle_key")
293        .map(|v| v.trim().to_string())
294        .filter(|v| !v.is_empty())
295        .unwrap_or_else(|| "F8".to_string());
296    let mut overrides = std::collections::HashMap::new();
297    overrides.insert(sidecar_key, "/sidecar toggle".to_string());
298    kb_registry.register_user(&overrides);
299
300    let registry = Arc::new(CommandRegistry::new_with_plugins(
301        BUILTIN_COMMANDS,
302        skills,
303        plugins,
304    ));
305    let tool = LoadSkillTool::new(registry.clone());
306    {
307        let mut tools = tools.write().await;
308        tools.register(Arc::new(tool));
309        // Task 17: bounded skill discovery beside load_skill (stable IDs +
310        // compact descriptions only; no bodies, no source paths).
311        tools.register(Arc::new(tool::SearchSkillsTool::new(registry.clone())));
312    }
313    (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
314}
315
316/// Re-walks discovery roots and swaps in the new skill set atomically.
317/// Built-ins and the existing `load_skill` tool registration are unchanged.
318pub fn reload_registry(registry: &CommandRegistry, config: &crate::SynapsConfig) {
319    let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
320    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
321    if !config.disabled_plugins.is_empty() {
322        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
323    }
324    tracing::info!(skills = skills.len(), "reloaded skills");
325    registry.rebuild_with_plugins(skills, plugins);
326}