1use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25use tracing::info;
26
27#[derive(Debug, Clone)]
33pub struct AutogenConfig {
34 pub enabled: bool,
37 pub threshold: usize,
40 pub max_pending: usize,
43 pub install_dir: Option<PathBuf>,
46}
47
48impl Default for AutogenConfig {
49 fn default() -> Self {
50 Self {
51 enabled: false,
52 threshold: 3,
53 max_pending: 10,
54 install_dir: None,
55 }
56 }
57}
58
59impl AutogenConfig {
60 pub fn install_dir(&self) -> PathBuf {
62 self.install_dir.clone().unwrap_or_else(|| {
63 #[cfg(feature = "native")]
64 {
65 dirs::home_dir()
66 .unwrap_or_else(|| PathBuf::from("."))
67 .join(".clawft")
68 .join("skills")
69 }
70 #[cfg(not(feature = "native"))]
71 {
72 PathBuf::from(".clawft").join("skills")
73 }
74 })
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
84pub struct ToolCallPattern {
85 pub tool_names: Vec<String>,
87}
88
89impl ToolCallPattern {
90 pub fn new(tool_names: Vec<String>) -> Self {
92 Self { tool_names }
93 }
94
95 pub fn suggested_name(&self) -> String {
97 if self.tool_names.is_empty() {
98 return "unnamed-skill".to_string();
99 }
100 let parts: Vec<&str> = self.tool_names.iter().map(|s| s.as_str()).collect();
101 let name = parts.join("-then-");
102 if name.len() > 60 {
104 format!("{}-auto", &name[..56])
105 } else {
106 format!("{name}-auto")
107 }
108 }
109
110 pub fn len(&self) -> usize {
112 self.tool_names.len()
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.tool_names.is_empty()
118 }
119}
120
121pub struct PatternDetector {
123 config: AutogenConfig,
125 pattern_counts: HashMap<ToolCallPattern, usize>,
127 recent_calls: Vec<String>,
129 max_window: usize,
131 reported: HashMap<ToolCallPattern, bool>,
133}
134
135impl PatternDetector {
136 pub fn new(config: AutogenConfig) -> Self {
138 Self {
139 config,
140 pattern_counts: HashMap::new(),
141 recent_calls: Vec::new(),
142 max_window: 10,
143 reported: HashMap::new(),
144 }
145 }
146
147 pub fn record_tool_call(&mut self, tool_name: &str) {
149 if !self.config.enabled {
150 return;
151 }
152
153 self.recent_calls.push(tool_name.to_string());
154
155 if self.recent_calls.len() > self.max_window * 3 {
157 self.recent_calls.drain(..self.max_window);
158 }
159
160 let n = self.recent_calls.len();
162 for len in 2..=self.max_window.min(n) {
163 let pattern = ToolCallPattern::new(
164 self.recent_calls[n - len..n].to_vec(),
165 );
166 *self.pattern_counts.entry(pattern).or_insert(0) += 1;
167 }
168 }
169
170 pub fn detect_candidates(&mut self) -> Vec<ToolCallPattern> {
175 if !self.config.enabled {
176 return vec![];
177 }
178
179 let threshold = self.config.threshold;
180 let mut candidates = Vec::new();
181
182 for (pattern, count) in &self.pattern_counts {
183 if *count >= threshold && !self.reported.contains_key(pattern) {
184 candidates.push(pattern.clone());
185 }
186 }
187
188 for candidate in &candidates {
190 self.reported.insert(candidate.clone(), true);
191 }
192
193 candidates
194 }
195
196 pub fn pattern_count(&self, pattern: &ToolCallPattern) -> usize {
198 self.pattern_counts.get(pattern).copied().unwrap_or(0)
199 }
200
201 pub fn reset(&mut self) {
203 self.pattern_counts.clear();
204 self.recent_calls.clear();
205 self.reported.clear();
206 }
207
208 pub fn is_enabled(&self) -> bool {
210 self.config.enabled
211 }
212}
213
214#[derive(Debug, Clone)]
220pub struct SkillCandidate {
221 pub name: String,
223 pub description: String,
225 pub pattern: ToolCallPattern,
227 pub skill_md: String,
229 pub state: CandidateState,
231}
232
233#[non_exhaustive]
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum CandidateState {
237 Pending,
239 Approved,
241 Rejected,
243}
244
245pub fn generate_skill_md(pattern: &ToolCallPattern) -> SkillCandidate {
247 let name = pattern.suggested_name();
248 let tools_list = pattern
249 .tool_names
250 .iter()
251 .map(|t| format!(" - {t}"))
252 .collect::<Vec<_>>()
253 .join("\n");
254
255 let description = format!(
256 "Auto-generated skill: executes {} in sequence",
257 pattern
258 .tool_names
259 .iter()
260 .map(|t| format!("`{t}`"))
261 .collect::<Vec<_>>()
262 .join(" -> ")
263 );
264
265 let instructions = format!(
266 "Execute the following tools in order:\n{}",
267 pattern
268 .tool_names
269 .iter()
270 .enumerate()
271 .map(|(i, t)| format!("{}. Use the `{t}` tool", i + 1))
272 .collect::<Vec<_>>()
273 .join("\n")
274 );
275
276 let skill_md = format!(
277 "---\n\
278 name: {name}\n\
279 description: \"{description}\"\n\
280 version: 0.1.0\n\
281 allowed-tools:\n\
282 {tools_list}\n\
283 user-invocable: false\n\
284 autogenerated: true\n\
285 ---\n\n\
286 {instructions}\n"
287 );
288
289 SkillCandidate {
290 name,
291 description,
292 pattern: pattern.clone(),
293 skill_md,
294 state: CandidateState::Pending,
295 }
296}
297
298pub fn install_pending_skill(
304 candidate: &SkillCandidate,
305 install_dir: &Path,
306) -> Result<PathBuf, String> {
307 let skill_dir = install_dir.join(&candidate.name);
308
309 std::fs::create_dir_all(&skill_dir)
310 .map_err(|e| format!("create skill dir: {e}"))?;
311
312 let skill_path = skill_dir.join("SKILL.md");
313 std::fs::write(&skill_path, &candidate.skill_md)
314 .map_err(|e| format!("write SKILL.md: {e}"))?;
315
316 let marker_path = skill_dir.join(".pending");
319 std::fs::write(&marker_path, "awaiting user approval")
320 .map_err(|e| format!("write .pending marker: {e}"))?;
321
322 info!(
323 skill = %candidate.name,
324 path = %skill_dir.display(),
325 "installed pending skill"
326 );
327
328 Ok(skill_dir)
329}
330
331pub fn approve_skill(skill_dir: &Path) -> Result<(), String> {
333 let marker = skill_dir.join(".pending");
334 if marker.exists() {
335 std::fs::remove_file(&marker).map_err(|e| format!("remove .pending: {e}"))?;
336 info!(skill_dir = %skill_dir.display(), "approved pending skill");
337 Ok(())
338 } else {
339 Err("skill is not in pending state".into())
340 }
341}
342
343pub fn reject_skill(skill_dir: &Path) -> Result<(), String> {
345 if skill_dir.exists() {
346 std::fs::remove_dir_all(skill_dir)
347 .map_err(|e| format!("remove skill dir: {e}"))?;
348 info!(skill_dir = %skill_dir.display(), "rejected and removed pending skill");
349 Ok(())
350 } else {
351 Err("skill directory does not exist".into())
352 }
353}
354
355pub fn is_pending(skill_dir: &Path) -> bool {
357 skill_dir.join(".pending").exists()
358}
359
360#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn enabled_config() -> AutogenConfig {
369 AutogenConfig {
370 enabled: true,
371 threshold: 3,
372 max_pending: 10,
373 install_dir: None,
374 }
375 }
376
377 fn disabled_config() -> AutogenConfig {
378 AutogenConfig::default()
379 }
380
381 #[test]
384 fn config_default_is_disabled() {
385 let config = AutogenConfig::default();
386 assert!(!config.enabled);
387 assert_eq!(config.threshold, 3);
388 assert_eq!(config.max_pending, 10);
389 }
390
391 #[test]
392 fn config_install_dir_default() {
393 let config = AutogenConfig::default();
394 let dir = config.install_dir();
395 assert!(dir.to_string_lossy().contains(".clawft"));
396 assert!(dir.to_string_lossy().contains("skills"));
397 }
398
399 #[test]
402 fn pattern_suggested_name() {
403 let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
404 let name = pattern.suggested_name();
405 assert!(name.contains("read_file"));
406 assert!(name.contains("edit_file"));
407 assert!(name.ends_with("-auto"));
408 }
409
410 #[test]
411 fn pattern_suggested_name_long_truncated() {
412 let tools: Vec<String> = (0..20).map(|i| format!("tool_{i}")).collect();
413 let pattern = ToolCallPattern::new(tools);
414 let name = pattern.suggested_name();
415 assert!(name.len() <= 65, "name too long: {}", name.len());
416 }
417
418 #[test]
419 fn pattern_empty() {
420 let pattern = ToolCallPattern::new(vec![]);
421 assert!(pattern.is_empty());
422 assert_eq!(pattern.len(), 0);
423 assert_eq!(pattern.suggested_name(), "unnamed-skill");
424 }
425
426 #[test]
429 fn detector_disabled_records_nothing() {
430 let mut detector = PatternDetector::new(disabled_config());
431 detector.record_tool_call("read_file");
432 detector.record_tool_call("edit_file");
433 let candidates = detector.detect_candidates();
434 assert!(candidates.is_empty());
435 }
436
437 #[test]
438 fn detector_does_not_fire_below_threshold() {
439 let mut detector = PatternDetector::new(enabled_config());
440 for _ in 0..2 {
442 detector.record_tool_call("read_file");
443 detector.record_tool_call("edit_file");
444 }
445 let candidates = detector.detect_candidates();
446 assert!(candidates.is_empty());
447 }
448
449 #[test]
450 fn detector_fires_at_threshold() {
451 let mut detector = PatternDetector::new(enabled_config());
452 for _ in 0..3 {
454 detector.record_tool_call("read_file");
455 detector.record_tool_call("edit_file");
456 }
457 let candidates = detector.detect_candidates();
458 assert!(
459 !candidates.is_empty(),
460 "should detect at least one pattern"
461 );
462 }
463
464 #[test]
465 fn detector_does_not_re_report() {
466 let mut detector = PatternDetector::new(enabled_config());
467 for _ in 0..5 {
468 detector.record_tool_call("read_file");
469 detector.record_tool_call("edit_file");
470 }
471 let first = detector.detect_candidates();
472 assert!(!first.is_empty());
473
474 let second = detector.detect_candidates();
476 assert!(second.is_empty(), "should not re-report same pattern");
477 }
478
479 #[test]
480 fn detector_reset_clears_state() {
481 let mut detector = PatternDetector::new(enabled_config());
482 for _ in 0..3 {
483 detector.record_tool_call("a");
484 detector.record_tool_call("b");
485 }
486 let _ = detector.detect_candidates();
487 detector.reset();
488
489 for _ in 0..3 {
491 detector.record_tool_call("a");
492 detector.record_tool_call("b");
493 }
494 let candidates = detector.detect_candidates();
495 assert!(!candidates.is_empty());
496 }
497
498 #[test]
499 fn detector_configurable_threshold() {
500 let config = AutogenConfig {
501 enabled: true,
502 threshold: 5,
503 ..Default::default()
504 };
505 let mut detector = PatternDetector::new(config);
506
507 for _ in 0..4 {
508 detector.record_tool_call("x");
509 detector.record_tool_call("y");
510 }
511 assert!(detector.detect_candidates().is_empty(), "below threshold");
512
513 detector.record_tool_call("x");
514 detector.record_tool_call("y");
515 assert!(!detector.detect_candidates().is_empty(), "at threshold");
516 }
517
518 #[test]
521 fn generate_skill_md_valid() {
522 let pattern = ToolCallPattern::new(vec![
523 "read_file".into(),
524 "edit_file".into(),
525 "write_file".into(),
526 ]);
527 let candidate = generate_skill_md(&pattern);
528
529 assert!(candidate.name.contains("auto"));
530 assert_eq!(candidate.state, CandidateState::Pending);
531 assert!(candidate.skill_md.contains("---"));
532 assert!(candidate.skill_md.contains("read_file"));
533 assert!(candidate.skill_md.contains("edit_file"));
534 assert!(candidate.skill_md.contains("write_file"));
535 assert!(candidate.skill_md.contains("autogenerated: true"));
536 assert!(candidate.skill_md.contains("user-invocable: false"));
537 }
538
539 #[test]
540 fn generate_skill_md_description_contains_tools() {
541 let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
542 let candidate = generate_skill_md(&pattern);
543 assert!(candidate.description.contains("`a`"));
544 assert!(candidate.description.contains("`b`"));
545 }
546
547 #[test]
550 fn install_pending_skill_creates_files() {
551 let dir = std::env::temp_dir().join("clawft_autogen_install_test");
552 let _ = std::fs::remove_dir_all(&dir);
553 let _ = std::fs::create_dir_all(&dir);
554
555 let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
556 let candidate = generate_skill_md(&pattern);
557
558 let result = install_pending_skill(&candidate, &dir);
559 assert!(result.is_ok());
560
561 let skill_dir = result.unwrap();
562 assert!(skill_dir.join("SKILL.md").exists());
563 assert!(skill_dir.join(".pending").exists());
564 assert!(is_pending(&skill_dir));
565
566 let _ = std::fs::remove_dir_all(&dir);
567 }
568
569 #[test]
570 fn approve_skill_removes_marker() {
571 let dir = std::env::temp_dir().join("clawft_autogen_approve_test");
572 let _ = std::fs::remove_dir_all(&dir);
573 let _ = std::fs::create_dir_all(&dir);
574
575 let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
576 let candidate = generate_skill_md(&pattern);
577 let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
578
579 assert!(is_pending(&skill_dir));
580 assert!(approve_skill(&skill_dir).is_ok());
581 assert!(!is_pending(&skill_dir));
582 assert!(skill_dir.join("SKILL.md").exists());
583
584 let _ = std::fs::remove_dir_all(&dir);
585 }
586
587 #[test]
588 fn reject_skill_removes_directory() {
589 let dir = std::env::temp_dir().join("clawft_autogen_reject_test");
590 let _ = std::fs::remove_dir_all(&dir);
591 let _ = std::fs::create_dir_all(&dir);
592
593 let pattern = ToolCallPattern::new(vec!["x".into(), "y".into()]);
594 let candidate = generate_skill_md(&pattern);
595 let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
596
597 assert!(skill_dir.exists());
598 assert!(reject_skill(&skill_dir).is_ok());
599 assert!(!skill_dir.exists());
600
601 let _ = std::fs::remove_dir_all(&dir);
602 }
603
604 #[test]
605 fn approve_non_pending_fails() {
606 let dir = std::env::temp_dir().join("clawft_autogen_approve_nopend");
607 let _ = std::fs::remove_dir_all(&dir);
608 let _ = std::fs::create_dir_all(&dir);
609
610 let result = approve_skill(&dir);
612 assert!(result.is_err());
613 assert!(result.unwrap_err().contains("not in pending state"));
614
615 let _ = std::fs::remove_dir_all(&dir);
616 }
617
618 #[test]
619 fn generated_skill_has_minimal_permissions() {
620 let pattern = ToolCallPattern::new(vec!["read_file".into()]);
621 let candidate = generate_skill_md(&pattern);
622
623 assert!(!candidate.skill_md.contains("shell: true"));
625 assert!(!candidate.skill_md.contains("network:"));
626 assert!(candidate.skill_md.contains("user-invocable: false"));
628 }
629
630 #[test]
631 fn is_enabled_reflects_config() {
632 let enabled = PatternDetector::new(enabled_config());
633 assert!(enabled.is_enabled());
634
635 let disabled = PatternDetector::new(disabled_config());
636 assert!(!disabled.is_enabled());
637 }
638}