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] = &[
27 SkillInfo {
28 name: "port",
29 description: "Port PyTorch scripts to flodl",
30 },
31];
32
33#[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
53fn 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
73fn 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
92pub 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 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 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 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 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
227pub 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
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}