1use std::path::PathBuf;
23use std::time::Duration;
24
25use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
26use tokio::sync::mpsc;
27use tracing::{debug, info, warn};
28
29use clawft_types::skill::SkillDefinition;
30
31use super::skills_v2::SharedSkillRegistry;
32
33#[derive(Debug, Clone)]
35pub struct SkillWatcherConfig {
36 pub workspace_dir: Option<PathBuf>,
38 pub user_dir: Option<PathBuf>,
40 pub extra_dirs: Vec<PathBuf>,
42 pub debounce: Duration,
44 pub builtin_skills: Vec<SkillDefinition>,
46 pub trust_workspace: bool,
48}
49
50impl Default for SkillWatcherConfig {
51 fn default() -> Self {
52 Self {
53 workspace_dir: None,
54 user_dir: None,
55 extra_dirs: Vec::new(),
56 debounce: Duration::from_millis(500),
57 builtin_skills: Vec::new(),
58 trust_workspace: false,
59 }
60 }
61}
62
63impl SkillWatcherConfig {
64 fn watch_dirs(&self) -> Vec<&PathBuf> {
66 let mut dirs = Vec::new();
67 if let Some(ref d) = self.workspace_dir {
68 dirs.push(d);
69 }
70 if let Some(ref d) = self.user_dir {
71 dirs.push(d);
72 }
73 for d in &self.extra_dirs {
74 dirs.push(d);
75 }
76 dirs
77 }
78}
79
80pub struct SkillWatcherHandle {
82 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
84}
85
86impl SkillWatcherHandle {
87 pub fn stop(mut self) {
89 if let Some(tx) = self.shutdown_tx.take() {
90 let _ = tx.send(());
91 }
92 }
93}
94
95impl Drop for SkillWatcherHandle {
96 fn drop(&mut self) {
97 if let Some(tx) = self.shutdown_tx.take() {
98 let _ = tx.send(());
99 }
100 }
101}
102
103pub fn start_watching(
113 config: SkillWatcherConfig,
114 registry: SharedSkillRegistry,
115) -> Result<SkillWatcherHandle, notify::Error> {
116 let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
117 let (event_tx, mut event_rx) = mpsc::channel::<Event>(100);
118
119 let mut watcher = RecommendedWatcher::new(
121 move |res: notify::Result<Event>| {
122 if let Ok(event) = res {
123 let _ = event_tx.blocking_send(event);
124 }
125 },
126 notify::Config::default(),
127 )?;
128
129 for dir in config.watch_dirs() {
131 if dir.exists() {
132 if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
133 warn!(
134 path = %dir.display(),
135 error = %e,
136 "failed to watch skill directory"
137 );
138 } else {
139 debug!(path = %dir.display(), "watching skill directory");
140 }
141 } else {
142 debug!(path = %dir.display(), "skill directory does not exist, skipping watch");
143 }
144 }
145
146 let debounce = config.debounce;
147 let ws_dir = config.workspace_dir.clone();
148 let user_dir = config.user_dir.clone();
149 let builtins = config.builtin_skills.clone();
150 let trust = config.trust_workspace;
151
152 tokio::spawn(async move {
154 let _watcher = watcher;
156
157 let mut pending_rebuild = false;
158 let mut debounce_deadline: Option<tokio::time::Instant> = None;
159
160 loop {
161 tokio::select! {
162 event = event_rx.recv() => {
164 match event {
165 Some(ev) => {
166 match ev.kind {
167 EventKind::Create(_)
168 | EventKind::Modify(_)
169 | EventKind::Remove(_) => {
170 pending_rebuild = true;
171 debounce_deadline = Some(
172 tokio::time::Instant::now() + debounce,
173 );
174 }
175 _ => {}
176 }
177 }
178 None => {
179 break;
181 }
182 }
183 }
184 _ = async {
186 match debounce_deadline {
187 Some(deadline) => tokio::time::sleep_until(deadline).await,
188 None => std::future::pending::<()>().await,
189 }
190 }, if pending_rebuild => {
191 pending_rebuild = false;
192 debounce_deadline = None;
193
194 let mut reg = registry.write().await;
195 match reg.rebuild(
196 ws_dir.as_deref(),
197 user_dir.as_deref(),
198 builtins.clone(),
199 trust,
200 ).await {
201 Ok(()) => {
202 info!(
203 count = reg.len(),
204 "skill registry reloaded after file change"
205 );
206 }
207 Err(e) => {
208 warn!(error = %e, "skill registry rebuild failed");
209 }
210 }
211 }
212 _ = &mut shutdown_rx => {
214 info!("skill watcher shutting down");
215 break;
216 }
217 }
218 }
219 });
220
221 Ok(SkillWatcherHandle {
222 shutdown_tx: Some(shutdown_tx),
223 })
224}
225
226#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::sync::atomic::{AtomicU64, Ordering};
234 use std::sync::Arc;
235
236 use tokio::sync::RwLock;
237
238 use super::super::skills_v2::SkillRegistry;
239 use clawft_types::skill::SkillDefinition;
240
241 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
242
243 fn temp_dir(prefix: &str) -> PathBuf {
244 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
245 let pid = std::process::id();
246 std::env::temp_dir().join(format!("clawft_watcher_{prefix}_{pid}_{id}"))
247 }
248
249 fn create_skill_md(dir: &std::path::Path, name: &str, desc: &str) {
250 let skill_dir = dir.join(name);
251 std::fs::create_dir_all(&skill_dir).unwrap();
252 let content =
253 format!("---\nname: {name}\ndescription: {desc}\n---\n\nInstructions for {name}.");
254 std::fs::write(skill_dir.join("SKILL.md"), content).unwrap();
255 }
256
257 #[tokio::test]
258 async fn test_watcher_config_defaults() {
259 let config = SkillWatcherConfig::default();
260 assert_eq!(config.debounce, Duration::from_millis(500));
261 assert!(config.workspace_dir.is_none());
262 assert!(config.user_dir.is_none());
263 assert!(config.extra_dirs.is_empty());
264 assert!(config.builtin_skills.is_empty());
265 assert!(!config.trust_workspace);
266 }
267
268 #[tokio::test]
269 async fn test_watcher_config_watch_dirs() {
270 let config = SkillWatcherConfig {
271 workspace_dir: Some(PathBuf::from("/ws/skills")),
272 user_dir: Some(PathBuf::from("/user/skills")),
273 extra_dirs: vec![PathBuf::from("/extra/skills")],
274 ..Default::default()
275 };
276 assert_eq!(config.watch_dirs().len(), 3);
277 }
278
279 #[tokio::test]
280 async fn test_watcher_handle_drop_sends_shutdown() {
281 let dir = temp_dir("handle_drop");
282 std::fs::create_dir_all(&dir).unwrap();
283 create_skill_md(&dir, "drop_test", "Drop test skill");
284
285 let registry = Arc::new(RwLock::new(
286 SkillRegistry::discover(Some(&dir), None, vec![])
287 .await
288 .unwrap(),
289 ));
290
291 let config = SkillWatcherConfig {
292 workspace_dir: Some(dir.clone()),
293 trust_workspace: true,
294 debounce: Duration::from_millis(50),
295 ..Default::default()
296 };
297
298 let handle = start_watching(config, registry).unwrap();
299 drop(handle);
301
302 tokio::time::sleep(Duration::from_millis(100)).await;
304
305 let _ = std::fs::remove_dir_all(&dir);
306 }
307
308 #[tokio::test]
309 async fn test_watcher_detects_new_skill() {
310 let dir = temp_dir("detect_new");
311 std::fs::create_dir_all(&dir).unwrap();
312 create_skill_md(&dir, "original", "Original skill");
313
314 let registry = Arc::new(RwLock::new(
315 SkillRegistry::discover(Some(&dir), None, vec![])
316 .await
317 .unwrap(),
318 ));
319
320 {
321 let reg = registry.read().await;
322 assert_eq!(reg.len(), 1);
323 assert!(reg.get("original").is_some());
324 }
325
326 let config = SkillWatcherConfig {
327 workspace_dir: Some(dir.clone()),
328 trust_workspace: true,
329 debounce: Duration::from_millis(100),
330 ..Default::default()
331 };
332
333 let handle = start_watching(config, registry.clone()).unwrap();
334
335 create_skill_md(&dir, "added", "Added skill");
337
338 tokio::time::sleep(Duration::from_millis(500)).await;
340
341 {
342 let reg = registry.read().await;
343 assert_eq!(reg.len(), 2);
344 assert!(reg.get("original").is_some());
345 assert!(reg.get("added").is_some());
346 }
347
348 handle.stop();
349 let _ = std::fs::remove_dir_all(&dir);
350 }
351
352 #[tokio::test]
353 async fn test_watcher_detects_skill_removal() {
354 let dir = temp_dir("detect_remove");
355 std::fs::create_dir_all(&dir).unwrap();
356 create_skill_md(&dir, "alpha", "Alpha");
357 create_skill_md(&dir, "beta", "Beta");
358
359 let registry = Arc::new(RwLock::new(
360 SkillRegistry::discover(Some(&dir), None, vec![])
361 .await
362 .unwrap(),
363 ));
364
365 {
366 let reg = registry.read().await;
367 assert_eq!(reg.len(), 2);
368 }
369
370 let config = SkillWatcherConfig {
371 workspace_dir: Some(dir.clone()),
372 trust_workspace: true,
373 debounce: Duration::from_millis(100),
374 ..Default::default()
375 };
376
377 let handle = start_watching(config, registry.clone()).unwrap();
378
379 std::fs::remove_dir_all(dir.join("beta")).unwrap();
381
382 tokio::time::sleep(Duration::from_millis(500)).await;
384
385 {
386 let reg = registry.read().await;
387 assert_eq!(reg.len(), 1);
388 assert!(reg.get("alpha").is_some());
389 assert!(reg.get("beta").is_none());
390 }
391
392 handle.stop();
393 let _ = std::fs::remove_dir_all(&dir);
394 }
395
396 #[tokio::test]
397 async fn test_watcher_detects_skill_modification() {
398 let dir = temp_dir("detect_modify");
399 std::fs::create_dir_all(&dir).unwrap();
400 create_skill_md(&dir, "mutable", "Original description");
401
402 let registry = Arc::new(RwLock::new(
403 SkillRegistry::discover(Some(&dir), None, vec![])
404 .await
405 .unwrap(),
406 ));
407
408 {
409 let reg = registry.read().await;
410 assert_eq!(reg.get("mutable").unwrap().description, "Original description");
411 }
412
413 let config = SkillWatcherConfig {
414 workspace_dir: Some(dir.clone()),
415 trust_workspace: true,
416 debounce: Duration::from_millis(100),
417 ..Default::default()
418 };
419
420 let handle = start_watching(config, registry.clone()).unwrap();
421
422 let modified =
424 "---\nname: mutable\ndescription: Updated description\n---\n\nUpdated instructions.";
425 std::fs::write(dir.join("mutable").join("SKILL.md"), modified).unwrap();
426
427 tokio::time::sleep(Duration::from_millis(500)).await;
429
430 {
431 let reg = registry.read().await;
432 assert_eq!(reg.get("mutable").unwrap().description, "Updated description");
433 }
434
435 handle.stop();
436 let _ = std::fs::remove_dir_all(&dir);
437 }
438
439 #[tokio::test]
440 async fn test_watcher_missing_dir_does_not_error() {
441 let registry = Arc::new(RwLock::new(
442 SkillRegistry::discover(None, None, vec![]).await.unwrap(),
443 ));
444
445 let config = SkillWatcherConfig {
446 workspace_dir: Some(PathBuf::from("/definitely/does/not/exist")),
447 debounce: Duration::from_millis(50),
448 ..Default::default()
449 };
450
451 let handle = start_watching(config, registry);
453 assert!(handle.is_ok());
454 handle.unwrap().stop();
455 }
456
457 #[tokio::test]
458 async fn test_registry_upsert_and_remove() {
459 let mut registry = SkillRegistry::discover(None, None, vec![]).await.unwrap();
460 assert!(registry.is_empty());
461
462 let skill = SkillDefinition::new("dynamic", "A dynamically added skill");
464 let prev = registry.upsert(skill);
465 assert!(prev.is_none());
466 assert_eq!(registry.len(), 1);
467 assert!(registry.get("dynamic").is_some());
468
469 let updated = SkillDefinition::new("dynamic", "Updated description");
471 let prev = registry.upsert(updated);
472 assert!(prev.is_some());
473 assert_eq!(prev.unwrap().description, "A dynamically added skill");
474 assert_eq!(registry.get("dynamic").unwrap().description, "Updated description");
475
476 let removed = registry.remove("dynamic");
478 assert!(removed.is_some());
479 assert!(registry.is_empty());
480
481 let removed = registry.remove("nonexistent");
483 assert!(removed.is_none());
484 }
485
486 #[tokio::test]
487 async fn test_registry_rebuild() {
488 let dir = temp_dir("rebuild");
489 std::fs::create_dir_all(&dir).unwrap();
490 create_skill_md(&dir, "first", "First skill");
491
492 let mut registry = SkillRegistry::discover(Some(&dir), None, vec![]).await.unwrap();
493 assert_eq!(registry.len(), 1);
494
495 create_skill_md(&dir, "second", "Second skill");
497
498 registry.rebuild(Some(&dir), None, vec![], true).await.unwrap();
500 assert_eq!(registry.len(), 2);
501 assert!(registry.get("first").is_some());
502 assert!(registry.get("second").is_some());
503
504 let _ = std::fs::remove_dir_all(&dir);
505 }
506
507 #[tokio::test]
508 async fn test_precedence_on_reload() {
509 let ws_dir = temp_dir("prec_ws");
510 let user_dir = temp_dir("prec_user");
511
512 std::fs::create_dir_all(&ws_dir).unwrap();
513 std::fs::create_dir_all(&user_dir).unwrap();
514
515 create_skill_md(&ws_dir, "shared", "Workspace version");
517 create_skill_md(&user_dir, "shared", "User version");
518
519 let registry = Arc::new(RwLock::new(
520 SkillRegistry::discover(Some(&ws_dir), Some(&user_dir), vec![])
521 .await
522 .unwrap(),
523 ));
524
525 {
526 let reg = registry.read().await;
527 assert_eq!(reg.get("shared").unwrap().description, "Workspace version");
529 }
530
531 let config = SkillWatcherConfig {
532 workspace_dir: Some(ws_dir.clone()),
533 user_dir: Some(user_dir.clone()),
534 trust_workspace: true,
535 debounce: Duration::from_millis(100),
536 ..Default::default()
537 };
538
539 let handle = start_watching(config, registry.clone()).unwrap();
540
541 std::fs::remove_dir_all(ws_dir.join("shared")).unwrap();
543
544 tokio::time::sleep(Duration::from_millis(500)).await;
545
546 {
547 let reg = registry.read().await;
548 assert_eq!(reg.get("shared").unwrap().description, "User version");
549 }
550
551 handle.stop();
552 let _ = std::fs::remove_dir_all(&ws_dir);
553 let _ = std::fs::remove_dir_all(&user_dir);
554 }
555}