lobe-cli 0.1.1

Lobe — local-first HTTP performance profiling CLI for developers. Spins up a capture proxy, records DNS/TCP/TLS/TTFB/download phases per request, and flags anomalies against grounded network baselines.
use std::fs;
use std::path::{Path, PathBuf};

use lobe_core::error::{Result, TloxError};

// The skill files travel with the binary. Sources live at `skills/` inside
// this crate so `cargo publish` picks them up in the packaged tarball. Keep
// them in sync with the workspace-root copies at `.claude/skills/…` and
// `.cursor/rules/…` when either changes.
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"));
    }
}