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_workspace_skill(skills_dir: &Path) -> Result<Value> {
34 let options = SkillOptions {
35 agent: AfSelection::Codex,
36 scope: AfScope::Workspace,
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 SkillAgentSelection::Hermes => AfSelection::Hermes,
74 }
75}
76
77fn convert_scope(scope: SkillScope) -> AfScope {
78 match scope {
79 SkillScope::Personal => AfScope::Personal,
80 SkillScope::Workspace => AfScope::Workspace,
81 }
82}
83
84fn to_app_error(err: SkillError) -> AppError {
85 let mut out = AppError::new("invalid_request", err.message);
86 if let Some(hint) = err.hint {
87 out = out.with_hint(hint);
88 }
89 out
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::cli::SkillWriteArgs;
96 use std::path::{Path, PathBuf};
97 use std::time::{SystemTime, UNIX_EPOCH};
98
99 fn temp_skills_dir(name: &str) -> PathBuf {
100 let suffix = SystemTime::now()
101 .duration_since(UNIX_EPOCH)
102 .map(|d| d.as_nanos())
103 .unwrap_or(0);
104 std::env::temp_dir().join(format!(
105 "afmail_skill_{name}_{}_{}",
106 std::process::id(),
107 suffix
108 ))
109 }
110
111 fn target_args(dir: &Path, agent: SkillAgentSelection) -> SkillTargetArgs {
112 SkillTargetArgs {
113 agent,
114 scope: SkillScope::Personal,
115 skills_dir: Some(dir.to_string_lossy().to_string()),
116 }
117 }
118
119 fn write_args(dir: &Path, agent: SkillAgentSelection, force: bool) -> SkillWriteArgs {
120 SkillWriteArgs {
121 target: target_args(dir, agent),
122 force,
123 }
124 }
125
126 #[test]
127 fn install_status_uninstall_opencode_skill() {
128 let dir = temp_skills_dir("opencode");
129 let install = handle_action(SkillAction::Install(write_args(
130 &dir,
131 SkillAgentSelection::Opencode,
132 false,
133 )));
134 assert!(install.is_ok());
135 let skill_path = dir.join("agent-first-mail").join("SKILL.md");
136 assert!(skill_path.is_file());
137
138 let status = handle_action(SkillAction::Status(target_args(
139 &dir,
140 SkillAgentSelection::Opencode,
141 )));
142 assert!(status.is_ok());
143 if let Ok(value) = status {
144 assert_eq!(value["code"], "skill_status");
145 assert_eq!(value["skill"], "agent-first-mail");
146 assert_eq!(value["installed_all"], true);
147 assert_eq!(value["valid_all"], true);
148 assert_eq!(value["current_all"], true);
149 assert_eq!(value["targets"][0]["agent"], "opencode");
150 }
151
152 let removed = handle_action(SkillAction::Uninstall(write_args(
153 &dir,
154 SkillAgentSelection::Opencode,
155 false,
156 )));
157 assert!(removed.is_ok());
158 assert!(!skill_path.exists());
159 let _ = std::fs::remove_dir_all(dir);
160 }
161
162 #[test]
163 fn refuses_unmanaged_skill_without_force() {
164 let dir = temp_skills_dir("unmanaged");
165 let skill_dir = dir.join("agent-first-mail");
166 let skill_path = skill_dir.join("SKILL.md");
167 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
168 assert!(
169 std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
170 );
171
172 let install = handle_action(SkillAction::Install(write_args(
173 &dir,
174 SkillAgentSelection::Codex,
175 false,
176 )));
177 assert!(install.is_err());
178 assert!(skill_path.exists());
179 let _ = std::fs::remove_dir_all(dir);
180 }
181}