use std::fs;
use std::path::{Path, PathBuf};
use lobe_core::error::{Result, TloxError};
const CLAUDE_CODE_SKILL: &str = include_str!("../skills/claude-code.md");
const CURSOR_RULE: &str = include_str!("../skills/cursor.mdc");
#[derive(Debug, Clone, Copy)]
enum SkillTarget {
ClaudeCode,
Cursor,
}
impl SkillTarget {
fn parse(value: &str) -> std::result::Result<Self, String> {
match value.to_ascii_lowercase().as_str() {
"claude-code" | "claude" | "claudecode" => Ok(SkillTarget::ClaudeCode),
"cursor" => Ok(SkillTarget::Cursor),
other => Err(format!(
"unknown skill target '{other}' — expected `claude-code` or `cursor`"
)),
}
}
}
pub fn run_install_skill(target: String, global: bool, force: bool) -> Result<()> {
let target = SkillTarget::parse(&target).map_err(TloxError::InvalidTarget)?;
let base = if global {
home_dir().ok_or_else(|| {
TloxError::InvalidTarget(
"could not resolve $HOME for --global install".to_string(),
)
})?
} else {
std::env::current_dir()?
};
let (path, contents, label) = match target {
SkillTarget::ClaudeCode => (
base.join(".claude/skills/lobe-diagnose/SKILL.md"),
CLAUDE_CODE_SKILL,
"Claude Code",
),
SkillTarget::Cursor => (
base.join(".cursor/rules/lobe-diagnose.mdc"),
CURSOR_RULE,
"Cursor",
),
};
if path.exists() && !force {
return Err(TloxError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"{} already exists. Re-run with --force to overwrite.",
path.display()
),
)));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, contents)?;
let scope = if global { "user (global)" } else { "project" };
println!(
"installed lobe-diagnose skill for {label}:\n {}\n scope: {scope}",
path.display()
);
if let SkillTarget::ClaudeCode = target {
println!("\nIn Claude Code: `/lobe-diagnose` or just ask about a slow API.");
} else {
println!("\nIn Cursor chat: `@lobe-diagnose` to invoke.");
}
Ok(())
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.filter(|value| !value.as_os_str().is_empty())
.and_then(|value| value.canonicalize().ok().or(Some(value)))
.filter(|value: &PathBuf| value.as_path() != Path::new(""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_accepts_expected_aliases() {
assert!(matches!(
SkillTarget::parse("claude-code"),
Ok(SkillTarget::ClaudeCode)
));
assert!(matches!(
SkillTarget::parse("CLAUDE"),
Ok(SkillTarget::ClaudeCode)
));
assert!(matches!(SkillTarget::parse("cursor"), Ok(SkillTarget::Cursor)));
assert!(SkillTarget::parse("vscode").is_err());
}
#[test]
fn bundled_skill_contents_are_non_empty() {
assert!(CLAUDE_CODE_SKILL.contains("lobe-diagnose"));
assert!(CURSOR_RULE.contains("lobe-diagnose"));
}
}