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
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.4"
version = "0.4.5"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.4"
version = "0.4.5"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+4
View File
@@ -77,8 +77,12 @@ plateforme, il compile automatiquement depuis les sources.
| am list | agents installés (gérés + détectés sur le PATH) |
| am list --all | tout le catalogue, avec l'état de chacun |
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
+6 -6
View File
@@ -441,20 +441,20 @@ moins de 2 secondes.
| [#57](https://git.dracodev.net/Projets/agent-manager/issues/57) | Orchestration de groupes (ordre, --parallel, attente de santé) | L |
| [#58](https://git.dracodev.net/Projets/agent-manager/issues/58) | Conteneurs — profils docker/podman par agent | L |
| [#59](https://git.dracodev.net/Projets/agent-manager/issues/59) | am doctor --watch — vérifications périodiques avec alertes | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | Partage de catalogue d'équipe (include par URL) | S |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
| [#68](https://git.dracodev.net/Projets/agent-manager/issues/68) | am migrate — assistant de transfert machine A → B | M |
| [#69](https://git.dracodev.net/Projets/agent-manager/issues/69) | ✅ Thèmes couleurs du REPL | S |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | Lien agent ↔ modèle — am run --model | M |
| [#70](https://git.dracodev.net/Projets/agent-manager/issues/70) | ✅ am models — inventaire des modèles locaux (ollama, llama.cpp, LM Studio) | M |
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M |
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | am models prune — purge des modèles inutilisés | S |
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | ✅ am models prune — purge des modèles inutilisés | S |
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | Plugin scripts (hooks avancés, intégration CI) | M |
+3
View File
@@ -20,6 +20,9 @@ settings:
stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL
self_update_repo: Projets/agent-manager # repo used by am self-update (release assets)
self_update_base_url: https://git.dracodev.net/api/v1 # serveur de release (Gitea ici; GitHub: https://api.github.com)
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
# --- Command aliases ----------------------------------------------------------
aliases:
+26
View File
@@ -0,0 +1,26 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-catalog 1 "catalog "
.SH NAME
catalog \- Manage remote catalogs: update the official one, add external ones
.SH SYNOPSIS
\fBcatalog\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR>
.SH DESCRIPTION
Manage remote catalogs: update the official one, add external ones
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.SH SUBCOMMANDS
.TP
catalog\-update(1)
Fetch the official catalog, show the diff and register it as an include
.TP
catalog\-add(1)
Register an external catalog (same YAML format) by URL
.TP
catalog\-list(1)
List the registered remote catalogs
.TP
catalog\-help(1)
Print this message or the help of the given subcommand(s)
+25
View File
@@ -0,0 +1,25 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-models 1 "models "
.SH NAME
models \- List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH SYNOPSIS
\fBmodels\fR [\fB\-\-prune\fR] [\fB\-\-dry\-run\fR] [\fB\-\-yes\fR] [\fB\-\-days\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.SH OPTIONS
.TP
\fB\-\-prune\fR
Purge unused models instead of listing them
.TP
\fB\-\-dry\-run\fR
Only list what would be pruned, delete nothing
.TP
\fB\-\-yes\fR
Answer yes to the deletion prompt
.TP
\fB\-\-days\fR \fI<DAYS>\fR
Consider a model unused after N days (default: 30)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
restart \- Restart an agent: stop, then start with the same options
.SH SYNOPSIS
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBrestart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-\-force\fR] [\fB\-\-timeout\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Restart an agent: stop, then start with the same options
.SH OPTIONS
@@ -27,6 +27,9 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-\-force\fR
Kill immediately instead of terminating gracefully
.TP
+4 -1
View File
@@ -4,11 +4,14 @@
.SH NAME
run \- Run the agent command directly with the given arguments (no process management)
.SH SYNOPSIS
\fBrun\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
\fBrun\fR [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIAGENT\fR> [\fIARGS...\fR]
.SH DESCRIPTION
Run the agent command directly with the given arguments (no process management)
.SH OPTIONS
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
start \- Start an agent (foreground by default, or detached with \-\-background)
.SH SYNOPSIS
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBstart\fR [\fB\-b\fR|\fB\-\-background\fR] [\fB\-f\fR|\fB\-\-foreground\fR] [\fB\-\-args\fR] [\fB\-\-env\fR] [\fB\-\-notify\fR] [\fB\-\-profile\fR] [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Start an agent (foreground by default, or detached with \-\-background)
.SH OPTIONS
@@ -27,6 +27,9 @@ Send a desktop notification once the agent has started
\fB\-\-profile\fR \fI<NAME>\fR
Apply an environment profile (env + args, defined in the config)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Local model to use for this run (validated against the local runtimes)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-suggest 1 "suggest "
.SH NAME
suggest \- Suggest agents matching a query, boosted by real usage (issue #61)
.SH SYNOPSIS
\fBsuggest\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIQUERY\fR>
.SH DESCRIPTION
Suggest agents matching a query, boosted by real usage (issue #61)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIQUERY\fR>
Free\-form request ("un agent pour du Python")
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-theme 1 "theme "
.SH NAME
theme \- Set or show the active color theme (persisted for the next run)
.SH SYNOPSIS
\fBtheme\fR [\fB\-h\fR|\fB\-\-help\fR] [\fINAME\fR]
.SH DESCRIPTION
Set or show the active color theme (persisted for the next run)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
[\fINAME\fR]
Theme name (omit to list available themes)
+16
View File
@@ -0,0 +1,16 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-tip 1 "tip "
.SH NAME
tip \- Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH SYNOPSIS
\fBtip\fR [\fB\-\-random\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.SH OPTIONS
.TP
\fB\-\-random\fR
Show a single random tip instead of the whole page
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+17 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.3"
.TH am 1 "am 0.4.5"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -84,12 +84,24 @@ Manage environment profiles (dev, prod, ...)
am\-man(1)
Generate a man page for am (or for one command)
.TP
am\-theme(1)
Set or show the active color theme (persisted for the next run)
.TP
am\-alias(1)
Manage command aliases (cc \-> claude\-code)
.TP
am\-secret(1)
Manage secrets in the OS keyring (never in plaintext config)
.TP
am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP
am\-catalog(1)
Manage remote catalogs: update the official one, add external ones
.TP
am\-suggest(1)
Suggest agents matching a query, boosted by real usage (issue #61)
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
@@ -150,6 +162,9 @@ Display help: the overview, a command, the command list, or a search
am\-version(1)
Display version and build information
.TP
am\-tip(1)
Cheat sheet: the most useful commands, their key options, your most\-used commands and a rotating tip of the day
.TP
am\-config(1)
Manage the configuration file
.TP
@@ -174,4 +189,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.3
v0.4.5
+2 -1
View File
@@ -133,7 +133,8 @@ impl Catalog {
/// Score a keyword against one haystack field: 100 for a full hit,
/// then subsequence and typo bonuses; 0 = no relation at all.
fn score_keyword(keyword: &str, hay: &str) -> u32 {
/// Public so `am suggest` can reuse the exact same ranking (issue #61).
pub fn score_keyword(keyword: &str, hay: &str) -> u32 {
if keyword.is_empty() {
return 0;
}
+303
View File
@@ -0,0 +1,303 @@
//! Remote catalogs (issues #60 #67, axes 7 & 9): fetching a catalog from
//! an http(s) URL, validating its format, and caching it on disk the
//! probe.rs way (keyed by URL + timestamp, TTL-bounded). Used both by
//! 'am catalog update/add' and by config `include:` entries pointing at
//! URLs.
//!
//! Rules:
//! * only http, https and file:// URLs are accepted (download::validate_url);
//! * a cached copy is served while it is fresh; on network failure the
//! stale cache is used with a warning (offline support);
//! * a malformed catalog is an error, never silently skipped.
use crate::config::{self, Config};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Default time-to-live of a cached remote catalog, in seconds (1 h).
pub const DEFAULT_TTL_SECS: u64 = 3600;
/// Base URL of the official catalog (the agent-manager repo on Gitea).
pub const DEFAULT_CATALOG_URL: &str =
"https://git.dracodev.net/Projets/agent-manager/raw/branch/main/config.yaml";
/// One cached catalog: the raw text, the URL it came from, and the fetch
/// time (seconds since the epoch).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedCatalog {
pub url: String,
pub fetched_at: u64,
pub text: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CatalogCache {
pub entries: BTreeMap<String, CachedCatalog>,
}
impl CatalogCache {
fn load(state_dir: &Path) -> Self {
std::fs::read_to_string(state_dir.join("catalog-cache.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save(&self, state_dir: &Path) {
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(state_dir.join("catalog-cache.json"), text);
}
}
}
/// Fetch the text of a catalog URL, using and refreshing the on-disk cache.
/// `force` bypasses the TTL (used by 'am catalog update').
pub fn fetch(app: &crate::app::App, url: &str, force: bool) -> Result<String> {
let ttl = app
.config
.settings
.catalog_cache_ttl_secs
.unwrap_or(DEFAULT_TTL_SECS);
fetch_with_cache(&app.events_dir(), url, force, ttl, Some(&app.log))
}
/// Fetch a catalog URL during config loading (no App available yet): the
/// cache lives in the state directory and the TTL is the default one.
pub fn fetch_for_config(state_dir: &Path, url: &str) -> Result<String> {
fetch_with_cache(state_dir, url, false, DEFAULT_TTL_SECS, None)
}
/// Shared fetch + cache logic.
fn fetch_with_cache(
state_dir: &Path,
url: &str,
force: bool,
ttl: u64,
log: Option<&crate::output::Logger>,
) -> Result<String> {
crate::download::validate_url(url)?;
let mut cache = CatalogCache::load(state_dir);
let now = now_secs();
// Fresh cache hit: no network.
if !force {
if let Some(entry) = cache.entries.get(url) {
if now.saturating_sub(entry.fetched_at) < ttl {
return Ok(entry.text.clone());
}
}
}
// Try the network; fall back to the stale cache on failure.
let fresh = fetch_url_text(url);
match fresh {
Ok(text) => {
cache.entries.insert(
url.to_string(),
CachedCatalog {
url: url.to_string(),
fetched_at: now,
text: text.clone(),
},
);
cache.save(state_dir);
Ok(text)
}
Err(net_err) => {
if let Some(entry) = cache.entries.get(url) {
if let Some(log) = log {
log.warn(&format!(
"cannot refresh catalog {url} ({net_err:#}) — using the cached copy from {}",
format_ts(entry.fetched_at)
));
}
Ok(entry.text.clone())
} else {
Err(net_err)
}
}
}
}
/// Fetch the raw text of an http(s) or file:// URL (no cache).
fn fetch_url_text(url: &str) -> Result<String> {
if let Some(local) = url.strip_prefix("file://") {
// 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 p = PathBuf::from(local);
return std::fs::read_to_string(&p)
.with_context(|| format!("cannot read catalog file {}", p.display()));
}
let resp = ureq::get(url)
.set("User-Agent", crate::download::USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.call()
.map_err(|e| match e {
ureq::Error::Status(code, r) => anyhow!(
"HTTP {code} {} while fetching {url}",
r.status_text()
),
other => anyhow!("cannot fetch {url}: {other}"),
})?;
resp.into_string()
.with_context(|| format!("reading the catalog body of {url}"))
}
/// Parse and validate a catalog YAML text; returns the parsed Config.
pub fn parse_catalog(text: &str) -> Result<Config> {
let cfg: Config = serde_yaml::from_str(text)
.map_err(|e| anyhow!("the remote catalog is not a valid config: {e}"))?;
let problems = config::validate(&cfg);
if !problems.is_empty() {
bail!(
"the remote catalog is invalid:\n - {}",
problems.join("\n - ")
);
}
Ok(cfg)
}
/// Diff between the local effective catalog and a remote one: agent names
/// that are new, removed, or changed (run command differs).
pub struct CatalogDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<(String, String, String)>, // (name, local run, remote run)
}
pub fn diff_catalogs(local: &Config, remote: &Config) -> CatalogDiff {
let local_names: BTreeMap<&str, &str> = local
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let remote_names: BTreeMap<&str, &str> = remote
.agents
.iter()
.map(|a| (a.name.as_str(), a.run.as_deref().unwrap_or("")))
.collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (name, run) in &remote_names {
match local_names.get(name) {
None => added.push((*name).to_string()),
Some(local_run) if local_run != run => {
changed.push(((*name).to_string(), (*local_run).to_string(), (*run).to_string()));
}
_ => {}
}
}
for name in local_names.keys() {
if !remote_names.contains_key(name) {
removed.push((*name).to_string());
}
}
added.sort();
removed.sort();
changed.sort();
CatalogDiff {
added,
removed,
changed,
}
}
/// The official catalog URL: the configured value, or the Gitea default.
pub fn official_url(app: &crate::app::App) -> String {
app.config
.settings
.catalog_url
.clone()
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn format_ts(secs: u64) -> String {
let dt = chrono::DateTime::from_timestamp(secs as i64, 0);
dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown date".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = "version: \"1.0\"\nagents:\n - name: alpha\n run: alpha\n install: { type: binary, url: \"https://x/a\" }\n";
#[test]
fn parses_and_validates_a_remote_catalog() {
let cfg = parse_catalog(VALID).unwrap();
assert_eq!(cfg.agents.len(), 1);
assert_eq!(cfg.agents[0].name, "alpha");
}
#[test]
fn rejects_malformed_catalogs() {
assert!(parse_catalog("not: [valid").is_err());
// Missing run on an installable agent is invalid.
assert!(parse_catalog(
"version: \"1.0\"\nagents:\n - name: x\n install: { type: npm, package: y }\n"
)
.is_err());
}
#[test]
fn diff_detects_added_removed_changed() {
let local: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: gone, run: gone, install: { type: binary, url: \"https://x/g\" } }\n - { name: tweaked, run: old, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let remote: Config = serde_yaml::from_str(
"version: \"1.0\"\nagents:\n - { name: keep, run: keep, install: { type: binary, url: \"https://x/k\" } }\n - { name: fresh, run: fresh, install: { type: binary, url: \"https://x/f\" } }\n - { name: tweaked, run: new, install: { type: binary, url: \"https://x/t\" } }\n",
)
.unwrap();
let d = diff_catalogs(&local, &remote);
assert_eq!(d.added, vec!["fresh".to_string()]);
assert_eq!(d.removed, vec!["gone".to_string()]);
assert_eq!(d.changed.len(), 1);
assert_eq!(d.changed[0].0, "tweaked");
assert_eq!(d.changed[0].1, "old");
assert_eq!(d.changed[0].2, "new");
}
#[test]
fn file_url_fetch_works_offline() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("cat.yaml");
std::fs::write(&p, VALID).unwrap();
let url = format!("file://{}", p.display());
let text = fetch_url_text(&url).unwrap();
assert!(text.contains("alpha"));
}
#[test]
fn cache_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut cache = CatalogCache::default();
cache.entries.insert(
"https://x".to_string(),
CachedCatalog {
url: "https://x".to_string(),
fetched_at: 42,
text: "body".to_string(),
},
);
cache.save(dir.path());
let back = CatalogCache::load(dir.path());
assert_eq!(back.entries["https://x"].text, "body");
}
}
+49
View File
@@ -175,6 +175,17 @@ pub enum Command {
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs),
/// Manage remote catalogs: update the official one, add external ones
#[command(subcommand)]
Catalog(CatalogCmd),
/// Suggest agents matching a query, boosted by real usage (issue #61)
Suggest {
/// Free-form request ("un agent pour du Python")
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
words: Vec<String>,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -387,6 +398,9 @@ pub enum Command {
Run {
/// Agent name or alias
agent: String,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>,
@@ -452,6 +466,41 @@ pub struct StartArgs {
/// Apply an environment profile (env + args, defined in the config)
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
/// Local model to use for this run (validated against the local runtimes)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
}
/// Arguments of the models command: inventory by default, prune with --prune.
#[derive(Args, Debug, Clone, Default)]
pub struct ModelsArgs {
/// Purge unused models instead of listing them
#[arg(long)]
pub prune: bool,
/// Only list what would be pruned, delete nothing
#[arg(long, conflicts_with = "yes")]
pub dry_run: bool,
/// Answer yes to the deletion prompt
#[arg(long)]
pub yes: bool,
/// Consider a model unused after N days (default: 30)
#[arg(long, value_name = "DAYS")]
pub days: Option<u64>,
}
/// Remote catalog subcommands (issues #60 #67).
#[derive(Subcommand, Debug, Clone)]
pub enum CatalogCmd {
/// Fetch the official catalog, show the diff and register it as an include
Update,
/// Register an external catalog (same YAML format) by URL
Add {
/// Catalog URL (http, https or file)
#[arg(value_name = "URL")]
url: String,
},
/// List the registered remote catalogs
List,
}
/// Environment profile subcommands (issue #40).
+166
View File
@@ -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}'");
}
}
}
+39 -9
View File
@@ -217,7 +217,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
register_include(app, &base)?;
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
user_config_path(app)?.display()
));
Ok(0)
}
/// Read the `include:` entries of the user config file (they are consumed
/// at load time, so the resolved config no longer lists them).
pub fn user_includes(app: &App) -> Vec<String> {
let Some(cfg_path) = user_config_path_if_any(app) else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
return Vec::new();
};
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
return Vec::new();
};
value
.get("include")
.and_then(|i| i.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
/// Add one entry to the `include:` list of the user config file (creating
/// the file with a template when it does not exist). Used by 'config add'
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
pub fn register_include(app: &App, entry: &str) -> Result<()> {
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
@@ -227,7 +262,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
let entry = serde_yaml::Value::String(entry.to_string());
if !seq.contains(&entry) {
seq.push(entry);
}
@@ -236,7 +271,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
entry.to_string(),
)]),
);
}
@@ -247,16 +282,11 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
Ok(())
}
#[cfg(test)]
+9 -3
View File
@@ -3,6 +3,7 @@
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
@@ -17,6 +18,7 @@ pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod models_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
@@ -24,17 +26,18 @@ pub mod run_cmd;
pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod suggest_cmd;
pub mod theme_cmd;
pub mod timeline_cmd;
pub mod tip_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd;
pub mod watch_cmd;
use crate::app::App;
use crate::cli::Command;
@@ -84,6 +87,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Agents { installed } => agents_cmd::run(app, *installed),
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::Catalog(sub) => catalog_cmd::run(app, sub),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -167,7 +173,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Config(sub) => config_cmd::run(app, sub),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, args } => run_cmd::run(app, agent, args),
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
+198
View File
@@ -0,0 +1,198 @@
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
//! and pruning of unused models (issues #70 #73, axe 11).
use super::*;
use crate::cli::ModelsArgs;
use crate::events::{Event, EventKind};
use crate::models::{self, RuntimeModel};
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct ModelRow {
runtime: String,
name: String,
size_bytes: Option<u64>,
quantization: Option<String>,
last_used: Option<String>,
}
#[derive(Serialize)]
struct RuntimeRow {
runtime: String,
detected: bool,
note: Option<String>,
models: usize,
}
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
if args.prune {
return prune(app, args);
}
let inventory = models::detect(app);
if app.json() {
print_json(&serde_json::json!({
"runtimes": inventory.runtimes,
"models": inventory.models,
}));
return Ok(0);
}
let theme = app.theme();
let color = app.color();
// Runtime status lines.
for r in &inventory.runtimes {
let icon = if r.detected { "✓" } else { "✗" };
let note = r.note.as_deref().unwrap_or("");
println!(" {icon} {} — {note}", r.runtime);
}
if !inventory.any_runtime() {
app.log.info(
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
);
return Ok(0);
}
if inventory.models.is_empty() {
app.log.info("runtimes detected but no models found");
return Ok(0);
}
// Table: runtime | model | size | quant | last used.
let rows: Vec<Vec<String>> = inventory
.models
.iter()
.map(|m| {
vec![
m.runtime.clone(),
m.name.clone(),
human_size(m.size_bytes),
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
]
})
.collect();
let _ = (theme, color);
print!(
"{}",
crate::output::boxed_table(
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
rows,
app.theme(),
app.color(),
)
);
println!(
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
inventory.models.len()
);
Ok(0)
}
/// prune: list unused models, then delete them after confirmation.
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
let days = args.days.unwrap_or_else(|| {
app.config
.settings
.models_prune_days
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
});
let inventory = models::detect(app);
let candidates = models::prune_candidates(&inventory.models, days);
let _ = &inventory;
if app.json() {
let rows: Vec<ModelRow> = candidates
.iter()
.map(|m| ModelRow {
runtime: m.runtime.clone(),
name: m.name.clone(),
size_bytes: m.size_bytes,
quantization: m.quantization.clone(),
last_used: m.last_used.clone(),
})
.collect();
print_json(&rows);
return Ok(if rows.is_empty() { 0 } else { 1 });
}
if candidates.is_empty() {
app.log.success(&format!(
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
));
return Ok(0);
}
println!(
"{} model(s) unused for more than {days} days (':latest' models are protected):",
candidates.len()
);
for m in &candidates {
let size = human_size(m.size_bytes);
let last = m.last_used.as_deref().unwrap_or("unknown");
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
}
if args.dry_run {
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
return Ok(0);
}
let asked = app
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
if !asked {
app.log.info("prune cancelled");
return Ok(0);
}
let mut removed = 0;
let mut errors = 0;
for m in &candidates {
match models::remove_model(m) {
Ok(()) => {
app.emit(
&Event::now(EventKind::Model)
.with_agent(m.runtime.clone())
.with_args(vec![m.name.clone()])
.with_reason("prune"),
);
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
removed += 1;
}
Err(e) => {
app.log.error(&format!("{}: {e:#}", m.name));
errors += 1;
}
}
}
// The inventory cache is stale after a prune: drop it so the next run
// rescans the runtimes.
let cache_path = app.events_dir().join("models-cache.json");
let _ = std::fs::remove_file(&cache_path);
if errors > 0 {
app.log
.error(&format!("{errors} model(s) could not be removed"));
return Ok(1);
}
app.log
.success(&format!("{removed} model(s) removed from the local runtimes"));
Ok(0)
}
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
fn human_size(bytes: Option<u64>) -> String {
match bytes {
None => "—".to_string(),
Some(b) => {
let kb = b as f64 / 1024.0;
let mb = kb / 1024.0;
let gb = mb / 1024.0;
if gb >= 1.0 {
format!("{gb:.1} GB")
} else if mb >= 1.0 {
format!("{mb:.0} MB")
} else {
format!("{kb:.0} KB")
}
}
}
}
/// Usage of each model name across sessions (for --json consumers).
#[allow(dead_code)]
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
BTreeMap::new()
}
+68 -3
View File
@@ -69,6 +69,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
&extra_args,
&extra_env,
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
@@ -299,10 +309,19 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
let mut extra_args: Vec<String> = extra
.iter()
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -339,6 +358,52 @@ fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Resolve --model against the local runtimes and build the transmission
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
/// model is an explicit error listing the available runtimes.
pub fn apply_model(
app: &App,
agent: &AgentDef,
model: &str,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let inventory = crate::models::detect(app);
if inventory.find(model).is_none() {
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
let hint = if known.is_empty() {
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
} else {
format!("available models: {} — run 'am models' for details", known.join(", "))
};
bail!("model '{model}' not found on any local runtime ({hint})");
}
let mut args = Vec::new();
let mut env = BTreeMap::new();
if let Some(var) = &agent.model_env {
env.insert(var.clone(), model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {var}",
agent.name
));
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {flag}",
agent.name
));
} else {
// Generic fallback: many local agents read AM_MODEL; the config can
// pin a specific channel (model_env / model_arg) instead.
env.insert("AM_MODEL".to_string(), model.to_string());
app.log.info(&format!(
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
agent.name
));
}
Ok((args, env))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
+229
View File
@@ -0,0 +1,229 @@
//! suggest: recommend agents for a natural-language request (issue #61,
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
//! category/tags) and boosts the ranking with real usage from the event
//! journal (launch counts and durations), with a short justification.
use super::*;
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
/// Top N suggestions shown.
const TOP_N: usize = 5;
#[derive(Serialize)]
struct SuggestRow {
name: String,
display_name: String,
category: Option<String>,
/// Combined score (fuzzy * usage boost).
score: f64,
/// Why this agent was suggested.
reason: String,
/// Real usage in the journal (when available).
launches: u64,
total_duration_s: u64,
}
pub fn run(app: &App, query: &str) -> Result<i32> {
let query = query.trim();
if query.is_empty() {
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
}
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
let scored: Vec<(u32, &AgentDef)> = app
.catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter_map(|a| {
let kw = query.to_lowercase();
let name = a.name.to_lowercase();
let title = a.title().to_lowercase();
let rest = format!(
"{} {} {}",
a.description.as_deref().unwrap_or("").to_lowercase(),
a.category.as_deref().unwrap_or("").to_lowercase(),
a.tags.join(" ").to_lowercase()
);
let score = score_token(&kw, &name)
.saturating_mul(3)
.saturating_add(score_token(&kw, &title).saturating_mul(2))
.saturating_add(score_token(&kw, &rest));
(score > 0).then_some((score, a))
})
.collect();
// 2. Real usage boost from the journal (stats_cmd aggregates launches
// and durations per agent).
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
let launches: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.launches))
.collect();
let duration: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.total_duration_s))
.collect();
let mut rows: Vec<SuggestRow> = scored
.iter()
.map(|(score, a)| {
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
// Boost: up to +50 % for the most used agents (log scale).
let boost = if l > 0 {
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
} else {
1.0
};
let final_score = (*score as f64) * boost;
let reason = justify(a, l, d);
SuggestRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
score: final_score,
reason,
launches: l,
total_duration_s: d,
}
})
.collect();
rows.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.name.cmp(&b.name))
});
rows.truncate(TOP_N);
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
let suggestions = app.catalog.suggest(query);
if suggestions.is_empty() {
app.log.info(&format!(
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
));
} else {
app.log.info(&format!(
"no agent matches '{query}' — vouliez-vous dire : {} ?",
suggestions.join(", ")
));
}
return Ok(0);
}
println!("suggestions for \"{query}\":");
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.category.clone().unwrap_or_else(|| "—".to_string()),
r.reason.clone(),
]);
}
print!("{}", table.render());
if rows.len() < scored.len() {
println!(
"\n ({} other match(es) not shown — 'am search {}' lists them)",
scored.len() - rows.len(),
query
);
}
Ok(0)
}
/// Short justification: matched field(s) + real usage when available.
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &a.category {
parts.push(format!("catégorie {c}"));
}
if !a.tags.is_empty() {
parts.push(format!("tags: {}", a.tags.join(", ")));
}
if launches > 0 {
parts.push(format!("{launches} lancement(s)"));
if duration_s >= 3600 {
parts.push(format!("{}h", duration_s / 3600));
}
}
if parts.is_empty() {
a.description
.as_deref()
.map(|d| {
let mut d = d.to_string();
d.truncate(60);
d
})
.unwrap_or_else(|| "correspond à la requête".to_string())
} else {
parts.join(" · ")
}
}
/// Reuse the catalog scoring helper (exported from catalog.rs).
fn score_token(keyword: &str, hay: &str) -> u32 {
crate::catalog::score_keyword(keyword, hay)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
AgentDef {
name: name.to_string(),
display_name: None,
description: Some(desc.to_string()),
category: Some("coding-agent".to_string()),
website: None,
install: None,
dependencies: vec![],
run: Some(name.to_string()),
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false,
note: None,
hidden: false,
platforms: vec![],
}
}
#[test]
fn scoring_prefers_relevant_agents() {
let a = agent("python-mate", "assistant python", &["python"]);
let b = agent("rust-mate", "assistant rust", &["rust"]);
let catalog = crate::catalog::Catalog::new(
vec![a, b],
BTreeMap::new(),
BTreeMap::new(),
);
let kw = "python";
let mut scored: Vec<(&str, u32)> = catalog
.agents()
.iter()
.filter_map(|x| {
let s = score_token(kw, &x.name.to_lowercase())
.saturating_mul(3)
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
.saturating_add(score_token(
kw,
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
));
(s > 0).then_some((x.name.as_str(), s))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
assert_eq!(scored[0].0, "python-mate");
}
}
+22
View File
@@ -97,6 +97,28 @@ pub static SECTIONS: &[TipSection] = &[
options: &[],
example: "search pi",
},
TipEntry {
usage: "suggest <requête>",
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
options: &[
("--prune", "purge les modèles inutilisés"),
("--dry-run", "simulation sans rien supprimer"),
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
],
example: "models --prune --dry-run",
},
TipEntry {
usage: "catalog update",
about: "rafraîchit le catalogue officiel (diff + confirmation)",
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
+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);
}
+6
View File
@@ -32,6 +32,10 @@ pub enum EventKind {
Shell,
/// Personal annotations: favorite, note, tags (issue #39).
Annotate,
/// Local model runtime action: prune of unused models (issue #73).
Model,
/// Remote catalog action: update or add of an external catalog (issue #60).
Catalog,
}
impl EventKind {
@@ -49,6 +53,8 @@ impl EventKind {
EventKind::Repl => "repl",
EventKind::Shell => "shell",
EventKind::Annotate => "annotate",
EventKind::Model => "model",
EventKind::Catalog => "catalog",
}
}
}
+54
View File
@@ -367,6 +367,60 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Supervise with auto-restart.", code: "watch claude-code --restart --notify" },
],
},
HelpSpec {
name: "models",
category: "Commands",
usage: "models {flags}",
about: "List the models of the local runtimes (ollama, llama.cpp, LM Studio) and prune unused ones.",
search_terms: &["ollama", "llama", "lm studio", "model", "prune", "gguf"],
flags: &[
HelpFlag { short: "", long: "--prune", value: "", desc: "Purge unused models instead of listing them" },
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Only list what would be pruned, delete nothing" },
HelpFlag { short: "", long: "--days", value: "DAYS", desc: "Consider a model unused after N days (default: 30)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "List the local models.", code: "models" },
HelpExample { desc: "Preview the prune candidates.", code: "models --prune --dry-run" },
HelpExample { desc: "Prune models unused for 60 days.", code: "models --prune --days 60" },
],
},
HelpSpec {
name: "catalog",
category: "Commands",
usage: "catalog {flags} <update|add|list> [url]",
about: "Manage remote agent catalogs: refresh the official one or register external catalogs by URL.",
search_terms: &["remote", "url", "team", "share", "include"],
flags: &[],
subcommands: &[
("update", "Fetch the official catalog, show the diff and register it"),
("add", "Register an external catalog by URL"),
("list", "List the registered remote catalogs"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Refresh the official catalog.", code: "catalog update" },
HelpExample { desc: "Add a team catalog.", code: "catalog add https://git.dracodev.net/team/agents.yaml" },
],
},
HelpSpec {
name: "suggest",
category: "Commands",
usage: "suggest <query>",
about: "Recommend agents for a natural-language request, ranked by relevance and real usage.",
search_terms: &["recommend", "recommendation", "find an agent", "what agent"],
flags: &[],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Find an agent for Python.", code: "suggest un agent pour du Python" },
HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" },
],
},
HelpSpec {
name: "alias",
category: "Commands",
+2
View File
@@ -13,6 +13,7 @@
pub mod app;
pub mod catalog;
pub mod catalog_remote;
pub mod cli;
pub mod commands;
pub mod config;
@@ -26,6 +27,7 @@ pub mod help;
pub mod history;
pub mod hooks;
pub mod installers;
pub mod models;
pub mod nav;
pub mod output;
pub mod probe;
+712
View File
@@ -0,0 +1,712 @@
//! Local model runtimes (axe 11): inventory of models available on
//! ollama, llama.cpp (llama-server) and LM Studio, with an on-disk cache
//! following the probe.rs pattern (keyed by runtime identity + timestamp),
//! and a prune helper for unused models.
//!
//! Rules:
//! * a missing runtime is reported, never fatal;
//! * the cache is bounded and short-lived (default TTL 30 s);
//! * parsing functions are pure and unit-tested with fixtures.
use crate::app::App;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use wait_timeout::ChildExt;
/// Default time-to-live of a cached runtime inventory, in seconds.
pub const CACHE_TTL_SECS: u64 = 30;
/// Default number of days before an unused model becomes a prune candidate.
pub const DEFAULT_PRUNE_DAYS: u64 = 30;
/// A model found on a local runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RuntimeModel {
/// Runtime name: "ollama", "llama.cpp" or "lm-studio".
pub runtime: String,
/// Model identifier (e.g. "llama3.1:8b").
pub name: String,
/// Disk size in bytes when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
/// Quantization level when known (e.g. "Q4_K_M").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantization: Option<String>,
/// RFC 3339 timestamp of the last use/modification when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used: Option<String>,
/// Absolute path of the model file (llama.cpp) or empty.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub path: String,
}
/// State of one runtime after detection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeInfo {
pub runtime: String,
/// Whether the runtime was found (binary on PATH / API reachable).
pub detected: bool,
/// Human-readable detail (version, endpoint, or why it was not found).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Full inventory: runtime states plus the models of the detected runtimes.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModelsInventory {
pub runtimes: Vec<RuntimeInfo>,
pub models: Vec<RuntimeModel>,
}
impl ModelsInventory {
/// The models of one runtime (when detected).
pub fn models_of(&self, runtime: &str) -> Vec<&RuntimeModel> {
self.models
.iter()
.filter(|m| m.runtime == runtime)
.collect()
}
/// Look up a model by name across every runtime (name matched exactly,
/// or by short name when it matches a single model).
pub fn find(&self, name: &str) -> Option<&RuntimeModel> {
let exact: Vec<&RuntimeModel> = self
.models
.iter()
.filter(|m| m.name == name)
.collect();
match exact.len() {
0 => {
// Short name (e.g. "llama3"): unique suffix match wins.
let short: Vec<&RuntimeModel> = self
.models
.iter()
.filter(|m| m.name.split(':').next() == Some(name))
.collect();
(short.len() == 1).then(|| short[0])
}
1 => Some(exact[0]),
_ => {
// Ambiguous: prefer the "latest" tag, then the first.
exact
.iter()
.find(|m| m.name.ends_with(":latest"))
.copied()
.or(Some(exact[0]))
}
}
}
/// Is at least one runtime detected?
pub fn any_runtime(&self) -> bool {
self.runtimes.iter().any(|r| r.detected)
}
}
// ---------------------------------------------------------------------------
// Detection
// ---------------------------------------------------------------------------
/// Detect every local model runtime and their models, using the on-disk
/// cache. A steady-state run does no subprocess and no HTTP request.
pub fn detect(app: &App) -> ModelsInventory {
let cache_path = app.events_dir().join("models-cache.json");
let mut cache: ModelsCache = std::fs::read_to_string(&cache_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let now = now_secs();
let ollama = cached_or_scan(
&mut cache,
"ollama",
&ollama_key(),
now,
CACHE_TTL_SECS,
detect_ollama,
);
let llamacpp = cached_or_scan(
&mut cache,
"llama.cpp",
&llamacpp_key(),
now,
CACHE_TTL_SECS,
detect_llamacpp,
);
let lmstudio = cached_or_scan(
&mut cache,
"lm-studio",
&"api".to_string(),
now,
CACHE_TTL_SECS,
detect_lmstudio,
);
if let Ok(text) = serde_json::to_string_pretty(&cache) {
let _ = std::fs::write(&cache_path, text);
}
let mut runtimes = Vec::new();
let mut models = Vec::new();
for (info, ms) in [ollama, llamacpp, lmstudio] {
runtimes.push(info);
models.extend(ms);
}
ModelsInventory { runtimes, models }
}
/// Run `scan` unless a fresh cache entry exists; stores the result.
fn cached_or_scan(
cache: &mut ModelsCache,
runtime: &str,
key: &str,
now: u64,
ttl: u64,
scan: fn() -> Vec<RuntimeModel>,
) -> (RuntimeInfo, Vec<RuntimeModel>) {
let fresh = cache
.entries
.get(runtime)
.filter(|e| e.key == key && now.saturating_sub(e.fetched_at) < ttl);
if let Some(entry) = fresh {
return (entry.info.clone(), entry.models.clone());
}
let models = scan();
let info = runtime_info(runtime, &models);
cache.entries.insert(
runtime.to_string(),
CacheEntry {
key: key.to_string(),
fetched_at: now,
info: info.clone(),
models: models.clone(),
},
);
(info, models)
}
/// Describe a runtime from its models: detected when there is at least one
/// model (or the binary was found even with zero models).
fn runtime_info(runtime: &str, models: &[RuntimeModel]) -> RuntimeInfo {
match runtime {
"ollama" => RuntimeInfo {
runtime: runtime.to_string(),
detected: which::which("ollama").is_ok(),
note: if models.is_empty() {
Some("binary not found on PATH, or 'ollama list' returned nothing".to_string())
} else {
Some(format!("{} model(s) via 'ollama list'", models.len()))
},
},
"llama.cpp" => RuntimeInfo {
runtime: runtime.to_string(),
detected: !models.is_empty(),
note: if models.is_empty() {
Some("llama-server/llama-cli not found, or no .gguf under the model directories".to_string())
} else {
Some(format!("{} model(s) found on disk", models.len()))
},
},
_ => RuntimeInfo {
runtime: runtime.to_string(),
detected: !models.is_empty(),
note: if models.is_empty() {
Some("no response from http://127.0.0.1:1234/v1/models".to_string())
} else {
Some(format!("{} model(s) via the local API", models.len()))
},
},
}
}
/// Identity key of the ollama binary (path + mtime + size), so a stale
/// cache entry is never reused after an ollama update.
fn ollama_key() -> String {
which::which("ollama")
.map(|p| bin_key(&p))
.unwrap_or_else(|_| "missing".to_string())
}
/// Identity key of the llama.cpp binaries (llama-server or llama-cli).
fn llamacpp_key() -> String {
let bin = which::which("llama-server")
.or_else(|_| which::which("llama-cli"))
.unwrap_or_default();
if bin.as_os_str().is_empty() {
"missing".to_string()
} else {
bin_key(&bin)
}
}
/// Run `ollama list --format json` and parse the model list. Any failure
/// (binary missing, timeout, bad output) yields an empty list.
fn detect_ollama() -> Vec<RuntimeModel> {
let Ok(bin) = which::which("ollama") else {
return Vec::new();
};
let output = match run_capture(&bin, &["list", "--format", "json"]) {
Some(o) => o,
None => return Vec::new(),
};
parse_ollama_list(&output).unwrap_or_default()
}
/// Parse the `ollama list --format json` output.
fn parse_ollama_list(json: &str) -> Option<Vec<RuntimeModel>> {
#[derive(Deserialize)]
struct OllamaModel {
name: String,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
details: Option<OllamaDetails>,
#[serde(default)]
modified_at: Option<String>,
}
#[derive(Deserialize)]
struct OllamaDetails {
#[serde(default)]
quantization_level: Option<String>,
}
#[derive(Deserialize)]
struct List {
#[serde(default)]
models: Vec<OllamaModel>,
}
let list: List = serde_json::from_str(json).ok()?;
Some(
list.models
.into_iter()
.map(|m| RuntimeModel {
runtime: "ollama".to_string(),
name: m.name,
size_bytes: m.size,
quantization: m.details.and_then(|d| d.quantization_level),
last_used: m.modified_at,
path: String::new(),
})
.collect(),
)
}
/// Scan the local disk for llama.cpp models (.gguf files) under the known
/// model directories. The quantization is read from the file name when
/// possible; the last use is the file modification time.
fn detect_llamacpp() -> Vec<RuntimeModel> {
let _ = which::which("llama-server").or_else(|_| which::which("llama-cli"));
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(h) = crate::config::home_dir() {
dirs.push(h.join("models"));
dirs.push(h.join(".cache").join("llama.cpp"));
dirs.push(h.join(".llama").join("models"));
}
if let Some(d) = std::env::var_os("LLAMA_MODELS_DIR") {
dirs.push(PathBuf::from(d));
}
let mut out = Vec::new();
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for dir in dirs {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if !p.is_file() || !p.to_string_lossy().to_lowercase().ends_with(".gguf") {
continue;
}
if seen.insert(p.display().to_string(), ()).is_some() {
continue;
}
let meta = std::fs::metadata(&p).ok();
let size = meta.as_ref().map(|m| m.len());
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.map(rfc3339_from_system_time);
let name = p
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("model")
.to_string();
out.push(RuntimeModel {
runtime: "llama.cpp".to_string(),
name,
size_bytes: size,
quantization: quantization_from_name(&p.display().to_string()),
last_used: mtime,
path: p.display().to_string(),
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Extract a quantization token (Q4_K_M, Q8_0, ...) from a file name.
fn quantization_from_name(name: &str) -> Option<String> {
// Patterns: "Q4_K_M", "Q8_0", "q4_0", "IQ4_XS", "Q5_K_S", ...
let up = name.to_uppercase();
let bytes: Vec<char> = up.chars().collect();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == 'Q' && bytes[i + 1].is_ascii_digit() {
let start = i;
let mut j = i + 1;
let mut digits = 0;
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == '_') {
if bytes[j].is_ascii_digit() {
digits += 1;
}
j += 1;
}
// Suffix letters allowed: Q4_K_M, Q5_K_S, Q6_K, Q3_K_L, ...
while j < bytes.len()
&& (bytes[j].is_ascii_uppercase() || bytes[j] == '_')
&& j - start < 10
{
j += 1;
}
// Also match the "IQ4_XS" style (I + Q + digits).
let token_start = if start > 0 && bytes[start - 1] == 'I' {
start - 1
} else {
start
};
let token: String = bytes[token_start..j].iter().collect();
if digits >= 1
&& token.len() >= 3
&& token
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
{
return Some(token);
}
i = j;
} else {
i += 1;
}
}
None
}
/// Query the LM Studio local API (OpenAI-compatible, default port 1234).
fn detect_lmstudio() -> Vec<RuntimeModel> {
let url = std::env::var("LM_STUDIO_URL").unwrap_or_else(|_| "http://127.0.0.1:1234/v1/models".to_string());
let resp = ureq::get(&url)
.timeout(Duration::from_secs(3))
.call();
let Ok(resp) = resp else {
return Vec::new();
};
let Ok(text) = resp.into_string() else {
return Vec::new();
};
parse_lmstudio_list(&text).unwrap_or_default()
}
/// Parse the LM Studio /v1/models response: {"data": [{"id": ...}]}.
fn parse_lmstudio_list(json: &str) -> Option<Vec<RuntimeModel>> {
#[derive(Deserialize)]
struct LmModel {
id: String,
}
#[derive(Deserialize)]
struct List {
data: Vec<LmModel>,
}
let list: List = serde_json::from_str(json).ok()?;
Some(
list.data
.into_iter()
.map(|m| RuntimeModel {
runtime: "lm-studio".to_string(),
name: m.id,
size_bytes: None,
quantization: None,
last_used: None,
path: String::new(),
})
.collect(),
)
}
// ---------------------------------------------------------------------------
// Prune
// ---------------------------------------------------------------------------
/// Candidates for pruning: models not used within `days` days. The
/// default model of a runtime (any name tagged ":latest" for ollama, and
/// the first model of a runtime when it has no tag) is never a candidate.
pub fn prune_candidates(models: &[RuntimeModel], days: u64) -> Vec<&RuntimeModel> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
models
.iter()
.filter(|m| !is_default_model(m))
.filter(|m| {
let Some(lu) = &m.last_used else {
// Unknown last use: keep the model (do not guess).
return false;
};
chrono::DateTime::parse_from_rfc3339(lu)
.map(|t| t.with_timezone(&chrono::Utc) < cutoff)
.unwrap_or(false)
})
.collect()
}
/// Is this model the default of its runtime (protected from pruning)?
fn is_default_model(m: &RuntimeModel) -> bool {
m.name.ends_with(":latest")
}
/// Remove one model from its runtime. ollama models are removed with
/// `ollama rm`; llama.cpp models are files that are deleted. LM Studio has
/// no deletion endpoint: an error explains why.
pub fn remove_model(m: &RuntimeModel) -> anyhow::Result<()> {
match m.runtime.as_str() {
"ollama" => {
let bin = which::which("ollama")
.map_err(|_| anyhow::anyhow!("ollama binary not found on PATH"))?;
let status = std::process::Command::new(&bin)
.arg("rm")
.arg(&m.name)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()?;
if status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("'ollama rm {}' failed", m.name))
}
}
"llama.cpp" => {
if m.path.is_empty() {
return Err(anyhow::anyhow!(
"model '{}' has no known file path — nothing to delete",
m.name
));
}
std::fs::remove_file(&m.path)
.map_err(|e| anyhow::anyhow!("cannot delete {}: {e}", m.path))
}
_ => Err(anyhow::anyhow!(
"LM Studio has no deletion API — remove model '{}' from the LM Studio UI",
m.name
)),
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a binary with arguments, capturing stdout, with a hard timeout.
/// Returns None when the binary fails, times out or cannot run.
fn run_capture(bin: &Path, args: &[&str]) -> Option<String> {
use std::io::Read;
let mut cmd = std::process::Command::new(bin);
cmd.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().ok()?;
let status = match child.wait_timeout(Duration::from_secs(8)) {
Ok(Some(s)) => s,
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Err(_) => return None,
};
if !status.success() {
return None;
}
let mut out = String::new();
child.stdout.take()?.read_to_string(&mut out).ok()?;
Some(out)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// RFC 3339 string of a system time (best effort).
fn rfc3339_from_system_time(t: SystemTime) -> String {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
}
/// Identity key of a binary: path + modification time + size (probe.rs style).
fn bin_key(bin: &Path) -> String {
let meta = std::fs::metadata(bin).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.map(|m| m.len()).unwrap_or(0);
format!("{}|{mtime}|{size}", bin.display())
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ModelsCache {
entries: BTreeMap<String, CacheEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheEntry {
key: String,
fetched_at: u64,
info: RuntimeInfo,
models: Vec<RuntimeModel>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const OLLAMA_FIXTURE: &str = r#"{
"models": [
{
"name": "llama3.1:8b",
"size": 4661247078,
"details": { "quantization_level": "Q4_K_M" },
"modified_at": "2026-08-10T12:00:00Z"
},
{
"name": "llama3.1:latest",
"size": 4661247078,
"details": { "quantization_level": "Q4_K_M" },
"modified_at": "2026-08-17T12:00:00Z"
},
{
"name": "qwen2.5-coder:7b",
"size": 4700000000,
"details": { "quantization_level": "Q8_0" },
"modified_at": "2026-01-05T09:00:00Z"
}
]
}"#;
#[test]
fn parses_ollama_list() {
let models = parse_ollama_list(OLLAMA_FIXTURE).unwrap();
assert_eq!(models.len(), 3);
assert_eq!(models[0].name, "llama3.1:8b");
assert_eq!(models[0].runtime, "ollama");
assert_eq!(models[0].quantization.as_deref(), Some("Q4_K_M"));
assert_eq!(models[0].last_used.as_deref(), Some("2026-08-10T12:00:00Z"));
assert_eq!(models[0].size_bytes, Some(4661247078));
}
#[test]
fn parses_ollama_empty_list() {
let models = parse_ollama_list(r#"{"models": []}"#).unwrap();
assert!(models.is_empty());
}
#[test]
fn ollama_bad_output_yields_none() {
assert!(parse_ollama_list("not json").is_none());
}
#[test]
fn parses_lmstudio_list() {
let models = parse_lmstudio_list(
r#"{"object":"list","data":[{"id":"llama-3.1-8b","object":"model"},{"id":"codestral"}]}"#,
)
.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0].runtime, "lm-studio");
assert_eq!(models[0].name, "llama-3.1-8b");
assert_eq!(models[0].size_bytes, None);
}
#[test]
fn finds_models_exactly_and_by_short_name() {
let inv = ModelsInventory {
runtimes: vec![],
models: vec![
RuntimeModel { runtime: "ollama".into(), name: "llama3.1:8b".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
RuntimeModel { runtime: "ollama".into(), name: "llama3.1:latest".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
RuntimeModel { runtime: "lm-studio".into(), name: "codestral".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() },
],
};
assert_eq!(inv.find("llama3.1:8b").unwrap().name, "llama3.1:8b");
// Short name that is ambiguous (two llama3.1 variants) is not resolved.
assert!(inv.find("llama3.1").is_none());
// A short name matching exactly one model works.
assert_eq!(inv.find("codestral").unwrap().name, "codestral");
assert!(inv.find("nope").is_none());
}
#[test]
fn prune_skips_latest_and_recent_models() {
let models = parse_ollama_list(OLLAMA_FIXTURE).unwrap();
// qwen was last used in January: candidate; llama3.1:8b (Aug 10)
// is recent enough with 30 days; :latest is protected.
let cands = prune_candidates(&models, 30);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].name, "qwen2.5-coder:7b");
// With 7 days, the Aug 10 model becomes a candidate too.
let cands = prune_candidates(&models, 7);
let names: Vec<&str> = cands.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"llama3.1:8b"));
assert!(names.contains(&"qwen2.5-coder:7b"));
assert!(!names.contains(&"llama3.1:latest"));
}
#[test]
fn models_without_last_used_are_kept() {
let m = RuntimeModel { runtime: "lm-studio".into(), name: "x".into(), size_bytes: None, quantization: None, last_used: None, path: String::new() };
assert!(prune_candidates(&[m.clone()], 1).is_empty());
// Unparsable timestamps are also kept.
let mut m2 = m.clone();
m2.last_used = Some("garbage".into());
assert!(prune_candidates(&[m2], 1).is_empty());
}
#[test]
fn quantization_is_read_from_file_names() {
assert_eq!(quantization_from_name("~/models/llama-3.1.Q4_K_M.gguf").as_deref(), Some("Q4_K_M"));
assert_eq!(quantization_from_name("model-q8_0.gguf").as_deref(), Some("Q8_0"));
assert_eq!(quantization_from_name("plain.gguf"), None);
}
#[test]
fn bin_key_is_stable_and_distinct() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
std::fs::write(&a, b"abc").unwrap();
let k1 = bin_key(&a);
std::fs::write(&a, b"abcd").unwrap();
let k2 = bin_key(&a);
assert_ne!(k1, k2);
}
#[test]
fn cache_roundtrip() {
let mut cache = ModelsCache::default();
let info = RuntimeInfo { runtime: "ollama".into(), detected: true, note: None };
cache.entries.insert(
"ollama".into(),
CacheEntry { key: "k".into(), fetched_at: 42, info, models: vec![] },
);
let text = serde_json::to_string(&cache).unwrap();
let back: ModelsCache = serde_json::from_str(&text).unwrap();
assert_eq!(back.entries["ollama"].fetched_at, 42);
}
}
+40 -1
View File
@@ -56,6 +56,8 @@ pub struct AmCompleter {
groups: Vec<String>,
/// 'profile' subcommands.
profile_sub: Vec<&'static str>,
/// 'catalog' subcommands (issues #60 #67).
catalog_sub: Vec<&'static str>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
@@ -104,6 +106,9 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("tags", "list personal tags"),
("profile", "manage environment profiles"),
("man", "readable man page in the terminal"),
("models", "list local models (ollama, llama.cpp, LM Studio)"),
("catalog", "manage remote catalogs"),
("suggest", "recommend an agent for a request"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"),
@@ -217,11 +222,12 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man",
"profile", "man", "models", "catalog", "suggest",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
profile_sub: vec!["list", "show"],
catalog_sub: vec!["update", "add", "list"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
gateway_shells: vec![
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
@@ -265,6 +271,9 @@ impl AmCompleter {
"profile" => {
pool = self.profile_sub.iter().map(|s| s.to_string()).collect();
}
"catalog" => {
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
@@ -759,6 +768,9 @@ pub fn banner_box(
rows.push(inner(
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
));
rows.push(inner(
" models models · models --prune · catalog · suggest".to_string(),
));
rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(),
));
@@ -1416,6 +1428,32 @@ fn handle_line(
return Ok(false);
}
},
"models" => Command::Models(crate::cli::ModelsArgs {
prune: flag("--prune"),
dry_run: flag("--dry-run"),
yes: flag("--yes") || app.yes(),
days: opt_value("--days").and_then(|v| v.parse().ok()),
}),
"catalog" => match rest.first().map(|s| s.as_str()) {
Some("update") | None => Command::Catalog(crate::cli::CatalogCmd::Update),
Some("add") if rest.len() >= 2 => Command::Catalog(crate::cli::CatalogCmd::Add {
url: rest[1].clone(),
}),
Some("list") => Command::Catalog(crate::cli::CatalogCmd::List),
_ => {
app.log.error("usage: catalog update | catalog add <url> | catalog list");
return Ok(false);
}
},
"suggest" => {
if rest.is_empty() {
app.log.error("usage: suggest <requête> — ex: suggest un agent pour du Python");
return Ok(false);
}
Command::Suggest {
words: rest.to_vec(),
}
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
@@ -1583,6 +1621,7 @@ fn handle_line(
let agent = need("agent name")?;
Command::Run {
agent,
model: opt_value("--model"),
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ agents:
#[test]
fn run_emits_one_run_event_with_exit_code() {
let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[OsString::from("hello")]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, &[OsString::from("hello")]).unwrap();
assert_eq!(code, 0);
let evs = events(&app);
assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs);
@@ -58,7 +58,7 @@ fn run_emits_one_run_event_with_exit_code() {
#[test]
fn dry_run_writes_no_event() {
let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT);
let code = run_cmd::run(&app, "echo-agent", &[]).unwrap();
let code = run_cmd::run(&app, "echo-agent", None, &[]).unwrap();
assert_eq!(code, 0);
assert!(events(&app).is_empty(), "dry-run must not append events");
assert!(events::journal_files(&app.events_dir()).is_empty());