feat: phase 2 — modèles locaux + catalogue distant + suggest (closes #70 #71 #73 #60 #67 #61)

Axe 11 (modèles) :
- am models : inventaire des runtimes locaux (ollama list --format json,
  llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon
  probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale
- am run <agent> --model <m> : résolution dans l'inventaire, transmission
  via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session
- am models --prune : candidats inutilisés depuis settings.models_prune_days
  (défaut 30), :latest toujours protégé, dry-run + confirmation + événement
  EventKind::Model journalisé

Axes 7/9 (catalogue) :
- am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/
  modifiés), confirmation, enregistrement en include
- am catalog add <url> : validation du format (parse + validate), cache
  catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé
- include par URL (http/https/file) dans config.yaml, résolu au chargement
  avec cache + TTL ; override local toujours prioritaire (issue #67)
- am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par
  usage réel (lancements + durée depuis le journal), top 5 justifié, --json

REPL : commandes models/catalog/suggest (parse, complétion, descriptions),
help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles).
Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
This commit is contained in:
2026-08-18 09:06:16 -04:00
parent 890ad953e5
commit 22bc529818
31 changed files with 2122 additions and 40 deletions
+166
View File
@@ -0,0 +1,166 @@
//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
//! 'am catalog update' refreshes the official catalog and applies it as an
//! include; 'am catalog add <url>' registers any external catalog (same
//! YAML format) the same way. Everything goes through the probe-style
//! cache in catalog_remote so offline runs keep working.
use super::*;
use crate::catalog_remote::{self, CatalogDiff};
use crate::cli::CatalogCmd;
use crate::commands::config_cmd::register_include;
use crate::events::{Event, EventKind};
use crate::output::print_json;
use anyhow::{anyhow, Result};
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
match cmd {
CatalogCmd::Update => update(app),
CatalogCmd::Add { url } => add(app, url),
CatalogCmd::List => list(app),
}
}
/// am catalog update: fetch the official catalog, show the diff against the
/// local catalog, ask for confirmation, then register the URL as an include.
fn update(app: &App) -> Result<i32> {
let url = catalog_remote::official_url(app);
app.log.info(&format!("fetching the official catalog: {url}"));
let text = catalog_remote::fetch(app, &url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"added": diff.added,
"removed": diff.removed,
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
}));
return Ok(0);
}
print_diff(&diff);
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
app.log
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
return Ok(0);
}
if app.dry_run() {
app.log.dry("would register the official catalog as an include");
return Ok(0);
}
if !app
.confirm(&format!(
"apply this catalog ({} agents, {} changes)?",
remote.agents.len(),
diff.added.len() + diff.removed.len() + diff.changed.len()
))?
{
app.log.info("catalog update cancelled");
return Ok(0);
}
register_include(app, &url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.clone()])
.with_reason("update"),
);
app.log.success(&format!(
"official catalog registered as an include ({url}) — it is effective from the next run"
));
Ok(0)
}
/// am catalog add <url>: validate and register an external catalog.
fn add(app: &App, url: &str) -> Result<i32> {
crate::download::validate_url(url)?;
if config_cmd::user_includes(app).iter().any(|i| i == url) {
app.log
.info(&format!("catalog {url} is already registered as an include"));
return Ok(0);
}
app.log.info(&format!("fetching catalog: {url}"));
// force: 'catalog add' must validate the current content of the URL,
// not a possibly stale cached copy.
let text = catalog_remote::fetch(app, url, true)?;
let remote = catalog_remote::parse_catalog(&text)?;
if app.json() {
print_json(&serde_json::json!({
"url": url,
"agents": remote.agents.len(),
"registered": false,
}));
return Ok(0);
}
if remote.agents.is_empty() {
return Err(anyhow!(
"the catalog at {url} defines no agents — refusing to register it"
));
}
if app.dry_run() {
app.log.dry(&format!(
"would register {url} ({:?} agents)",
remote.agents.len()
));
return Ok(0);
}
register_include(app, url)?;
app.emit(
&Event::now(EventKind::Catalog)
.with_args(vec![url.to_string()])
.with_reason("add"),
);
app.log.success(&format!(
"catalog {url} registered ({} agents) — effective from the next run",
remote.agents.len()
));
Ok(0)
}
/// am catalog list: show the registered remote catalogs (includes).
/// The resolved config consumes its includes at load time, so this reads
/// the user config file directly (like 'config add').
fn list(app: &App) -> Result<i32> {
let urls: Vec<String> = config_cmd::user_includes(app)
.iter()
.filter(|i| {
i.starts_with("http://")
|| i.starts_with("https://")
|| i.starts_with("file://")
})
.cloned()
.collect();
if app.json() {
print_json(&urls);
return Ok(0);
}
if urls.is_empty() {
app.log.info(
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
);
return Ok(0);
}
println!("remote catalogs ({}):", urls.len());
for u in &urls {
println!(" - {u}");
}
Ok(0)
}
fn print_diff(diff: &CatalogDiff) {
if !diff.added.is_empty() {
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
}
if !diff.removed.is_empty() {
println!(
" - {} removed: {}",
diff.removed.len(),
diff.removed.join(", ")
);
}
if !diff.changed.is_empty() {
println!(" ~ {} changed run command(s):", diff.changed.len());
for (name, from, to) in &diff.changed {
println!(" {name}: '{from}' -> '{to}'");
}
}
}
+39 -9
View File
@@ -217,7 +217,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
register_include(app, &base)?;
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
user_config_path(app)?.display()
));
Ok(0)
}
/// Read the `include:` entries of the user config file (they are consumed
/// at load time, so the resolved config no longer lists them).
pub fn user_includes(app: &App) -> Vec<String> {
let Some(cfg_path) = user_config_path_if_any(app) else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
return Vec::new();
};
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
return Vec::new();
};
value
.get("include")
.and_then(|i| i.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
/// Add one entry to the `include:` list of the user config file (creating
/// the file with a template when it does not exist). Used by 'config add'
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
pub fn register_include(app: &App, entry: &str) -> Result<()> {
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
@@ -227,7 +262,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
let entry = serde_yaml::Value::String(entry.to_string());
if !seq.contains(&entry) {
seq.push(entry);
}
@@ -236,7 +271,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
entry.to_string(),
)]),
);
}
@@ -247,16 +282,11 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
Ok(())
}
#[cfg(test)]
+9 -3
View File
@@ -3,6 +3,7 @@
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
pub mod dashboard_cmd;
@@ -17,6 +18,7 @@ pub mod list_cmd;
pub mod log_cmd;
pub mod logs_cmd;
pub mod man_cmd;
pub mod models_cmd;
pub mod open_cmd;
pub mod profile_cmd;
pub mod projects_cmd;
@@ -24,17 +26,18 @@ pub mod run_cmd;
pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod self_update;
pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod suggest_cmd;
pub mod theme_cmd;
pub mod timeline_cmd;
pub mod tip_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
pub mod watch_cmd;
pub mod version_cmd;
pub mod watch_cmd;
use crate::app::App;
use crate::cli::Command;
@@ -84,6 +87,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Agents { installed } => agents_cmd::run(app, *installed),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Models(args) => models_cmd::run(app, args),
Command::Catalog(sub) => catalog_cmd::run(app, sub),
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -167,7 +173,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
Command::Config(sub) => config_cmd::run(app, sub),
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
Command::Run { agent, args } => run_cmd::run(app, agent, args),
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
Command::SelfUninstall => self_uninstall::run(app),
+198
View File
@@ -0,0 +1,198 @@
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
//! and pruning of unused models (issues #70 #73, axe 11).
use super::*;
use crate::cli::ModelsArgs;
use crate::events::{Event, EventKind};
use crate::models::{self, RuntimeModel};
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
struct ModelRow {
runtime: String,
name: String,
size_bytes: Option<u64>,
quantization: Option<String>,
last_used: Option<String>,
}
#[derive(Serialize)]
struct RuntimeRow {
runtime: String,
detected: bool,
note: Option<String>,
models: usize,
}
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
if args.prune {
return prune(app, args);
}
let inventory = models::detect(app);
if app.json() {
print_json(&serde_json::json!({
"runtimes": inventory.runtimes,
"models": inventory.models,
}));
return Ok(0);
}
let theme = app.theme();
let color = app.color();
// Runtime status lines.
for r in &inventory.runtimes {
let icon = if r.detected { "✓" } else { "✗" };
let note = r.note.as_deref().unwrap_or("");
println!(" {icon} {} — {note}", r.runtime);
}
if !inventory.any_runtime() {
app.log.info(
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
);
return Ok(0);
}
if inventory.models.is_empty() {
app.log.info("runtimes detected but no models found");
return Ok(0);
}
// Table: runtime | model | size | quant | last used.
let rows: Vec<Vec<String>> = inventory
.models
.iter()
.map(|m| {
vec![
m.runtime.clone(),
m.name.clone(),
human_size(m.size_bytes),
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
]
})
.collect();
let _ = (theme, color);
print!(
"{}",
crate::output::boxed_table(
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
rows,
app.theme(),
app.color(),
)
);
println!(
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
inventory.models.len()
);
Ok(0)
}
/// prune: list unused models, then delete them after confirmation.
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
let days = args.days.unwrap_or_else(|| {
app.config
.settings
.models_prune_days
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
});
let inventory = models::detect(app);
let candidates = models::prune_candidates(&inventory.models, days);
let _ = &inventory;
if app.json() {
let rows: Vec<ModelRow> = candidates
.iter()
.map(|m| ModelRow {
runtime: m.runtime.clone(),
name: m.name.clone(),
size_bytes: m.size_bytes,
quantization: m.quantization.clone(),
last_used: m.last_used.clone(),
})
.collect();
print_json(&rows);
return Ok(if rows.is_empty() { 0 } else { 1 });
}
if candidates.is_empty() {
app.log.success(&format!(
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
));
return Ok(0);
}
println!(
"{} model(s) unused for more than {days} days (':latest' models are protected):",
candidates.len()
);
for m in &candidates {
let size = human_size(m.size_bytes);
let last = m.last_used.as_deref().unwrap_or("unknown");
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
}
if args.dry_run {
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
return Ok(0);
}
let asked = app
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
if !asked {
app.log.info("prune cancelled");
return Ok(0);
}
let mut removed = 0;
let mut errors = 0;
for m in &candidates {
match models::remove_model(m) {
Ok(()) => {
app.emit(
&Event::now(EventKind::Model)
.with_agent(m.runtime.clone())
.with_args(vec![m.name.clone()])
.with_reason("prune"),
);
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
removed += 1;
}
Err(e) => {
app.log.error(&format!("{}: {e:#}", m.name));
errors += 1;
}
}
}
// The inventory cache is stale after a prune: drop it so the next run
// rescans the runtimes.
let cache_path = app.events_dir().join("models-cache.json");
let _ = std::fs::remove_file(&cache_path);
if errors > 0 {
app.log
.error(&format!("{errors} model(s) could not be removed"));
return Ok(1);
}
app.log
.success(&format!("{removed} model(s) removed from the local runtimes"));
Ok(0)
}
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
fn human_size(bytes: Option<u64>) -> String {
match bytes {
None => "—".to_string(),
Some(b) => {
let kb = b as f64 / 1024.0;
let mb = kb / 1024.0;
let gb = mb / 1024.0;
if gb >= 1.0 {
format!("{gb:.1} GB")
} else if mb >= 1.0 {
format!("{mb:.0} MB")
} else {
format!("{kb:.0} KB")
}
}
}
}
/// Usage of each model name across sessions (for --json consumers).
#[allow(dead_code)]
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
BTreeMap::new()
}
+68 -3
View File
@@ -69,6 +69,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
&extra_args,
&extra_env,
)?;
// --model (issue #71): resolve against the local runtimes, then
// transmit through the agent's declared channel (env var or flag).
let mut extra_args = extra_args;
let mut extra_env = extra_env;
if let Some(m) = opts.model.as_deref() {
let agent = require_agent(app, &target)?;
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
@@ -299,10 +309,19 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
let mut extra_args: Vec<String> = extra
.iter()
.map(|o| o.to_string_lossy().to_string())
.collect();
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
if let Some(m) = model {
let (a, e) = apply_model(app, agent, m)?;
extra_args.extend(a);
extra_env.extend(e);
}
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
@@ -339,6 +358,52 @@ fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Resolve --model against the local runtimes and build the transmission
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
/// model is an explicit error listing the available runtimes.
pub fn apply_model(
app: &App,
agent: &AgentDef,
model: &str,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let inventory = crate::models::detect(app);
if inventory.find(model).is_none() {
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
let hint = if known.is_empty() {
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
} else {
format!("available models: {} — run 'am models' for details", known.join(", "))
};
bail!("model '{model}' not found on any local runtime ({hint})");
}
let mut args = Vec::new();
let mut env = BTreeMap::new();
if let Some(var) = &agent.model_env {
env.insert(var.clone(), model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {var}",
agent.name
));
} else if let Some(flag) = &agent.model_arg {
args.push(flag.clone());
args.push(model.to_string());
app.log.verbose(&format!(
"model '{model}' transmitted to {} via {flag}",
agent.name
));
} else {
// Generic fallback: many local agents read AM_MODEL; the config can
// pin a specific channel (model_env / model_arg) instead.
env.insert("AM_MODEL".to_string(), model.to_string());
app.log.info(&format!(
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
agent.name
));
}
Ok((args, env))
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
+229
View File
@@ -0,0 +1,229 @@
//! suggest: recommend agents for a natural-language request (issue #61,
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
//! category/tags) and boosts the ranking with real usage from the event
//! journal (launch counts and durations), with a short justification.
use super::*;
use crate::output::print_json;
use anyhow::Result;
use serde::Serialize;
use std::collections::BTreeMap;
/// Top N suggestions shown.
const TOP_N: usize = 5;
#[derive(Serialize)]
struct SuggestRow {
name: String,
display_name: String,
category: Option<String>,
/// Combined score (fuzzy * usage boost).
score: f64,
/// Why this agent was suggested.
reason: String,
/// Real usage in the journal (when available).
launches: u64,
total_duration_s: u64,
}
pub fn run(app: &App, query: &str) -> Result<i32> {
let query = query.trim();
if query.is_empty() {
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
}
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
let scored: Vec<(u32, &AgentDef)> = app
.catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter_map(|a| {
let kw = query.to_lowercase();
let name = a.name.to_lowercase();
let title = a.title().to_lowercase();
let rest = format!(
"{} {} {}",
a.description.as_deref().unwrap_or("").to_lowercase(),
a.category.as_deref().unwrap_or("").to_lowercase(),
a.tags.join(" ").to_lowercase()
);
let score = score_token(&kw, &name)
.saturating_mul(3)
.saturating_add(score_token(&kw, &title).saturating_mul(2))
.saturating_add(score_token(&kw, &rest));
(score > 0).then_some((score, a))
})
.collect();
// 2. Real usage boost from the journal (stats_cmd aggregates launches
// and durations per agent).
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
let launches: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.launches))
.collect();
let duration: BTreeMap<&str, u64> = usage
.iter()
.map(|s| (s.agent.as_str(), s.total_duration_s))
.collect();
let mut rows: Vec<SuggestRow> = scored
.iter()
.map(|(score, a)| {
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
// Boost: up to +50 % for the most used agents (log scale).
let boost = if l > 0 {
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
} else {
1.0
};
let final_score = (*score as f64) * boost;
let reason = justify(a, l, d);
SuggestRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
score: final_score,
reason,
launches: l,
total_duration_s: d,
}
})
.collect();
rows.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.name.cmp(&b.name))
});
rows.truncate(TOP_N);
if app.json() {
print_json(&rows);
return Ok(0);
}
if rows.is_empty() {
let suggestions = app.catalog.suggest(query);
if suggestions.is_empty() {
app.log.info(&format!(
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
));
} else {
app.log.info(&format!(
"no agent matches '{query}' — vouliez-vous dire : {} ?",
suggestions.join(", ")
));
}
return Ok(0);
}
println!("suggestions for \"{query}\":");
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.category.clone().unwrap_or_else(|| "—".to_string()),
r.reason.clone(),
]);
}
print!("{}", table.render());
if rows.len() < scored.len() {
println!(
"\n ({} other match(es) not shown — 'am search {}' lists them)",
scored.len() - rows.len(),
query
);
}
Ok(0)
}
/// Short justification: matched field(s) + real usage when available.
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = &a.category {
parts.push(format!("catégorie {c}"));
}
if !a.tags.is_empty() {
parts.push(format!("tags: {}", a.tags.join(", ")));
}
if launches > 0 {
parts.push(format!("{launches} lancement(s)"));
if duration_s >= 3600 {
parts.push(format!("{}h", duration_s / 3600));
}
}
if parts.is_empty() {
a.description
.as_deref()
.map(|d| {
let mut d = d.to_string();
d.truncate(60);
d
})
.unwrap_or_else(|| "correspond à la requête".to_string())
} else {
parts.join(" · ")
}
}
/// Reuse the catalog scoring helper (exported from catalog.rs).
fn score_token(keyword: &str, hay: &str) -> u32 {
crate::catalog::score_keyword(keyword, hay)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
AgentDef {
name: name.to_string(),
display_name: None,
description: Some(desc.to_string()),
category: Some("coding-agent".to_string()),
website: None,
install: None,
dependencies: vec![],
run: Some(name.to_string()),
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
installable: false,
note: None,
hidden: false,
platforms: vec![],
}
}
#[test]
fn scoring_prefers_relevant_agents() {
let a = agent("python-mate", "assistant python", &["python"]);
let b = agent("rust-mate", "assistant rust", &["rust"]);
let catalog = crate::catalog::Catalog::new(
vec![a, b],
BTreeMap::new(),
BTreeMap::new(),
);
let kw = "python";
let mut scored: Vec<(&str, u32)> = catalog
.agents()
.iter()
.filter_map(|x| {
let s = score_token(kw, &x.name.to_lowercase())
.saturating_mul(3)
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
.saturating_add(score_token(
kw,
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
));
(s > 0).then_some((x.name.as_str(), s))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
assert_eq!(scored[0].0, "python-mate");
}
}
+22
View File
@@ -97,6 +97,28 @@ pub static SECTIONS: &[TipSection] = &[
options: &[],
example: "search pi",
},
TipEntry {
usage: "suggest <requête>",
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
options: &[("--json", "sortie machine-readable")],
example: "suggest un agent pour du Python",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
options: &[
("--prune", "purge les modèles inutilisés"),
("--dry-run", "simulation sans rien supprimer"),
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
],
example: "models --prune --dry-run",
},
TipEntry {
usage: "catalog update",
about: "rafraîchit le catalogue officiel (diff + confirmation)",
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",