1pub 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#[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#[derive(Clone)]
50pub struct LoadedSkill {
51 pub name: String,
52 pub description: String,
53 pub(crate) source: SkillSource,
56 pub plugin: Option<String>, pub base_dir: PathBuf, pub source_path: PathBuf, }
60
61impl 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#[derive(Clone)]
85pub(crate) enum SkillSource {
86 Inline(String),
88 Lazy {
90 fingerprint: loader::SkillFingerprint,
91 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 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
126const SKILL_ID_SEGMENT_MAX_BYTES: usize = 64;
130const SKILL_ID_ENCODED_PREFIX: &str = "enc-";
132const SKILL_ID_DIGEST_PREFIX: &str = "sha-";
134const 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
150fn 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
181pub 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
197pub(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
204pub 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 "keepwarm",
231 "trace",
232 "memory",
233 "ping",
234 "keybinds",
235 "sidecar",
236];
237
238pub async fn register(
242 tools: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
243 config: &crate::SynapsConfig,
244) -> (
245 Arc<CommandRegistry>,
246 Arc<std::sync::RwLock<keybinds::KeybindRegistry>>,
247) {
248 let (mut plugins, mut skills) =
252 tokio::task::spawn_blocking(|| loader::load_all(&loader::default_roots()))
253 .await
254 .expect("skills::loader::load_all panicked");
255 skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
256
257 if !config.disabled_plugins.is_empty() {
259 plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
260 }
261
262 tracing::info!(
263 plugins = plugins.len(),
264 skills = skills.len(),
265 "loaded plugins and skills"
266 );
267
268 let mut kb_registry = keybinds::KeybindRegistry::new();
270 for plugin in &plugins {
271 if let Some(ref manifest) = plugin.manifest {
272 if !manifest.keybinds.is_empty() {
273 kb_registry.register_plugin(&manifest.name, &manifest.keybinds, &plugin.root);
274 tracing::info!(
275 plugin = manifest.name.as_str(),
276 count = manifest.keybinds.len(),
277 "registered plugin keybinds"
278 );
279 }
280 }
281 }
282
283 if !config.keybinds.is_empty() {
285 kb_registry.register_user(&config.keybinds);
286 }
287
288 let sidecar_key = crate::config::read_config_value("sidecar_toggle_key")
294 .map(|v| v.trim().to_string())
295 .filter(|v| !v.is_empty())
296 .unwrap_or_else(|| "F8".to_string());
297 let mut overrides = std::collections::HashMap::new();
298 overrides.insert(sidecar_key, "/sidecar toggle".to_string());
299 kb_registry.register_user(&overrides);
300
301 let registry = Arc::new(CommandRegistry::new_with_plugins(
302 BUILTIN_COMMANDS,
303 skills,
304 plugins,
305 ));
306 let tool = LoadSkillTool::new(registry.clone());
307 {
308 let mut tools = tools.write().await;
309 tools.register(Arc::new(tool));
310 tools.register(Arc::new(tool::SearchSkillsTool::new(registry.clone())));
313 }
314 (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
315}
316
317pub fn reload_registry(registry: &CommandRegistry, config: &crate::SynapsConfig) {
320 let (mut plugins, mut skills) = loader::load_all(&loader::default_roots());
321 skills = config::filter_disabled(skills, &config.disabled_plugins, &config.disabled_skills);
322 if !config.disabled_plugins.is_empty() {
323 plugins.retain(|p| !config.disabled_plugins.iter().any(|d| d == &p.name));
324 }
325 tracing::info!(skills = skills.len(), "reloaded skills");
326 registry.rebuild_with_plugins(skills, plugins);
327}