objectiveai_cli/filesystem/tools/client.rs
1//! Tool discovery on the local filesystem.
2//!
3//! Tools live at `<bin_dir>/tools/<owner>/<name>/<version>/` (machine-
4//! wide, shared by every state) with the manifest as `objectiveai.json`
5//! inside the version folder and the tool's executable payload in the
6//! nested `cli/` subfolder — the same `<dir>/cli/` model plugins use.
7//! The manifest's `exec` is a per-OS command vector; at run time the
8//! current platform's vector is appended with the caller's args and
9//! invoked with CWD = `<version folder>/cli/`. Relative program paths
10//! resolve against that `cli/` folder; bare names keep PATH-lookup
11//! semantics.
12
13use std::path::{Path, PathBuf};
14
15use super::super::Client;
16use super::{Exec, Manifest};
17
18/// Parse an on-disk `objectiveai.json` into a [`Manifest`]. The
19/// manifest declares its own `owner` / `name` / `version`; nothing is
20/// derived from the path. `None` on missing / unreadable / malformed
21/// files.
22async fn parse_manifest_file(path: &Path) -> Option<Manifest> {
23 let bytes = tokio::fs::read(path).await.ok()?;
24 serde_json::from_slice(&bytes).ok()
25}
26
27/// The current platform's exec vector from a per-OS [`Exec`]. Shared
28/// with the plugins tier, whose manifests carry the same `Exec` type.
29pub(crate) fn platform_exec(exec: &Exec) -> Vec<String> {
30 if cfg!(target_os = "windows") {
31 exec.windows.clone()
32 } else if cfg!(target_os = "macos") {
33 exec.macos.clone()
34 } else {
35 exec.linux.clone()
36 }
37}
38
39impl Client {
40 /// The tools directory: `<bin_dir>/tools`.
41 pub fn tools_dir(&self) -> PathBuf {
42 self.bin_dir().join("tools")
43 }
44
45 /// A tool's version directory:
46 /// `<base_dir>/tools/<owner>/<name>/<version>`. Holds the
47 /// `objectiveai.json` manifest; the executable payload lives in the
48 /// [`Self::tool_cli_dir`] subfolder.
49 pub fn tool_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
50 self.tools_dir().join(owner).join(name).join(version)
51 }
52
53 /// A tool's cli working directory: `<tool_dir>/cli/`. The exec runs
54 /// here and relative program paths resolve against it; the manifest
55 /// `objectiveai.json` stays in the parent version folder. Mirrors
56 /// [`Client::plugin_cli_dir`].
57 pub fn tool_cli_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
58 self.tool_dir(owner, name, version).join("cli")
59 }
60
61 /// Resolve a tool coordinate to its `(exec_vector, cwd)` for the
62 /// current platform. `cwd` is the version folder's `cli/` subdir
63 /// (the exec working directory); `exec_vector` is the manifest's
64 /// per-OS command (possibly empty when the tool declares no command
65 /// for this platform — the caller treats that as an error). `None`
66 /// when the manifest is missing/malformed.
67 pub async fn resolve_tool(
68 &self,
69 owner: &str,
70 name: &str,
71 version: &str,
72 ) -> Option<(Vec<String>, PathBuf)> {
73 let manifest = self.get_tool(owner, name, version).await?;
74 let cli_dir = self.tool_cli_dir(owner, name, version);
75 Some((platform_exec(&manifest.exec), cli_dir))
76 }
77
78 /// Look up a single tool manifest by coordinate. Reads
79 /// `<base_dir>/tools/<owner>/<name>/<version>/objectiveai.json`.
80 /// `None` on missing / unreadable / malformed files.
81 pub async fn get_tool(
82 &self,
83 owner: &str,
84 name: &str,
85 version: &str,
86 ) -> Option<Manifest> {
87 let path = self.tool_dir(owner, name, version).join("objectiveai.json");
88 parse_manifest_file(&path).await
89 }
90
91 /// Enumerate tool manifests by walking the
92 /// `tools/<owner>/<name>/<version>/objectiveai.json` tree. Every
93 /// failure mode — missing dir, unreadable file, malformed JSON — is
94 /// silently skipped.
95 ///
96 /// Results are sorted by manifest mtime descending, then
97 /// `skip(offset).take(limit)` is applied — matching `list_plugins`.
98 /// Pass `(0, usize::MAX)` for an unbounded list.
99 pub async fn list_tools(
100 &self,
101 offset: usize,
102 limit: usize,
103 ) -> Vec<Manifest> {
104 let paths = collect_manifest_paths(self.tools_dir()).await;
105 let futures = paths.into_iter().map(|p| async move {
106 let bundle = parse_manifest_file(&p).await?;
107 let modified = tokio::fs::metadata(&p)
108 .await
109 .ok()?
110 .modified()
111 .ok()?
112 .duration_since(std::time::SystemTime::UNIX_EPOCH)
113 .ok()?
114 .as_secs();
115 Some((modified, bundle))
116 });
117 let mut entries: Vec<(u64, Manifest)> =
118 futures::future::join_all(futures)
119 .await
120 .into_iter()
121 .flatten()
122 .collect();
123 entries.sort_by(|a, b| b.0.cmp(&a.0));
124 let iter = entries.into_iter().map(|(_, m)| m);
125 if offset > 0 || limit < usize::MAX {
126 iter.skip(offset).take(limit).collect()
127 } else {
128 iter.collect()
129 }
130 }
131}
132
133/// Walk `<root>/<owner>/<name>/<version>/objectiveai.json` and collect
134/// every existing manifest file path. Shared shape with the plugins
135/// tier. Any non-directory / unreadable level is skipped.
136pub(crate) async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
137 let mut out: Vec<PathBuf> = Vec::new();
138 let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
139 return out;
140 };
141 while let Ok(Some(owner_e)) = owners.next_entry().await {
142 let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
143 continue;
144 };
145 while let Ok(Some(name_e)) = names.next_entry().await {
146 let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
147 continue;
148 };
149 while let Ok(Some(ver_e)) = versions.next_entry().await {
150 let manifest = ver_e.path().join("objectiveai.json");
151 if tokio::fs::metadata(&manifest)
152 .await
153 .map(|m| m.is_file())
154 .unwrap_or(false)
155 {
156 out.push(manifest);
157 }
158 }
159 }
160 }
161 out
162}