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)]
68pub enum SkillSub {
69    /// Show whether the skill is installed, valid, and up to date.
70    Status(TargetArgs),
71    /// Install or refresh the skill.
72    Install(WriteArgs),
73    /// Remove a managed skill.
74    Uninstall(WriteArgs),
75}
76
77#[derive(ClapArgs, Debug)]
78pub struct TargetArgs {
79    /// Agent to manage.
80    #[arg(long, default_value = "all")]
81    pub agent: SkillAgentArg,
82    /// Skill scope.
83    #[arg(long, default_value = "personal")]
84    pub scope: SkillScopeArg,
85    /// Skills directory; requires a single concrete --agent.
86    #[arg(long = "skills-dir")]
87    pub skills_dir: Option<String>,
88}
89
90#[derive(ClapArgs, Debug)]
91pub struct WriteArgs {
92    #[command(flatten)]
93    pub target: TargetArgs,
94    /// Overwrite or remove a skill this tool did not manage.
95    #[arg(long)]
96    pub force: bool,
97}
98
99pub async fn run(args: Args) -> Result<(), Error> {
100    let (action, code, target, force) = match args.sub {
101        SkillSub::Status(t) => (SkillAction::Status, "skill_status", t, false),
102        SkillSub::Install(w) => (SkillAction::Install, "skill_install", w.target, w.force),
103        SkillSub::Uninstall(w) => (SkillAction::Uninstall, "skill_uninstall", w.target, w.force),
104    };
105    let options = build_options(target, force);
106    let report = skill::run_skill_admin(&SPEC, action, &options).map_err(to_error)?;
107    output::emit(code, &report)
108}
109
110/// Convert the `--agent` / `--scope` flags into the library options.
111fn build_options(target: TargetArgs, force: bool) -> SkillOptions {
112    SkillOptions {
113        agent: target.agent.into(),
114        scope: target.scope.into(),
115        skills_dir: target.skills_dir,
116        force,
117    }
118}
119
120/// afhttp's Error has no hint field, so fold the skill hint into the detail.
121fn to_error(err: SkillError) -> Error {
122    let detail = match err.hint {
123        Some(hint) => format!("{} ({hint})", err.message),
124        None => err.message,
125    };
126    Error::new(ErrorCode::InvalidArgument, detail)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::path::PathBuf;
133    use std::time::{SystemTime, UNIX_EPOCH};
134
135    fn temp_skills_dir(tag: &str) -> PathBuf {
136        let suffix = SystemTime::now()
137            .duration_since(UNIX_EPOCH)
138            .map(|d| d.as_nanos())
139            .unwrap_or(0);
140        std::env::temp_dir().join(format!("afhttp_skill_{tag}_{suffix}"))
141    }
142
143    #[test]
144    fn build_options_maps_flags() {
145        let ok = build_options(
146            TargetArgs {
147                agent: SkillAgentArg::Opencode,
148                scope: SkillScopeArg::Workspace,
149                skills_dir: Some("/tmp/x".into()),
150            },
151            true,
152        );
153        assert_eq!(ok.agent, SkillAgentSelection::Opencode);
154        assert_eq!(ok.scope, SkillScope::Workspace);
155        assert!(ok.force);
156    }
157
158    #[test]
159    fn install_status_uninstall_roundtrip() {
160        let dir = temp_skills_dir("opencode");
161        let options = SkillOptions {
162            agent: SkillAgentSelection::Opencode,
163            scope: SkillScope::Personal,
164            skills_dir: Some(dir.to_string_lossy().into_owned()),
165            force: false,
166        };
167
168        skill::run_skill_admin(&SPEC, SkillAction::Install, &options).unwrap();
169        let skill_path = dir.join("agent-first-http").join("SKILL.md");
170        assert!(skill_path.is_file());
171
172        let report = skill::run_skill_admin(&SPEC, SkillAction::Status, &options).unwrap();
173        let status = serde_json::to_value(&report).unwrap();
174        assert_eq!(status["installed_all"], true);
175        assert_eq!(status["valid_all"], true);
176        assert_eq!(status["current_all"], true);
177        assert_eq!(status["targets"][0]["agent"], "opencode");
178
179        skill::run_skill_admin(&SPEC, SkillAction::Uninstall, &options).unwrap();
180        assert!(!skill_path.exists());
181        let _ = std::fs::remove_dir_all(dir);
182    }
183}