agent_first_http/cli/cmd/
skill.rs1use 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#[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#[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
50const 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 Status(TargetArgs),
68 Install(WriteArgs),
70 Uninstall(WriteArgs),
72}
73
74#[derive(ClapArgs, Debug)]
75pub struct TargetArgs {
76 #[arg(long, default_value = "all")]
78 pub agent: SkillAgentArg,
79 #[arg(long, default_value = "personal")]
81 pub scope: SkillScopeArg,
82 #[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 #[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
107fn 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
117fn 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}