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