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 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}