Skip to main content

agent_engine/skills/
state.rs

1//! Persisted plugin management state: ~/.synaps-cli/plugins.json.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct PluginsState {
8    #[serde(default)]
9    pub marketplaces: Vec<Marketplace>,
10    #[serde(default)]
11    pub installed: Vec<InstalledPlugin>,
12    #[serde(default)]
13    pub trusted_hosts: Vec<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Marketplace {
18    pub name: String,
19    pub url: String,
20    #[serde(default)]
21    pub description: Option<String>,
22    #[serde(default)]
23    pub last_refreshed: Option<String>,
24    #[serde(default)]
25    pub cached_plugins: Vec<CachedPlugin>,
26    /// Git clone URL for the marketplace repo. Set when the marketplace
27    /// hosts Claude-Code-style plugins whose `source` is `./<subdir>`.
28    #[serde(default)]
29    pub repo_url: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CachedPlugin {
34    pub name: String,
35    pub source: String,
36    #[serde(default)]
37    pub version: Option<String>,
38    #[serde(default)]
39    pub description: Option<String>,
40    #[serde(default)]
41    pub index: Option<CachedPluginIndexMetadata>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CachedPluginIndexMetadata {
46    pub repository: String,
47    #[serde(default)]
48    pub subdir: Option<String>,
49    pub checksum_algorithm: String,
50    pub checksum_value: String,
51    #[serde(default)]
52    pub compatibility_synaps: Option<String>,
53    #[serde(default)]
54    pub compatibility_extension_protocol: Option<String>,
55    pub has_extension: bool,
56    #[serde(default)]
57    pub skills: Vec<String>,
58    #[serde(default)]
59    pub permissions: Vec<String>,
60    #[serde(default)]
61    pub hooks: Vec<String>,
62    #[serde(default)]
63    pub commands: Vec<String>,
64    #[serde(default)]
65    pub providers: Vec<crate::skills::plugin_index::PluginIndexProviderCapability>,
66    #[serde(default)]
67    pub trust_publisher: Option<String>,
68    #[serde(default)]
69    pub trust_homepage: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
73#[serde(tag = "state", rename_all = "snake_case")]
74pub enum SetupStatus {
75    #[default]
76    NotRequired,
77    Succeeded {
78        log_path: Option<String>,
79    },
80    Failed {
81        message: String,
82        log_path: Option<String>,
83    },
84}
85
86impl SetupStatus {
87    pub fn allows_extension_load(&self) -> bool {
88        !matches!(self, SetupStatus::Failed { .. })
89    }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct InstalledPlugin {
94    pub name: String,
95    #[serde(default)]
96    pub marketplace: Option<String>,
97    pub source_url: String,
98    pub installed_commit: String,
99    #[serde(default)]
100    pub latest_commit: Option<String>,
101    pub installed_at: String,
102    /// When the plugin was installed from a subdir of a marketplace repo
103    /// (Claude-Code-style layout), this is the subdir name. `source_url`
104    /// then refers to the marketplace repo, not a standalone plugin repo.
105    #[serde(default)]
106    pub source_subdir: Option<String>,
107    /// Optional index checksum captured at install time for index-backed plugins.
108    /// Used to verify future installs/updates before applying them.
109    #[serde(default)]
110    pub checksum_algorithm: Option<String>,
111    #[serde(default)]
112    pub checksum_value: Option<String>,
113    /// Post-install setup/prebuilt/verify status. Missing in older state files
114    /// defaults to `NotRequired` for backward compatibility.
115    #[serde(default)]
116    pub setup_status: SetupStatus,
117}
118
119impl PluginsState {
120    pub fn load_from(path: &Path) -> std::io::Result<Self> {
121        match std::fs::read_to_string(path) {
122            Ok(c) => serde_json::from_str(&c)
123                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
124            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
125            Err(e) => Err(e),
126        }
127    }
128
129    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
130        if let Some(p) = path.parent() {
131            std::fs::create_dir_all(p)?;
132        }
133        let json = serde_json::to_string_pretty(self)
134            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
135        // Atomic write via unique temp file + rename (avoids concurrent trampling)
136        let parent = path.parent().unwrap_or(Path::new("."));
137        let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
138        std::io::Write::write_all(&mut tmp, json.as_bytes())?;
139        // fsync before rename so data is durable on power loss. Through the
140        // temp file's own handle: Windows refuses to flush a read-only one
141        // ("Access is denied"), which failed every plugins.json save there.
142        tmp.as_file().sync_all()?;
143        tmp.persist(path).map_err(|e| e.error).map(|_| ())
144    }
145
146    /// Resolve the on-disk path for the current profile.
147    pub fn default_path() -> std::path::PathBuf {
148        crate::config::resolve_write_path("plugins.json")
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn plugins_state_round_trip() {
158        let s = PluginsState {
159            marketplaces: vec![Marketplace {
160                name: "pi-skills".into(),
161                url: "https://github.com/maha-media/pi-skills".into(),
162                description: Some("…".into()),
163                last_refreshed: Some("2026-04-18T12:00:00Z".into()),
164                cached_plugins: vec![CachedPlugin {
165                    name: "web".into(),
166                    source: "https://github.com/maha-media/pi-web.git".into(),
167                    version: Some("1.0".into()),
168                    description: Some("Web tools".into()),
169                    index: None,
170                }],
171                repo_url: Some("https://github.com/maha-media/pi-skills.git".into()),
172            }],
173            installed: vec![InstalledPlugin {
174                name: "web".into(),
175                marketplace: Some("pi-skills".into()),
176                source_url: "https://github.com/maha-media/pi-web.git".into(),
177                installed_commit: "abc123".into(),
178                latest_commit: Some("abc123".into()),
179                installed_at: "2026-04-18T12:01:00Z".into(),
180                source_subdir: None,
181                checksum_algorithm: None,
182                checksum_value: None,
183                setup_status: Default::default(),
184            }],
185            trusted_hosts: vec!["github.com/maha-media".into()],
186        };
187        let json = serde_json::to_string(&s).unwrap();
188        let back: PluginsState = serde_json::from_str(&json).unwrap();
189        assert_eq!(back.marketplaces.len(), 1);
190        assert_eq!(back.installed.len(), 1);
191        assert_eq!(back.trusted_hosts, vec!["github.com/maha-media"]);
192    }
193
194    #[test]
195    fn plugins_state_defaults_to_empty() {
196        let empty: PluginsState = serde_json::from_str("{}").unwrap();
197        assert!(empty.marketplaces.is_empty());
198        assert!(empty.installed.is_empty());
199        assert!(empty.trusted_hosts.is_empty());
200    }
201
202    #[test]
203    fn plugins_state_load_missing_file_is_empty() {
204        let dir = tempfile::tempdir().unwrap();
205        let path = dir.path().join("plugins.json");
206        let loaded = PluginsState::load_from(&path).unwrap();
207        assert!(loaded.marketplaces.is_empty());
208    }
209
210    #[test]
211    fn plugins_state_save_and_load_round_trip_on_disk() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("plugins.json");
214        let mut s = PluginsState::default();
215        s.trusted_hosts.push("github.com/x".into());
216        s.save_to(&path).unwrap();
217        let back = PluginsState::load_from(&path).unwrap();
218        assert_eq!(back.trusted_hosts, vec!["github.com/x"]);
219    }
220
221    #[test]
222    fn plugins_state_load_malformed_is_error() {
223        let dir = tempfile::tempdir().unwrap();
224        let path = dir.path().join("plugins.json");
225        std::fs::write(&path, "not json").unwrap();
226        assert!(PluginsState::load_from(&path).is_err());
227    }
228}