use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::info;
#[derive(Debug, Clone)]
pub struct AutogenConfig {
pub enabled: bool,
pub threshold: usize,
pub max_pending: usize,
pub install_dir: Option<PathBuf>,
}
impl Default for AutogenConfig {
fn default() -> Self {
Self {
enabled: false,
threshold: 3,
max_pending: 10,
install_dir: None,
}
}
}
impl AutogenConfig {
pub fn install_dir(&self) -> PathBuf {
self.install_dir.clone().unwrap_or_else(|| {
#[cfg(feature = "native")]
{
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".clawft")
.join("skills")
}
#[cfg(not(feature = "native"))]
{
PathBuf::from(".clawft").join("skills")
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ToolCallPattern {
pub tool_names: Vec<String>,
}
impl ToolCallPattern {
pub fn new(tool_names: Vec<String>) -> Self {
Self { tool_names }
}
pub fn suggested_name(&self) -> String {
if self.tool_names.is_empty() {
return "unnamed-skill".to_string();
}
let parts: Vec<&str> = self.tool_names.iter().map(|s| s.as_str()).collect();
let name = parts.join("-then-");
if name.len() > 60 {
format!("{}-auto", &name[..56])
} else {
format!("{name}-auto")
}
}
pub fn len(&self) -> usize {
self.tool_names.len()
}
pub fn is_empty(&self) -> bool {
self.tool_names.is_empty()
}
}
pub struct PatternDetector {
config: AutogenConfig,
pattern_counts: HashMap<ToolCallPattern, usize>,
recent_calls: Vec<String>,
max_window: usize,
reported: HashMap<ToolCallPattern, bool>,
}
impl PatternDetector {
pub fn new(config: AutogenConfig) -> Self {
Self {
config,
pattern_counts: HashMap::new(),
recent_calls: Vec::new(),
max_window: 10,
reported: HashMap::new(),
}
}
pub fn record_tool_call(&mut self, tool_name: &str) {
if !self.config.enabled {
return;
}
self.recent_calls.push(tool_name.to_string());
if self.recent_calls.len() > self.max_window * 3 {
self.recent_calls.drain(..self.max_window);
}
let n = self.recent_calls.len();
for len in 2..=self.max_window.min(n) {
let pattern = ToolCallPattern::new(
self.recent_calls[n - len..n].to_vec(),
);
*self.pattern_counts.entry(pattern).or_insert(0) += 1;
}
}
pub fn detect_candidates(&mut self) -> Vec<ToolCallPattern> {
if !self.config.enabled {
return vec![];
}
let threshold = self.config.threshold;
let mut candidates = Vec::new();
for (pattern, count) in &self.pattern_counts {
if *count >= threshold && !self.reported.contains_key(pattern) {
candidates.push(pattern.clone());
}
}
for candidate in &candidates {
self.reported.insert(candidate.clone(), true);
}
candidates
}
pub fn pattern_count(&self, pattern: &ToolCallPattern) -> usize {
self.pattern_counts.get(pattern).copied().unwrap_or(0)
}
pub fn reset(&mut self) {
self.pattern_counts.clear();
self.recent_calls.clear();
self.reported.clear();
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}
#[derive(Debug, Clone)]
pub struct SkillCandidate {
pub name: String,
pub description: String,
pub pattern: ToolCallPattern,
pub skill_md: String,
pub state: CandidateState,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CandidateState {
Pending,
Approved,
Rejected,
}
pub fn generate_skill_md(pattern: &ToolCallPattern) -> SkillCandidate {
let name = pattern.suggested_name();
let tools_list = pattern
.tool_names
.iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<_>>()
.join("\n");
let description = format!(
"Auto-generated skill: executes {} in sequence",
pattern
.tool_names
.iter()
.map(|t| format!("`{t}`"))
.collect::<Vec<_>>()
.join(" -> ")
);
let instructions = format!(
"Execute the following tools in order:\n{}",
pattern
.tool_names
.iter()
.enumerate()
.map(|(i, t)| format!("{}. Use the `{t}` tool", i + 1))
.collect::<Vec<_>>()
.join("\n")
);
let skill_md = format!(
"---\n\
name: {name}\n\
description: \"{description}\"\n\
version: 0.1.0\n\
allowed-tools:\n\
{tools_list}\n\
user-invocable: false\n\
autogenerated: true\n\
---\n\n\
{instructions}\n"
);
SkillCandidate {
name,
description,
pattern: pattern.clone(),
skill_md,
state: CandidateState::Pending,
}
}
pub fn install_pending_skill(
candidate: &SkillCandidate,
install_dir: &Path,
) -> Result<PathBuf, String> {
let skill_dir = install_dir.join(&candidate.name);
std::fs::create_dir_all(&skill_dir)
.map_err(|e| format!("create skill dir: {e}"))?;
let skill_path = skill_dir.join("SKILL.md");
std::fs::write(&skill_path, &candidate.skill_md)
.map_err(|e| format!("write SKILL.md: {e}"))?;
let marker_path = skill_dir.join(".pending");
std::fs::write(&marker_path, "awaiting user approval")
.map_err(|e| format!("write .pending marker: {e}"))?;
info!(
skill = %candidate.name,
path = %skill_dir.display(),
"installed pending skill"
);
Ok(skill_dir)
}
pub fn approve_skill(skill_dir: &Path) -> Result<(), String> {
let marker = skill_dir.join(".pending");
if marker.exists() {
std::fs::remove_file(&marker).map_err(|e| format!("remove .pending: {e}"))?;
info!(skill_dir = %skill_dir.display(), "approved pending skill");
Ok(())
} else {
Err("skill is not in pending state".into())
}
}
pub fn reject_skill(skill_dir: &Path) -> Result<(), String> {
if skill_dir.exists() {
std::fs::remove_dir_all(skill_dir)
.map_err(|e| format!("remove skill dir: {e}"))?;
info!(skill_dir = %skill_dir.display(), "rejected and removed pending skill");
Ok(())
} else {
Err("skill directory does not exist".into())
}
}
pub fn is_pending(skill_dir: &Path) -> bool {
skill_dir.join(".pending").exists()
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_config() -> AutogenConfig {
AutogenConfig {
enabled: true,
threshold: 3,
max_pending: 10,
install_dir: None,
}
}
fn disabled_config() -> AutogenConfig {
AutogenConfig::default()
}
#[test]
fn config_default_is_disabled() {
let config = AutogenConfig::default();
assert!(!config.enabled);
assert_eq!(config.threshold, 3);
assert_eq!(config.max_pending, 10);
}
#[test]
fn config_install_dir_default() {
let config = AutogenConfig::default();
let dir = config.install_dir();
assert!(dir.to_string_lossy().contains(".clawft"));
assert!(dir.to_string_lossy().contains("skills"));
}
#[test]
fn pattern_suggested_name() {
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let name = pattern.suggested_name();
assert!(name.contains("read_file"));
assert!(name.contains("edit_file"));
assert!(name.ends_with("-auto"));
}
#[test]
fn pattern_suggested_name_long_truncated() {
let tools: Vec<String> = (0..20).map(|i| format!("tool_{i}")).collect();
let pattern = ToolCallPattern::new(tools);
let name = pattern.suggested_name();
assert!(name.len() <= 65, "name too long: {}", name.len());
}
#[test]
fn pattern_empty() {
let pattern = ToolCallPattern::new(vec![]);
assert!(pattern.is_empty());
assert_eq!(pattern.len(), 0);
assert_eq!(pattern.suggested_name(), "unnamed-skill");
}
#[test]
fn detector_disabled_records_nothing() {
let mut detector = PatternDetector::new(disabled_config());
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
let candidates = detector.detect_candidates();
assert!(candidates.is_empty());
}
#[test]
fn detector_does_not_fire_below_threshold() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..2 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let candidates = detector.detect_candidates();
assert!(candidates.is_empty());
}
#[test]
fn detector_fires_at_threshold() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..3 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let candidates = detector.detect_candidates();
assert!(
!candidates.is_empty(),
"should detect at least one pattern"
);
}
#[test]
fn detector_does_not_re_report() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..5 {
detector.record_tool_call("read_file");
detector.record_tool_call("edit_file");
}
let first = detector.detect_candidates();
assert!(!first.is_empty());
let second = detector.detect_candidates();
assert!(second.is_empty(), "should not re-report same pattern");
}
#[test]
fn detector_reset_clears_state() {
let mut detector = PatternDetector::new(enabled_config());
for _ in 0..3 {
detector.record_tool_call("a");
detector.record_tool_call("b");
}
let _ = detector.detect_candidates();
detector.reset();
for _ in 0..3 {
detector.record_tool_call("a");
detector.record_tool_call("b");
}
let candidates = detector.detect_candidates();
assert!(!candidates.is_empty());
}
#[test]
fn detector_configurable_threshold() {
let config = AutogenConfig {
enabled: true,
threshold: 5,
..Default::default()
};
let mut detector = PatternDetector::new(config);
for _ in 0..4 {
detector.record_tool_call("x");
detector.record_tool_call("y");
}
assert!(detector.detect_candidates().is_empty(), "below threshold");
detector.record_tool_call("x");
detector.record_tool_call("y");
assert!(!detector.detect_candidates().is_empty(), "at threshold");
}
#[test]
fn generate_skill_md_valid() {
let pattern = ToolCallPattern::new(vec![
"read_file".into(),
"edit_file".into(),
"write_file".into(),
]);
let candidate = generate_skill_md(&pattern);
assert!(candidate.name.contains("auto"));
assert_eq!(candidate.state, CandidateState::Pending);
assert!(candidate.skill_md.contains("---"));
assert!(candidate.skill_md.contains("read_file"));
assert!(candidate.skill_md.contains("edit_file"));
assert!(candidate.skill_md.contains("write_file"));
assert!(candidate.skill_md.contains("autogenerated: true"));
assert!(candidate.skill_md.contains("user-invocable: false"));
}
#[test]
fn generate_skill_md_description_contains_tools() {
let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let candidate = generate_skill_md(&pattern);
assert!(candidate.description.contains("`a`"));
assert!(candidate.description.contains("`b`"));
}
#[test]
fn install_pending_skill_creates_files() {
let dir = std::env::temp_dir().join("clawft_autogen_install_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["read_file".into(), "edit_file".into()]);
let candidate = generate_skill_md(&pattern);
let result = install_pending_skill(&candidate, &dir);
assert!(result.is_ok());
let skill_dir = result.unwrap();
assert!(skill_dir.join("SKILL.md").exists());
assert!(skill_dir.join(".pending").exists());
assert!(is_pending(&skill_dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn approve_skill_removes_marker() {
let dir = std::env::temp_dir().join("clawft_autogen_approve_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["a".into(), "b".into()]);
let candidate = generate_skill_md(&pattern);
let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
assert!(is_pending(&skill_dir));
assert!(approve_skill(&skill_dir).is_ok());
assert!(!is_pending(&skill_dir));
assert!(skill_dir.join("SKILL.md").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reject_skill_removes_directory() {
let dir = std::env::temp_dir().join("clawft_autogen_reject_test");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let pattern = ToolCallPattern::new(vec!["x".into(), "y".into()]);
let candidate = generate_skill_md(&pattern);
let skill_dir = install_pending_skill(&candidate, &dir).unwrap();
assert!(skill_dir.exists());
assert!(reject_skill(&skill_dir).is_ok());
assert!(!skill_dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn approve_non_pending_fails() {
let dir = std::env::temp_dir().join("clawft_autogen_approve_nopend");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let result = approve_skill(&dir);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in pending state"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn generated_skill_has_minimal_permissions() {
let pattern = ToolCallPattern::new(vec!["read_file".into()]);
let candidate = generate_skill_md(&pattern);
assert!(!candidate.skill_md.contains("shell: true"));
assert!(!candidate.skill_md.contains("network:"));
assert!(candidate.skill_md.contains("user-invocable: false"));
}
#[test]
fn is_enabled_reflects_config() {
let enabled = PatternDetector::new(enabled_config());
assert!(enabled.is_enabled());
let disabled = PatternDetector::new(disabled_config());
assert!(!disabled.is_enabled());
}
}