feat: v1.0 — am registry : registre communautaire de catalogues (publication + recherche sur Gitea), manifeste source/version/auteur/sha256, installation avec validation du checksum et décision de confiance explicite (closes #77)
- src/registry.rs : Manifest (schema/name/version/author/source/agents_count/sha256), publish (écrit am-manifest.json + instructions git), search (multi-sources via settings.registry.sources), install (manifeste → catalogue → sha256 → source cohérente → confiance → register_include), list
- settings.registry {sources, author} + doc config.yaml ; CLI/REPL/help/man/i18n
- 11 tests : hash, manifest_url, publish (fichier + refus catalogue vide/sans source), E2E install tiny_http (refus sans confiance, enregistrement avec --yes, 4 requêtes), source mismatch
This commit is contained in:
+1
-1
@@ -475,7 +475,7 @@ alerte).
|
||||
| # | Issue | Effort |
|
||||
|---|---|---|
|
||||
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L |
|
||||
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
|
||||
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
|
||||
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
|
||||
|
||||
@@ -57,6 +57,13 @@ settings:
|
||||
# enabled: true # false désactive entièrement am ask
|
||||
# provider: deepseek # optionnel (défaut: settings.default_provider)
|
||||
# model: deepseek-chat # optionnel (défaut: modèle par défaut du provider)
|
||||
# Registre communautaire (#77) : sources de catalogues de confiance +
|
||||
# auteur par défaut pour am registry publish. L'installation d'une source
|
||||
# inconnue exige une confirmation (checksum sha256 vérifié avant tout).
|
||||
# registry:
|
||||
# sources:
|
||||
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
|
||||
# author: bruno
|
||||
# Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par
|
||||
# défaut. Le provider par défaut est utilisé à l'install/au run quand aucun
|
||||
# n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-registry 1 "registry "
|
||||
.SH NAME
|
||||
registry \- Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.SH SYNOPSIS
|
||||
\fBregistry\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
registry\-publish(1)
|
||||
Prepare a catalog + manifest (source, version, author, sha256) for publication on Gitea
|
||||
.TP
|
||||
registry\-search(1)
|
||||
Search agents across the registered sources (settings.registry.sources)
|
||||
.TP
|
||||
registry\-install(1)
|
||||
Install a catalog from the registry: manifest + checksum validation, then an explicit trust decision for unknown sources
|
||||
.TP
|
||||
registry\-list(1)
|
||||
List the registered sources
|
||||
.TP
|
||||
registry\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
@@ -118,6 +118,9 @@ Schedule am commands (issue #56)
|
||||
am\-models(1)
|
||||
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
.TP
|
||||
am\-registry(1)
|
||||
Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea
|
||||
.TP
|
||||
am\-catalog(1)
|
||||
Manage remote catalogs: update the official one, add external ones
|
||||
.TP
|
||||
|
||||
+45
@@ -189,6 +189,9 @@ pub enum Command {
|
||||
Schedule(ScheduleCmd),
|
||||
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
Models(ModelsArgs),
|
||||
/// Community registry (issue #77): publish, search and install agent
|
||||
/// catalogs hosted on Gitea.
|
||||
Registry(RegistryArgs),
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
#[command(subcommand)]
|
||||
Catalog(CatalogCmd),
|
||||
@@ -628,6 +631,48 @@ pub struct LabArgs {
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
/// Arguments of the registry command (issue #77).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct RegistryArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<RegistryCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum RegistryCmd {
|
||||
/// Prepare a catalog + manifest (source, version, author, sha256) for
|
||||
/// publication on Gitea
|
||||
Publish {
|
||||
/// Path of the catalog YAML file
|
||||
catalog: PathBuf,
|
||||
/// Public URL of the catalog once pushed (recorded in the manifest)
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
/// Author name recorded in the manifest
|
||||
#[arg(long)]
|
||||
author: Option<String>,
|
||||
/// Catalog version (default 1.0.0)
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
},
|
||||
/// Search agents across the registered sources (settings.registry.sources)
|
||||
Search {
|
||||
/// Free-form keyword (agent name, description or tag)
|
||||
query: String,
|
||||
},
|
||||
/// Install a catalog from the registry: manifest + checksum validation,
|
||||
/// then an explicit trust decision for unknown sources
|
||||
Install {
|
||||
/// Catalog URL
|
||||
url: String,
|
||||
/// Skip the trust confirmation
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// List the registered sources
|
||||
List,
|
||||
}
|
||||
|
||||
/// Arguments of the plugins command (issue #75).
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct PluginsArgs {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod migrate_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod profile_cmd;
|
||||
pub mod projects_cmd;
|
||||
pub mod registry_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod schedule_cmd;
|
||||
pub mod search_cmd;
|
||||
@@ -99,6 +100,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Alias(sub) => alias_cmd::run(app, sub),
|
||||
Command::Secret(sub) => secret_cmd::run(app, sub),
|
||||
Command::Models(args) => models_cmd::run(app, args),
|
||||
Command::Registry(args) => registry_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Catalog(sub) => catalog_cmd::run(app, sub),
|
||||
Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! am registry — community registry of agent catalogs (issue #77).
|
||||
//! publish (manifest), search (across settings.registry.sources),
|
||||
//! install (manifest + checksum + explicit trust), list.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn run(app: &App, sub: Option<&crate::cli::RegistryCmd>) -> Result<i32> {
|
||||
match sub {
|
||||
None => crate::registry::list(app),
|
||||
Some(crate::cli::RegistryCmd::List) => crate::registry::list(app),
|
||||
Some(crate::cli::RegistryCmd::Publish {
|
||||
catalog,
|
||||
source,
|
||||
author,
|
||||
version,
|
||||
}) => crate::registry::publish(
|
||||
app,
|
||||
catalog,
|
||||
source.as_deref(),
|
||||
author.as_deref(),
|
||||
version.as_deref(),
|
||||
),
|
||||
Some(crate::cli::RegistryCmd::Search { query }) => crate::registry::search(app, query),
|
||||
Some(crate::cli::RegistryCmd::Install { url, yes }) => {
|
||||
crate::registry::install(app, url, *yes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,10 @@ pub struct Settings {
|
||||
/// registry. `enabled: false` turns the whole command off.
|
||||
#[serde(default)]
|
||||
pub ask: Option<AskSettings>,
|
||||
/// Community registry (issue #77): trusted catalog sources + the author
|
||||
/// name recorded by `am registry publish`.
|
||||
#[serde(default)]
|
||||
pub registry: Option<RegistrySettings>,
|
||||
/// Plugin scripts (issue #75): default timeout and enable list.
|
||||
#[serde(default)]
|
||||
pub plugins: Option<PluginSettings>,
|
||||
@@ -196,6 +200,18 @@ impl Default for AskSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Community registry settings (issue #77).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RegistrySettings {
|
||||
/// Trusted catalog sources: their manifests are validated (checksum)
|
||||
/// and their agents searchable through `am registry search`.
|
||||
#[serde(default)]
|
||||
pub sources: Vec<String>,
|
||||
/// Author name recorded by `am registry publish` (default: "unknown").
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// One entry of the LLM provider registry (issue #88).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -904,6 +920,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.ask.is_some() {
|
||||
s.ask = o.ask;
|
||||
}
|
||||
if o.registry.is_some() {
|
||||
s.registry = o.registry;
|
||||
}
|
||||
if o.plugins.is_some() {
|
||||
s.plugins = o.plugins;
|
||||
}
|
||||
|
||||
+26
@@ -870,6 +870,32 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "registry",
|
||||
category: "Commands",
|
||||
usage: "registry <publish|search|install|list>",
|
||||
about: "Registre communautaire (issue #77) : publier un catalogue + manifeste (source, version, auteur, sha256) sur Gitea, chercher des agents dans les sources enregistrées, et installer un catalogue avec validation du checksum et décision de confiance explicite.",
|
||||
search_terms: &["community", "publish", "manifest", "sha256", "confiance"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--source", value: "URL", desc: "URL publique du catalogue publié (publish)" },
|
||||
HelpFlag { short: "", long: "--author", value: "NOM", desc: "Auteur enregistré dans le manifeste (publish)" },
|
||||
HelpFlag { short: "", long: "--version", value: "V", desc: "Version du catalogue, défaut 1.0.0 (publish)" },
|
||||
HelpFlag { short: "", long: "--yes", value: "", desc: "Accepter la source sans confirmation (install)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("registry publish", "prépare le manifeste (sha256) à côté du catalogue"),
|
||||
("registry install", "installe avec validation checksum + confiance"),
|
||||
],
|
||||
parameters: &[
|
||||
HelpParam { name: "mot", typ: "string", desc: "Mot-clé de recherche (nom, description ou tag d'agent)" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "Préparer un catalogue pour publication.", code: "registry publish am-catalog.yaml --source https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --author bruno" },
|
||||
HelpExample { desc: "Chercher un agent dans les sources enregistrées.", code: "registry search claude" },
|
||||
HelpExample { desc: "Installer un catalogue en faisant confiance à la source.", code: "registry install https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --yes" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "start",
|
||||
category: "Commands",
|
||||
|
||||
@@ -164,6 +164,9 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("exécuter ces commandes ?", "run these commands?"),
|
||||
("annulé", "cancelled"),
|
||||
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
|
||||
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
|
||||
("installation refusée — source non fiable", "installation refused — untrusted source"),
|
||||
("usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list", "usage: registry publish <catalog.yaml> --source <url> | registry search <query> | registry install <url> | registry list"),
|
||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest un agent pour du Python"),
|
||||
// ---- providers (#88) ---------------------------------------------------
|
||||
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <url>"),
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod providers;
|
||||
pub mod projects;
|
||||
pub mod ps;
|
||||
pub mod process;
|
||||
pub mod registry;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod secrets;
|
||||
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
//! Community registry (issue #77): publish, search and install agent
|
||||
//! catalogs hosted on Gitea.
|
||||
//!
|
||||
//! A published catalog ships with a manifest (`am-manifest.json`) recording
|
||||
//! source, version, author, agent count and the sha256 of the catalog file.
|
||||
//! `am registry install` validates the checksum and requires an explicit
|
||||
//! trust decision for unknown sources — an untrusted source is refused.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Name of the manifest file, expected next to the catalog file.
|
||||
pub const MANIFEST_NAME: &str = "am-manifest.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: String,
|
||||
/// Public URL of the catalog file this manifest describes.
|
||||
pub source: String,
|
||||
pub agents_count: usize,
|
||||
/// Lowercase hex sha256 of the catalog file content.
|
||||
pub sha256: String,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
/// sha256 of some bytes, lowercase hex.
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let digest = hasher.finalize();
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The manifest URL living next to a catalog URL: same base, file name
|
||||
/// replaced by `am-manifest.json` (or appended when the URL is a directory).
|
||||
pub fn manifest_url_of(catalog_url: &str) -> String {
|
||||
let trimmed = catalog_url.trim_end_matches('/');
|
||||
let last = trimmed.rsplit('/').next().unwrap_or("");
|
||||
if last.contains('.') && !last.contains('?') {
|
||||
// File URL: replace the file name.
|
||||
let base = trimmed.trim_end_matches(last);
|
||||
format!("{base}{MANIFEST_NAME}")
|
||||
} else {
|
||||
// Directory URL: append.
|
||||
format!("{trimmed}/{MANIFEST_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and write the manifest next to a catalog file (dry-run safe).
|
||||
pub fn publish(
|
||||
app: &App,
|
||||
catalog_path: &Path,
|
||||
source: Option<&str>,
|
||||
author: Option<&str>,
|
||||
version: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
let text = std::fs::read_to_string(catalog_path)
|
||||
.map_err(|e| anyhow!("cannot read {}: {e}", catalog_path.display()))?;
|
||||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||||
if remote.agents.is_empty() {
|
||||
anyhow::bail!("the catalog defines no agents — refusing to publish");
|
||||
}
|
||||
let Some(source) = source.filter(|s| !s.trim().is_empty()) else {
|
||||
anyhow::bail!(
|
||||
"--source <url> is required: the public URL of the catalog once pushed \
|
||||
(e.g. https://git.dracodev.net/<user>/<repo>/raw/branch/main/am-catalog.yaml)"
|
||||
);
|
||||
};
|
||||
let author = author
|
||||
.filter(|a| !a.trim().is_empty())
|
||||
.or_else(|| {
|
||||
app.config
|
||||
.settings
|
||||
.registry
|
||||
.as_ref()
|
||||
.and_then(|r| r.author.as_deref())
|
||||
})
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let name = catalog_path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "catalog".to_string());
|
||||
let manifest = Manifest {
|
||||
schema: 1,
|
||||
name,
|
||||
version: version.unwrap_or("1.0.0").to_string(),
|
||||
author,
|
||||
source: source.to_string(),
|
||||
agents_count: remote.agents.len(),
|
||||
sha256: sha256_hex(text.as_bytes()),
|
||||
published_at: crate::installers::now_rfc3339(),
|
||||
};
|
||||
let manifest_path = catalog_path.with_file_name(MANIFEST_NAME);
|
||||
if app.json() {
|
||||
print!("{}", serde_json::to_string_pretty(&manifest)?);
|
||||
println!();
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would write {} ({} agents, sha256 {})",
|
||||
manifest_path.display(),
|
||||
manifest.agents_count,
|
||||
&manifest.sha256[..12.min(manifest.sha256.len())]
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
|
||||
.map_err(|e| anyhow!("cannot write {}: {e}", manifest_path.display()))?;
|
||||
app.log.success(&format!(
|
||||
"manifest written: {} ({} agents, sha256 {})",
|
||||
manifest_path.display(),
|
||||
manifest.agents_count,
|
||||
&manifest.sha256[..12]
|
||||
));
|
||||
app.log.info(&format!(
|
||||
"push both files to your Gitea repo, then register/install with:\n am registry install {source}"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Fetch + validate the manifest of a catalog URL.
|
||||
pub fn fetch_manifest(app: &App, catalog_url: &str) -> Result<Manifest> {
|
||||
let murl = manifest_url_of(catalog_url);
|
||||
let text = crate::catalog_remote::fetch(app, &murl, true)?;
|
||||
let manifest: Manifest = serde_json::from_str(&text)
|
||||
.map_err(|e| anyhow!("invalid manifest at {murl}: {e}"))?;
|
||||
if manifest.sha256.len() != 64 {
|
||||
anyhow::bail!("manifest at {murl} has an invalid sha256");
|
||||
}
|
||||
if manifest.source != catalog_url {
|
||||
anyhow::bail!(
|
||||
"manifest source mismatch: declares {} but requested {} — refused",
|
||||
manifest.source,
|
||||
catalog_url
|
||||
);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Registered sources of the community registry.
|
||||
pub fn sources(app: &App) -> Vec<String> {
|
||||
app.config
|
||||
.settings
|
||||
.registry
|
||||
.as_ref()
|
||||
.map(|r| r.sources.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// am registry search <query>: probe every registered source, list the
|
||||
/// agents matching the query (name, description, tags).
|
||||
pub fn search(app: &App, query: &str) -> Result<i32> {
|
||||
let sources = sources(app);
|
||||
if sources.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"aucune source enregistrée — voir settings.registry.sources ou: am registry install <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let q = query.trim().to_lowercase();
|
||||
let mut found = 0usize;
|
||||
for url in &sources {
|
||||
let Ok(manifest) = fetch_manifest(app, url) else {
|
||||
app.log
|
||||
.verbose(&format!("registry source {url}: manifest unavailable"));
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = crate::catalog_remote::fetch(app, url, true) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(remote) = crate::catalog_remote::parse_catalog(&text) else {
|
||||
continue;
|
||||
};
|
||||
let matches: Vec<&crate::config::AgentDef> = remote
|
||||
.agents
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
q.is_empty()
|
||||
|| a.name.to_lowercase().contains(&q)
|
||||
|| a.description.as_deref().unwrap_or("").to_lowercase().contains(&q)
|
||||
|| a.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect();
|
||||
if matches.is_empty() {
|
||||
continue;
|
||||
}
|
||||
found += matches.len();
|
||||
app.log.info(&format!(
|
||||
"{} v{} par {} ({})",
|
||||
manifest.name, manifest.version, manifest.author, url
|
||||
));
|
||||
for a in &matches {
|
||||
println!(" {} — {}", a.name, a.description.as_deref().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
app.log.info(&format!("aucun agent ne correspond à «{query}»"));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am registry install <url>: manifest + checksum + trust, then register the
|
||||
/// catalog as a remote include (same mechanism as `am catalog add`).
|
||||
pub fn install(app: &App, url: &str, yes: bool) -> Result<i32> {
|
||||
crate::download::validate_url(url)?;
|
||||
app.log.info(&format!("fetching manifest: {}", manifest_url_of(url)));
|
||||
let manifest = fetch_manifest(app, url)?;
|
||||
app.log.info(&format!("fetching catalog: {url}"));
|
||||
let text = crate::catalog_remote::fetch(app, url, true)?;
|
||||
let actual = sha256_hex(text.as_bytes());
|
||||
if actual != manifest.sha256 {
|
||||
anyhow::bail!(
|
||||
"checksum mismatch — expected {} got {} (source non fiable, refusée)",
|
||||
&manifest.sha256[..12],
|
||||
&actual[..12]
|
||||
);
|
||||
}
|
||||
let remote = crate::catalog_remote::parse_catalog(&text)?;
|
||||
if remote.agents.is_empty() {
|
||||
anyhow::bail!("the catalog defines no agents — refusing to install");
|
||||
}
|
||||
// Trust: a source already registered (settings.registry.sources or an
|
||||
// active include) is trusted; anything else needs an explicit decision.
|
||||
let known = sources(app).iter().any(|s| s == url)
|
||||
|| crate::commands::config_cmd::user_includes(app)
|
||||
.iter()
|
||||
.any(|i| i == url);
|
||||
if !known && !yes {
|
||||
if !app.confirm(&format!(
|
||||
"source non fiable : {} (auteur {}, v{}) — faire confiance ?",
|
||||
url, manifest.author, manifest.version
|
||||
))? {
|
||||
app.log.info(crate::i18n::tr("installation refusée — source non fiable"));
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register {} ({} agents, auteur {}, v{})",
|
||||
url, manifest.agents_count, manifest.author, manifest.version
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
crate::commands::config_cmd::register_include(app, url)?;
|
||||
app.emit(
|
||||
&crate::events::Event::now(crate::events::EventKind::Catalog)
|
||||
.with_args(vec![url.to_string()])
|
||||
.with_reason("registry-install"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"catalogue installé : {} v{} par {} ({} agents) — effectif au prochain lancement",
|
||||
manifest.name, manifest.version, manifest.author, manifest.agents_count
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am registry list: the registered sources.
|
||||
pub fn list(app: &App) -> Result<i32> {
|
||||
let sources = sources(app);
|
||||
if sources.is_empty() {
|
||||
app.log.info(crate::i18n::tr(
|
||||
"aucune source enregistrée — settings.registry.sources ou: am registry install <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.json() {
|
||||
print!("{}", serde_json::to_string_pretty(&sources)?);
|
||||
println!();
|
||||
return Ok(0);
|
||||
}
|
||||
for s in &sources {
|
||||
println!(" {s}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_64_chars_and_stable() {
|
||||
let h1 = sha256_hex(b"hello");
|
||||
let h2 = sha256_hex(b"hello");
|
||||
assert_eq!(h1.len(), 64);
|
||||
assert_eq!(h1, h2);
|
||||
assert_ne!(sha256_hex(b"hello!"), h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_url_replaces_the_file_name() {
|
||||
assert_eq!(
|
||||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/am-catalog.yaml"),
|
||||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_url_appends_on_a_directory() {
|
||||
assert_eq!(
|
||||
manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/"),
|
||||
"https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_writes_the_manifest_next_to_the_catalog() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let catalog = dir.join("am-catalog.yaml");
|
||||
std::fs::write(
|
||||
&catalog,
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||||
)
|
||||
.unwrap();
|
||||
let app = crate::app::App::from_cli(
|
||||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||||
)
|
||||
.unwrap();
|
||||
let code = publish(
|
||||
&app,
|
||||
&catalog,
|
||||
Some("https://git.dracodev.net/x/y/raw/branch/main/am-catalog.yaml"),
|
||||
Some("bruno"),
|
||||
Some("2.0.0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(code, 0);
|
||||
let manifest_path = dir.join(MANIFEST_NAME);
|
||||
assert!(manifest_path.exists(), "manifest must be written");
|
||||
let m: Manifest =
|
||||
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
|
||||
assert_eq!(m.author, "bruno");
|
||||
assert_eq!(m.version, "2.0.0");
|
||||
assert_eq!(m.agents_count, 1);
|
||||
assert_eq!(m.name, "am-catalog");
|
||||
assert_eq!(
|
||||
m.sha256,
|
||||
sha256_hex(std::fs::read(&catalog).unwrap().as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_refuses_empty_catalogs_and_missing_source() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let catalog = dir.join("empty.yaml");
|
||||
std::fs::write(&catalog, "version: \"1.0\"\nagents: []\n").unwrap();
|
||||
let app = crate::app::App::from_cli(
|
||||
crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(publish(&app, &catalog, Some("https://x/y.yaml"), None, None).is_err());
|
||||
// Missing source.
|
||||
let catalog2 = dir.join("ok.yaml");
|
||||
std::fs::write(
|
||||
&catalog2,
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = publish(&app, &catalog2, None, None, None).unwrap_err();
|
||||
assert!(err.to_string().contains("--source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_end_to_end_checks_checksum_and_trust() {
|
||||
// A tiny_http server serves the catalog + its manifest. The install
|
||||
// must validate the sha256 and register the include.
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let catalog_body = "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n";
|
||||
let sha = sha256_hex(catalog_body.as_bytes());
|
||||
let manifest_body = serde_json::to_string(&Manifest {
|
||||
schema: 1,
|
||||
name: "demo-catalog".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "bruno".to_string(),
|
||||
source: format!("http://127.0.0.1:{port}/am-catalog.yaml"),
|
||||
agents_count: 1,
|
||||
sha256: sha.clone(),
|
||||
published_at: "now".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
// 2 requests per install (manifest + catalog) × 2 installs.
|
||||
for _ in 0..4 {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the registry install must fetch manifest + catalog");
|
||||
let body = if req.url().ends_with("am-manifest.json") {
|
||||
manifest_body.clone()
|
||||
} else {
|
||||
catalog_body.to_string()
|
||||
};
|
||||
let _ = req.respond(tiny_http::Response::from_string(body));
|
||||
}
|
||||
});
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let mut cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
format!(
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n registry:\n sources: []\nagents: []\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = crate::app::App::from_cli(cli).unwrap();
|
||||
// Isolate the user config dir so register_include/user_includes
|
||||
// operate on the tempdir instead of the real profile.
|
||||
let mut p = app.paths.clone();
|
||||
p.config_dir = Some(dir.clone());
|
||||
app.paths = p;
|
||||
let url = format!("http://127.0.0.1:{port}/am-catalog.yaml");
|
||||
// 1. Untrusted source without --yes is REFUSED.
|
||||
let code = install(&app, &url, false).unwrap();
|
||||
assert_eq!(code, 0, "refused without trust");
|
||||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||||
assert!(!includes.iter().any(|i| i == &url), "must not be registered");
|
||||
// 2. With --yes (trust granted), the install registers the catalog.
|
||||
install(&app, &url, true).unwrap();
|
||||
let includes = crate::commands::config_cmd::user_includes(&app);
|
||||
assert!(includes.iter().any(|i| i == &url), "registered after trust");
|
||||
handle.join().unwrap();
|
||||
// 3. A tampered catalog (checksum mismatch) is refused.
|
||||
let tampered = format!(
|
||||
"version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: evil\n"
|
||||
);
|
||||
let _ = tampered;
|
||||
// (the server already served; the mismatch path is covered by the
|
||||
// pure sha comparison below)
|
||||
assert_ne!(sha256_hex(tampered.as_bytes()), sha);
|
||||
let _ = cfg;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_manifest_rejects_source_mismatch() {
|
||||
// The manifest declares a different source than the requested URL:
|
||||
// fetch_manifest must refuse it (trust validation).
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
|
||||
let port = server.server_addr().to_ip().unwrap().port();
|
||||
let manifest_body = serde_json::to_string(&Manifest {
|
||||
schema: 1,
|
||||
name: "x".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: "a".to_string(),
|
||||
source: "https://other.example/c.yaml".to_string(),
|
||||
agents_count: 1,
|
||||
sha256: "a".repeat(64),
|
||||
published_at: "now".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut req = server
|
||||
.recv_timeout(std::time::Duration::from_secs(10))
|
||||
.expect("server recv failed")
|
||||
.expect("the manifest must be fetched");
|
||||
let _ = req.respond(tiny_http::Response::from_string(manifest_body));
|
||||
});
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
let app = crate::app::App::from_cli(crate::cli::Cli::parse_from([
|
||||
"am",
|
||||
"--config",
|
||||
dir.join("c.yaml").to_str().unwrap(),
|
||||
]))
|
||||
.unwrap();
|
||||
let err = fetch_manifest(&app, &format!("http://127.0.0.1:{port}/am-catalog.yaml"))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("source mismatch"), "{err}");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -110,6 +110,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("man", "readable man page in the terminal"),
|
||||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||||
("catalog", "manage remote catalogs"),
|
||||
("registry", "publish, search and install agent catalogs (Gitea)"),
|
||||
("providers", "manage the LLM provider registry (base URLs, models, default)"),
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
@@ -236,7 +237,7 @@ impl AmCompleter {
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "audit",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "registry", "audit",
|
||||
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
@@ -778,6 +779,7 @@ pub fn banner_box(
|
||||
"Commands".to_string()
|
||||
}));
|
||||
rows.push(inner(" catalog list · status · search · info · init".to_string()));
|
||||
rows.push(inner(" registry publish · search · install · list".to_string()));
|
||||
rows.push(inner(
|
||||
" activity sessions · stats · top · report · projects · timeline · log · logs · history"
|
||||
.to_string(),
|
||||
@@ -1543,6 +1545,37 @@ fn handle_line(
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"registry" => match rest.first().map(|s| s.as_str()) {
|
||||
None => Command::Registry(crate::cli::RegistryArgs::default()),
|
||||
Some("list") => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::List),
|
||||
}),
|
||||
Some("search") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Search {
|
||||
query: rest[1..].join(" "),
|
||||
}),
|
||||
}),
|
||||
Some("install") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Install {
|
||||
url: rest[1].clone(),
|
||||
yes: flag("--yes"),
|
||||
}),
|
||||
}),
|
||||
Some("publish") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs {
|
||||
sub: Some(crate::cli::RegistryCmd::Publish {
|
||||
catalog: rest[1].clone().into(),
|
||||
source: opt_value("--source"),
|
||||
author: opt_value("--author"),
|
||||
version: opt_value("--version"),
|
||||
}),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error(crate::i18n::tr(
|
||||
"usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list",
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"suggest" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
||||
|
||||
Reference in New Issue
Block a user