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