nemo-relay-cli 0.6.0-rc.2

Coding-agent gateway CLI for NeMo Relay observability.
Documentation
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Install directory layout, persisted state, and filesystem helpers.

use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::{Value, json};

use crate::installation::generation::GENERATION_FILE_NAME;

use super::{MarketplaceHost, PLUGIN_NAME};

#[derive(Debug, Clone)]
pub(crate) struct PluginInstallOptions {
    pub(crate) install_dir: PathBuf,
    pub(crate) operation_lock_dir: PathBuf,
    pub(crate) force: bool,
    pub(crate) dry_run: bool,
    pub(crate) skip_doctor: bool,
}

#[derive(Debug, Clone, Copy, Default)]
pub(super) struct HostRegistrationProgress {
    pub(super) host_plugin_added: bool,
    pub(super) host_marketplace_added: bool,
}

impl HostRegistrationProgress {
    pub(super) fn any_added(self) -> bool {
        self.host_plugin_added || self.host_marketplace_added
    }
}

#[derive(Debug, Clone)]
pub(super) struct PluginLayout {
    pub(super) host_arg: &'static str,
    pub(super) host_label: &'static str,
    pub(super) marketplace_root: PathBuf,
    pub(super) marketplace_manifest: PathBuf,
    pub(super) plugin_root: PathBuf,
    pub(super) plugin_manifest: PathBuf,
    pub(super) mcp_config: PathBuf,
    pub(super) generation_fence: PathBuf,
    pub(super) generation_lock: PathBuf,
    pub(super) hooks_path: PathBuf,
    pub(super) state_path: PathBuf,
}

impl PluginLayout {
    pub(super) fn new(host: impl MarketplaceHost, install_dir: &Path) -> Self {
        let marketplace_root = install_dir.join(format!("{}-marketplace", host.install_arg()));
        let marketplace_manifest = host
            .marketplace_manifest_relative()
            .iter()
            .fold(marketplace_root.clone(), |path, component| {
                path.join(component)
            });
        let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME);
        let plugin_manifest = host
            .plugin_manifest_relative()
            .iter()
            .fold(plugin_root.clone(), |path, component| path.join(component));
        let mcp_config = plugin_root.join(".mcp.json");
        let generation_fence = plugin_root.join(GENERATION_FILE_NAME);
        let generation_lock = install_dir.join(format!(
            ".nemo-relay-{}-mcp-generation.lock",
            host.install_arg()
        ));
        let hooks_path = plugin_root.join("hooks").join("hooks.json");
        let state_path = state_path(host, install_dir);
        Self {
            host_arg: host.install_arg(),
            host_label: host.label(),
            marketplace_root,
            marketplace_manifest,
            plugin_root,
            plugin_manifest,
            mcp_config,
            generation_fence,
            generation_lock,
            hooks_path,
            state_path,
        }
    }

    pub(super) fn validate_persisted_state(&self, state: &PluginState) -> Result<(), String> {
        if !same_selected_path(&state.marketplace_root, &self.marketplace_root)
            || !same_selected_path(&state.plugin_root, &self.plugin_root)
        {
            return Err(format!(
                "refusing persisted {} plugin state outside the selected install layout {}",
                self.host_label,
                self.state_path.display()
            ));
        }
        if self.marketplace_root.exists() {
            let install_dir = self.state_path.parent().ok_or_else(|| {
                format!(
                    "plugin state path {} has no parent",
                    self.state_path.display()
                )
            })?;
            let canonical_install = install_dir.canonicalize().map_err(|error| {
                format!(
                    "failed to validate install directory {}: {error}",
                    install_dir.display()
                )
            })?;
            let canonical_marketplace = self.marketplace_root.canonicalize().map_err(|error| {
                format!(
                    "failed to validate marketplace root {}: {error}",
                    self.marketplace_root.display()
                )
            })?;
            if !canonical_marketplace.starts_with(&canonical_install) {
                return Err(format!(
                    "refusing persisted {} marketplace root outside the selected install directory",
                    self.host_label
                ));
            }
        }
        Ok(())
    }
}

fn same_selected_path(persisted: &Path, selected: &Path) -> bool {
    persisted == selected
        || persisted
            .canonicalize()
            .ok()
            .zip(selected.canonicalize().ok())
            .is_some_and(|(persisted, selected)| persisted == selected)
}

#[derive(Debug, Clone)]
pub(super) struct PluginState {
    pub(super) marketplace_root: PathBuf,
    pub(super) plugin_root: PathBuf,
    pub(super) host_plugin_removed: bool,
    pub(super) host_marketplace_removed: bool,
    pub(super) plugin_setup_installed: bool,
}

pub(super) trait CanonicalizeOrSelf {
    fn canonicalize_or_self(self) -> Self;
}

impl CanonicalizeOrSelf for PathBuf {
    fn canonicalize_or_self(self) -> Self {
        self.canonicalize().unwrap_or(self)
    }
}

pub(super) fn default_install_dir() -> PathBuf {
    default_install_dir_for(
        env::consts::OS,
        env::var_os("HOME"),
        env::var_os("USERPROFILE"),
        env::var_os("LOCALAPPDATA"),
        env::var_os("XDG_DATA_HOME"),
    )
}

