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.
This commit is contained in:
2026-08-18 09:06:16 -04:00
parent 890ad953e5
commit 22bc529818
31 changed files with 2122 additions and 40 deletions
+78 -8
View File
@@ -105,6 +105,18 @@ pub struct Settings {
/// Update policy: how aggressively 'am update' should act (issue #65).
#[serde(default)]
pub update_policy: Option<UpdatePolicy>,
/// A model is a prune candidate after this many days without use
/// (issue #73, default 30).
#[serde(default)]
pub models_prune_days: Option<u64>,
/// Base URL of the official catalog used by 'am catalog update'
/// (issue #60; default: the embedded catalog on Gitea).
#[serde(default)]
pub catalog_url: Option<String>,
/// How long (seconds) a remote catalog stays cached before a refresh
/// is attempted (issue #60, default 3600).
#[serde(default)]
pub catalog_cache_ttl_secs: Option<u64>,
}
/// Policy controlling how aggressively 'am update' upgrades installed agents.
@@ -204,6 +216,13 @@ pub struct AgentDef {
/// Pinned version: 'am update' will refuse to install a different one (issue #65).
#[serde(default)]
pub pin_version: Option<String>,
/// Environment variable carrying the model name at launch
/// (issue #71, e.g. ANTHROPIC_MODEL for claude-code).
#[serde(default)]
pub model_env: Option<String>,
/// CLI flag carrying the model name at launch (issue #71, e.g. "--model").
#[serde(default)]
pub model_arg: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
@@ -513,7 +532,10 @@ pub fn parse_file(path: &Path) -> Result<Config> {
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canon.clone()) {
return Err(anyhow!("config include cycle detected at {}", path.display()));
return Err(anyhow!(
"config include cycle detected at {}",
path.display()
));
}
let mut cfg = parse_file(path)?;
let includes = cfg.include.clone();
@@ -523,19 +545,55 @@ fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Resul
let mut merged = Config::default();
merged.version = cfg.version.clone();
for inc in &includes {
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
let sub = load_file_with_includes(&abs, visited)?;
let sub = load_include(inc, base_dir, visited)?;
merge(&mut merged, sub);
}
merge(&mut merged, cfg);
Ok(merged)
}
/// Load one include entry: a local path (relative to the including file) or
/// a remote catalog URL (http/https/file, cached on disk, issue #67).
fn load_include(inc: &str, base_dir: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let lower = inc.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let state_dir = crate::app::state_dir();
let text = crate::catalog_remote::fetch_for_config(&state_dir, inc)?;
let sub = crate::catalog_remote::parse_catalog(&text)
.with_context(|| format!("remote include {inc} is not a valid catalog"))?;
// Remote includes can themselves reference other URLs; local paths
// inside them are resolved against the config directory.
let mut merged = Config::default();
merged.version = sub.version.clone();
for sub_inc in &sub.include {
let nested = load_include(sub_inc, base_dir, visited)?;
merge(&mut merged, nested);
}
let mut own = sub;
own.include.clear();
merge(&mut merged, own);
return Ok(merged);
}
if lower.starts_with("file://") {
let local = inc.strip_prefix("file://").unwrap_or(inc);
// Windows: "file:///C:/x" -> "C:/x" (drop the leading slash).
let local = if cfg!(windows) && local.starts_with('/') {
local.strip_prefix('/').unwrap_or(local)
} else {
local
};
let abs = PathBuf::from(local);
return load_file_with_includes(&abs, visited);
}
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
load_file_with_includes(&abs, visited)
}
/// Merge an overlay config into a base config: user definitions replace
/// default agents with the same name; maps are merged key by key.
pub fn merge(base: &mut Config, overlay: Config) {
@@ -568,6 +626,18 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.theme.is_some() {
s.theme = o.theme;
}
if o.update_policy.is_some() {
s.update_policy = o.update_policy;
}
if o.models_prune_days.is_some() {
s.models_prune_days = o.models_prune_days;
}
if o.catalog_url.is_some() {
s.catalog_url = o.catalog_url;
}
if o.catalog_cache_ttl_secs.is_some() {
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}