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:
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user