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