1use std::fs;
7use std::path::{Path, PathBuf};
8
9const 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
17struct 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#[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
50fn 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
70fn 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
89pub 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 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 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 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 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
225pub 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
267pub 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}