nolgia-cli 0.2.3

CLI for the Nolgia generative media platform (image, audio, video)
//! Bundled agent skills: SKILL.md packs that teach AI agents (Claude Code,
//! hermes, Cursor, ...) how to generate on the NOLGIA platform. Embedded in
//! the binary so `brew install` + `nolgia skills install` is the whole story.

use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand, ValueEnum};
use serde::Serialize;
use std::{
    fs,
    path::{Path, PathBuf},
};

use crate::output::{OutputFormat, print_json};

pub struct BundledSkill {
    pub name: &'static str,
    pub content: &'static str,
}

pub const SKILLS: &[BundledSkill] = &[
    BundledSkill {
        name: "nolgia-platform",
        content: include_str!("../../skills/nolgia-platform/SKILL.md"),
    },
    BundledSkill {
        name: "nolgia-video-prompting",
        content: include_str!("../../skills/nolgia-video-prompting/SKILL.md"),
    },
    BundledSkill {
        name: "nolgia-ugc-ads",
        content: include_str!("../../skills/nolgia-ugc-ads/SKILL.md"),
    },
];

#[derive(Subcommand, Debug)]
pub enum SkillsCommand {
    /// List the skills bundled with this binary
    List,
    /// Print a bundled skill to stdout
    Show(ShowArgs),
    /// Install bundled skills for an AI agent to use
    Install(InstallArgs),
}

#[derive(Args, Debug)]
pub struct ShowArgs {
    pub name: String,
}

#[derive(Args, Debug)]
pub struct InstallArgs {
    /// Skills to install (default: all)
    pub names: Vec<String>,
    /// Where to install
    #[arg(long, value_enum, default_value_t = Target::ClaudeUser)]
    pub target: Target,
    /// Custom directory (implies --target dir)
    #[arg(long)]
    pub dir: Option<PathBuf>,
    /// Overwrite existing skill files
    #[arg(long, default_value_t = false)]
    pub force: bool,
}

#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
pub enum Target {
    /// ~/.claude/skills/<name>/SKILL.md (Claude Code, user-wide)
    ClaudeUser,
    /// ./.claude/skills/<name>/SKILL.md (current project)
    ClaudeProject,
    /// $HERMES_HOME/skills/<name>/SKILL.md (hermes-agent)
    Hermes,
    /// --dir <path>/<name>/SKILL.md
    Dir,
}

#[derive(Serialize)]
struct SkillInfo {
    name: &'static str,
    description: String,
}

#[derive(Serialize)]
struct Installed {
    name: &'static str,
    path: String,
}

/// Pull the (possibly folded multi-line) `description:` value out of the
/// YAML frontmatter without a YAML dependency.
fn description_of(content: &str) -> String {
    let mut in_frontmatter = false;
    for line in content.lines() {
        if line.trim_end() == "---" {
            if in_frontmatter {
                break;
            }
            in_frontmatter = true;
            continue;
        }
        if in_frontmatter && let Some(rest) = line.strip_prefix("description:") {
            let d = rest.trim().trim_matches('"');
            let first_sentence = d.split(". ").next().unwrap_or(d);
            return first_sentence.trim_end_matches('.').to_string();
        }
    }
    String::new()
}

fn find(name: &str) -> Result<&'static BundledSkill> {
    SKILLS.iter().find(|s| s.name == name).with_context(|| {
        let names: Vec<_> = SKILLS.iter().map(|s| s.name).collect();
        format!(
            "unknown skill {name:?} — bundled skills: {}",
            names.join(", ")
        )
    })
}

fn target_root(target: Target, dir: Option<&Path>) -> Result<PathBuf> {
    if let Some(d) = dir {
        return Ok(d.to_path_buf());
    }
    match target {
        Target::Dir => bail!("--target dir requires --dir <path>"),
        Target::ClaudeProject => Ok(PathBuf::from(".claude/skills")),
        Target::ClaudeUser => {
            let home = std::env::var_os("HOME")
                .or_else(|| std::env::var_os("USERPROFILE"))
                .context("cannot resolve home directory")?;
            Ok(PathBuf::from(home).join(".claude/skills"))
        }
        Target::Hermes => {
            let home = std::env::var("HERMES_HOME").unwrap_or_else(|_| "/opt/data".into());
            Ok(PathBuf::from(home).join("skills"))
        }
    }
}

pub fn run(command: SkillsCommand, format: OutputFormat) -> Result<()> {
    match command {
        SkillsCommand::List => list(format),
        SkillsCommand::Show(args) => show(args),
        SkillsCommand::Install(args) => install(args, format),
    }
}

fn list(format: OutputFormat) -> Result<()> {
    let infos: Vec<SkillInfo> = SKILLS
        .iter()
        .map(|s| SkillInfo {
            name: s.name,
            description: description_of(s.content),
        })
        .collect();
    match format {
        OutputFormat::Json => print_json(&infos),
        OutputFormat::Text => {
            for info in infos {
                println!("{:24} {}", info.name, info.description);
            }
            Ok(())
        }
    }
}

fn show(args: ShowArgs) -> Result<()> {
    print!("{}", find(&args.name)?.content);
    Ok(())
}

fn install(args: InstallArgs, format: OutputFormat) -> Result<()> {
    let root = target_root(args.target, args.dir.as_deref())?;
    let selected: Vec<&BundledSkill> = if args.names.is_empty() {
        SKILLS.iter().collect()
    } else {
        args.names
            .iter()
            .map(|n| find(n))
            .collect::<Result<Vec<_>>>()?
    };

    let mut installed = Vec::new();
    for skill in selected {
        let dir = root.join(skill.name);
        let path = dir.join("SKILL.md");
        if path.exists() && !args.force {
            bail!(
                "{} already exists — pass --force to overwrite",
                path.display()
            );
        }
        fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
        fs::write(&path, skill.content).with_context(|| format!("writing {}", path.display()))?;
        installed.push(Installed {
            name: skill.name,
            path: path.display().to_string(),
        });
    }

    match format {
        OutputFormat::Json => print_json(&installed),
        OutputFormat::Text => {
            for i in &installed {
                println!("installed {} -> {}", i.name, i.path);
            }
            println!(
                "{} skill(s) installed. Agents pick them up on their next session.",
                installed.len()
            );
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bundled_skills_have_valid_frontmatter() {
        for skill in SKILLS {
            assert!(
                skill.content.starts_with("---"),
                "{} missing frontmatter",
                skill.name
            );
            assert!(
                skill.content.contains(&format!("name: {}", skill.name)),
                "{} frontmatter name mismatch",
                skill.name
            );
            assert!(
                !description_of(skill.content).is_empty(),
                "{} missing description",
                skill.name
            );
        }
    }

    #[test]
    fn install_writes_files_and_respects_force() {
        let tmp = std::env::temp_dir().join(format!("nolgia-skills-test-{}", std::process::id()));
        let args = InstallArgs {
            names: vec!["nolgia-platform".into()],
            target: Target::Dir,
            dir: Some(tmp.clone()),
            force: false,
        };
        install(args, OutputFormat::Text).unwrap();
        let path = tmp.join("nolgia-platform/SKILL.md");
        assert!(path.exists());
        // second run without --force must fail
        let again = InstallArgs {
            names: vec!["nolgia-platform".into()],
            target: Target::Dir,
            dir: Some(tmp.clone()),
            force: false,
        };
        assert!(install(again, OutputFormat::Text).is_err());
        std::fs::remove_dir_all(&tmp).ok();
    }
}