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.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
//! 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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user