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