Skip to main content

flodl_cli/
skill.rs

1//! AI coding assistant skill management.
2//!
3//! Detects the user's AI tool, copies the right adapter and skill files.
4//! Skills live in `ai/skills/` (universal) and `ai/adapters/<tool>/` (tool-specific).
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9// ---------------------------------------------------------------------------
10// Embedded adapters (for when we're not in a repo checkout)
11// ---------------------------------------------------------------------------
12
13const CLAUDE_ADAPTER: &str = include_str!("../assets/skills/claude-port.md");
14const SKILL_GUIDE: &str = include_str!("../assets/skills/port-guide.md");
15const SKILL_INSTRUCTIONS: &str = include_str!("../assets/skills/port-instructions.md");
16
17// ---------------------------------------------------------------------------
18// Skill registry
19// ---------------------------------------------------------------------------
20
21struct SkillInfo {
22    name: &'static str,
23    description: &'static str,
24}
25
26const SKILLS: &[SkillInfo] = &[SkillInfo {
27    name: "port",
28    description: "Port PyTorch scripts to flodl",
29}];
30
31// ---------------------------------------------------------------------------
32// Tool detection
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Clone, Copy)]
36enum Tool {
37    Claude,
38    Cursor,
39}
40
41impl Tool {
42    fn name(&self) -> &'static str {
43        match self {
44            Tool::Claude => "Claude Code",
45            Tool::Cursor => "Cursor",
46        }
47    }
48}
49
50/// Detect which AI tools are present in the current directory.
51fn detect_tools() -> Vec<Tool> {
52    let mut tools = Vec::new();
53    if Path::new(".claude").is_dir() || Path::new(".claude").exists() {
54        tools.push(Tool::Claude);
55    }
56    if Path::new(".cursor").is_dir() || Path::new(".cursorrules").exists() {
57        tools.push(Tool::Cursor);
58    }
59    tools
60}
61
62fn parse_tool(name: &str) -> Option<Tool> {
63    match name.to_lowercase().as_str() {
64        "claude" | "claude-code" => Some(Tool::Claude),
65        "cursor" => Some(Tool::Cursor),
66        _ => None,
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Source locator
72// ---------------------------------------------------------------------------
73
74/// Find the ai/ directory in a repo checkout (walk up from cwd).
75fn find_ai_dir() -> Option<PathBuf> {
76    let mut dir = std::env::current_dir().ok()?;
77    for _ in 0..5 {
78        let candidate = dir.join("ai/skills");
79        if candidate.is_dir() {
80            return Some(dir.join("ai"));
81        }
82        if !dir.pop() {
83            break;
84        }
85    }
86    None
87}
88
89// ---------------------------------------------------------------------------
90// Install
91// ---------------------------------------------------------------------------
92
93/// Install skills for the detected (or specified) AI tool.
94pub fn install(tool_override: Option<&str>, skill_filter: Option<&str>) -> Result<(), String> {
95    let tools = if let Some(name) = tool_override {
96        vec![
97            parse_tool(name)
98                .ok_or_else(|| format!("unknown tool: '{}'. Supported: claude, cursor", name))?,
99        ]
100    } else {
101        let detected = detect_tools();
102        if detected.is_empty() {
103            // Default to Claude if nothing detected
104            println!("No AI tool config detected. Defaulting to Claude Code.");
105            println!("  (Override with: fdl skill install --tool cursor)");
106            println!();
107            vec![Tool::Claude]
108        } else {
109            detected
110        }
111    };
112
113    let ai_dir = find_ai_dir();
114
115    for tool in &tools {
116        match tool {
117            Tool::Claude => install_claude(&ai_dir, skill_filter)?,
118            Tool::Cursor => install_cursor(&ai_dir, skill_filter)?,
119        }
120    }
121
122    Ok(())
123}
124
125fn install_claude(ai_dir: &Option<PathBuf>, skill_filter: Option<&str>) -> Result<(), String> {
126    let skills_to_install: Vec<&SkillInfo> = SKILLS
127        .iter()
128        .filter(|s| skill_filter.is_none() || skill_filter == Some(s.name))
129        .collect();
130
131    if skills_to_install.is_empty() {
132        return Err(format!(
133            "unknown skill: '{}'. Available: {}",
134            skill_filter.unwrap_or(""),
135            SKILLS.iter().map(|s| s.name).collect::<Vec<_>>().join(", ")
136        ));
137    }
138
139    for skill in &skills_to_install {
140        let skill_dir = PathBuf::from(format!(".claude/skills/{}", skill.name));
141        let updating = skill_dir.join("SKILL.md").exists();
142        fs::create_dir_all(&skill_dir)
143            .map_err(|e| format!("cannot create {}: {}", skill_dir.display(), e))?;
144
145        // Install SKILL.md (adapter)
146        let adapter_content = if let Some(ai) = ai_dir {
147            let adapter_path = ai.join("adapters/claude/port-skill.md");
148            fs::read_to_string(&adapter_path).unwrap_or_else(|_| CLAUDE_ADAPTER.to_string())
149        } else {
150            CLAUDE_ADAPTER.to_string()
151        };
152        write_file(&skill_dir.join("SKILL.md"), &adapter_content)?;
153
154        // Install universal skill files alongside the adapter
155        let guide_content = if let Some(ai) = ai_dir {
156            let path = ai.join(format!("skills/{}/guide.md", skill.name));
157            fs::read_to_string(&path).unwrap_or_else(|_| SKILL_GUIDE.to_string())
158        } else {
159            SKILL_GUIDE.to_string()
160        };
161        write_file(&skill_dir.join("guide.md"), &guide_content)?;
162
163        let instructions_content = if let Some(ai) = ai_dir {
164            let path = ai.join(format!("skills/{}/instructions.md", skill.name));
165            fs::read_to_string(&path).unwrap_or_else(|_| SKILL_INSTRUCTIONS.to_string())
166        } else {
167            SKILL_INSTRUCTIONS.to_string()
168        };
169        write_file(&skill_dir.join("instructions.md"), &instructions_content)?;
170
171        let verb = if updating { "Updated" } else { "Installed" };
172        println!("  {} /{} skill for Claude Code", verb, skill.name);
173        println!("    -> .claude/skills/{}/SKILL.md", skill.name);
174        println!("    -> .claude/skills/{}/guide.md", skill.name);
175        println!("    -> .claude/skills/{}/instructions.md", skill.name);
176    }
177
178    println!();
179    println!("Claude Code skills ready. Try: /port my_model.py");
180    Ok(())
181}
182
183fn install_cursor(ai_dir: &Option<PathBuf>, skill_filter: Option<&str>) -> Result<(), String> {
184    if skill_filter.is_some() && skill_filter != Some("port") {
185        return Err(format!("unknown skill: '{}'", skill_filter.unwrap_or("")));
186    }
187
188    // For Cursor, append porting context to .cursorrules
189    let rules_path = PathBuf::from(".cursorrules");
190    let existing = fs::read_to_string(&rules_path).unwrap_or_default();
191
192    if existing.contains("flodl porting") {
193        println!("  Cursor rules already contain flodl porting context.");
194        return Ok(());
195    }
196
197    let guide_content = if let Some(ai) = ai_dir {
198        let path = ai.join("skills/port/guide.md");
199        fs::read_to_string(&path).unwrap_or_else(|_| SKILL_GUIDE.to_string())
200    } else {
201        SKILL_GUIDE.to_string()
202    };
203
204    let cursor_block = format!(
205        "\n\n# flodl porting\n\n\
206         When asked to port PyTorch code to flodl, follow this guide:\n\n\
207         {}\n",
208        guide_content
209    );
210
211    let new_content = format!("{}{}", existing, cursor_block);
212    write_file(&rules_path, &new_content)?;
213
214    println!("  Installed flodl porting context for Cursor");
215    println!("    -> .cursorrules (appended)");
216    println!();
217    println!("Cursor ready. Ask: \"Port this PyTorch code to flodl\"");
218    Ok(())
219}
220
221fn write_file(path: &Path, content: &str) -> Result<(), String> {
222    fs::write(path, content).map_err(|e| format!("cannot write {}: {}", path.display(), e))
223}
224
225// ---------------------------------------------------------------------------
226// List
227// ---------------------------------------------------------------------------
228
229pub fn list() {
230    println!("Available skills:");
231    println!();
232    for skill in SKILLS {
233        println!("  {:<12} {}", skill.name, skill.description);
234    }
235    println!();
236
237    let tools = detect_tools();
238    if tools.is_empty() {
239        println!("No AI tool detected. Install with: fdl skill install");
240    } else {
241        println!("Detected tools:");
242        for tool in &tools {
243            let installed = check_installed(tool);
244            let status = if installed {
245                "installed"
246            } else {
247                "not installed"
248            };
249            println!("  {:<16} {}", tool.name(), status);
250        }
251        println!();
252        if tools.iter().any(|t| !check_installed(t)) {
253            println!("Run: fdl skill install");
254        }
255    }
256}
257
258fn check_installed(tool: &Tool) -> bool {
259    match tool {
260        Tool::Claude => Path::new(".claude/skills/port/SKILL.md").exists(),
261        Tool::Cursor => fs::read_to_string(".cursorrules")
262            .map(|c| c.contains("flodl porting"))
263            .unwrap_or(false),
264    }
265}
266
267// ---------------------------------------------------------------------------
268// Usage
269// ---------------------------------------------------------------------------
270
271pub fn print_usage() {
272    println!("fdl skill -- manage AI coding assistant skills");
273    println!();
274    println!("USAGE:");
275    println!("    fdl skill <command> [options]");
276    println!();
277    println!("COMMANDS:");
278    println!("    install            Install skills for detected AI tool");
279    println!("        --tool <name>  Force a specific tool (claude, cursor)");
280    println!("        --skill <name> Install only one skill");
281    println!("    list               Show available skills and detected tools");
282    println!();
283    println!("SUPPORTED TOOLS:");
284    println!("    claude             Claude Code (.claude/skills/)");
285    println!("    cursor             Cursor (.cursorrules)");
286    println!();
287    println!("EXAMPLES:");
288    println!("    fdl skill install              # auto-detect tool, install all skills");
289    println!("    fdl skill install --tool claude # force Claude Code");
290    println!("    fdl skill list                 # show what's available");
291}