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 "close",
224 "settings",
225 "plugins",
226 "extensions",
227 "status",
228 "stats",
229 "context",
230 "budget",
231 "workers",
232 "confirms",
233 "keepwarm",
234 "pin",
238 "keep-warm",
239 "clients",
240 "takeover",
242 "trace",
243 "memory",
244 "auth",
245 "ping",
246 "keybinds",
247 "sidecar",
248 "watch",
249];
250
251pub 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 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 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 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 if !config.keybinds.is_empty() {
298 kb_registry.register_user(&config.keybinds);
299 }
300
301 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 tools.register(Arc::new(tool::SearchSkillsTool::new(registry.clone())));
326 }
327 (registry, Arc::new(std::sync::RwLock::new(kb_registry)))
328}
329
330pub 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}