Skip to main content

speccoder_runtime/format/speckit/
extensions.rs

1//! Spec Kit's installed extensions: the one reader of `.specify/extensions*`.
2//! `specify extension list --json` when the CLI is installed (read-only),
3//! else the registry files it keeps.
4
5use std::path::Path;
6use std::time::{Duration, Instant};
7
8use crate::format::{Extension, Inventory, InventorySource};
9
10/// The extensions installed in the project at `root`; `None` when it has
11/// no extension files and the CLI lists nothing.
12#[must_use]
13pub fn inventory(root: &Path) -> Option<Inventory> {
14    if !root.join(".specify/extensions").is_dir() && !root.join(".specify/extensions.yml").is_file()
15    {
16        return None;
17    }
18    cli(root).or_else(|| files(root))
19}
20
21/// The CLI's answer, if it is installed and answers within a few seconds.
22fn cli(root: &Path) -> Option<Inventory> {
23    let mut child = std::process::Command::new("specify")
24        .args(["extension", "list", "--json"])
25        .current_dir(root)
26        .stdin(std::process::Stdio::null())
27        .stdout(std::process::Stdio::piped())
28        .stderr(std::process::Stdio::null())
29        .spawn()
30        .ok()?;
31    let began = Instant::now();
32    loop {
33        match child.try_wait() {
34            Ok(Some(status)) if status.success() => break,
35            Ok(Some(_)) | Err(_) => return None,
36            Ok(None) if began.elapsed() > Duration::from_secs(20) => {
37                let _ = child.kill();
38                let _ = child.wait();
39                return None;
40            }
41            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
42        }
43    }
44    let mut out = String::new();
45    std::io::Read::read_to_string(child.stdout.as_mut()?, &mut out).ok()?;
46    parse_cli(&out)
47}
48
49/// `specify extension list --json`: `[{id, version, enabled, source: {kind}}]`.
50#[must_use]
51pub fn parse_cli(text: &str) -> Option<Inventory> {
52    let start = text.find('[')?;
53    let v: serde_json::Value = serde_json::from_str(text[start..].trim()).ok()?;
54    let entries = v
55        .as_array()?
56        .iter()
57        .filter_map(|e| {
58            Some(Extension {
59                id: e["id"].as_str()?.to_owned(),
60                version: e["version"].as_str().unwrap_or_default().to_owned(),
61                enabled: e["enabled"].as_bool().unwrap_or(false),
62                source: e["source"]["kind"]
63                    .as_str()
64                    .or_else(|| e["source"].as_str())
65                    .unwrap_or_default()
66                    .to_owned(),
67            })
68        })
69        .collect();
70    Some(Inventory {
71        source: InventorySource::Cli,
72        entries,
73    })
74}
75
76/// The registry (`.specify/extensions/.registry`) and the installed list
77/// (`.specify/extensions.yml`): an extension the list does not name is
78/// listed as not enabled.
79#[must_use]
80pub fn files(root: &Path) -> Option<Inventory> {
81    let registry: serde_json::Value =
82        std::fs::read_to_string(root.join(".specify/extensions/.registry"))
83            .ok()
84            .and_then(|t| serde_json::from_str(&t).ok())
85            .unwrap_or_default();
86    let installed = std::fs::read_to_string(root.join(".specify/extensions.yml"))
87        .ok()
88        .map(|t| installed_list(&t));
89    let mut entries: Vec<Extension> = registry["extensions"]
90        .as_object()
91        .into_iter()
92        .flatten()
93        .map(|(id, e)| Extension {
94            id: id.clone(),
95            version: e["version"].as_str().unwrap_or_default().to_owned(),
96            enabled: e["enabled"].as_bool().unwrap_or(false)
97                && installed.as_ref().is_none_or(|i| i.contains(id)),
98            source: e["source"].as_str().unwrap_or_default().to_owned(),
99        })
100        .collect();
101    entries.sort_by(|a, b| a.id.cmp(&b.id));
102    if entries.is_empty() && installed.is_none() {
103        return None;
104    }
105    Some(Inventory {
106        source: InventorySource::Files,
107        entries,
108    })
109}
110
111/// The ids under `installed:` in `extensions.yml` (a YAML list of words).
112fn installed_list(text: &str) -> Vec<String> {
113    let mut out = Vec::new();
114    let mut inside = false;
115    for line in text.lines() {
116        if line.starts_with("installed:") {
117            inside = true;
118            let rest = line.trim_start_matches("installed:").trim();
119            // `installed: [bug, git]`
120            if let Some(list) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
121                out.extend(
122                    list.split(',')
123                        .map(|w| w.trim().trim_matches(['"', '\'']).to_owned())
124                        .filter(|w| !w.is_empty()),
125                );
126                inside = false;
127            }
128            continue;
129        }
130        if inside {
131            match line.trim_start().strip_prefix("- ") {
132                Some(id) if line.starts_with([' ', '-']) => {
133                    out.push(id.trim().trim_matches(['"', '\'']).to_owned());
134                }
135                _ if line.trim().is_empty() => {}
136                _ => inside = false,
137            }
138        }
139    }
140    out
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn the_installed_list_is_read_in_both_yaml_forms() {
149        assert_eq!(
150            installed_list("installed:\n- bug\n- git\nsettings:\n  x: 1\n"),
151            ["bug", "git"]
152        );
153        assert_eq!(
154            installed_list("installed: [bug, \"git\"]\n"),
155            ["bug", "git"]
156        );
157        assert!(installed_list("settings: {}\n").is_empty());
158    }
159
160    #[test]
161    fn the_cli_answer_names_id_version_state_and_source() {
162        let inv = parse_cli("[{\"id\": \"bug\", \"version\": \"1.0.1\", \"enabled\": false, \"source\": {\"kind\": \"local\"}}]")
163            .unwrap_or_else(|| panic!("parsed"));
164        assert_eq!(inv.source, InventorySource::Cli);
165        assert_eq!(inv.entries[0].source, "local");
166        assert!(inv.enabled("bug").is_none());
167    }
168}
169
170/// Where Spec Kit installs an extension, relative to the project.
171pub const DIR: &str = ".specify/extensions";
172
173/// What an installed extension's `extension.yml` says, as speccoder hosts it.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct Manifest {
176    /// `extension.id`.
177    pub id: Option<String>,
178    /// `extension.version`.
179    pub version: Option<String>,
180    /// `extension.description`.
181    pub description: Option<String>,
182    /// `provides.commands[].name`.
183    pub commands: Vec<String>,
184    /// `hooks`: `(event, command, prompt, mandatory)`.
185    pub hooks: Vec<(String, String, Option<String>, bool)>,
186}
187
188/// The folders of the extensions installed and enabled in the project at
189/// `project` (every folder with an `extension.yml` when Spec Kit keeps no
190/// registry), sorted.
191#[must_use]
192pub fn folders(project: &Path) -> Vec<std::path::PathBuf> {
193    let listed = files(project);
194    let mut out: Vec<std::path::PathBuf> = std::fs::read_dir(project.join(DIR))
195        .into_iter()
196        .flatten()
197        .flatten()
198        .map(|e| e.path())
199        .filter(|p| p.join("extension.yml").is_file())
200        .filter(|p| {
201            let id = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
202            listed
203                .as_ref()
204                .is_none_or(|i| i.entries.is_empty() || i.enabled(id).is_some())
205        })
206        .collect();
207    out.sort();
208    out
209}
210
211/// The `extension.yml` in `folder`.
212///
213/// # Errors
214///
215/// It cannot be read or parsed.
216pub fn manifest(folder: &Path) -> Result<Manifest, String> {
217    let text = std::fs::read_to_string(folder.join("extension.yml"))
218        .map_err(|e| format!("extension.yml: {e}"))?;
219    let v: serde_json::Value =
220        serde_saphyr::from_str(&text).map_err(|e| format!("extension.yml: {e}"))?;
221    let ext = &v["extension"];
222    let mut hooks = Vec::new();
223    if let Some(all) = v["hooks"].as_object() {
224        for (event, h) in all {
225            for h in h.as_array().cloned().unwrap_or_else(|| vec![h.clone()]) {
226                if let Some(command) = h["command"].as_str() {
227                    hooks.push((
228                        event.clone(),
229                        command.to_owned(),
230                        h["prompt"].as_str().map(str::to_owned),
231                        h["optional"].as_bool() == Some(false),
232                    ));
233                }
234            }
235        }
236    }
237    Ok(Manifest {
238        id: ext["id"].as_str().map(str::to_owned),
239        version: ext["version"].as_str().map(str::to_owned),
240        description: ext["description"].as_str().map(str::to_owned),
241        commands: v["provides"]["commands"]
242            .as_array()
243            .into_iter()
244            .flatten()
245            .filter_map(|c| c["name"].as_str().map(str::to_owned))
246            .collect(),
247        hooks,
248    })
249}
250
251/// The skill a command installs as (`speckit.threatspec.model` →
252/// `speckit-threatspec-model`).
253#[must_use]
254pub fn skill_of(command: &str) -> String {
255    command.replace('.', "-")
256}