llman 0.0.28

A tool for managing LLM application rules(prompts) ...
Documentation
use super::config::load_or_create_config;
use super::templates::{skill_templates, workflow_command_templates};
use crate::fs_utils::atomic_write_with_mode;
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use anyhow::{Result, anyhow};
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct UpdateSkillsArgs {
    pub no_interactive: bool,
    pub commands_only: bool,
    pub skills_only: bool,
}

const LLMAN_SDD_COMMAND_IDS: &[&str] = &[
    "explore", "onboard", "propose", "new", "continue", "ff", "apply", "verify", "sync", "archive",
];

const REQUIRED_ETHICS_KEYS: &[&str] = &[
    "ethics.risk_level",
    "ethics.prohibited_actions",
    "ethics.required_evidence",
    "ethics.refusal_contract",
    "ethics.escalation_policy",
];

pub fn run(args: UpdateSkillsArgs) -> Result<()> {
    run_with_root(Path::new("."), args)
}

pub(crate) fn run_with_root(root: &Path, args: UpdateSkillsArgs) -> Result<()> {
    let llmanspec_path = root.join(LLMANSPEC_DIR_NAME);
    if !llmanspec_path.exists() {
        let cmd = "llman sdd init";
        return Err(anyhow!(t!("sdd.update_skills.no_llmanspec", cmd = cmd)));
    }

    let generate_skills = !args.commands_only;
    let generate_commands = !args.skills_only;

    let config = load_or_create_config(&llmanspec_path)?;

    if generate_skills {
        let templates = skill_templates(&config, root)?;
        enforce_ethics_governance(&templates)?;
        let skills_base = root.join(".agents").join("skills");
        write_tool_skills(&skills_base, &templates)?;
    }

    if generate_commands {
        let commands = workflow_command_templates(&config, root)?;
        write_llman_sdd_claude_commands(root, &commands)?;
    }

    Ok(())
}

fn enforce_ethics_governance(templates: &[super::templates::SkillTemplate]) -> Result<()> {
    for template in templates {
        for key in REQUIRED_ETHICS_KEYS {
            if !template.content.contains(key) {
                return Err(anyhow!(
                    "missing required ethics governance key '{}' in template '{}'",
                    key,
                    template.name
                ));
            }
        }
    }
    Ok(())
}

fn write_tool_skills(base: &Path, templates: &[super::templates::SkillTemplate]) -> Result<()> {
    fs::create_dir_all(base)?;
    for template in templates {
        let dir_name = template.name.trim_end_matches(".md");
        let skill_dir = base.join(dir_name);
        fs::create_dir_all(&skill_dir)?;
        let skill_path = skill_dir.join("SKILL.md");
        atomic_write_with_mode(&skill_path, template.content.as_bytes(), None)?;
    }
    Ok(())
}

fn write_llman_sdd_claude_commands(
    root: &Path,
    commands: &[super::templates::WorkflowCommandTemplate],
) -> Result<()> {
    let base = root.join(".claude/commands/llman-sdd");
    fs::create_dir_all(&base)?;
    for id in LLMAN_SDD_COMMAND_IDS {
        let body = find_command_body(commands, id)?;
        let cmd_name = format!("LLMAN SDD: {}", capitalize(id));
        let cmd_description = format!("Run the llman-sdd-{} skill", id);
        let cmd_tags = vec!["workflow", "sdd", "llman-sdd", id];
        let content = format!(
            "---\nname: {}\ndescription: {}\ncategory: {}\ntags: {}\n---\n\n{}\n",
            yaml_string(&cmd_name),
            yaml_string(&cmd_description),
            yaml_string("Workflow"),
            yaml_tags(&cmd_tags),
            body.trim_end()
        );
        let path = base.join(format!("{}.md", id));
        atomic_write_with_mode(&path, content.as_bytes(), None)?;
    }
    Ok(())
}

fn find_command_body<'a>(
    commands: &'a [super::templates::WorkflowCommandTemplate],
    id: &str,
) -> Result<&'a str> {
    commands
        .iter()
        .find(|t| t.id == id)
        .map(|t| t.content.as_str())
        .ok_or_else(|| {
            anyhow!(t!(
                "sdd.update_skills.missing_workflow_command_template",
                id = id
            ))
        })
}

fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
    }
}

fn yaml_string(value: &str) -> String {
    let escaped = value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n");
    format!("\"{}\"", escaped)
}

