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    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_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}