Skip to main content

agent_first_http/cli/cmd/
skill.rs

1//! `afhttp skill` subcommand. Installs/uninstalls/reports status of the embedded
2//! Agent Skill across Codex, Claude Code, opencode, and Hermes via the shared
3//! `agent_first_data::skill` admin.
4
5use agent_first_data::skill::{
6    self, SkillAction, SkillAgentSelection, SkillError, SkillOptions, SkillScope, SkillSpec,
7};
8
9use crate::cli::output;
10use crate::shared::error::{Error, ErrorCode};
11
12/// The embedded skill this binary installs.
13const SPEC: SkillSpec = SkillSpec {
14    name: "agent-first-http",
15    source: include_str!("../../../skills/agent-first-http/SKILL.md"),
16    title: "Agent-First HTTP",
17    marker_slug: "afhttp",
18    assets: &[],
19};
20
21#[derive(Debug)]
22pub struct Args {
23    pub sub: SkillSub,
24}
25
26#[derive(Debug)]
27pub enum SkillSub {
28    Status(TargetArgs),
29    Install(WriteArgs),
30    Uninstall(WriteArgs),
31}
32
33/// `--agent` and `--scope` arrive as registry-checked enum values, so the
34/// selector types below map from a closed set rather than parsing one.
35#[derive(Debug)]
36pub struct TargetArgs {
37    pub agent: String,
38    pub scope: String,
39    pub skills_dir: Option<String>,
40}
41
42#[derive(Debug)]
43pub struct WriteArgs {
44    pub target: TargetArgs,
45    pub force: bool,
46}
47
48pub async fn run(args: Args) -> Result<(), Error> {
49    let (action, code, target, force) = match args.sub {
50        SkillSub::Status(t) => (SkillAction::Status, "skill_status", t, false),
51        SkillSub::Install(w) => (SkillAction::Install, "skill_install", w.target, w.force),
52        SkillSub::Uninstall(w) => (SkillAction::Uninstall, "skill_uninstall", w.target, w.force),
53    };
54    let options = build_options(target, force);
55    let report = skill::run_skill_admin(&SPEC, action, &options).map_err(to_error)?;
56    output::emit(code, &report)
57}
58
59/// Convert the `--agent` / `--scope` flags into the library options.
60fn build_options(target: TargetArgs, force: bool) -> SkillOptions {
61    SkillOptions {
62        agent: match target.agent.as_str() {
63            "codex" => SkillAgentSelection::Codex,
64            "claude-code" => SkillAgentSelection::ClaudeCode,
65            "opencode" => SkillAgentSelection::Opencode,
66            "hermes" => SkillAgentSelection::Hermes,
67            _ => SkillAgentSelection::All,
68        },
69        scope: match target.scope.as_str() {
70            "workspace" => SkillScope::Workspace,
71            _ => SkillScope::Personal,
72        },
73        skills_dir: target.skills_dir,
74        force,
75    }
76}
77
78/// afhttp's Error has no hint field, so fold the skill hint into the detail.
79fn to_error(err: SkillError) -> Error {
80    let detail = match err.hint {
81        Some(hint) => format!("{} ({hint})", err.message),
82        None => err.message,
83    };
84    Error::new(ErrorCode::InvalidArgument, detail)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::path::PathBuf;
91    use std::time::{SystemTime, UNIX_EPOCH};
92
93    fn temp_skills_dir(tag: &str) -> PathBuf {
94        let suffix = SystemTime::now()
95            .duration_since(UNIX_EPOCH)
96            .map(|d| d.as_nanos())
97            .unwrap_or(0);
98        std::env::temp_dir().join(format!("afhttp_skill_{tag}_{suffix}"))
99    }
100
101    #[test]
102    fn build_options_maps_every_registry_value() {
103        for (agent, expected) in [
104            ("all", SkillAgentSelection::All),
105            ("codex", SkillAgentSelection::Codex),
106            ("claude-code", SkillAgentSelection::ClaudeCode),
107            ("opencode", SkillAgentSelection::Opencode),
108            ("hermes", SkillAgentSelection::Hermes),
109        ] {
110            let options = build_options(
111                TargetArgs {
112                    agent: agent.into(),
113                    scope: "personal".into(),
114                    skills_dir: None,
115                },
116                false,
117            );
118            assert_eq!(options.agent, expected, "{agent}");
119        }
120
121        let ok = build_options(
122            TargetArgs {
123                agent: "opencode".into(),
124                scope: "workspace".into(),
125                skills_dir: Some("/tmp/x".into()),
126            },
127            true,
128        );
129        assert_eq!(ok.scope, SkillScope::Workspace);
130        assert!(ok.force);
131    }
132
133    #[test]
134    fn install_status_uninstall_roundtrip() {
135        let dir = temp_skills_dir("opencode");
136        let options = SkillOptions {
137            agent: SkillAgentSelection::Opencode,
138            scope: SkillScope::Personal,
139            skills_dir: Some(dir.to_string_lossy().into_owned()),
140            force: false,
141        };
142
143        skill::run_skill_admin(&SPEC, SkillAction::Install, &options).unwrap();
144        let skill_path = dir.join("agent-first-http").join("SKILL.md");
145        assert!(skill_path.is_file());
146
147        let report = skill::run_skill_admin(&SPEC, SkillAction::Status, &options).unwrap();
148        let status = serde_json::to_value(&report).unwrap();
149        assert_eq!(status["installed_all"], true);
150        assert_eq!(status["valid_all"], true);
151        assert_eq!(status["current_all"], true);
152        assert_eq!(status["targets"][0]["agent"], "opencode");
153
154        skill::run_skill_admin(&SPEC, SkillAction::Uninstall, &options).unwrap();
155        assert!(!skill_path.exists());
156        let _ = std::fs::remove_dir_all(dir);
157    }
158}