Skip to main content

agent_runtime/doctor/
skill_surface.rs

1//! Codex active skill-surface classifier for `agent-runtime doctor`.
2//!
3//! This diagnostic is shape-only. It reads the rendered link map and
4//! source tree to classify install intent; it deliberately does not stat
5//! `$CODEX_HOME` or attempt to reproduce Codex Desktop discovery.
6
7use super::{DoctorFinding, DoctorSeverity};
8use crate::install::link_map::{EntryKind, LinkEntry, LinkMap};
9use serde::Serialize;
10use std::ffi::OsStr;
11use std::path::{Component, Path, PathBuf};
12
13pub const CLASS: &str = "skill-surface";
14pub const FILE_SYMLINK_WARNING: &str = "codex.active-skill.file-symlink";
15pub const CODEX_ACCEPTANCE_BOUNDARY: &str = "shape validation only; live Codex Desktop discovery still requires `codex debug prompt-input` in a fresh session";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct SkillSurfaceReport {
19    pub product: String,
20    pub items: Vec<SkillSurfaceItem>,
21    pub findings: Vec<DoctorFinding>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub acceptance_boundary: Option<String>,
24}
25
26impl SkillSurfaceReport {
27    pub fn empty(product: &str) -> Self {
28        Self {
29            product: product.to_string(),
30            items: Vec::new(),
31            findings: Vec::new(),
32            acceptance_boundary: acceptance_boundary(product).map(str::to_string),
33        }
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct SkillSurfaceItem {
39    pub id: String,
40    pub source: String,
41    pub destination: String,
42    pub link_mode: SkillSurfaceLinkMode,
43    pub expected_codex_discoverable: CodexDiscoverability,
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    pub warnings: Vec<SkillSurfaceWarning>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum SkillSurfaceLinkMode {
51    File,
52    Directory,
53    RecursiveFile,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CodexDiscoverability {
58    Yes,
59    No,
60    NotApplicable,
61}
62
63impl Serialize for CodexDiscoverability {
64    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
65    where
66        S: serde::Serializer,
67    {
68        match self {
69            Self::Yes => serializer.serialize_bool(true),
70            Self::No => serializer.serialize_bool(false),
71            Self::NotApplicable => serializer.serialize_str("not-applicable"),
72        }
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct SkillSurfaceWarning {
78    pub code: &'static str,
79    pub message: String,
80    pub remediation: &'static str,
81}
82
83pub fn acceptance_boundary(product: &str) -> Option<&'static str> {
84    (product == "codex").then_some(CODEX_ACCEPTANCE_BOUNDARY)
85}
86
87pub fn check(product: &str, source_root: &Path, link_map: &LinkMap) -> SkillSurfaceReport {
88    let mut items = Vec::new();
89    let mut findings = Vec::new();
90    for entry in &link_map.entries {
91        let Some(item) = classify_entry(product, source_root, entry) else {
92            continue;
93        };
94        for warning in &item.warnings {
95            findings.push(DoctorFinding {
96                product: product.to_string(),
97                check: CLASS,
98                severity: DoctorSeverity::Warn,
99                entry_id: Some(item.id.clone()),
100                path: Some(PathBuf::from(&item.destination)),
101                message: format!(
102                    "{}: {}; {}",
103                    warning.code, warning.message, warning.remediation
104                ),
105            });
106        }
107        items.push(item);
108    }
109    SkillSurfaceReport {
110        product: product.to_string(),
111        items,
112        findings,
113        acceptance_boundary: acceptance_boundary(product).map(str::to_string),
114    }
115}
116
117fn classify_entry(
118    product: &str,
119    source_root: &Path,
120    entry: &LinkEntry,
121) -> Option<SkillSurfaceItem> {
122    let source = entry.source.as_deref()?;
123    let source_abs = source_root.join(source);
124    let link_mode = link_mode(&source_abs, entry);
125    let destination = clean_rel_path(&entry.destination);
126    let expected_codex_discoverable =
127        expected_codex_discoverable(product, destination.as_deref(), link_mode, entry);
128    let warnings = warnings(product, destination.as_deref(), &entry.id);
129    Some(SkillSurfaceItem {
130        id: entry.id.clone(),
131        source: source.to_string(),
132        destination: entry.destination.clone(),
133        link_mode,
134        expected_codex_discoverable,
135        warnings,
136    })
137}
138
139fn link_mode(source_abs: &Path, entry: &LinkEntry) -> SkillSurfaceLinkMode {
140    if entry.kind == EntryKind::SymlinkedFile && entry.recursive {
141        return SkillSurfaceLinkMode::RecursiveFile;
142    }
143    match std::fs::symlink_metadata(source_abs) {
144        Ok(meta) if meta.is_dir() => SkillSurfaceLinkMode::Directory,
145        _ => SkillSurfaceLinkMode::File,
146    }
147}
148
149fn expected_codex_discoverable(
150    product: &str,
151    destination: Option<&Path>,
152    link_mode: SkillSurfaceLinkMode,
153    entry: &LinkEntry,
154) -> CodexDiscoverability {
155    if product != "codex" {
156        return CodexDiscoverability::NotApplicable;
157    }
158    let Some(destination) = destination else {
159        return CodexDiscoverability::NotApplicable;
160    };
161    if !is_skills_prefixed(destination) {
162        return CodexDiscoverability::NotApplicable;
163    }
164    if entry.kind == EntryKind::SymlinkedFile
165        && !entry.recursive
166        && link_mode == SkillSurfaceLinkMode::Directory
167        && is_domain_nested_skill_leaf(destination)
168    {
169        CodexDiscoverability::Yes
170    } else {
171        CodexDiscoverability::No
172    }
173}
174
175fn warnings(
176    product: &str,
177    destination: Option<&Path>,
178    _entry_id: &str,
179) -> Vec<SkillSurfaceWarning> {
180    let Some(destination) = destination else {
181        return Vec::new();
182    };
183    if product == "codex" && is_skill_md_leaf(destination) {
184        vec![SkillSurfaceWarning {
185            code: FILE_SYMLINK_WARNING,
186            message: format!(
187                "Codex active skill destination `{}` is a SKILL.md file symlink",
188                destination.display()
189            ),
190            remediation: "use a directory-symlink leaf at `skills/<domain>/<skill>`",
191        }]
192    } else {
193        Vec::new()
194    }
195}
196
197fn clean_rel_path(raw: &str) -> Option<PathBuf> {
198    let path = Path::new(raw);
199    if path.as_os_str().is_empty() || path.is_absolute() {
200        return None;
201    }
202    if path
203        .components()
204        .any(|component| !matches!(component, Component::Normal(_)))
205    {
206        return None;
207    }
208    Some(path.to_path_buf())
209}
210
211fn is_skills_prefixed(path: &Path) -> bool {
212    matches!(path.components().next(), Some(Component::Normal(first)) if first == OsStr::new("skills"))
213}
214
215fn is_domain_nested_skill_leaf(path: &Path) -> bool {
216    let components: Vec<_> = path.components().collect();
217    components.len() >= 3
218        && matches!(components[0], Component::Normal(first) if first == OsStr::new("skills"))
219        && !matches!(components.last(), Some(Component::Normal(last)) if *last == OsStr::new("SKILL.md"))
220}
221
222fn is_skill_md_leaf(path: &Path) -> bool {
223    is_skills_prefixed(path)
224        && matches!(
225            path.file_name().and_then(|name| name.to_str()),
226            Some("SKILL.md")
227        )
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::install::link_map::LinkEntry;
234    use pretty_assertions::assert_eq;
235    use std::fs;
236    use tempfile::TempDir;
237
238    fn entry(id: &str, source: &str, destination: &str, recursive: bool) -> LinkEntry {
239        LinkEntry {
240            id: id.to_string(),
241            kind: EntryKind::SymlinkedFile,
242            source: Some(source.to_string()),
243            destination: destination.to_string(),
244            recursive,
245            surface: None,
246            comment_style: None,
247            body_template: None,
248        }
249    }
250
251    #[test]
252    fn classifies_domain_nested_directory_skill_as_codex_discoverable() {
253        let tmp = TempDir::new().unwrap();
254        let source = tmp
255            .path()
256            .join("build/codex/plugins/reporting/skills/daily-brief");
257        fs::create_dir_all(&source).unwrap();
258        fs::write(source.join("SKILL.md"), "# daily brief\n").unwrap();
259
260        let item = classify_entry(
261            "codex",
262            tmp.path(),
263            &entry(
264                "reporting.daily-brief",
265                "build/codex/plugins/reporting/skills/daily-brief",
266                "skills/reporting/daily-brief",
267                false,
268            ),
269        )
270        .unwrap();
271
272        assert_eq!(item.link_mode, SkillSurfaceLinkMode::Directory);
273        assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::Yes);
274        assert_eq!(item.warnings, Vec::new());
275    }
276
277    #[test]
278    fn classifies_skill_md_file_leaf_as_not_discoverable_with_warning() {
279        let tmp = TempDir::new().unwrap();
280        let source = tmp
281            .path()
282            .join("build/codex/plugins/reporting/skills/daily-brief/SKILL.md");
283        fs::create_dir_all(source.parent().unwrap()).unwrap();
284        fs::write(&source, "# daily brief\n").unwrap();
285
286        let item = classify_entry(
287            "codex",
288            tmp.path(),
289            &entry(
290                "reporting.daily-brief",
291                "build/codex/plugins/reporting/skills/daily-brief/SKILL.md",
292                "skills/reporting/daily-brief/SKILL.md",
293                false,
294            ),
295        )
296        .unwrap();
297
298        assert_eq!(item.link_mode, SkillSurfaceLinkMode::File);
299        assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::No);
300        assert_eq!(item.warnings.len(), 1);
301        assert_eq!(item.warnings[0].code, FILE_SYMLINK_WARNING);
302    }
303
304    #[test]
305    fn classifies_recursive_skill_entry_as_not_discoverable() {
306        let tmp = TempDir::new().unwrap();
307        let source = tmp.path().join("build/codex/plugins/reporting/skills");
308        fs::create_dir_all(source.join("daily-brief")).unwrap();
309        fs::write(source.join("daily-brief/SKILL.md"), "# daily brief\n").unwrap();
310
311        let item = classify_entry(
312            "codex",
313            tmp.path(),
314            &entry(
315                "reporting.skills-tree",
316                "build/codex/plugins/reporting/skills",
317                "skills/reporting",
318                true,
319            ),
320        )
321        .unwrap();
322
323        assert_eq!(item.link_mode, SkillSurfaceLinkMode::RecursiveFile);
324        assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::No);
325    }
326
327    #[test]
328    fn classifies_non_skills_destination_as_not_applicable() {
329        let tmp = TempDir::new().unwrap();
330        let source = tmp
331            .path()
332            .join("targets/codex/plugins/reporting/.codex-plugin/plugin.json");
333        fs::create_dir_all(source.parent().unwrap()).unwrap();
334        fs::write(&source, "{}\n").unwrap();
335
336        let item = classify_entry(
337            "codex",
338            tmp.path(),
339            &entry(
340                "reporting.plugin-manifest",
341                "targets/codex/plugins/reporting/.codex-plugin/plugin.json",
342                "plugins/reporting/.codex-plugin/plugin.json",
343                false,
344            ),
345        )
346        .unwrap();
347
348        assert_eq!(item.link_mode, SkillSurfaceLinkMode::File);
349        assert_eq!(
350            item.expected_codex_discoverable,
351            CodexDiscoverability::NotApplicable
352        );
353    }
354}