fn yaml_tags(tags: &[&str]) -> String {
    let items: Vec<String> = tags.iter().map(|tag| yaml_string(tag)).collect();
    format!("[{}]", items.join(", "))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    const EXPECTED_WORKFLOW_COMMANDS: &[&str] = &[
        "explore", "onboard", "propose", "new", "continue", "ff", "apply", "verify", "sync",
        "archive",
    ];

    const EXPECTED_WORKFLOW_SKILLS: &[&str] = &[
        "llman-sdd-onboard",
        "llman-sdd-propose",
        "llman-sdd-new-change",
        "llman-sdd-archive",
        "llman-sdd-explore",
        "llman-sdd-continue",
        "llman-sdd-ff",
        "llman-sdd-apply",
        "llman-sdd-verify",
        "llman-sdd-sync",
    ];

    #[test]
    fn update_skills_writes_skills_to_agents_skills() {
        let dir = tempdir().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(LLMANSPEC_DIR_NAME)).expect("create llmanspec");

        let args = UpdateSkillsArgs {
            no_interactive: true,
            commands_only: false,
            skills_only: false,
        };

        super::run_with_root(root, args).expect("update-skills");

        for skill in EXPECTED_WORKFLOW_SKILLS {
            assert!(
                root.join(".agents/skills")
                    .join(skill)
                    .join("SKILL.md")
                    .exists(),
                "missing skill {skill}"
            );
        }

        for cmd in EXPECTED_WORKFLOW_COMMANDS {
            let path = root
                .join(".claude/commands/llman-sdd")
                .join(format!("{cmd}.md"));
            assert!(path.exists(), "missing command {cmd}");
            let content = fs::read_to_string(&path).expect("read command");
            assert!(
                content.contains("name:"),
                "command missing frontmatter name: {cmd}"
            );
            assert!(
                content.contains("category:"),
                "command missing frontmatter category: {cmd}"
            );
            assert!(
                content.contains("name: \"LLMAN SDD:"),
                "command name format mismatch: {cmd}"
            );
        }
    }

    #[test]
    fn update_skills_new_style_requires_ethics_governance_keys() {
        let dir = tempdir().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(LLMANSPEC_DIR_NAME)).expect("create llmanspec");

        let override_skill = root.join("templates/sdd/en/skills/llman-sdd-onboard.md");
        fs::create_dir_all(override_skill.parent().expect("parent")).expect("mkdir");
        fs::write(
            &override_skill,
            r#"---
name: "llman-sdd-onboard"
description: "override for test"
---

## Context
- test
## Goal
- test
## Constraints
- test
## Workflow
- test
## Decision Policy
- test
## Output Contract
- test
## Ethics Governance
- `ethics.risk_level`: test
- `ethics.prohibited_actions`: test
- `ethics.required_evidence`: test
- `ethics.refusal_contract`: test
"#,
        )
        .expect("write override");

        let args = UpdateSkillsArgs {
            no_interactive: true,
            commands_only: false,
            skills_only: false,
        };

        let result = super::run_with_root(root, args);
        assert!(result.is_err(), "expected missing ethics key failure");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("ethics.escalation_policy"),
            "unexpected error message: {err}"
        );
    }

    #[test]
    fn update_skills_commands_only_skips_skills_output() {
        let dir = tempdir().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(LLMANSPEC_DIR_NAME)).expect("create llmanspec");

        let args = UpdateSkillsArgs {
            no_interactive: true,
            commands_only: true,
            skills_only: false,
        };

        super::run_with_root(root, args).expect("update-skills");
        assert!(root.join(".claude/commands/llman-sdd/new.md").exists());
        assert!(!root.join(".agents/skills").exists());
    }

    #[test]
    fn update_skills_skills_only_skips_workflow_commands() {
        let dir = tempdir().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(LLMANSPEC_DIR_NAME)).expect("create llmanspec");

        let args = UpdateSkillsArgs {
            no_interactive: true,
            commands_only: false,
            skills_only: true,
        };

        super::run_with_root(root, args).expect("update-skills");
        assert!(
            root.join(".agents/skills/llman-sdd-onboard/SKILL.md")
                .exists()
        );
        assert!(!root.join(".claude/commands/llman-sdd").exists());
    }

    #[test]
    fn update_skills_errors_without_llmanspec() {
        let dir = tempdir().expect("tempdir");
        let root = dir.path();

        let args = UpdateSkillsArgs {
            no_interactive: true,
            commands_only: false,
            skills_only: false,
        };

        let result = super::run_with_root(root, args);
        assert!(result.is_err(), "expected error without llmanspec dir");
        let err = result.unwrap_err().to_string().to_lowercase();
        assert!(
            err.contains("no llmanspec"),
            "unexpected error message: {err}"
        );
    }
}