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    "trace",
230    "memory",
231    "ping",
232    "keybinds",
233    "sidecar",
234];
235
236/// Load all skills, apply disable filters, build the command registry,
237/// build the keybind registry, and register the `load_skill` tool.
238/// Returns (command_registry, keybind_registry).
239pub async fn register(
240    tools: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
241    config: &crate::SynapsConfig,
242) -> (
243    Arc<CommandRegistry>,
244    Arc<std::sync::RwLock<keybinds::KeybindRegistry>>,
245) {
246    // The fs-walk (read_dir + read_to_string + canonicalize across multiple roots)
247    // is fully synchronous std::fs; do it on a blocking pool so we don't park a
248    // tokio worker during boot. Behavior is identical — same inputs, same output.
249    let (mut plugins, mut skills) =
250        tokio::task::spawn_blocking(|| loader::load_all(&loader::default_roots()))
251            .await
252            .expect("skills::loader::load_all panicked");
253    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
254
255    // Filter disabled plugins from commands, keybinds, and help too — not just skills
256    if !config.disabled_plugins.is_empty() {
257        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
258    }
259
260    tracing::info!(
261        plugins = plugins.len(),
262        skills = skills.len(),
263        "loaded plugins and skills"
264    );
265
266    // Build keybind registry from plugin manifests
267    let mut kb_registry = keybinds::KeybindRegistry::new();
268    for plugin in &plugins {
269        if let Some(ref manifest) = plugin.manifest {
270            if !manifest.keybinds.is_empty() {
271                kb_registry.register_plugin(&manifest.name, &manifest.keybinds, &plugin.root);
272                tracing::info!(
273                    plugin = manifest.name.as_str(),
274                    count = manifest.keybinds.len(),
275                    "registered plugin keybinds"
276                );
277            }
278        }
279    }
280
281    // Apply user keybind overrides from config
282    if !config.keybinds.is_empty() {
283        kb_registry.register_user(&config.keybinds);
284    }
285
286    // Synthesize the sidecar toggle keybind. The selected key in
287    // `sidecar_toggle_key` is the *only* active sidecar toggle binding —
288    // there's no plugin-level F8 anymore, so picking a value in
289    // /settings → Sidecar fully replaces the previous chord. Defaults to
290    // F8 when no value has been chosen.
291    let sidecar_key = crate::config::read_config_value("sidecar_toggle_key")
292        .map(|v| v.trim().to_string())
293        .filter(|v| !v.is_empty())
294        .unwrap_or_else(|| "F8".to_string());
295    let mut overrides = std::collections::HashMap::new();
296    overrides.insert(sidecar_key, "/sidecar toggle".to_string());
297    kb_registry.register_user(&overrides);
298
299    let registry = Arc::new(CommandRegistry::new_with_plugins(
300        BUILTIN_COMMANDS,
301        skills,
302        plugins,
303    ));
304    let tool = LoadSkillTool::new(registry.clone());
305    {
306        let mut tools = tools.write().await;
307        tools.register(Arc::new(tool));
308        // Task 17: bounded skill discovery beside load_skill (stable IDs +
309        // compact descriptions only; no bodies, no source paths).
310        tools.register(Arc::new(tool::SearchSkillsTool::new(registry.clone())));
311    }
312    (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
313}
314
315/// Re-walks discovery roots and swaps in the new skill set atomically.
316/// Built-ins and the existing `load_skill` tool registration are unchanged.
317pub fn reload_registry(registry: &CommandRegistry, config: &crate::SynapsConfig) {
318    let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
319    skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
320    if !config.disabled_plugins.is_empty() {
321        plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
322    }
323    tracing::info!(skills = skills.len(), "reloaded skills");
324    registry.rebuild_with_plugins(skills, plugins);
325}