1use 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 SkillReport, SkillScope as AfScope, SkillSpec,
10};
11use serde_json::Value;
12use std::path::{Path, PathBuf};
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 update_workspace_gitignore = matches!(
24 &action,
25 SkillAction::Install(write) if write.target.scope == SkillScope::Workspace
26 );
27 let (af_action, options) = split_action(action);
28 let report = skill::run_skill_admin(&SPEC, af_action, &options).map_err(to_app_error)?;
29 if update_workspace_gitignore {
30 update_gitignore_for_workspace_install(&report)?;
31 }
32 serde_json::to_value(&report).map_err(|e| {
33 AppError::new(
34 "internal_error",
35 format!("failed to serialize skill report: {e}"),
36 )
37 })
38}
39
40#[derive(Debug, Clone)]
41pub(crate) struct WorkspaceSkillInstall {
42 pub(crate) skill_dir: PathBuf,
43 pub(crate) skill_path: PathBuf,
44}
45
46pub(crate) fn install_codex_workspace_skill(skills_dir: &Path) -> Result<WorkspaceSkillInstall> {
47 let options = SkillOptions {
48 agent: AfSelection::Codex,
49 scope: AfScope::Workspace,
50 skills_dir: Some(skills_dir.to_string_lossy().to_string()),
51 force: false,
52 };
53 let report =
54 skill::run_skill_admin(&SPEC, AfAction::Install, &options).map_err(to_app_error)?;
55 workspace_skill_install_from_report(&report)
56}
57
58fn update_gitignore_for_workspace_install(report: &SkillReport) -> Result<()> {
59 let SkillReport::Install { targets, .. } = report else {
60 return Ok(());
61 };
62 let skill_dirs = targets
63 .iter()
64 .filter_map(|target| target.skill_path.parent().map(Path::to_path_buf))
65 .collect::<Vec<_>>();
66 let agent_root =
67 std::env::current_dir().map_err(|e| AppError::io("resolve current directory", &e))?;
68 crate::store::ensure_agent_gitignore_ignores_skill_dirs(&agent_root, &skill_dirs)
69}
70
71fn workspace_skill_install_from_report(report: &SkillReport) -> Result<WorkspaceSkillInstall> {
72 let SkillReport::Install { targets, .. } = report else {
73 return Err(AppError::new(
74 "internal_error",
75 "unexpected skill report kind after install",
76 ));
77 };
78 let Some(target) = targets.first() else {
79 return Err(AppError::new(
80 "internal_error",
81 "skill install returned no targets",
82 ));
83 };
84 Ok(WorkspaceSkillInstall {
85 skill_dir: target
86 .skill_path
87 .parent()
88 .map(Path::to_path_buf)
89 .ok_or_else(|| {
90 AppError::new(
91 "internal_error",
92 "skill install returned a path without a parent",
93 )
94 })?,
95 skill_path: target.skill_path.clone(),
96 })
97}
98
99fn split_action(action: SkillAction) -> (AfAction, SkillOptions) {
100 match action {
101 SkillAction::Status(target) => (AfAction::Status, options(target, false)),
102 SkillAction::Install(write) => (AfAction::Install, options(write.target, write.force)),
103 SkillAction::Uninstall(write) => (AfAction::Uninstall, options(write.target, write.force)),
104 }
105}
106
107fn options(target: SkillTargetArgs, force: bool) -> SkillOptions {
108 SkillOptions {
109 agent: convert_agent(target.agent),
110 scope: convert_scope(target.scope),
111 skills_dir: target.skills_dir,
112 force,
113 }
114}
115
116fn convert_agent(agent: SkillAgentSelection) -> AfSelection {
117 match agent {
118 SkillAgentSelection::All => AfSelection::All,
119 SkillAgentSelection::Codex => AfSelection::Codex,
120 SkillAgentSelection::ClaudeCode => AfSelection::ClaudeCode,
121 SkillAgentSelection::Opencode => AfSelection::Opencode,
122 SkillAgentSelection::Hermes => AfSelection::Hermes,
123 }
124}
125
126fn convert_scope(scope: SkillScope) -> AfScope {
127 match scope {
128 SkillScope::Personal => AfScope::Personal,
129 SkillScope::Workspace => AfScope::Workspace,
130 }
131}
132
133fn to_app_error(err: SkillError) -> AppError {
134 let mut out = AppError::new("invalid_request", err.message);
135 if let Some(hint) = err.hint {
136 out = out.with_hint(hint);
137 }
138 out
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::cli::SkillWriteArgs;
145 use std::path::{Path, PathBuf};
146 use std::time::{SystemTime, UNIX_EPOCH};
147
148 fn temp_skills_dir(name: &str) -> PathBuf {
149 let suffix = SystemTime::now()
150 .duration_since(UNIX_EPOCH)
151 .map(|d| d.as_nanos())
152 .unwrap_or(0);
153 std::env::temp_dir().join(format!(
154 "afmail_skill_{name}_{}_{}",
155 std::process::id(),
156 suffix
157 ))
158 }
159
160 fn target_args(dir: &Path, agent: SkillAgentSelection) -> SkillTargetArgs {
161 SkillTargetArgs {
162 agent,
163 scope: SkillScope::Personal,
164 skills_dir: Some(dir.to_string_lossy().to_string()),
165 }
166 }
167
168 fn write_args(dir: &Path, agent: SkillAgentSelection, force: bool) -> SkillWriteArgs {
169 SkillWriteArgs {
170 target: target_args(dir, agent),
171 force,
172 }
173 }
174
175 #[test]
176 fn install_status_uninstall_opencode_skill() {
177 let dir = temp_skills_dir("opencode");
178 let install = handle_action(SkillAction::Install(write_args(
179 &dir,
180 SkillAgentSelection::Opencode,
181 false,
182 )));
183 assert!(install.is_ok());
184 let skill_path = dir.join("agent-first-mail").join("SKILL.md");
185 assert!(skill_path.is_file());
186
187 let status = handle_action(SkillAction::Status(target_args(
188 &dir,
189 SkillAgentSelection::Opencode,
190 )));
191 assert!(status.is_ok());
192 if let Ok(value) = status {
193 assert_eq!(value["code"], "skill_status");
194 assert_eq!(value["skill"], "agent-first-mail");
195 assert_eq!(value["installed_all"], true);
196 assert_eq!(value["valid_all"], true);
197 assert_eq!(value["current_all"], true);
198 assert_eq!(value["targets"][0]["agent"], "opencode");
199 }
200
201 let removed = handle_action(SkillAction::Uninstall(write_args(
202 &dir,
203 SkillAgentSelection::Opencode,
204 false,
205 )));
206 assert!(removed.is_ok());
207 assert!(!skill_path.exists());
208 let _ = std::fs::remove_dir_all(dir);
209 }
210
211 #[test]
212 fn refuses_unmanaged_skill_without_force() {
213 let dir = temp_skills_dir("unmanaged");
214 let skill_dir = dir.join("agent-first-mail");
215 let skill_path = skill_dir.join("SKILL.md");
216 assert!(std::fs::create_dir_all(&skill_dir).is_ok());
217 assert!(
218 std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
219 );
220
221 let install = handle_action(SkillAction::Install(write_args(
222 &dir,
223 SkillAgentSelection::Codex,
224 false,
225 )));
226 assert!(install.is_err());
227 assert!(skill_path.exists());
228 let _ = std::fs::remove_dir_all(dir);
229 }
230}