Skip to main content

agent_first_mail/
skill_admin.rs

1//! `afmail skill` subcommand. Installs/uninstalls/reports status of the embedded
2//! Agent Skill across Codex, Claude Code, opencode, and Hermes via the shared
3//! `agent_first_data::skill` admin — the same implementation every spore uses.
4
5use 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
14/// The embedded skill this binary installs.
15const 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}