Skip to main content

objectiveai_cli/filesystem/plugins/
client.rs

1//! Plugin discovery on the local filesystem.
2//!
3//! Installed plugins live at
4//! `<base_dir>/plugins/<owner>/<name>/<version>/`, with the cli-side
5//! payload at `…/cli/` (the exec working directory, extracted from
6//! the manifest's `cli_zip` when one is declared), the optional
7//! viewer bundle at `…/viewer/`, and the manifest as
8//! `…/objectiveai.json` inside the version folder. The cli's
9//! `plugins run` dispatch uses [`Client::resolve_plugin`] to turn an
10//! `(owner, name, version)` coordinate into the platform's exec
11//! vector plus that `cli/` working directory — the same model tools
12//! use, with the extra `cli` folder.
13//!
14//! The GitHub-install pipeline itself lives in
15//! [`crate::filesystem::install`]; the `install_plugin*` methods here
16//! are thin wrappers that drive it with `plugins::Manifest`.
17
18use std::path::{Path, PathBuf};
19
20use super::super::Client;
21use super::Manifest;
22use crate::filesystem::install::{
23    ExtraAsset, InstallKind, InstallManifest, platform_cli_zip,
24    validate_install_inputs,
25};
26
27/// Parse an on-disk `objectiveai.json` into a [`Manifest`] (and
28/// [`Manifest::validate`] it). The manifest declares its own `owner` /
29/// `name` / `version`; nothing is derived from the path. `None` on
30/// missing / unreadable / malformed / invalid files.
31async fn parse_manifest_file(path: &Path) -> Option<Manifest> {
32    let bytes = tokio::fs::read(path).await.ok()?;
33    let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
34    manifest.validate().ok()?;
35    Some(manifest)
36}
37
38/// Walk `<root>/<owner>/<name>/<version>/objectiveai.json` and collect
39/// every existing manifest file path. Any non-directory / unreadable
40/// level is skipped.
41async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
42    let mut out: Vec<PathBuf> = Vec::new();
43    let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
44        return out;
45    };
46    while let Ok(Some(owner_e)) = owners.next_entry().await {
47        let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
48            continue;
49        };
50        while let Ok(Some(name_e)) = names.next_entry().await {
51            let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
52                continue;
53            };
54            while let Ok(Some(ver_e)) = versions.next_entry().await {
55                let manifest = ver_e.path().join("objectiveai.json");
56                if tokio::fs::metadata(&manifest)
57                    .await
58                    .map(|m| m.is_file())
59                    .unwrap_or(false)
60                {
61                    out.push(manifest);
62                }
63            }
64        }
65    }
66    out
67}
68
69impl Client {
70    /// The plugins directory: `<bin_dir>/plugins` — installed
71    /// plugins are machine-wide, shared by every state.
72    pub fn plugins_dir(&self) -> PathBuf {
73        self.bin_dir().join("plugins")
74    }
75
76    /// The directory that holds a plugin's installed artifacts:
77    /// `<plugins_dir>/<owner>/<name>/<version>/`. Contains the
78    /// manifest `objectiveai.json`, the `cli/` exec working
79    /// directory, and an optional `viewer/` bundle.
80    pub fn plugin_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
81        self.plugins_dir().join(owner).join(name).join(version)
82    }
83
84    /// A plugin's cli working directory: `<plugin_dir>/cli/`. The
85    /// manifest's exec runs with this as CWD; `cli_zip` extracts
86    /// into it at install time.
87    pub fn plugin_cli_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
88        self.plugin_dir(owner, name, version).join("cli")
89    }
90
91    /// Resolve a plugin coordinate to its `(exec_vector, cli_dir)`
92    /// for the current platform — the same contract
93    /// [`Client::resolve_tool`](crate::filesystem::Client::resolve_tool)
94    /// has, with the plugin's `cli/` folder as the working directory.
95    /// `exec_vector` may be empty when the manifest declares no
96    /// command for this platform (viewer-only plugins; the caller
97    /// treats that as an error). `None` when the manifest is
98    /// missing/malformed/invalid.
99    pub async fn resolve_plugin(
100        &self,
101        owner: &str,
102        name: &str,
103        version: &str,
104    ) -> Option<(Vec<String>, PathBuf)> {
105        let manifest = self.get_plugin(owner, name, version).await?;
106        let cli_dir = self.plugin_cli_dir(owner, name, version);
107        Some((
108            crate::filesystem::tools::platform_exec(&manifest.exec),
109            cli_dir,
110        ))
111    }
112
113    /// Look up a single plugin manifest by coordinate. Reads
114    /// `<base_dir>/plugins/<owner>/<name>/<version>/objectiveai.json`.
115    /// Returns `None` if the file is missing, unreadable, malformed, or
116    /// invalid.
117    pub async fn get_plugin(
118        &self,
119        owner: &str,
120        name: &str,
121        version: &str,
122    ) -> Option<Manifest> {
123        let path = self
124            .plugin_dir(owner, name, version)
125            .join("objectiveai.json");
126        parse_manifest_file(&path).await
127    }
128
129    /// Enumerate plugin manifests by walking the
130    /// `plugins/<owner>/<name>/<version>/objectiveai.json` tree. Every
131    /// failure mode — missing dir, unreadable file, malformed JSON,
132    /// missing required field — is silently skipped; the return type is
133    /// plain `Vec` rather than `Result` to reflect that.
134    ///
135    /// Results are sorted by manifest mtime descending (most recently
136    /// modified first), then `skip(offset).take(limit)` is applied —
137    /// matching the convention of the logs list endpoints. Pass
138    /// `(0, usize::MAX)` for an unbounded list.
139    ///
140    /// The directory walk is sequential but per-file read+parse runs
141    /// concurrently via [`futures::future::join_all`].
142    pub async fn list_plugins(
143        &self,
144        offset: usize,
145        limit: usize,
146    ) -> Vec<Manifest> {
147        let paths = collect_manifest_paths(self.plugins_dir()).await;
148        let futures = paths.into_iter().map(|p| async move {
149            let bundle = parse_manifest_file(&p).await?;
150            let modified = tokio::fs::metadata(&p)
151                .await
152                .ok()?
153                .modified()
154                .ok()?
155                .duration_since(std::time::SystemTime::UNIX_EPOCH)
156                .ok()?
157                .as_secs();
158            Some((modified, bundle))
159        });
160        let mut entries: Vec<(u64, Manifest)> =
161            futures::future::join_all(futures)
162                .await
163                .into_iter()
164                .flatten()
165                .collect();
166        entries.sort_by(|a, b| b.0.cmp(&a.0));
167        let iter = entries.into_iter().map(|(_, m)| m);
168        if offset > 0 || limit < usize::MAX {
169            iter.skip(offset).take(limit).collect()
170        } else {
171            iter.collect()
172        }
173    }
174}
175
176impl Client {
177    /// Install a plugin from a GitHub repository: fetch
178    /// `objectiveai.json`, download the current platform's `cli_zip`
179    /// plus the `viewer_zip` (when declared), extract them, and persist
180    /// the manifest verbatim. Thin wrapper over the shared
181    /// [`Client::install_from_github_at`] engine. `headers` attaches to
182    /// every request (e.g. `Authorization`); the cli passes `None`. The
183    /// `bool` is always `true` on success (retained for wire compat).
184    pub async fn install_plugin(
185        &self,
186        owner: &str,
187        repository: &str,
188        commit_sha: Option<&str>,
189        headers: Option<&indexmap::IndexMap<String, String>>,
190        upgrade: bool,
191    ) -> Result<bool, super::super::Error> {
192        self.install_from_github_at::<Manifest>(
193            "https://raw.githubusercontent.com",
194            "https://github.com",
195            owner,
196            repository,
197            commit_sha,
198            headers,
199            upgrade,
200        )
201        .await
202    }
203
204    /// Fetch + parse `<owner>/<repo>/<ref>/objectiveai.json`. Exposed so
205    /// callers can inspect the manifest before committing to an install
206    /// (e.g. for whitelist checks).
207    pub async fn fetch_plugin_manifest(
208        &self,
209        owner: &str,
210        repository: &str,
211        commit_sha: Option<&str>,
212        headers: Option<&indexmap::IndexMap<String, String>>,
213    ) -> Result<Manifest, super::super::Error> {
214        self.fetch_manifest_at::<Manifest>(
215            "https://raw.githubusercontent.com",
216            owner,
217            repository,
218            commit_sha,
219            headers,
220        )
221        .await
222    }
223
224    /// Given an already-parsed manifest, download its release assets and
225    /// persist it. `source` is retained for API compatibility and is
226    /// unused — the engine derives every path from the coordinate.
227    pub async fn install_plugin_from_manifest(
228        &self,
229        owner: &str,
230        repository: &str,
231        manifest: &Manifest,
232        source: &str,
233        headers: Option<&indexmap::IndexMap<String, String>>,
234        upgrade: bool,
235    ) -> Result<bool, super::super::Error> {
236        let _ = source;
237        // Public entry — callers may hand us a manifest with no fetch
238        // ever happening, so validate inputs here (cheap + idempotent).
239        validate_install_inputs(InstallKind::Plugin, owner, repository, None)?;
240        self.install_parsed_at::<Manifest>(
241            "https://github.com",
242            owner,
243            repository,
244            manifest,
245            headers,
246            upgrade,
247        )
248        .await
249    }
250
251    /// Test-only entry threading mock URL bases through the engine.
252    #[cfg(test)]
253    pub(super) async fn install_plugin_at(
254        &self,
255        raw_base: &str,
256        releases_base: &str,
257        owner: &str,
258        repository: &str,
259        commit_sha: Option<&str>,
260        headers: Option<&indexmap::IndexMap<String, String>>,
261        upgrade: bool,
262    ) -> Result<bool, super::super::Error> {
263        self.install_from_github_at::<Manifest>(
264            raw_base,
265            releases_base,
266            owner,
267            repository,
268            commit_sha,
269            headers,
270            upgrade,
271        )
272        .await
273    }
274
275    /// Test-only fetch-only entry, mirrors `install_plugin_at`.
276    #[cfg(test)]
277    pub(super) async fn fetch_plugin_manifest_at(
278        &self,
279        raw_base: &str,
280        owner: &str,
281        repository: &str,
282        commit_sha: Option<&str>,
283        headers: Option<&indexmap::IndexMap<String, String>>,
284    ) -> Result<Manifest, super::super::Error> {
285        self.fetch_manifest_at::<Manifest>(
286            raw_base, owner, repository, commit_sha, headers,
287        )
288        .await
289    }
290}
291
292/// Plugins install via the shared engine: cli bundle + a viewer bundle
293/// (when `viewer_zip` is declared), under the `plugins/` dir tree.
294impl InstallManifest for Manifest {
295    const KIND: InstallKind = InstallKind::Plugin;
296    fn validate_manifest(&self) -> Result<(), &'static str> {
297        self.validate()
298    }
299    fn version(&self) -> &str {
300        &self.version
301    }
302    fn cli_zip_for_platform(&self) -> Option<&str> {
303        platform_cli_zip(&self.cli_zip)
304    }
305    fn extra_assets(&self) -> Vec<ExtraAsset> {
306        match &self.viewer_zip {
307            Some(filename) => vec![ExtraAsset {
308                filename: filename.clone(),
309                subdir: "viewer",
310            }],
311            None => Vec::new(),
312        }
313    }
314}