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,
Show(ShowArgs),
Install(InstallArgs),
}
#[derive(Args, Debug)]
pub struct ShowArgs {
pub name: String,
}
#[derive(Args, Debug)]
pub struct InstallArgs {
pub names: Vec<String>,
#[arg(long, value_enum, default_value_t = Target::ClaudeUser)]
pub target: Target,
#[arg(long)]
pub dir: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub force: bool,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
pub enum Target {
ClaudeUser,
ClaudeProject,
Hermes,
Dir,
}
#[derive(Serialize)]
struct SkillInfo {
name: &'static str,
description: String,
}
#[derive(Serialize)]
struct Installed {
name: &'static str,
path: String,
}
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());
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();
}
}