Files
agent-manager/src/commands/catalog_cmd.rs
T
bruno 22bc529818 feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)
Axe 11 (modèles) :
- am models : inventaire des runtimes locaux (ollama list --format json,
  llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon
  probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale
- am run <agent> --model <m> : résolution dans l'inventaire, transmission
  via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session
- am models --prune : candidats inutilisés depuis settings.models_prune_days
  (défaut 30), :latest toujours protégé, dry-run + confirmation + événement
  EventKind::Model journalisé

Axes 7/9 (catalogue) :
- am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/
  modifiés), confirmation, enregistrement en include
- am catalog add <url> : validation du format (parse + validate), cache
  catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé
- include par URL (http/https/file) dans config.yaml, résolu au chargement
  avec cache + TTL ; override local toujours prioritaire (issue #67)
- am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par
  usage réel (lancements + durée depuis le journal), top 5 justifié, --json

REPL : commandes models/catalog/suggest (parse, complétion, descriptions),
help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles).
Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
2026-08-18 09:06:16 -04:00

167 lines
5.5 KiB
Rust

//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
//! 'am catalog update' refreshes the official catalog and applies it as an
//! include; 'am catalog add <url>' registers any external catalog (same
//! YAML format) the same way. Everything goes through the probe-style
//! cache in catalog_remote so offline runs keep working.
use super::*;
use crate::catalog_remote::{self, CatalogDiff};
use crate::cli::CatalogCmd;
use crate::commands::config_cmd::register_include;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use anyhow::{anyhow, Result};
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
match cmd {
CatalogCmd::Update => update(app),
CatalogCmd::Add { url } => add(app, url),
CatalogCmd::List => list(app),
}
}
/// am catalog update: fetch the official catalog, show the diff against the
/// local catalog, ask for confirmation, then register the URL as an include.
fn update(app: &App) -> Result<i32> {
let url = catalog_remote::official_url(app);
app.log.info(&format!("fetching the official catalog: {url}"));
let text = catalog_remote::fetch(app, &url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"added": diff.added,
"removed": diff.removed,
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
}));
return Ok(0);
}
print_diff(&diff);
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
app.log
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
return Ok(0);
}
if app.dry_run() {
app.log.dry("would register the official catalog as an include");
return Ok(0);
}
if !app
.confirm(&format!(
"apply this catalog ({} agents, {} changes)?",
remote.agents.len(),
diff.added.len() + diff.removed.len() + diff.changed.len()
))?
{
app.log.info("catalog update cancelled");
return Ok(0);
}
register_include(app, &url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.clone()])
.with_reason("update"),
);
app.log.success(&format!(
"official catalog registered as an include ({url}) — it is effective from the next run"
));
Ok(0)
}
/// am catalog add <url>: validate and register an external catalog.
fn add(app: &App, url: &str) -> Result<i32> {
crate::download::validate_url(url)?;
if config_cmd::user_includes(app).iter().any(|i| i == url) {
app.log
.info(&format!("catalog {url} is already registered as an include"));
return Ok(0);
}
app.log.info(&format!("fetching catalog: {url}"));
// force: 'catalog add' must validate the current content of the URL,
// not a possibly stale cached copy.
let text = catalog_remote::fetch(app, url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"registered": false,
}));
return Ok(0);
}
if remote.agents.is_empty() {
return Err(anyhow!(
"the catalog at {url} defines no agents — refusing to register it"
));
}
if app.dry_run() {
app.log.dry(&format!(
"would register {url} ({:?} agents)",
remote.agents.len()
));
return Ok(0);
}
register_include(app, url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.to_string()])
.with_reason("add"),
);
app.log.success(&format!(
"catalog {url} registered ({} agents) — effective from the next run",
remote.agents.len()
));
Ok(0)
}
/// am catalog list: show the registered remote catalogs (includes).
/// The resolved config consumes its includes at load time, so this reads
/// the user config file directly (like 'config add').
fn list(app: &App) -> Result<i32> {
let urls: Vec<String> = config_cmd::user_includes(app)
.iter()
.filter(|i| {
i.starts_with("http://")
|| i.starts_with("https://")
|| i.starts_with("file://")
})
.cloned()
.collect();
if app.json() {
print_json(&urls);
return Ok(0);
}
if urls.is_empty() {
app.log.info(
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
);
return Ok(0);
}
println!("remote catalogs ({}):", urls.len());
for u in &urls {
println!(" - {u}");
}
Ok(0)
}
fn print_diff(diff: &CatalogDiff) {
if !diff.added.is_empty() {
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
}
if !diff.removed.is_empty() {
println!(
" - {} removed: {}",
diff.removed.len(),
diff.removed.join(", ")
);
}
if !diff.changed.is_empty() {
println!(" ~ {} changed run command(s):", diff.changed.len());
for (name, from, to) in &diff.changed {
println!(" {name}: '{from}' -> '{to}'");
}
}
}