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}