pub(super) fn default_install_dir_for(
    os: &str,
    home: Option<OsString>,
    userprofile: Option<OsString>,
    localappdata: Option<OsString>,
    xdg_data_home: Option<OsString>,
) -> PathBuf {
    let home = home
        .or(userprofile)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    match os {
        "macos" => home
            .join("Library")
            .join("Application Support")
            .join("nemo-relay")
            .join("plugins"),
        "windows" => localappdata
            .map(PathBuf::from)
            .unwrap_or_else(|| home.join("AppData").join("Local"))
            .join("nemo-relay")
            .join("plugins"),
        _ => xdg_data_home
            .map(PathBuf::from)
            .unwrap_or_else(|| home.join(".local").join("share"))
            .join("nemo-relay")
            .join("plugins"),
    }
}

pub(super) fn write_state(
    layout: &PluginLayout,
    options: &PluginInstallOptions,
) -> Result<(), String> {
    write_state_for_host_arg(
        layout.host_arg,
        &PluginState {
            marketplace_root: layout.marketplace_root.clone(),
            plugin_root: layout.plugin_root.clone(),
            host_plugin_removed: false,
            host_marketplace_removed: false,
            plugin_setup_installed: false,
        },
        layout
            .state_path
            .parent()
            .expect("state_path should have a parent directory"),
        options,
    )
}

pub(super) fn mark_plugin_setup_installed(
    host: impl MarketplaceHost,
    layout: &PluginLayout,
    options: &PluginInstallOptions,
) -> Result<(), String> {
    let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState {
        marketplace_root: layout.marketplace_root.clone(),
        plugin_root: layout.plugin_root.clone(),
        host_plugin_removed: false,
        host_marketplace_removed: false,
        plugin_setup_installed: false,
    });
    state.plugin_setup_installed = true;
    write_state_for_host(host, &state, &options.install_dir, options)
}

pub(super) fn write_state_for_host(
    host: impl MarketplaceHost,
    state: &PluginState,
    install_dir: &Path,
    options: &PluginInstallOptions,
) -> Result<(), String> {
    write_state_for_host_arg(host.install_arg(), state, install_dir, options)
}

fn write_state_for_host_arg(
    host_arg: &str,
    state: &PluginState,
    install_dir: &Path,
    options: &PluginInstallOptions,
) -> Result<(), String> {
    let path = state_path_for_arg(host_arg, install_dir);
    if options.dry_run {
        println!("write {}", path.display());
        return Ok(());
    }
    write_json(
        &path,
        &json!({
            "host": host_arg,
            "marketplaceRoot": state.marketplace_root,
            "pluginRoot": state.plugin_root,
            "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed,
            "hostPluginRemoved": state.host_plugin_removed,
            "hostMarketplaceRemoved": state.host_marketplace_removed,
            "pluginSetupInstalled": state.plugin_setup_installed
        }),
    )
}

pub(super) fn read_state(host: impl MarketplaceHost, install_dir: &Path) -> Option<PluginState> {
    let raw = fs::read_to_string(state_path(host, install_dir)).ok()?;
    let value = serde_json::from_str::<Value>(&raw).ok()?;
    let legacy_host_unregistered = value
        .get("hostUnregistered")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    Some(PluginState {
        marketplace_root: PathBuf::from(value.get("marketplaceRoot")?.as_str()?),
        plugin_root: PathBuf::from(value.get("pluginRoot")?.as_str()?),
        host_plugin_removed: value
            .get("hostPluginRemoved")
            .and_then(Value::as_bool)
            .unwrap_or(legacy_host_unregistered),
        host_marketplace_removed: value
            .get("hostMarketplaceRemoved")
            .and_then(Value::as_bool)
            .unwrap_or(legacy_host_unregistered),
        plugin_setup_installed: value
            .get("pluginSetupInstalled")
            .and_then(Value::as_bool)
            .unwrap_or(true),
    })
}

pub(super) fn state_path(host: impl MarketplaceHost, install_dir: &Path) -> PathBuf {
    state_path_for_arg(host.install_arg(), install_dir)
}

fn state_path_for_arg(host_arg: &str, install_dir: &Path) -> PathBuf {
    install_dir.join(format!("{host_arg}.json"))
}

pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
    }
    let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?;
    bytes.push(b'\n');
    crate::filesystem::atomic_write(path, &bytes)
}

pub(super) fn remove_path(path: &Path, options: &PluginInstallOptions) -> Result<(), String> {
    if options.dry_run {
        println!("remove {}", path.display());
        return Ok(());
    }
    fs::remove_dir_all(path)
        .or_else(|error| {
            if error.kind() == std::io::ErrorKind::NotFound {
                Ok(())
            } else {
                Err(error)
            }
        })
        .or_else(|_| fs::remove_file(path))
        .or_else(|error| {
            if error.kind() == std::io::ErrorKind::NotFound {
                Ok(())
            } else {
                Err(format!("failed to remove {}: {error}", path.display()))
            }
        })
}