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, and opencode via the shared
3//! `agent_first_data::skill` admin.
4
5use clap::{Args as ClapArgs, Subcommand};
6
7use agent_first_data::skill::{
8    self, SkillAction, SkillAgentSelection, SkillError, SkillOptions, SkillScope, SkillSpec,
9};
10
11use crate::cli::output;
12use crate::shared::error::{Error, ErrorCode};
13
14/// The embedded skill this binary installs.
15const SPEC: SkillSpec = SkillSpec {
16    name: "agent-first-http",
17    source: include_str!("../../../skills/agent-first-http.md"),
18    title: "Agent-First HTTP",
19    marker_slug: "afhttp",
20};
21
22#[derive(ClapArgs, Debug)]
23pub struct Args {
24    #[command(subcommand)]
25    pub sub: SkillSub,
26}
27
28#[derive(Subcommand, Debug)]
29pub enum SkillSub {
30    /// Show whether the skill is installed, valid, and up to date.
31    Status(TargetArgs),
32    /// Install or refresh the skill.
33    Install(WriteArgs),
34    /// Remove a managed skill.
35    Uninstall(WriteArgs),
36}
37
38#[derive(ClapArgs, Debug)]
39pub struct TargetArgs {
40    /// Agent to manage: all, codex, claude-code, opencode.
41    #[arg(long, default_value = "all")]
42    pub agent: String,
43    /// Skill scope: personal or project (project is Claude Code / opencode only).
44    #[arg(long, default_value = "personal")]
45    pub scope: String,
46    /// Skills directory; requires a single concrete --agent.
47    #[arg(long = "skills-dir")]
48    pub skills_dir: Option<String>,
49}
50
51#[derive(ClapArgs, Debug)]
52pub struct WriteArgs {
53    #[command(flatten)]
54    pub target: TargetArgs,
55    /// Overwrite or remove a skill this tool did not manage.
56    #[arg(long)]
57    pub force: bool,
58}
59
60pub async fn run(args: Args) -> Result<(), Error> {
61    let (action, code, target, force) = match args.sub {
62        SkillSub::Status(t) => (SkillAction::Status, "skill_status", t, false),
63        SkillSub::Install(w) => (SkillAction::Install, "skill_install", w.target, w.force),
64        SkillSub::Uninstall(w) => (SkillAction::Uninstall, "skill_uninstall", w.target, w.force),
65    };
66    let options = build_options(target, force)?;
67    let report = skill::run_skill_admin(&SPEC, action, &options).map_err(to_error)?;
68    output::emit(code, &report)
69}
70
71/// Parse the `--agent` / `--scope` string flags into the library enums.
72fn build_options(target: TargetArgs, force: bool) -> Result<SkillOptions, Error> {
73    let agent = match target.agent.as_str() {
74        "all" => SkillAgentSelection::All,
75        "codex" => SkillAgentSelection::Codex,
76        "claude-code" => SkillAgentSelection::ClaudeCode,
77        "opencode" => SkillAgentSelection::Opencode,
78        other => {
79            return Err(Error::new(
80                ErrorCode::InvalidArgument,
81                format!("invalid --agent '{other}': expected all, codex, claude-code, opencode"),
82            ))
83        }
84    };
85    let scope = match target.scope.as_str() {
86        "personal" => SkillScope::Personal,
87        "project" => SkillScope::Project,
88        other => {
89            return Err(Error::new(
90                ErrorCode::InvalidArgument,
91                format!("invalid --scope '{other}': expected personal, project"),
92            ))
93        }
94    };
95    Ok(SkillOptions {
96        agent,
97        scope,
98        skills_dir: target.skills_dir,
99        force,
100    })
101}
102
103/// afhttp's Error has no hint field, so fold the skill hint into the detail.
104fn to_error(err: SkillError) -> Error {
105    let detail = match err.hint {
106        Some(hint) => format!("{} ({hint})", err.message),
107        None => err.message,
108    };
109    Error::new(ErrorCode::InvalidArgument, detail)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::path::PathBuf;
116    use std::time::{SystemTime, UNIX_EPOCH};
117
118    fn temp_skills_dir(tag: &str) -> PathBuf {
119        let suffix = SystemTime::now()
120            .duration_since(UNIX_EPOCH)
121            .map(|d| d.as_nanos())
122            .unwrap_or(0);
123        std::env::temp_dir().join(format!("afhttp_skill_{tag}_{suffix}"))
124    }
125
126    #[test]
127    fn build_options_parses_and_rejects() {
128        let ok = build_options(
129            TargetArgs {
130                agent: "opencode".into(),
131                scope: "project".into(),
132                skills_dir: Some("/tmp/x".into()),
133            },
134            true,
135        )
136        .unwrap();
137        assert_eq!(ok.agent, SkillAgentSelection::Opencode);
138        assert_eq!(ok.scope, SkillScope::Project);
139        assert!(ok.force);
140
141        let bad = build_options(
142            TargetArgs {
143                agent: "emacs".into(),
144                scope: "personal".into(),
145                skills_dir: None,
146            },
147            false,
148        );
149        assert_eq!(bad.unwrap_err().error_code, ErrorCode::InvalidArgument);
150    }
151
152    #[test]
153    fn install_status_uninstall_roundtrip() {
154        let dir = temp_skills_dir("opencode");
155        let options = SkillOptions {
156            agent: SkillAgentSelection::Opencode,
157            scope: SkillScope::Personal,
158            skills_dir: Some(dir.to_string_lossy().into_owned()),
159            force: false,
160        };
161
162        skill::run_skill_admin(&SPEC, SkillAction::Install, &options).unwrap();
163        let skill_path = dir.join("agent-first-http").join("SKILL.md");
164        assert!(skill_path.is_file());
165
166        let report = skill::run_skill_admin(&SPEC, SkillAction::Status, &options).unwrap();
167        let status = serde_json::to_value(&report).unwrap();
168        assert_eq!(status["installed_all"], true);
169        assert_eq!(status["valid_all"], true);
170        assert_eq!(status["current_all"], true);
171        assert_eq!(status["targets"][0]["agent"], "opencode");
172
173        skill::run_skill_admin(&SPEC, SkillAction::Uninstall, &options).unwrap();
174        assert!(!skill_path.exists());
175        let _ = std::fs::remove_dir_all(dir);
176    }
177}