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