Skip to main content

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