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