1use std::path::{Path, PathBuf};
15
16use super::super::Client;
17use super::{Manifest, ManifestWithNameAndSource};
18
19async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
25 let bytes = tokio::fs::read(path).await.ok()?;
26 let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
27 manifest.validate().ok()?;
28 let name = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
31 let source = path.to_string_lossy().into_owned();
32 Some(ManifestWithNameAndSource {
33 name,
34 manifest,
35 source,
36 })
37}
38
39async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
43 let mut out: Vec<PathBuf> = Vec::new();
44 let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
45 return out;
46 };
47 while let Ok(Some(owner_e)) = owners.next_entry().await {
48 let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
49 continue;
50 };
51 while let Ok(Some(name_e)) = names.next_entry().await {
52 let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
53 continue;
54 };
55 while let Ok(Some(ver_e)) = versions.next_entry().await {
56 let manifest = ver_e.path().join("objectiveai.json");
57 if tokio::fs::metadata(&manifest)
58 .await
59 .map(|m| m.is_file())
60 .unwrap_or(false)
61 {
62 out.push(manifest);
63 }
64 }
65 }
66 }
67 out
68}
69
70impl Client {
71 pub fn plugins_dir(&self) -> PathBuf {
74 self.bin_dir().join("plugins")
75 }
76
77 pub fn plugin_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
82 self.plugins_dir().join(owner).join(name).join(version)
83 }
84
85 pub fn plugin_cli_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
89 self.plugin_dir(owner, name, version).join("cli")
90 }
91
92 pub async fn resolve_plugin(
101 &self,
102 owner: &str,
103 name: &str,
104 version: &str,
105 ) -> Option<(Vec<String>, PathBuf)> {
106 let bundle = self.get_plugin(owner, name, version).await?;
107 let cli_dir = self.plugin_cli_dir(owner, name, version);
108 Some((
109 crate::filesystem::tools::platform_exec(&bundle.manifest.exec),
110 cli_dir,
111 ))
112 }
113
114 pub async fn get_plugin(
119 &self,
120 owner: &str,
121 name: &str,
122 version: &str,
123 ) -> Option<ManifestWithNameAndSource> {
124 let path = self
125 .plugin_dir(owner, name, version)
126 .join("objectiveai.json");
127 parse_manifest_file(&path).await
128 }
129
130 pub async fn list_plugins(
144 &self,
145 offset: usize,
146 limit: usize,
147 ) -> Vec<ManifestWithNameAndSource> {
148 let paths = collect_manifest_paths(self.plugins_dir()).await;
149 let futures = paths.into_iter().map(|p| async move {
150 let bundle = parse_manifest_file(&p).await?;
151 let modified = tokio::fs::metadata(&p)
152 .await
153 .ok()?
154 .modified()
155 .ok()?
156 .duration_since(std::time::SystemTime::UNIX_EPOCH)
157 .ok()?
158 .as_secs();
159 Some((modified, bundle))
160 });
161 let mut entries: Vec<(u64, ManifestWithNameAndSource)> =
162 futures::future::join_all(futures)
163 .await
164 .into_iter()
165 .flatten()
166 .collect();
167 entries.sort_by(|a, b| b.0.cmp(&a.0));
168 let iter = entries.into_iter().map(|(_, m)| m);
169 if offset > 0 || limit < usize::MAX {
170 iter.skip(offset).take(limit).collect()
171 } else {
172 iter.collect()
173 }
174 }
175}
176
177impl Client {
178 pub async fn install_plugin(
203 &self,
204 owner: &str,
205 repository: &str,
206 commit_sha: Option<&str>,
207 headers: Option<&indexmap::IndexMap<String, String>>,
208 upgrade: bool,
209 ) -> Result<bool, super::super::Error> {
210 validate_install_inputs(owner, repository, commit_sha)?;
211 let manifest = self
212 .fetch_plugin_manifest(owner, repository, commit_sha, headers)
213 .await?;
214 let source = raw_manifest_url(owner, repository, commit_sha);
215 self.install_plugin_from_manifest(
216 owner, repository, &manifest, &source, headers, upgrade,
217 )
218 .await
219 }
220
221 pub async fn fetch_plugin_manifest(
226 &self,
227 owner: &str,
228 repository: &str,
229 commit_sha: Option<&str>,
230 headers: Option<&indexmap::IndexMap<String, String>>,
231 ) -> Result<Manifest, super::super::Error> {
232 self.fetch_plugin_manifest_impl(
233 "https://raw.githubusercontent.com",
234 owner,
235 repository,
236 commit_sha,
237 headers,
238 )
239 .await
240 }
241
242 pub async fn install_plugin_from_manifest(
246 &self,
247 owner: &str,
248 repository: &str,
249 manifest: &Manifest,
250 source: &str,
251 headers: Option<&indexmap::IndexMap<String, String>>,
252 upgrade: bool,
253 ) -> Result<bool, super::super::Error> {
254 validate_install_inputs(owner, repository, None)?;
259 self.install_from_manifest_impl(
260 "https://github.com",
261 owner,
262 repository,
263 manifest,
264 source,
265 headers,
266 upgrade,
267 )
268 .await
269 }
270
271 #[cfg(test)]
276 pub(super) async fn install_plugin_at(
277 &self,
278 raw_base: &str,
279 releases_base: &str,
280 owner: &str,
281 repository: &str,
282 commit_sha: Option<&str>,
283 headers: Option<&indexmap::IndexMap<String, String>>,
284 upgrade: bool,
285 ) -> Result<bool, super::super::Error> {
286 validate_install_inputs(owner, repository, commit_sha)?;
287 let manifest = self
288 .fetch_plugin_manifest_impl(
289 raw_base, owner, repository, commit_sha, headers,
290 )
291 .await?;
292 let reference = commit_sha.unwrap_or("HEAD");
293 let source = format!(
294 "{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
295 );
296 self.install_from_manifest_impl(
297 releases_base,
298 owner,
299 repository,
300 &manifest,
301 &source,
302 headers,
303 upgrade,
304 )
305 .await
306 }
307
308 #[cfg(test)]
310 pub(super) async fn fetch_plugin_manifest_at(
311 &self,
312 raw_base: &str,
313 owner: &str,
314 repository: &str,
315 commit_sha: Option<&str>,
316 headers: Option<&indexmap::IndexMap<String, String>>,
317 ) -> Result<Manifest, super::super::Error> {
318 self.fetch_plugin_manifest_impl(
319 raw_base, owner, repository, commit_sha, headers,
320 )
321 .await
322 }
323
324 async fn fetch_plugin_manifest_impl(
325 &self,
326 raw_base: &str,
327 owner: &str,
328 repository: &str,
329 commit_sha: Option<&str>,
330 headers: Option<&indexmap::IndexMap<String, String>>,
331 ) -> Result<Manifest, super::super::Error> {
332 let http = reqwest::Client::new();
333 let header_map = build_headers(headers)?;
334 let reference = commit_sha.unwrap_or("HEAD");
335 let manifest_url = format!(
336 "{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
337 );
338 let resp = http
339 .get(&manifest_url)
340 .headers(header_map)
341 .send()
342 .await
343 .map_err(super::InstallError::ManifestRequest)?;
344 let status = resp.status();
345 let bytes = resp
346 .bytes()
347 .await
348 .map_err(super::InstallError::ManifestResponse)?;
349 if !status.is_success() {
350 return Err(super::InstallError::ManifestBadStatus {
351 code: status,
352 url: manifest_url,
353 body: String::from_utf8_lossy(&bytes).into_owned(),
354 }
355 .into());
356 }
357 let mut de = serde_json::Deserializer::from_slice(&bytes);
358 let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
359 .map_err(super::InstallError::ManifestParse)?;
360 manifest
361 .validate()
362 .map_err(super::InstallError::ManifestInvalid)?;
363 Ok(manifest)
364 }
365
366 async fn install_from_manifest_impl(
367 &self,
368 releases_base: &str,
369 owner: &str,
370 repository: &str,
371 manifest: &Manifest,
372 _source: &str,
373 headers: Option<&indexmap::IndexMap<String, String>>,
374 upgrade: bool,
375 ) -> Result<bool, super::super::Error> {
376 let tool_name = manifest.tool_name(repository);
381 if tool_name.len() > 100 {
382 return Err(super::InstallError::ToolNameTooLong {
383 len: tool_name.len(),
384 tool_name,
385 }
386 .into());
387 }
388
389 let version = manifest.version.clone();
390 let plugin_dir = self.plugin_dir(owner, repository, &version);
391 let cli_dir = self.plugin_cli_dir(owner, repository, &version);
392 let viewer_dir = plugin_dir.join("viewer");
393 let manifest_path = plugin_dir.join("objectiveai.json");
394
395 let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
398 if manifest_exists && !upgrade {
399 return Err(super::InstallError::AlreadyInstalled {
400 repository: repository.to_string(),
401 }
402 .into());
403 }
404
405 let http = reqwest::Client::new();
410 let cli_zip_bytes: Option<Vec<u8>> = if let Some(cli_zip_name) =
411 &manifest.cli_zip
412 {
413 let cli_url = format!(
414 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{cli_zip_name}",
415 version = manifest.version,
416 );
417 let resp = http
418 .get(&cli_url)
419 .headers(build_headers(headers)?)
420 .send()
421 .await
422 .map_err(super::InstallError::CliZipRequest)?;
423 let status = resp.status();
424 if !status.is_success() {
425 return Err(super::InstallError::CliZipBadStatus {
426 code: status,
427 url: cli_url,
428 }
429 .into());
430 }
431 Some(
432 resp.bytes()
433 .await
434 .map_err(super::InstallError::CliZipResponse)?
435 .to_vec(),
436 )
437 } else {
438 None
439 };
440
441 let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) =
442 &manifest.viewer_zip
443 {
444 let viewer_url = format!(
445 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
446 version = manifest.version,
447 );
448 let resp = http
449 .get(&viewer_url)
450 .headers(build_headers(headers)?)
451 .send()
452 .await
453 .map_err(super::InstallError::ViewerZipRequest)?;
454 let status = resp.status();
455 if !status.is_success() {
456 return Err(super::InstallError::ViewerZipBadStatus {
457 code: status,
458 url: viewer_url,
459 }
460 .into());
461 }
462 Some(
463 resp.bytes()
464 .await
465 .map_err(super::InstallError::ViewerZipResponse)?
466 .to_vec(),
467 )
468 } else {
469 None
470 };
471
472 let manifest_bytes: Vec<u8> = {
473 let mut manifest = manifest.clone();
479 manifest.owner = owner.to_string();
480 serde_json::to_vec_pretty(&manifest)
481 .map_err(super::InstallError::ManifestSerialize)?
482 };
483
484 let _ = tokio::fs::remove_file(&manifest_path).await;
495 let _ = tokio::fs::remove_dir_all(&cli_dir).await;
496 let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
497 tokio::fs::create_dir_all(&plugin_dir).await.map_err(|e| {
498 super::InstallError::PluginDirCreate(plugin_dir.clone(), e)
499 })?;
500
501 tokio::try_join!(
504 write_zip_branch(cli_dir, cli_zip_bytes),
505 write_zip_branch(viewer_dir, zip_bytes),
506 )?;
507
508 write_manifest_branch(manifest_path, manifest_bytes).await?;
511
512 Ok(true)
513 }
514}
515
516async fn write_zip_branch(
520 dir: PathBuf,
521 zip_bytes: Option<Vec<u8>>,
522) -> Result<(), super::InstallError> {
523 let Some(bytes) = zip_bytes else {
524 return Ok(());
525 };
526 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
527 super::InstallError::ZipExtract(dir.clone(), e.to_string())
528 })?;
529 let dir_for_blocking = dir.clone();
530 tokio::task::spawn_blocking(move || {
531 let cursor = std::io::Cursor::new(bytes);
532 let mut archive = zip::ZipArchive::new(cursor)
533 .map_err(|e| format!("zip archive open: {e}"))?;
534 archive
535 .extract(&dir_for_blocking)
536 .map_err(|e| format!("extract: {e}"))
537 })
538 .await
539 .map_err(|e| super::InstallError::ZipExtract(dir.clone(), format!("join: {e}")))?
540 .map_err(|e| super::InstallError::ZipExtract(dir.clone(), e))?;
541 Ok(())
542}
543
544async fn write_manifest_branch(
545 manifest_path: PathBuf,
546 bytes: Vec<u8>,
547) -> Result<(), super::InstallError> {
548 crate::filesystem::util::write_atomic(&manifest_path, &bytes)
551 .await
552 .map_err(|e| {
553 super::InstallError::ManifestPersist(manifest_path.clone(), e)
554 })
555}
556
557fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
562 if repository.eq_ignore_ascii_case("objectiveai") {
563 return Err(super::InstallError::ReservedRepositoryName {
564 repository: repository.to_string(),
565 });
566 }
567 Ok(())
568}
569
570fn validate_identifier(
576 kind: &'static str,
577 value: &str,
578) -> Result<(), super::InstallError> {
579 let valid_len = !value.is_empty() && value.len() <= 128;
580 let valid_chars = value
581 .chars()
582 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
583 if !valid_len || !valid_chars {
584 return Err(super::InstallError::InvalidIdentifier {
585 kind,
586 value: value.to_string(),
587 });
588 }
589 Ok(())
590}
591
592fn validate_install_inputs(
599 owner: &str,
600 repository: &str,
601 commit_sha: Option<&str>,
602) -> Result<(), super::InstallError> {
603 check_repository_name(repository)?;
604 validate_identifier("owner", owner)?;
605 validate_identifier("repository", repository)?;
606 if let Some(sha) = commit_sha {
607 validate_identifier("commit", sha)?;
608 }
609 Ok(())
610}
611
612pub fn raw_manifest_url(
617 owner: &str,
618 repository: &str,
619 commit_sha: Option<&str>,
620) -> String {
621 let reference = commit_sha.unwrap_or("HEAD");
622 format!(
623 "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
624 )
625}
626
627pub(super) fn build_headers(
628 headers: Option<&indexmap::IndexMap<String, String>>,
629) -> Result<reqwest::header::HeaderMap, super::InstallError> {
630 let mut out = reqwest::header::HeaderMap::new();
631 let Some(h) = headers else {
632 return Ok(out);
633 };
634 for (k, v) in h {
635 let name = reqwest::header::HeaderName::from_bytes(k.as_bytes())
636 .map_err(|e| super::InstallError::InvalidHeaderName {
637 name: k.clone(),
638 reason: e.to_string(),
639 })?;
640 let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
641 super::InstallError::InvalidHeaderValue {
642 name: k.clone(),
643 reason: e.to_string(),
644 }
645 })?;
646 out.insert(name, value);
647 }
648 Ok(out)
649}