agent_first_mail/
skill_admin.rs1use crate::cli::{SkillAction, SkillAgentSelection, SkillScope, SkillTargetArgs};
6use crate::error::{AppError, Result};
7use agent_first_data::skill::{
8 self, SkillAction as AfAction, SkillAgentSelection as AfSelection, SkillError, SkillOptions,
9 SkillScope as AfScope, SkillSpec,
10};
11use serde_json::Value;
12use std::path::Path;
13
14const SPEC: SkillSpec = SkillSpec {
16 name: "agent-first-mail",
17 source: include_str!("../skills/agent-first-mail.md"),
18 title: "Agent-First Mail",
19 marker_slug: "afmail",
20};
21
22pub fn handle_action(action: SkillAction) -> Result<Value> {
23 let (af_action, options) = split_action(action);
24 let report = skill::run_skill_admin(&SPEC, af_action, &options).map_err(to_app_error)?;
25 serde_json::to_value(&report).map_err(|e| {
26 AppError::new(
27 "internal_error",
28 format!("failed to serialize skill report: {e}"),
29 )
30 })
31}
32
33pub(crate) fn install_codex_project_skill(skills_dir: &Path) -> Result<Value> {
34 let options = SkillOptions {
35 agent: AfSelection::Codex,
36 scope: AfScope::Project,
37 skills_dir: Some(skills_dir.to_string_lossy().to_string()),
38 force: false,
39 };
40 let report =
41 skill::run_skill_admin(&SPEC, AfAction::Install, &options).map_err(to_app_error)?;
42 serde_json::to_value(&report).map_err(|e| {
43 AppError::new(
44 "internal_error",
45 format!("failed to serialize skill report: {e}"),
46 )
47 })
48}
49
50fn split_action(action: SkillAction) -> (AfAction, SkillOptions) {
51 match action {
52 SkillAction::Status(target) => (AfAction::Status, options(target, false)),
53 SkillAction::Install(write) => (AfAction::Install, options(write.target, write.force)),
54 SkillAction::Uninstall(write) => (AfAction::Uninstall, options(write.target, write.force)),
55 }
56}
57
58fn options(target: SkillTargetArgs, force: bool) -> SkillOptions {
59 SkillOptions {
60 agent: convert_agent(target.agent),
61 scope: convert_scope(target.scope),
62 skills_dir: target.skills_dir,
63 force,
64 }
65}
66
67fn convert_agent(agent: SkillAgentSelection) -> AfSelection {
68 match agent {
69 SkillAgentSelection::All => AfSelection::All,
70 SkillAgentSelection::Codex => AfSelection::Codex,
71 SkillAgentSelection::ClaudeCode => AfSelection::ClaudeCode,
72 SkillAgentSelection::Opencode => AfSelection::Opencode,
73 }
74}
75
76fn convert_scope(scope: SkillScope) -> AfScope {
77 match scope {
78 SkillScope::Personal => AfScope::Personal,
79 SkillScope::Project => AfScope::Project,
80 }
81}
82
83fn to_app_error(err: SkillError) -> AppError {
84 let mut out = AppError::new("invalid_request", err.message);
85 if let Some(hint) = err.hint {
86 out = out.with_hint(hint);
87 }
88 out
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::cli::SkillWriteArgs;
95 use std::path::{Path, PathBuf};
96 use std::time::{SystemTime, UNIX_EPOCH};
97
98 fn temp_skills_dir(name: &str) -> PathBuf {
99 let suffix = SystemTime::now()
100 .duration_since(UNIX_EPOCH)
101 .map(|d| d.as_nanos())
102 .unwrap_or(0);
103 std::env::temp_dir().join(format!(
104 "afmail_skill_{name}_{}_{}",
105 std::process::id(),
106 suffix
107 ))
108 }
109
110 fn target_args(dir: &Path, agent: SkillAgentSelection) -> SkillTargetArgs {
111 SkillTargetArgs {
112 agent,
113 scope: SkillScope::Personal,
114 skills_dir: Some(dir.to_string_lossy().to_string()),
115 }
116 }
117
118 fn write_args(dir: &Path, agent: SkillAgentSelection, force: bool) -> SkillWriteArgs {
119 SkillWriteArgs {
120 target: target_args(dir, agent),
121 force,
122 }
123 }
124
125 #[test]
126 fn install_status_uninstall_opencode_skill() {
127 let dir = temp_skills_dir("opencode");
128 let install = handle_action(SkillAction::Install(write_args(
129 &dir,
130 SkillAgentSelection::Opencode,
131 false,
132 )));
133 assert!(install.is_ok());
134 let skill_path = dir.join("agent-first-mail").join("SKILL.md");
135 assert!(skill_path.is_file());
136
137 let status = handle_action(SkillAction::Status(target_args(
138 &dir,
139 SkillAgentSelection::Opencode,
140 )));
141 assert!(status.is_ok());
142 if let Ok(value) = status {
143 assert_eq!(value["code"], "skill_status");
144 assert_eq!(value["skill"], "agent-first-mail");
145 assert_eq!(value["installed_all"], true);
146 assert_eq!(value["valid_all"], true);
147 assert_eq!(value["current_all"], true);
148 assert_eq!(value["targets"][0]["agent"], "opencode");
149 }
150
151 let removed = handle_action(SkillAction::Uninstall(write_args(
152 &dir,
153 SkillAgentSelection::Opencode,
154 false,
155 )));
156 assert!(removed.is_ok());
157 assert!(!skill_path.exists());
158 let _ = std::fs::remove_dir_all(dir);
159 }
160
161 #[test]
162 fn refuses_unmanaged_skill_without_force() {
163 let dir = temp_skills_dir("unmanaged");
164 let skill_dir = dir.join("agent-first-mail");
165 let skill_path = skill_dir.join("SKILL.md");
166 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
167 assert!(
168 std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
169 );
170
171 let install = handle_action(SkillAction::Install(write_args(
172 &dir,
173 SkillAgentSelection::Codex,
174 false,
175 )));
176 assert!(install.is_err());
177 assert!(skill_path.exists());
178 let _ = std::fs::remove_dir_all(dir);
179 }
180}