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 "trace",
231 "memory",
232 "ping",
233 "keybinds",
234 "sidecar",
235];
236
237pub 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 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 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 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 if !config.keybinds.is_empty() {
284 kb_registry.register_user(&config.keybinds);
285 }
286
287 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 tools.register(Arc::new(tool::SearchSkillsTool::new(registry.clone())));
312 }
313 (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
314}
315
316pub 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}