objectiveai-cli 2.2.1

ObjectiveAI command-line interface and embeddable library
Documentation
//! Tool discovery on the local filesystem.
//!
//! Tools live at `<bin_dir>/tools/<owner>/<name>/<version>/` (machine-
//! wide, shared by every state) with the manifest as `objectiveai.json`
//! inside the version folder and the tool's executable payload in the
//! nested `cli/` subfolder — the same `<dir>/cli/` model plugins use.
//! The manifest's `exec` is a per-OS command vector; at run time the
//! current platform's vector is appended with the caller's args and
//! invoked with CWD = `<version folder>/cli/`. Relative program paths
//! resolve against that `cli/` folder; bare names keep PATH-lookup
//! semantics.

use std::path::{Path, PathBuf};

use super::super::Client;
use super::{Exec, Manifest};

/// Parse an on-disk `objectiveai.json` into a [`Manifest`]. The
/// manifest declares its own `owner` / `name` / `version`; nothing is
/// derived from the path. `None` on missing / unreadable / malformed
/// files.
async fn parse_manifest_file(path: &Path) -> Option<Manifest> {
    let bytes = tokio::fs::read(path).await.ok()?;
    serde_json::from_slice(&bytes).ok()
}

/// The current platform's exec vector from a per-OS [`Exec`]. Shared
/// with the plugins tier, whose manifests carry the same `Exec` type.
pub(crate) fn platform_exec(exec: &Exec) -> Vec<String> {
    if cfg!(target_os = "windows") {
        exec.windows.clone()
    } else if cfg!(target_os = "macos") {
        exec.macos.clone()
    } else {
        exec.linux.clone()
    }
}

impl Client {
    /// The tools directory: `<bin_dir>/tools`.
    pub fn tools_dir(&self) -> PathBuf {
        self.bin_dir().join("tools")
    }

    /// A tool's version directory:
    /// `<base_dir>/tools/<owner>/<name>/<version>`. Holds the
    /// `objectiveai.json` manifest; the executable payload lives in the
    /// [`Self::tool_cli_dir`] subfolder.
    pub fn tool_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
        self.tools_dir().join(owner).join(name).join(version)
    }

    /// A tool's cli working directory: `<tool_dir>/cli/`. The exec runs
    /// here and relative program paths resolve against it; the manifest
    /// `objectiveai.json` stays in the parent version folder. Mirrors
    /// [`Client::plugin_cli_dir`].
    pub fn tool_cli_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
        self.tool_dir(owner, name, version).join("cli")
    }

    /// Resolve a tool coordinate to its `(exec_vector, cwd)` for the
    /// current platform. `cwd` is the version folder's `cli/` subdir
    /// (the exec working directory); `exec_vector` is the manifest's
    /// per-OS command (possibly empty when the tool declares no command
    /// for this platform — the caller treats that as an error). `None`
    /// when the manifest is missing/malformed.
    pub async fn resolve_tool(
        &self,
        owner: &str,
        name: &str,
        version: &str,
    ) -> Option<(Vec<String>, PathBuf)> {
        let manifest = self.get_tool(owner, name, version).await?;
        let cli_dir = self.tool_cli_dir(owner, name, version);
        Some((platform_exec(&manifest.exec), cli_dir))
    }

    /// Look up a single tool manifest by coordinate. Reads
    /// `<base_dir>/tools/<owner>/<name>/<version>/objectiveai.json`.
    /// `None` on missing / unreadable / malformed files.
    pub async fn get_tool(
        &self,
        owner: &str,
        name: &str,
        version: &str,
    ) -> Option<Manifest> {
        let path = self.tool_dir(owner, name, version).join("objectiveai.json");
        parse_manifest_file(&path).await
    }

    /// Enumerate tool manifests by walking the
    /// `tools/<owner>/<name>/<version>/objectiveai.json` tree. Every
    /// failure mode — missing dir, unreadable file, malformed JSON — is
    /// silently skipped.
    ///
    /// Results are sorted by manifest mtime descending, then
    /// `skip(offset).take(limit)` is applied — matching `list_plugins`.
    /// Pass `(0, usize::MAX)` for an unbounded list.
    pub async fn list_tools(
        &self,
        offset: usize,
        limit: usize,
    ) -> Vec<Manifest> {
        let paths = collect_manifest_paths(self.tools_dir()).await;
        let futures = paths.into_iter().map(|p| async move {
            let bundle = parse_manifest_file(&p).await?;
            let modified = tokio::fs::metadata(&p)
                .await
                .ok()?
                .modified()
                .ok()?
                .duration_since(std::time::SystemTime::UNIX_EPOCH)
                .ok()?
                .as_secs();
            Some((modified, bundle))
        });
        let mut entries: Vec<(u64, Manifest)> =
            futures::future::join_all(futures)
                .await
                .into_iter()
                .flatten()
                .collect();
        entries.sort_by(|a, b| b.0.cmp(&a.0));
        let iter = entries.into_iter().map(|(_, m)| m);
        if offset > 0 || limit < usize::MAX {
            iter.skip(offset).take(limit).collect()
        } else {
            iter.collect()
        }
    }
}

/// Walk `<root>/<owner>/<name>/<version>/objectiveai.json` and collect
/// every existing manifest file path. Shared shape with the plugins
/// tier. Any non-directory / unreadable level is skipped.
pub(crate) async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
        return out;
    };
    while let Ok(Some(owner_e)) = owners.next_entry().await {
        let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
            continue;
        };
        while let Ok(Some(name_e)) = names.next_entry().await {
            let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
                continue;
            };
            while let Ok(Some(ver_e)) = versions.next_entry().await {
                let manifest = ver_e.path().join("objectiveai.json");
                if tokio::fs::metadata(&manifest)
                    .await
                    .map(|m| m.is_file())
                    .unwrap_or(false)
                {
                    out.push(manifest);
                }
            }
        }
    }
    out
}