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, and opencode 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    SkillScope as AfScope, SkillSpec,
10};
11use serde_json::Value;
12use std::path::Path;
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 (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}