agent_first_http/cli/cmd/
skill.rs1use 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
14const 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 Status(TargetArgs),
32 Install(WriteArgs),
34 Uninstall(WriteArgs),
36}
37
38#[derive(ClapArgs, Debug)]
39pub struct TargetArgs {
40 #[arg(long, default_value = "all")]
42 pub agent: String,
43 #[arg(long, default_value = "personal")]
45 pub scope: String,
46 #[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 #[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
71fn 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
103fn 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}