diff --git a/Cargo.lock b/Cargo.lock index 612c048..797f3f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a9415c8..b6ee714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 99e4830..d45b547 100644 --- a/README.md +++ b/README.md @@ -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 | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » | +| am suggest | recommande un agent pour une demande en langage naturel (tags + usage réel) | | am info | 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 | 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) diff --git a/ROADMAP.md b/ROADMAP.md index 7dd6cd3..ffb30c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | 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 | 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 | diff --git a/config.yaml b/config.yaml index 488ed64..f52342f 100644 --- a/config.yaml +++ b/config.yaml @@ -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: diff --git a/man/am-catalog.1 b/man/am-catalog.1 new file mode 100644 index 0000000..64a374c --- /dev/null +++ b/man/am-catalog.1 @@ -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) diff --git a/man/am-models.1 b/man/am-models.1 new file mode 100644 index 0000000..dd60867 --- /dev/null +++ b/man/am-models.1 @@ -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\fR +Consider a model unused after N days (default: 30) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/am-restart.1 b/man/am-restart.1 index 6b18591..b957e56 100644 --- a/man/am-restart.1 +++ b/man/am-restart.1 @@ -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\fR Apply an environment profile (env + args, defined in the config) .TP +\fB\-\-model\fR \fI\fR +Local model to use for this run (validated against the local runtimes) +.TP \fB\-\-force\fR Kill immediately instead of terminating gracefully .TP diff --git a/man/am-run.1 b/man/am-run.1 index b75e379..a3a3684 100644 --- a/man/am-run.1 +++ b/man/am-run.1 @@ -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\fR +Local model to use for this run (validated against the local runtimes) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/man/am-start.1 b/man/am-start.1 index c414011..7b7f32c 100644 --- a/man/am-start.1 +++ b/man/am-start.1 @@ -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\fR Apply an environment profile (env + args, defined in the config) .TP +\fB\-\-model\fR \fI\fR +Local model to use for this run (validated against the local runtimes) +.TP \fB\-h\fR, \fB\-\-help\fR Print help .TP diff --git a/man/am-suggest.1 b/man/am-suggest.1 new file mode 100644 index 0000000..8f0f2d1 --- /dev/null +++ b/man/am-suggest.1 @@ -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") diff --git a/man/am-theme.1 b/man/am-theme.1 new file mode 100644 index 0000000..6f86f90 --- /dev/null +++ b/man/am-theme.1 @@ -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) diff --git a/man/am-tip.1 b/man/am-tip.1 new file mode 100644 index 0000000..6124ef0 --- /dev/null +++ b/man/am-tip.1 @@ -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 diff --git a/man/am.1 b/man/am.1 index 14af47e..c5f0ca5 100644 --- a/man/am.1 +++ b/man/am.1 @@ -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 diff --git a/src/catalog.rs b/src/catalog.rs index 5fc5d88..ec72804 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -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; } diff --git a/src/catalog_remote.rs b/src/catalog_remote.rs new file mode 100644 index 0000000..dba300c --- /dev/null +++ b/src/catalog_remote.rs @@ -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, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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, + pub removed: Vec, + 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"); + } +} diff --git a/src/cli.rs b/src/cli.rs index e1d35be..3c10db0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + }, /// 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, /// Arguments passed through to the agent command #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")] args: Vec, @@ -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, + /// Local model to use for this run (validated against the local runtimes) + #[arg(long, value_name = "MODEL")] + pub model: Option, +} + +/// 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, +} + +/// 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). diff --git a/src/commands/catalog_cmd.rs b/src/commands/catalog_cmd.rs new file mode 100644 index 0000000..ce17e91 --- /dev/null +++ b/src/commands/catalog_cmd.rs @@ -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 ' 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 { + 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 { + 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::>(), + })); + 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 : validate and register an external catalog. +fn add(app: &App, url: &str) -> Result { + 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 { + let urls: Vec = 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 ' 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}'"); + } + } +} diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index e576396..75926ac 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -217,7 +217,42 @@ fn add_file(app: &App, file: &Path) -> Result { 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 { + 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::(&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 { 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 { 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 { _ => { 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)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0c54eeb..66be5df 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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 { 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 { 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), diff --git a/src/commands/models_cmd.rs b/src/commands/models_cmd.rs new file mode 100644 index 0000000..35a32ca --- /dev/null +++ b/src/commands/models_cmd.rs @@ -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, + quantization: Option, + last_used: Option, +} + +#[derive(Serialize)] +struct RuntimeRow { + runtime: String, + detected: bool, + note: Option, + models: usize, +} + +pub fn run(app: &App, args: &ModelsArgs) -> Result { + 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> = 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 { + 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 = 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) -> 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 { + BTreeMap::new() +} diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index 231d3c8..b517f4e 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -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) - } /// run: execute the agent command directly, no process management. -pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result { +pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result { let agent = require_agent(app, target)?; - let extra_args: Vec = 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 = extra + .iter() + .map(|o| o.to_string_lossy().to_string()) + .collect(); + let mut extra_env: BTreeMap = 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 { 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, BTreeMap)> { + let inventory = crate::models::detect(app); + if inventory.find(model).is_none() { + let known: Vec = 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 { let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?; diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs new file mode 100644 index 0000000..f2a7563 --- /dev/null +++ b/src/commands/suggest_cmd.rs @@ -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, + /// 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 { + 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 = 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 = 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"); + } +} diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index 38c81c1..0a2fa8f 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -97,6 +97,28 @@ pub static SECTIONS: &[TipSection] = &[ options: &[], example: "search pi", }, + TipEntry { + usage: "suggest ", + 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 ", "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 ", "ajoute un catalogue d'équipe par URL")], + example: "catalog add https://git.dracodev.net/team/agents.yaml", + }, TipEntry { usage: "install ", about: "installe l'agent et ses dépendances (Node, Python, Rust…)", diff --git a/src/config.rs b/src/config.rs index a541731..d58a7b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,6 +105,18 @@ pub struct Settings { /// Update policy: how aggressively 'am update' should act (issue #65). #[serde(default)] pub update_policy: Option, + /// A model is a prune candidate after this many days without use + /// (issue #73, default 30). + #[serde(default)] + pub models_prune_days: Option, + /// 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, + /// 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, } /// 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, + /// Environment variable carrying the model name at launch + /// (issue #71, e.g. ANTHROPIC_MODEL for claude-code). + #[serde(default)] + pub model_env: Option, + /// CLI flag carrying the model name at launch (issue #71, e.g. "--model"). + #[serde(default)] + pub model_arg: Option, #[serde(default)] pub tags: Vec, /// When false, the agent is listed but cannot be installed locally. @@ -513,7 +532,10 @@ pub fn parse_file(path: &Path) -> Result { fn load_file_with_includes(path: &Path, visited: &mut HashSet) -> Result { 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) -> 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) -> Result { + 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); } diff --git a/src/events.rs b/src/events.rs index 759cf6f..342891d 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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", } } } diff --git a/src/help.rs b/src/help.rs index c46b031..b3ee174 100644 --- a/src/help.rs +++ b/src/help.rs @@ -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} [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 ", + 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", diff --git a/src/lib.rs b/src/lib.rs index 1b93b25..3bbbd83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..98df3ad --- /dev/null +++ b/src/models.rs @@ -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, + /// Quantization level when known (e.g. "Q4_K_M"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quantization: Option, + /// RFC 3339 timestamp of the last use/modification when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used: Option, + /// 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, +} + +/// Full inventory: runtime states plus the models of the detected runtimes. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ModelsInventory { + pub runtimes: Vec, + pub models: Vec, +} + +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, +) -> (RuntimeInfo, Vec) { + 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 { + 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> { + #[derive(Deserialize)] + struct OllamaModel { + name: String, + #[serde(default)] + size: Option, + #[serde(default)] + details: Option, + #[serde(default)] + modified_at: Option, + } + #[derive(Deserialize)] + struct OllamaDetails { + #[serde(default)] + quantization_level: Option, + } + #[derive(Deserialize)] + struct List { + #[serde(default)] + models: Vec, + } + 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 { + let _ = which::which("llama-server").or_else(|_| which::which("llama-cli")); + let mut dirs: Vec = 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 = 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 { + // Patterns: "Q4_K_M", "Q8_0", "q4_0", "IQ4_XS", "Q5_K_S", ... + let up = name.to_uppercase(); + let bytes: Vec = 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 { + 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> { + #[derive(Deserialize)] + struct LmModel { + id: String, + } + #[derive(Deserialize)] + struct List { + data: Vec, + } + 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 { + 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 = 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CacheEntry { + key: String, + fetched_at: u64, + info: RuntimeInfo, + models: Vec, +} + +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/src/repl.rs b/src/repl.rs index b870ae1..f69c009 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -56,6 +56,8 @@ pub struct AmCompleter { groups: Vec, /// '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, @@ -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 | catalog list"); + return Ok(false); + } + }, + "suggest" => { + if rest.is_empty() { + app.log.error("usage: suggest — 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(), } } diff --git a/tests/instrumentation_test.rs b/tests/instrumentation_test.rs index 74b7b26..a075472 100644 --- a/tests/instrumentation_test.rs +++ b/tests/instrumentation_test.rs @@ -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());