use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context as _;
use argonauts_adapter_acp::acp_adapter;
use argonauts_adapter_manifest::manifest_adapter_with;
use argonauts_conductor::plan_usage::find_unsandboxed;
use argonauts_core::adapter::Transport;
use argonauts_core::layout::user_files;
use argonauts_core::paths::{create_private_dir_all, write_private_file};
use argonauts_core::registry::{CliManifest, ModelRegistry};
use argonauts_core::settings::Settings;
use argonauts_core::{Adapter, Paths, project_config_path};
use argonauts_registry::clis::{builtin_home_template, check_manifest};
use argonauts_registry::models::discovery::ClaudeSource;
use argonauts_registry::models::{
AskOpenCode, CatalogSource, FileRegistry, ProjectModels, project_models_path,
registry_with_trust,
};
use crate::trust::TrustStore;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RepoModels {
root: PathBuf,
state_dir: PathBuf,
}
impl RepoModels {
pub(crate) fn new(root: &Path, state_dir: &Path) -> Self {
Self {
root: root.to_owned(),
state_dir: state_dir.to_owned(),
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn trusted(&self) -> bool {
TrustStore::load(&self.state_dir).was_trusted(&project_config_path(&self.root))
}
pub(crate) fn trusted_hashes(&self) -> Vec<String> {
TrustStore::load(&self.state_dir)
.recorded(&project_models_path(&self.root))
.into_iter()
.collect()
}
fn read(&self) -> Option<ProjectModels> {
match ProjectModels::read(&project_models_path(&self.root), &self.trusted_hashes()) {
Ok(project) => Some(project),
Err(err) => {
tracing::warn!("{err}");
None
}
}
}
}
pub(crate) fn registry(
settings: &Settings,
repo: Option<&RepoModels>,
cache: Option<&Path>,
) -> Arc<dyn ModelRegistry> {
file_registry(settings, repo, cache)
}
pub(crate) fn file_registry(
settings: &Settings,
repo: Option<&RepoModels>,
cache: Option<&Path>,
) -> Arc<FileRegistry> {
let trusted = repo.map(RepoModels::trusted_hashes).unwrap_or_default();
let claude = cache.map(|_| ClaudeSource::for_settings(settings));
let catalog = cache.map(|_| CatalogSource::argonauts());
let opencode = AskOpenCode {
confine: crate::confine::read_only(settings, repo),
in_repo: repo.is_some_and(RepoModels::trusted),
};
registry_with_trust(
settings,
repo.map(RepoModels::root),
&trusted,
cache,
claude,
catalog,
Some(opencode),
)
}
pub(crate) fn manifests(
settings: &Settings,
home: &Paths,
repo: Option<&RepoModels>,
turned_on: &[String],
) -> Vec<CliManifest> {
let project = repo.and_then(RepoModels::read);
let repo_root = repo.map(RepoModels::root);
let entries = argonauts_registry::cli_manifests(
&home.global_models(),
project.as_ref().map(ProjectModels::path),
project.as_ref().is_some_and(ProjectModels::trusted),
repo_root,
turned_on,
);
for warning in &entries.warnings {
tracing::warn!("{warning}");
}
entries
.manifests
.into_iter()
.filter_map(|manifest| with_binary(settings, manifest, repo_root))
.collect()
}
fn with_binary(
settings: &Settings,
mut manifest: CliManifest,
repo_root: Option<&Path>,
) -> Option<CliManifest> {
let Some(binary) = settings
.agents
.get(&manifest.id)
.and_then(|agent| agent.binary.as_ref())
else {
if !manifest.binary.contains('/')
&& let Ok(found) = find_unsandboxed(&manifest.binary, repo_root)
{
manifest.binary = found.display().to_string();
}
return Some(manifest);
};
manifest.binary = binary.display().to_string();
let check = check_manifest(&manifest, repo_root);
if !check.is_ok() {
tracing::warn!(
"agents.{}.binary: CLI {} is left out: {}",
manifest.id,
manifest.id,
check.errors.join("; ")
);
return None;
}
Some(manifest)
}
#[derive(Default)]
pub(crate) struct CliAdapters {
made: Vec<(CliManifest, Arc<dyn Adapter>)>,
}
impl CliAdapters {
pub(crate) fn adapters(
&mut self,
manifests: Vec<CliManifest>,
builtins: &[Arc<dyn Adapter>],
home: &Paths,
) -> Vec<Arc<dyn Adapter>> {
manifests
.into_iter()
.map(|manifest| {
if let Err(err) = install_home_template(home, &manifest) {
tracing::warn!("CLI {}: {err:#}", manifest.id);
}
if let Some((_, adapter)) = self.made.iter().find(|(made, _)| *made == manifest) {
return Arc::clone(adapter);
}
let adapter = match manifest.transport {
Transport::Acp => acp_adapter(manifest.clone()),
Transport::Lines => manifest_adapter_with(manifest.clone(), builtins),
};
self.made.push((manifest, Arc::clone(&adapter)));
adapter
})
.collect()
}
}
pub(crate) fn install_home_template(home: &Paths, manifest: &CliManifest) -> anyhow::Result<()> {
let Some(template) = &manifest.home else {
return Ok(());
};
let Some(files) = builtin_home_template(&template.template) else {
return Ok(());
};
let folder = home.home().join(user_files::CLIS).join(&template.template);
for (path, contents) in files {
let file = folder.join(path);
if file.exists() {
continue;
}
if let Some(dir) = file.parent() {
create_private_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
write_private_file(&file, contents.as_bytes())
.with_context(|| format!("writing {}", file.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use argonauts_core::RepoId;
use argonauts_core::layout::project_files;
use argonauts_core::settings::agents::AgentSettings;
use super::*;
const HERMES: &str = "hermes";
fn hermes_on() -> Vec<String> {
vec![HERMES.to_owned()]
}
fn ids(manifests: &[CliManifest]) -> Vec<&str> {
manifests
.iter()
.map(|manifest| manifest.id.as_str())
.collect()
}
fn with_agent_binary(id: &str, binary: &str) -> Settings {
let mut settings = Settings::default();
settings.agents.insert(
id.to_owned(),
AgentSettings {
binary: Some(PathBuf::from(binary)),
..AgentSettings::default()
},
);
settings
}
#[test]
fn hermes_counts_only_once_a_layer_turns_it_on() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let off = manifests(&Settings::default(), &home, None, &[]);
assert!(!ids(&off).contains(&HERMES), "{:?}", ids(&off));
assert!(ids(&off).contains(&"opencode-acp"), "{:?}", ids(&off));
let on = manifests(&Settings::default(), &home, None, &hermes_on());
assert!(ids(&on).contains(&HERMES), "{:?}", ids(&on));
}
#[test]
fn your_binary_replaces_the_entrys_and_must_be_absolute_and_outside_the_repo() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let repo_root = dir.path().join("repo");
std::fs::create_dir_all(&repo_root).unwrap();
let repo = RepoModels::new(&repo_root, &dir.path().join("state"));
let settings = with_agent_binary(HERMES, "/opt/hermes/bin/hermes");
let found = manifests(&settings, &home, Some(&repo), &hermes_on());
let hermes = found.iter().find(|manifest| manifest.id == HERMES).unwrap();
assert_eq!(hermes.binary, "/opt/hermes/bin/hermes");
let relative = with_agent_binary(HERMES, "bin/hermes");
let found = manifests(&relative, &home, Some(&repo), &hermes_on());
assert!(!ids(&found).contains(&HERMES), "{:?}", ids(&found));
let inside = repo_root.join("hermes");
let inside = with_agent_binary(HERMES, &inside.display().to_string());
let found = manifests(&inside, &home, Some(&repo), &hermes_on());
assert!(!ids(&found).contains(&HERMES), "{:?}", ids(&found));
}
#[test]
fn a_profile_naming_its_binary_runs_the_one_on_path() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let found = manifests(&Settings::default(), &home, None, &hermes_on());
let mut hermes = found
.into_iter()
.find(|manifest| manifest.id == HERMES)
.unwrap();
hermes.binary = "sh".to_owned();
let named = with_binary(&Settings::default(), hermes, None).unwrap();
assert!(Path::new(&named.binary).is_absolute(), "{}", named.binary);
assert!(named.binary.ends_with("/sh"), "{}", named.binary);
}
#[test]
fn the_repos_entries_wait_for_trust_in_that_exact_text() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let root = dir.path().join("repo");
std::fs::create_dir_all(root.join(".argonauts")).unwrap();
let path = root.join(".argonauts").join(project_files::MODELS);
let entry = "[[cli]]\nid = \"outside\"\ndisplay_name = \"Outside\"\nbinary = \"/opt/outside\"\nprovider = \"o\"\nread_only_args = []\nbuild_args = []\n";
std::fs::write(&path, entry).unwrap();
let state_dir = home.state_dir(&RepoId::for_repo(&root).unwrap());
let repo = RepoModels::new(&root, &state_dir);
assert!(
!ids(&manifests(&Settings::default(), &home, Some(&repo), &[])).contains(&"outside")
);
std::fs::create_dir_all(&state_dir).unwrap();
TrustStore::load(&state_dir).trust(&path).unwrap();
assert!(
ids(&manifests(&Settings::default(), &home, Some(&repo), &[])).contains(&"outside")
);
std::fs::write(&path, format!("{entry}# changed\n")).unwrap();
assert!(
!ids(&manifests(&Settings::default(), &home, Some(&repo), &[])).contains(&"outside"),
"a change needs trust again"
);
}
#[test]
fn hermes_home_template_is_written_once_and_yours_is_kept() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let found = manifests(&Settings::default(), &home, None, &hermes_on());
let hermes = found.iter().find(|manifest| manifest.id == HERMES).unwrap();
install_home_template(&home, hermes).unwrap();
let config = home
.home()
.join(user_files::CLIS)
.join(HERMES)
.join("config.yaml");
let written = std::fs::read_to_string(&config).unwrap();
assert_eq!(
builtin_home_template(HERMES)
.unwrap()
.iter()
.find(|(path, _)| *path == "config.yaml")
.map(|(_, contents)| *contents),
Some(written.as_str())
);
std::fs::write(&config, "# mine\n").unwrap();
install_home_template(&home, hermes).unwrap();
assert_eq!(std::fs::read_to_string(&config).unwrap(), "# mine\n");
}
#[test]
fn an_unchanged_entry_keeps_its_adapter() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let mut made = CliAdapters::default();
let builtins = crate::adapters::builtin_adapters();
let first = made.adapters(
manifests(&Settings::default(), &home, None, &hermes_on()),
&builtins,
&home,
);
let again = made.adapters(
manifests(&Settings::default(), &home, None, &hermes_on()),
&builtins,
&home,
);
assert_eq!(first.len(), again.len());
assert!(first.iter().zip(&again).all(|(a, b)| Arc::ptr_eq(a, b)));
let moved = with_agent_binary(HERMES, "/opt/other/hermes");
let changed = made.adapters(
manifests(&moved, &home, None, &hermes_on()),
&builtins,
&home,
);
let hermes = changed
.iter()
.find(|adapter| adapter.id() == HERMES)
.unwrap();
assert_eq!(hermes.binary(), "/opt/other/hermes");
}
}