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