Skip to main content

objectiveai_sdk/filesystem/plugins/
client.rs

1//! Plugin discovery on the local filesystem.
2//!
3//! Installed plugins live at `<base_dir>/plugins/<name>/`, with the
4//! binary at `<base_dir>/plugins/<name>/plugin` (or `plugin.exe` on
5//! Windows) and the optional viewer bundle at
6//! `<base_dir>/plugins/<name>/viewer/`. The manifest is persisted
7//! alongside at `<base_dir>/plugins/<name>.json`. The cli's
8//! external-subcommand dispatch uses [`Client::resolve_plugin`] to
9//! turn a user-supplied plugin name into an executable path.
10
11use std::path::{Path, PathBuf};
12
13use super::super::Client;
14use super::{Manifest, ManifestWithNameAndSource};
15
16/// Two-step parse: try `ManifestWithNameAndSource` first (installed
17/// plugins persist `name` + `source`), then fall back to a bare
18/// `Manifest` with `name = file_stem` and `source = absolute_path`
19/// (hand-edited / pre-existing manifests). Returns `None` on missing
20/// / unreadable / malformed files.
21async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
22    let bytes = tokio::fs::read(path).await.ok()?;
23    if let Ok(full) = serde_json::from_slice::<ManifestWithNameAndSource>(&bytes) {
24        return Some(full);
25    }
26    let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
27    let name = path.file_stem()?.to_str()?.to_string();
28    let source = path.to_string_lossy().into_owned();
29    Some(ManifestWithNameAndSource { name, manifest, source })
30}
31
32impl Client {
33    /// The plugins directory: `<base_dir>/plugins`.
34    pub fn plugins_dir(&self) -> PathBuf {
35        self.base_dir().join("plugins")
36    }
37
38    /// The directory that holds a plugin's installed artifacts:
39    /// `<plugins_dir>/<name>/`. Contains the binary, an optional
40    /// `viewer/` bundle, and any runtime state the plugin writes.
41    pub fn plugin_dir(&self, name: &str) -> PathBuf {
42        self.plugins_dir().join(name)
43    }
44
45    /// Canonical path of a plugin's binary:
46    /// `<plugins_dir>/<name>/plugin` on Unix, `…/plugin.exe` on
47    /// Windows. Used by both `install_plugin` (write target) and
48    /// `resolve_plugin` (read target) so the two cannot drift.
49    pub fn plugin_binary_path(&self, name: &str) -> PathBuf {
50        self.plugin_dir(name)
51            .join(if cfg!(windows) { "plugin.exe" } else { "plugin" })
52    }
53
54    /// Resolve a plugin name to its executable path. Returns
55    /// `Some(path)` when [`Self::plugin_binary_path`] exists on disk
56    /// as a regular file, `None` otherwise.
57    ///
58    /// Uses `tokio::fs::metadata` so it doesn't block the runtime.
59    pub async fn resolve_plugin(&self, name: &str) -> Option<PathBuf> {
60        let path = self.plugin_binary_path(name);
61        tokio::fs::metadata(&path)
62            .await
63            .map(|m| m.is_file())
64            .unwrap_or(false)
65            .then_some(path)
66    }
67
68    /// Look up a single plugin manifest by name. Reads
69    /// `<base_dir>/plugins/<name>.json`. If the file persists `name`
70    /// and `source` (as installed plugins do), they're returned
71    /// verbatim; otherwise the wrapper is synthesized with
72    /// `name = <name>` and `source = absolute_path`. Returns `None`
73    /// if the file is missing, unreadable, or malformed.
74    pub async fn get_plugin(&self, name: &str) -> Option<ManifestWithNameAndSource> {
75        let path = self.plugins_dir().join(format!("{name}.json"));
76        parse_manifest_file(&path).await
77    }
78
79    /// Enumerate plugin manifests in the plugins directory. Reads each
80    /// `.json` file in `<base_dir>/plugins/`, deserializes it as a
81    /// [`Manifest`], and pairs it with the file's stem (`name`) and
82    /// absolute path (`source`). Every failure mode — missing dir,
83    /// unreadable file, malformed JSON, missing required field — is
84    /// silently skipped; the return type is plain `Vec` rather than
85    /// `Result` to reflect that.
86    ///
87    /// Results are sorted by manifest mtime descending (most recently
88    /// modified first), then `skip(offset).take(limit)` is applied —
89    /// matching the convention of the logs list endpoints. Pass
90    /// `(0, usize::MAX)` for an unbounded list.
91    ///
92    /// The directory scan is sequential (intrinsic to `read_dir`) but
93    /// per-file read+parse runs concurrently via
94    /// [`futures::future::join_all`].
95    pub async fn list_plugins(&self, offset: usize, limit: usize) -> Vec<ManifestWithNameAndSource> {
96        let dir = self.plugins_dir();
97        let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await else {
98            return Vec::new();
99        };
100        let mut paths: Vec<PathBuf> = Vec::new();
101        while let Ok(Some(entry)) = read_dir.next_entry().await {
102            let path = entry.path();
103            if path.extension().and_then(|e| e.to_str()) == Some("json") {
104                paths.push(path);
105            }
106        }
107        let futures = paths.into_iter().map(|p| async move {
108            let bundle = parse_manifest_file(&p).await?;
109            let modified = tokio::fs::metadata(&p)
110                .await
111                .ok()?
112                .modified()
113                .ok()?
114                .duration_since(std::time::SystemTime::UNIX_EPOCH)
115                .ok()?
116                .as_secs();
117            Some((modified, bundle))
118        });
119        let mut entries: Vec<(u64, ManifestWithNameAndSource)> = futures::future::join_all(futures)
120            .await
121            .into_iter()
122            .flatten()
123            .collect();
124        entries.sort_by(|a, b| b.0.cmp(&a.0));
125        let iter = entries.into_iter().map(|(_, m)| m);
126        if offset > 0 || limit < usize::MAX {
127            iter.skip(offset).take(limit).collect()
128        } else {
129            iter.collect()
130        }
131    }
132}
133
134#[cfg(feature = "http")]
135impl Client {
136    /// Install a plugin from a GitHub repository.
137    ///
138    /// 1. Fetches `objectiveai.json` from `raw.githubusercontent.com`
139    ///    at the supplied `commit_sha` (or the default branch via
140    ///    `HEAD` when none).
141    /// 2. Parses it as a [`Manifest`].
142    /// 3. Looks up the current platform in `manifest.binaries`. If
143    ///    absent (or this host's platform isn't recognized by
144    ///    [`super::Platform::current`]), returns `Ok(false)` — the
145    ///    plugin simply doesn't support this host.
146    /// 4. Downloads the matching release asset from
147    ///    `https://github.com/<owner>/<repository>/releases/download/v<version>/<asset>`.
148    /// 5. Writes it to `<base_dir>/plugins/<repository>/plugin`
149    ///    (`plugin.exe` on Windows). Sets mode `0o755` on Unix so the
150    ///    binary is executable.
151    ///
152    /// `headers` is an optional `IndexMap<String, String>` that gets
153    /// attached to both HTTP requests (e.g. `Authorization` for
154    /// private repos / higher rate limits). The cli always passes
155    /// `None`.
156    ///
157    /// Failures past step 3 are returned as
158    /// [`super::InstallError`] wrapped by
159    /// [`super::super::Error::Install`].
160    pub async fn install_plugin(
161        &self,
162        owner: &str,
163        repository: &str,
164        commit_sha: Option<&str>,
165        headers: Option<&indexmap::IndexMap<String, String>>,
166        upgrade: bool,
167    ) -> Result<bool, super::super::Error> {
168        check_repository_name(repository)?;
169        let manifest = self
170            .fetch_plugin_manifest(owner, repository, commit_sha, headers)
171            .await?;
172        let source = raw_manifest_url(owner, repository, commit_sha);
173        self.install_plugin_from_manifest(owner, repository, &manifest, &source, headers, upgrade)
174            .await
175    }
176
177    /// Step 1 of `install_plugin`: fetch `<owner>/<repo>/<ref>/objectiveai.json`
178    /// from `raw.githubusercontent.com` and parse it as a [`Manifest`].
179    /// Exposed publicly so callers can inspect the manifest before
180    /// committing to an install (e.g. for whitelist checks).
181    pub async fn fetch_plugin_manifest(
182        &self,
183        owner: &str,
184        repository: &str,
185        commit_sha: Option<&str>,
186        headers: Option<&indexmap::IndexMap<String, String>>,
187    ) -> Result<Manifest, super::super::Error> {
188        self.fetch_plugin_manifest_impl(
189            "https://raw.githubusercontent.com",
190            owner,
191            repository,
192            commit_sha,
193            headers,
194        )
195        .await
196    }
197
198    /// Step 2 of `install_plugin`: given an already-parsed manifest,
199    /// pick the binary for the current platform (`Ok(false)` if
200    /// absent), download it from the corresponding release asset,
201    /// and write it to `<plugins_dir>/<repository>/plugin[.exe]`.
202    pub async fn install_plugin_from_manifest(
203        &self,
204        owner: &str,
205        repository: &str,
206        manifest: &Manifest,
207        source: &str,
208        headers: Option<&indexmap::IndexMap<String, String>>,
209        upgrade: bool,
210    ) -> Result<bool, super::super::Error> {
211        check_repository_name(repository)?;
212        self.install_from_manifest_impl(
213            "https://github.com",
214            owner,
215            repository,
216            manifest,
217            source,
218            headers,
219            upgrade,
220        )
221        .await
222    }
223
224    /// Test-only entry point that exposes the raw / releases URL
225    /// bases so in-process mock servers can intercept the requests.
226    /// Threads both URLs through the same fetch + install_from path
227    /// used by production.
228    #[cfg(test)]
229    pub(super) async fn install_plugin_at(
230        &self,
231        raw_base: &str,
232        releases_base: &str,
233        owner: &str,
234        repository: &str,
235        commit_sha: Option<&str>,
236        headers: Option<&indexmap::IndexMap<String, String>>,
237        upgrade: bool,
238    ) -> Result<bool, super::super::Error> {
239        check_repository_name(repository)?;
240        let manifest = self
241            .fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
242            .await?;
243        let reference = commit_sha.unwrap_or("HEAD");
244        let source = format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
245        self.install_from_manifest_impl(
246            releases_base,
247            owner,
248            repository,
249            &manifest,
250            &source,
251            headers,
252            upgrade,
253        )
254        .await
255    }
256
257    /// Test-only fetch-only entry point, mirrors `install_plugin_at`.
258    #[cfg(test)]
259    pub(super) async fn fetch_plugin_manifest_at(
260        &self,
261        raw_base: &str,
262        owner: &str,
263        repository: &str,
264        commit_sha: Option<&str>,
265        headers: Option<&indexmap::IndexMap<String, String>>,
266    ) -> Result<Manifest, super::super::Error> {
267        self.fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
268            .await
269    }
270
271    async fn fetch_plugin_manifest_impl(
272        &self,
273        raw_base: &str,
274        owner: &str,
275        repository: &str,
276        commit_sha: Option<&str>,
277        headers: Option<&indexmap::IndexMap<String, String>>,
278    ) -> Result<Manifest, super::super::Error> {
279        let http = reqwest::Client::new();
280        let header_map = build_headers(headers)?;
281        let reference = commit_sha.unwrap_or("HEAD");
282        let manifest_url =
283            format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
284        let resp = http
285            .get(&manifest_url)
286            .headers(header_map)
287            .send()
288            .await
289            .map_err(super::InstallError::ManifestRequest)?;
290        let status = resp.status();
291        let bytes = resp
292            .bytes()
293            .await
294            .map_err(super::InstallError::ManifestResponse)?;
295        if !status.is_success() {
296            return Err(super::InstallError::ManifestBadStatus {
297                code: status,
298                url: manifest_url,
299                body: String::from_utf8_lossy(&bytes).into_owned(),
300            }
301            .into());
302        }
303        let mut de = serde_json::Deserializer::from_slice(&bytes);
304        let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
305            .map_err(super::InstallError::ManifestParse)?;
306        Ok(manifest)
307    }
308
309    async fn install_from_manifest_impl(
310        &self,
311        releases_base: &str,
312        owner: &str,
313        repository: &str,
314        manifest: &Manifest,
315        source: &str,
316        headers: Option<&indexmap::IndexMap<String, String>>,
317        upgrade: bool,
318    ) -> Result<bool, super::super::Error> {
319        // 1. Platform match (no disk touches).
320        let Some(platform) = super::Platform::current() else {
321            return Ok(false);
322        };
323        let Some(binary_name) = manifest.binaries.get(platform) else {
324            return Ok(false);
325        };
326
327        let plugins_dir = self.plugins_dir();
328        let plugin_dir = self.plugin_dir(repository);
329        let binary_path = self.plugin_binary_path(repository);
330        let viewer_dir = plugin_dir.join("viewer");
331        let manifest_path = plugins_dir.join(format!("{repository}.json"));
332
333        // 2. Existing-install check: the manifest sibling file is the
334        //    source of truth for "this plugin is installed."
335        let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
336        if manifest_exists && !upgrade {
337            return Err(super::InstallError::AlreadyInstalled {
338                repository: repository.to_string(),
339            }
340            .into());
341        }
342
343        // 3. Clean prior install data when --upgrade. Best-effort: any
344        //    delete failure surfaces later as a write-phase error
345        //    (e.g. ManifestPersist) if the artifact is truly stuck.
346        //    Extra entries under <plugin_dir>/ are untouched.
347        if upgrade {
348            let _ = tokio::fs::remove_file(&manifest_path).await;
349            let _ = tokio::fs::remove_file(&binary_path).await;
350            let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
351        }
352
353        // 4. Network phase: fetch everything into memory before any
354        //    disk write. A network failure here leaves the disk in
355        //    whatever state step 3 left it in (empty if upgrade,
356        //    unchanged if fresh install — since step 2's check would
357        //    have refused).
358        let http = reqwest::Client::new();
359        let bin_bytes: Vec<u8> = {
360            let binary_url = format!(
361                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{binary_name}",
362                version = manifest.version,
363            );
364            let resp = http
365                .get(&binary_url)
366                .headers(build_headers(headers)?)
367                .send()
368                .await
369                .map_err(super::InstallError::BinaryRequest)?;
370            let status = resp.status();
371            if !status.is_success() {
372                return Err(super::InstallError::BinaryBadStatus {
373                    code: status,
374                    url: binary_url,
375                }
376                .into());
377            }
378            resp.bytes()
379                .await
380                .map_err(super::InstallError::BinaryResponse)?
381                .to_vec()
382        };
383
384        let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) = &manifest.viewer_zip {
385            let viewer_url = format!(
386                "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
387                version = manifest.version,
388            );
389            let resp = http
390                .get(&viewer_url)
391                .headers(build_headers(headers)?)
392                .send()
393                .await
394                .map_err(super::InstallError::ViewerZipRequest)?;
395            let status = resp.status();
396            if !status.is_success() {
397                return Err(super::InstallError::ViewerZipBadStatus {
398                    code: status,
399                    url: viewer_url,
400                }
401                .into());
402            }
403            Some(
404                resp.bytes()
405                    .await
406                    .map_err(super::InstallError::ViewerZipResponse)?
407                    .to_vec(),
408            )
409        } else {
410            None
411        };
412
413        let manifest_bytes: Vec<u8> = {
414            let bundle = ManifestWithNameAndSource {
415                name: repository.to_string(),
416                manifest: manifest.clone(),
417                source: source.to_string(),
418            };
419            serde_json::to_vec_pretty(&bundle).map_err(super::InstallError::ManifestSerialize)?
420        };
421
422        // 5. Plugin dir setup. Idempotent — preserves any pre-existing
423        //    "extra data" the plugin's runtime created.
424        tokio::fs::create_dir_all(&plugin_dir)
425            .await
426            .map_err(|e| super::InstallError::PluginDirCreate(plugin_dir.clone(), e))?;
427
428        // 6. Concurrent write phase via try_join!. Three branches fan
429        //    out, short-circuit on first error.
430        tokio::try_join!(
431            write_binary_branch(binary_path, bin_bytes),
432            write_viewer_branch(viewer_dir, zip_bytes),
433            write_manifest_branch(manifest_path, manifest_bytes),
434        )?;
435
436        Ok(true)
437    }
438}
439
440#[cfg(feature = "http")]
441async fn write_binary_branch(
442    binary_path: PathBuf,
443    bytes: Vec<u8>,
444) -> Result<(), super::InstallError> {
445    tokio::fs::write(&binary_path, &bytes)
446        .await
447        .map_err(|e| super::InstallError::BinaryWrite(binary_path.clone(), e))?;
448    #[cfg(unix)]
449    {
450        use std::os::unix::fs::PermissionsExt;
451        let perms = std::fs::Permissions::from_mode(0o755);
452        tokio::fs::set_permissions(&binary_path, perms)
453            .await
454            .map_err(|e| super::InstallError::Chmod(binary_path.clone(), e))?;
455    }
456    Ok(())
457}
458
459#[cfg(feature = "http")]
460async fn write_viewer_branch(
461    viewer_dir: PathBuf,
462    zip_bytes: Option<Vec<u8>>,
463) -> Result<(), super::InstallError> {
464    let Some(bytes) = zip_bytes else {
465        return Ok(());
466    };
467    tokio::fs::create_dir_all(&viewer_dir)
468        .await
469        .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e.to_string()))?;
470    let viewer_dir_for_blocking = viewer_dir.clone();
471    tokio::task::spawn_blocking(move || {
472        let cursor = std::io::Cursor::new(bytes);
473        let mut archive = zip::ZipArchive::new(cursor)
474            .map_err(|e| format!("zip archive open: {e}"))?;
475        archive
476            .extract(&viewer_dir_for_blocking)
477            .map_err(|e| format!("extract: {e}"))
478    })
479    .await
480    .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), format!("join: {e}")))?
481    .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e))?;
482    Ok(())
483}
484
485#[cfg(feature = "http")]
486async fn write_manifest_branch(
487    manifest_path: PathBuf,
488    bytes: Vec<u8>,
489) -> Result<(), super::InstallError> {
490    tokio::fs::write(&manifest_path, &bytes)
491        .await
492        .map_err(|e| super::InstallError::ManifestPersist(manifest_path.clone(), e))
493}
494
495/// Reject reserved plugin repository names before any install
496/// side-effect. `objectiveai` (case-insensitive) is reserved because
497/// the viewer uses it as the Tauri channel name for built-in events;
498/// a plugin with that repository name would shadow them.
499#[cfg(feature = "http")]
500fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
501    if repository.eq_ignore_ascii_case("objectiveai") {
502        return Err(super::InstallError::ReservedRepositoryName {
503            repository: repository.to_string(),
504        });
505    }
506    Ok(())
507}
508
509/// Convention: the raw-GitHub URL we'd fetch `objectiveai.json` from
510/// for a given (owner, repository, optional commit sha). Defaults to
511/// `HEAD` when no commit is supplied. Lifted out so the cli and the
512/// SDK's own `install_plugin` wrapper share one source of truth.
513pub fn raw_manifest_url(owner: &str, repository: &str, commit_sha: Option<&str>) -> String {
514    let reference = commit_sha.unwrap_or("HEAD");
515    format!(
516        "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
517    )
518}
519
520#[cfg(feature = "http")]
521pub(super) fn build_headers(
522    headers: Option<&indexmap::IndexMap<String, String>>,
523) -> Result<reqwest::header::HeaderMap, super::InstallError> {
524    let mut out = reqwest::header::HeaderMap::new();
525    let Some(h) = headers else {
526        return Ok(out);
527    };
528    for (k, v) in h {
529        let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
530            super::InstallError::InvalidHeaderName {
531                name: k.clone(),
532                reason: e.to_string(),
533            }
534        })?;
535        let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
536            super::InstallError::InvalidHeaderValue {
537                name: k.clone(),
538                reason: e.to_string(),
539            }
540        })?;
541        out.insert(name, value);
542    }
543    Ok(out)
544}