feat: v0.7.0 — registre de providers LLM : am providers list/show/add/remove/default, settings.providers + default_provider (closes #88)
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
+1
-1
@@ -497,7 +497,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés
|
||||
|
||||
| # | Issue | Effort | Dépend de |
|
||||
|---|---|---|---|
|
||||
| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — |
|
||||
| [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — |
|
||||
| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 |
|
||||
| [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 |
|
||||
| [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 |
|
||||
|
||||
+14
@@ -43,6 +43,20 @@ settings:
|
||||
# le journal et l'historique (logs/ et backups/ exclus automatiquement).
|
||||
# sync_repo: https://git.dracodev.net/bruno/am-state.git
|
||||
# sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in)
|
||||
# Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par
|
||||
# défaut. Le provider par défaut est utilisé à l'install/au run quand aucun
|
||||
# n'est donné. Les tokens ne vont JAMAIS ici — ils vivent dans le keyring OS
|
||||
# sous providers/<nom>/api_key (issue #89, am providers set-token).
|
||||
# default_provider: openai
|
||||
# providers:
|
||||
# openai:
|
||||
# base_url: https://api.openai.com/v1
|
||||
# default_model: gpt-5.2
|
||||
# models: [gpt-5.2, gpt-5.1-mini, o4-mini]
|
||||
# deepseek:
|
||||
# base_url: https://api.deepseek.com
|
||||
# default_model: deepseek-chat
|
||||
# models: [deepseek-chat, deepseek-reasoner]
|
||||
|
||||
# --- Command aliases ----------------------------------------------------------
|
||||
aliases:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am-providers 1 "providers "
|
||||
.SH NAME
|
||||
providers \- Manage the LLM provider registry: base URLs, models, default provider
|
||||
.SH SYNOPSIS
|
||||
\fBproviders\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
Manage the LLM provider registry: base URLs, models, default provider
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help
|
||||
.SH SUBCOMMANDS
|
||||
.TP
|
||||
providers\-list(1)
|
||||
List the registered providers (the default one is starred)
|
||||
.TP
|
||||
providers\-show(1)
|
||||
Show one provider in detail (base URL, models, default model)
|
||||
.TP
|
||||
providers\-add(1)
|
||||
Register a provider, or update an existing one
|
||||
.TP
|
||||
providers\-remove(1)
|
||||
Remove a provider from the registry
|
||||
.TP
|
||||
providers\-default(1)
|
||||
Set the default provider
|
||||
.TP
|
||||
providers\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.6.0"
|
||||
.TH am 1 "am 0.7.0"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -121,6 +121,9 @@ List local models (ollama, llama.cpp, LM Studio) and prune unused ones
|
||||
am\-catalog(1)
|
||||
Manage remote catalogs: update the official one, add external ones
|
||||
.TP
|
||||
am\-providers(1)
|
||||
Manage the LLM provider registry: base URLs, models, default provider
|
||||
.TP
|
||||
am\-suggest(1)
|
||||
Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
.TP
|
||||
@@ -232,4 +235,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.6.0
|
||||
v0.7.0
|
||||
|
||||
+45
@@ -192,6 +192,8 @@ pub enum Command {
|
||||
/// Manage remote catalogs: update the official one, add external ones
|
||||
#[command(subcommand)]
|
||||
Catalog(CatalogCmd),
|
||||
/// Manage the LLM provider registry: base URLs, models, default provider
|
||||
Providers(ProviderArgs),
|
||||
/// Suggest agents matching a query, boosted by real usage (issue #61)
|
||||
Suggest {
|
||||
/// Free-form request ("un agent pour du Python")
|
||||
@@ -634,6 +636,49 @@ pub enum CatalogCmd {
|
||||
List,
|
||||
}
|
||||
|
||||
/// LLM provider registry subcommands (issue #88). The subcommand is
|
||||
/// optional: `am providers` alone lists the registry.
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub struct ProviderArgs {
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<ProvidersCmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ProvidersCmd {
|
||||
/// List the registered providers (the default one is starred)
|
||||
List,
|
||||
/// Show one provider in detail (base URL, models, default model)
|
||||
Show {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Register a provider, or update an existing one
|
||||
Add {
|
||||
/// Provider name (lowercase slug, e.g. openai)
|
||||
name: String,
|
||||
/// Base URL of the provider API
|
||||
#[arg(long, value_name = "URL", required = true)]
|
||||
base_url: String,
|
||||
/// Model used by default for this provider
|
||||
#[arg(long, value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
/// Comma-separated model list
|
||||
#[arg(long, value_name = "MODELS")]
|
||||
models: Option<String>,
|
||||
},
|
||||
/// Remove a provider from the registry
|
||||
Remove {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
/// Set the default provider
|
||||
Default {
|
||||
/// Provider name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Environment profile subcommands (issue #40).
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ProfileCmd {
|
||||
|
||||
@@ -114,14 +114,21 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
|
||||
/// exists. Used by the REPL 'theme <name>' command so a switched theme
|
||||
/// survives a restart.
|
||||
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
|
||||
let path = match &app.config_origin {
|
||||
Some(p) => p.clone(),
|
||||
None => user_config_path(app)?,
|
||||
};
|
||||
let path = active_config_path(app)?;
|
||||
write_dotted_key(&path, key, value)?;
|
||||
reload_and_validate(&path)
|
||||
}
|
||||
|
||||
/// Path of the configuration file that is actually in effect: the loaded
|
||||
/// origin when one exists, otherwise the user config file (created on
|
||||
/// demand). Mutating commands write here so the change survives a reload.
|
||||
pub(crate) fn active_config_path(app: &App) -> Result<PathBuf> {
|
||||
match &app.config_origin {
|
||||
Some(p) => Ok(p.clone()),
|
||||
None => user_config_path(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set one dotted key (e.g. settings.default_shell) in a YAML config file.
|
||||
fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
@@ -155,7 +162,7 @@ fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Reload a written config file to make sure the change stays valid.
|
||||
fn reload_and_validate(path: &Path) -> Result<()> {
|
||||
pub(crate) fn reload_and_validate(path: &Path) -> Result<()> {
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
|
||||
config::load(&cli).map_err(|e| {
|
||||
anyhow!("the new value makes the configuration invalid: {e:#}")
|
||||
@@ -183,7 +190,7 @@ fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Ensure the user config file exists (creating a template when needed).
|
||||
fn user_config_path(app: &App) -> Result<PathBuf> {
|
||||
pub(crate) fn user_config_path(app: &App) -> Result<PathBuf> {
|
||||
let dir = app
|
||||
.paths
|
||||
.config_dir
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod suggest_cmd;
|
||||
pub mod sync_cmd;
|
||||
pub mod playbook_cmd;
|
||||
pub mod plugins_cmd;
|
||||
pub mod providers_cmd;
|
||||
pub mod theme_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod tip_cmd;
|
||||
@@ -99,6 +100,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
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::Providers(args) => providers_cmd::run(app, args.sub.as_ref()),
|
||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||
Command::Start(a) => run_cmd::start(app, a),
|
||||
Command::Stop {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! providers: the LLM provider registry (issue #88). `settings.providers`
|
||||
//! maps a provider name to its base URL, model list and default model; the
|
||||
//! default provider is `settings.default_provider`. Tokens are NOT handled
|
||||
//! here — they live in the OS keyring (issue #89).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ProvidersCmd;
|
||||
use crate::config::ProviderDef;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use crate::providers;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
|
||||
match sub {
|
||||
None => list(app),
|
||||
Some(ProvidersCmd::List) => list(app),
|
||||
Some(ProvidersCmd::Show { name }) => show(app, name),
|
||||
Some(ProvidersCmd::Add {
|
||||
name,
|
||||
base_url,
|
||||
model,
|
||||
models,
|
||||
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
|
||||
Some(ProvidersCmd::Remove { name }) => remove(app, name),
|
||||
Some(ProvidersCmd::Default { name }) => set_default(app, name),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list / show
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let providers = providers::all(&app.config);
|
||||
let default_name = providers::default_name(&app.config);
|
||||
if app.json() {
|
||||
let rows: Vec<serde_json::Value> = providers
|
||||
.iter()
|
||||
.map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str())))
|
||||
.collect();
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
if providers.is_empty() {
|
||||
app.log.info(&crate::i18n::tr(
|
||||
"aucun provider enregistré — voir: am providers add <nom> --base-url <url>",
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table = crate::output::Table::new(vec![
|
||||
"PROVIDER",
|
||||
"BASE URL",
|
||||
"DÉFAUT",
|
||||
"MODÈLES",
|
||||
]);
|
||||
for (name, def) in providers {
|
||||
let star = if default_name == Some(name.as_str()) {
|
||||
"★"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
table.row(vec![
|
||||
format!("{name}{star}"),
|
||||
def.base_url.clone(),
|
||||
def.default_model.clone().unwrap_or_default(),
|
||||
def.models.join(", "),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn show(app: &App, name: &str) -> Result<i32> {
|
||||
let def = providers::get(&app.config, name)
|
||||
.ok_or_else(|| anyhow!(crate::tr_fmt!("provider '{}' inconnu", name)))?;
|
||||
let is_default = providers::default_name(&app.config) == Some(name);
|
||||
if app.json() {
|
||||
print_json(&provider_json(name, def, is_default));
|
||||
return Ok(0);
|
||||
}
|
||||
println!("provider: {name}");
|
||||
if is_default {
|
||||
println!("(provider par défaut)");
|
||||
}
|
||||
println!(" base_url: {}", def.base_url);
|
||||
println!(
|
||||
" modèle par défaut: {}",
|
||||
def.default_model.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(" modèles: {}", def.models.join(", "));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// One provider as a JSON object. Tokens are never part of this shape.
|
||||
fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"base_url": def.base_url,
|
||||
"default_model": def.default_model,
|
||||
"models": def.models,
|
||||
"is_default": is_default,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add / remove / default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result<i32> {
|
||||
if !providers::is_valid_name(name) {
|
||||
bail!(
|
||||
"{}",
|
||||
crate::tr_fmt!(
|
||||
"nom de provider invalide '{}' — attendu un slug [a-z0-9_-]",
|
||||
name
|
||||
)
|
||||
);
|
||||
}
|
||||
if base_url.trim().is_empty() {
|
||||
bail!("{}", crate::i18n::tr("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]"));
|
||||
}
|
||||
let models_list: Vec<String> = models
|
||||
.map(|m| m.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
||||
.unwrap_or_default();
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register provider {name} (base_url={base_url}, default_model={})",
|
||||
model.unwrap_or("-")
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
let existed = providers::get(&app.config, name).is_some();
|
||||
let def = ProviderDef {
|
||||
base_url: base_url.trim().to_string(),
|
||||
default_model: model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()),
|
||||
models: models_list,
|
||||
};
|
||||
write_provider(app, name, &def)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec![format!("action={}", if existed { "update" } else { "add" }), format!("provider={name}")]),
|
||||
);
|
||||
if existed {
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' mis à jour", name));
|
||||
} else {
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' ajouté", name));
|
||||
app.log.info(&format!(
|
||||
"{}",
|
||||
crate::tr_fmt!(
|
||||
"positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)",
|
||||
name
|
||||
)
|
||||
));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn remove(app: &App, name: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
if providers::default_name(&app.config) == Some(name) {
|
||||
bail!(crate::tr_fmt!(
|
||||
"le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut",
|
||||
name
|
||||
));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would remove provider {name}"));
|
||||
return Ok(0);
|
||||
}
|
||||
let path = crate::commands::config_cmd::active_config_path(app)?;
|
||||
let mut root = read_yaml(&path)?;
|
||||
remove_path(&mut root, &["settings", "providers", name])?;
|
||||
write_yaml(&path, &root)?;
|
||||
crate::commands::config_cmd::reload_and_validate(&path)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec!["action=remove".to_string(), format!("provider={name}")]),
|
||||
);
|
||||
app.log.success(&crate::tr_fmt!("provider '{}' supprimé", name));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn set_default(app: &App, name: &str) -> Result<i32> {
|
||||
if providers::get(&app.config, name).is_none() {
|
||||
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!("would set the default provider to {name}"));
|
||||
return Ok(0);
|
||||
}
|
||||
crate::commands::config_cmd::persist_setting(app, "settings.default_provider", name)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Provider)
|
||||
.with_args(vec!["action=default".to_string(), format!("provider={name}")]),
|
||||
);
|
||||
app.log.success(&crate::tr_fmt!("provider par défaut : {}", name));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config file edition (settings.providers.<name>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Write (or update) one provider entry in the active configuration file.
|
||||
fn write_provider(app: &App, name: &str, def: &ProviderDef) -> Result<()> {
|
||||
let path = crate::commands::config_cmd::active_config_path(app)?;
|
||||
let mut root = read_yaml(&path)?;
|
||||
let base = [
|
||||
"settings",
|
||||
"providers",
|
||||
name,
|
||||
"base_url",
|
||||
];
|
||||
set_path(&mut root, &base, serde_yaml::Value::String(def.base_url.clone()))?;
|
||||
if let Some(m) = &def.default_model {
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", name, "default_model"],
|
||||
serde_yaml::Value::String(m.clone()),
|
||||
)?;
|
||||
}
|
||||
if !def.models.is_empty() {
|
||||
let seq = serde_yaml::Value::Sequence(
|
||||
def.models.iter().map(|m| serde_yaml::Value::String(m.clone())).collect(),
|
||||
);
|
||||
set_path(&mut root, &["settings", "providers", name, "models"], seq)?;
|
||||
}
|
||||
write_yaml(&path, &root)?;
|
||||
crate::commands::config_cmd::reload_and_validate(&path)
|
||||
}
|
||||
|
||||
fn read_yaml(path: &Path) -> Result<serde_yaml::Value> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?;
|
||||
serde_yaml::from_str(&text).map_err(|e| anyhow!("{} is not valid YAML: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> {
|
||||
std::fs::write(path, serde_yaml::to_string(root)?)
|
||||
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Set a value at a dotted path, creating intermediate mappings.
|
||||
fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> {
|
||||
let mut node = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let entry = node
|
||||
.entry(serde_yaml::Value::String(part.to_string()))
|
||||
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
|
||||
node = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
||||
}
|
||||
node.insert(
|
||||
serde_yaml::Value::String(parts.last().unwrap().to_string()),
|
||||
value,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the key at a dotted path (missing keys are a no-op).
|
||||
fn remove_path(root: &mut serde_yaml::Value, parts: &[&str]) -> Result<()> {
|
||||
let mut node = root
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let Some(entry) = node.get_mut(serde_yaml::Value::String(part.to_string())) else {
|
||||
return Ok(());
|
||||
};
|
||||
node = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
||||
}
|
||||
node.remove(serde_yaml::Value::String(parts.last().unwrap().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::App;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// App whose active config is a file inside a leaked TempDir (same
|
||||
/// pattern as config_cmd's tests).
|
||||
fn app_with_config(text: &str) -> (App, PathBuf) {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, text).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
(app, cfg)
|
||||
}
|
||||
|
||||
const BASE: &str = "version: \"1.0\"\nagents: []\n";
|
||||
|
||||
#[test]
|
||||
fn set_path_descends_and_writes_sequences() {
|
||||
let mut root: serde_yaml::Value = serde_yaml::from_str(BASE).unwrap();
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", "deepseek", "base_url"],
|
||||
serde_yaml::Value::String("https://api.deepseek.com".into()),
|
||||
)
|
||||
.unwrap();
|
||||
set_path(
|
||||
&mut root,
|
||||
&["settings", "providers", "deepseek", "models"],
|
||||
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("deepseek-chat".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(root["settings"]["providers"]["deepseek"]["base_url"], "https://api.deepseek.com");
|
||||
assert_eq!(root["settings"]["providers"]["deepseek"]["models"][0], "deepseek-chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_path_deletes_the_leaf() {
|
||||
let mut root: serde_yaml::Value =
|
||||
serde_yaml::from_str("version: \"1.0\"\nsettings:\n providers:\n openai:\n base_url: x\nagents: []\n")
|
||||
.unwrap();
|
||||
remove_path(&mut root, &["settings", "providers", "openai"]).unwrap();
|
||||
assert!(root["settings"]["providers"].get("openai").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_then_reload_exposes_the_provider() {
|
||||
let (app, cfg) = app_with_config(BASE);
|
||||
add(
|
||||
&app,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com",
|
||||
Some("deepseek-chat"),
|
||||
Some("deepseek-chat, deepseek-reasoner"),
|
||||
)
|
||||
.unwrap();
|
||||
// A fresh load of the same config sees the provider.
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
let def = providers::get(&reloaded.config, "deepseek").expect("provider persisted");
|
||||
assert_eq!(def.base_url, "https://api.deepseek.com");
|
||||
assert_eq!(def.default_model.as_deref(), Some("deepseek-chat"));
|
||||
assert_eq!(def.models, vec!["deepseek-chat", "deepseek-reasoner"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rejects_an_invalid_name_or_missing_url() {
|
||||
let (app, _cfg) = app_with_config(BASE);
|
||||
assert!(add(&app, "OpenAI", "https://x", None, None).is_err());
|
||||
assert!(add(&app, "openai", " ", None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_and_set_default_flow() {
|
||||
// Config mutations apply on the next process (same convention as
|
||||
// alias/config set): reload between operations like a real CLI user.
|
||||
let (app, cfg) = app_with_config(BASE);
|
||||
add(&app, "openai", "https://api.openai.com/v1", Some("gpt-5.2"), None).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
set_default(&reloaded, "openai").unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
assert_eq!(providers::default_name(&reloaded.config), Some("openai"));
|
||||
// Cannot remove the default provider.
|
||||
assert!(remove(&reloaded, "openai").is_err());
|
||||
// Removing an unknown provider is an error too.
|
||||
assert!(remove(&reloaded, "nope").is_err());
|
||||
// After changing the default, removal works.
|
||||
add(&reloaded, "deepseek", "https://api.deepseek.com", None, None).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
set_default(&reloaded, "deepseek").unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli).unwrap();
|
||||
assert!(remove(&reloaded, "openai").is_ok());
|
||||
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded2 = App::from_cli(cli2).unwrap();
|
||||
assert!(providers::get(&reloaded2.config, "openai").is_none());
|
||||
assert_eq!(providers::default_name(&reloaded2.config), Some("deepseek"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_writes_nothing() {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(&cfg, BASE).unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--dry-run", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
add(&app, "openai", "https://api.openai.com/v1", None, None).unwrap();
|
||||
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let reloaded = App::from_cli(cli2).unwrap();
|
||||
assert!(providers::get(&reloaded.config, "openai").is_none());
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,12 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[],
|
||||
example: "secret set API_KEY --agent pi --value sk-…",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "providers add <nom> --base-url <url>",
|
||||
about: "registre centralisé des providers LLM (modèles, modèle par défaut)",
|
||||
options: &[],
|
||||
example: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "favorite · note · tag",
|
||||
about: "annotez vos agents : étoile, note libre, tags personnels",
|
||||
|
||||
@@ -139,6 +139,30 @@ pub struct Settings {
|
||||
/// Plugin scripts (issue #75): default timeout and enable list.
|
||||
#[serde(default)]
|
||||
pub plugins: Option<PluginSettings>,
|
||||
/// Name of the provider used by default (issue #88) — the provider
|
||||
/// whose default model is applied at install/run when none is given.
|
||||
#[serde(default)]
|
||||
pub default_provider: Option<String>,
|
||||
/// LLM provider registry (issue #88): provider name -> base URL,
|
||||
/// available models and default model. API tokens are NOT stored here —
|
||||
/// they live in the OS keyring under `providers/<name>/api_key`
|
||||
/// (issue #89).
|
||||
#[serde(default)]
|
||||
pub providers: Option<BTreeMap<String, ProviderDef>>,
|
||||
}
|
||||
|
||||
/// One entry of the LLM provider registry (issue #88).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProviderDef {
|
||||
/// Base URL of the provider API (e.g. https://api.openai.com/v1).
|
||||
pub base_url: String,
|
||||
/// Model used by default for this provider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
/// Models offered by this provider.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Plugin scripts settings (issue #75): how long one plugin may run and
|
||||
@@ -248,6 +272,24 @@ impl Settings {
|
||||
self.stop_timeout_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up one provider of the registry (issue #88).
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderDef> {
|
||||
self.providers.as_ref().and_then(|m| m.get(name))
|
||||
}
|
||||
|
||||
/// Name of the default provider (issue #88).
|
||||
pub fn default_provider_name(&self) -> Option<&str> {
|
||||
self.default_provider.as_deref()
|
||||
}
|
||||
|
||||
/// The default provider entry, when both the name and the definition
|
||||
/// exist (issue #88).
|
||||
pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> {
|
||||
let name = self.default_provider.as_ref()?;
|
||||
let def = self.provider(name)?;
|
||||
Some((name, def))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -769,6 +811,21 @@ pub fn merge(base: &mut Config, overlay: Config) {
|
||||
if o.plugins.is_some() {
|
||||
s.plugins = o.plugins;
|
||||
}
|
||||
if o.default_provider.is_some() {
|
||||
s.default_provider = o.default_provider;
|
||||
}
|
||||
// Providers merge key by key (issue #88): an overlay adds or replaces
|
||||
// one provider without wiping the others.
|
||||
if let Some(overlay_providers) = o.providers {
|
||||
match s.providers.as_mut() {
|
||||
Some(map) => {
|
||||
for (k, v) in overlay_providers {
|
||||
map.insert(k, v);
|
||||
}
|
||||
}
|
||||
None => s.providers = Some(overlay_providers),
|
||||
}
|
||||
}
|
||||
for (k, v) in o.hooks {
|
||||
s.hooks.insert(k, v);
|
||||
}
|
||||
@@ -834,6 +891,28 @@ pub fn validate(config: &Config) -> Vec<String> {
|
||||
problems.push(format!("agent '{}': dependency without a name", agent.name));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &config.settings.default_provider {
|
||||
if config.settings.provider(d).is_none() {
|
||||
problems.push(format!(
|
||||
"settings.default_provider '{d}' is not defined in settings.providers"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(providers) = &config.settings.providers {
|
||||
for (name, p) in providers {
|
||||
if p.base_url.trim().is_empty() {
|
||||
problems.push(format!("provider '{name}': 'base_url' is required"));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
problems.push(format!(
|
||||
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ pub enum EventKind {
|
||||
Alert,
|
||||
/// One benchmark run of 'am lab' (issue #62).
|
||||
Lab,
|
||||
/// LLM provider registry change: add, remove, default (issue #88).
|
||||
Provider,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
@@ -73,6 +75,7 @@ impl EventKind {
|
||||
EventKind::Cost => "cost",
|
||||
EventKind::Alert => "alert",
|
||||
EventKind::Lab => "lab",
|
||||
EventKind::Provider => "provider",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -678,6 +678,33 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "providers",
|
||||
category: "Commands",
|
||||
usage: "providers {flags} <list|show|add|remove|default>",
|
||||
about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring (issue #89).",
|
||||
search_terms: &["provider", "token", "registry", "llm", "model", "api"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" },
|
||||
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model used by default for this provider (add)" },
|
||||
HelpFlag { short: "", long: "--models", value: "MODELS", desc: "Comma-separated model list (add)" },
|
||||
],
|
||||
subcommands: &[
|
||||
("list", "List the registered providers (default starred)"),
|
||||
("show", "Show one provider in detail"),
|
||||
("add", "Register or update a provider"),
|
||||
("remove", "Remove a provider"),
|
||||
("default", "Set the default provider"),
|
||||
],
|
||||
parameters: &[],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List the providers.", code: "providers" },
|
||||
HelpExample { desc: "Register DeepSeek.", code: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat --models deepseek-chat,deepseek-reasoner" },
|
||||
HelpExample { desc: "Make it the default.", code: "providers default deepseek" },
|
||||
HelpExample { desc: "Machine-readable list.", code: "providers --json" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "history",
|
||||
category: "Commands",
|
||||
|
||||
+12
@@ -160,6 +160,18 @@ pub const CATALOG: &[(&str, &str)] = &[
|
||||
("début", "start"),
|
||||
("fin", "end"),
|
||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
|
||||
// ---- providers (#88) ---------------------------------------------------
|
||||
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <url>"),
|
||||
("provider '{}' inconnu", "unknown provider '{}'"),
|
||||
("nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", "invalid provider name '{}' — expected a slug [a-z0-9_-]"),
|
||||
("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]", "usage: providers add <name> --base-url <url> [--model <m>] [--models m1,m2]"),
|
||||
("provider '{}' mis à jour", "provider '{}' updated"),
|
||||
("provider '{}' ajouté", "provider '{}' added"),
|
||||
("positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", "store the token in the keyring: am providers set-token {} (issue #89, coming)"),
|
||||
("provider '{}' supprimé", "provider '{}' removed"),
|
||||
("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"),
|
||||
("provider par défaut : {}", "default provider: {}"),
|
||||
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
|
||||
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
|
||||
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
|
||||
("vouliez-vous dire", "did you mean"),
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod models;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod providers;
|
||||
pub mod projects;
|
||||
pub mod ps;
|
||||
pub mod process;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! LLM provider registry (issue #88): resolution helpers over the
|
||||
//! `settings.providers` map — the default provider, its default model and
|
||||
//! name validation. Tokens are deliberately out of scope here: they live in
|
||||
//! the OS keyring under `providers/<name>/api_key` (issue #89).
|
||||
//!
|
||||
//! The install-time configuration (issues #90, #92) will resolve the
|
||||
//! provider/model of an agent through these helpers.
|
||||
|
||||
use crate::config::{Config, ProviderDef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Empty provider map shared by the helpers below (never mutated).
|
||||
static EMPTY: BTreeMap<String, ProviderDef> = BTreeMap::new();
|
||||
|
||||
/// Every provider of the effective configuration (empty when unset).
|
||||
pub fn all(config: &Config) -> &BTreeMap<String, ProviderDef> {
|
||||
config.settings.providers.as_ref().unwrap_or(&EMPTY)
|
||||
}
|
||||
|
||||
/// Look up one provider by name.
|
||||
pub fn get<'a>(config: &'a Config, name: &str) -> Option<&'a ProviderDef> {
|
||||
config.settings.provider(name)
|
||||
}
|
||||
|
||||
/// Name of the default provider, when set and defined.
|
||||
pub fn default_name(config: &Config) -> Option<&str> {
|
||||
let (name, _) = config.settings.default_provider()?;
|
||||
Some(name.as_str())
|
||||
}
|
||||
|
||||
/// The default provider entry (name + definition).
|
||||
pub fn default(config: &Config) -> Option<(&String, &ProviderDef)> {
|
||||
config.settings.default_provider()
|
||||
}
|
||||
|
||||
/// Model to use for a provider: the explicit model when given, otherwise
|
||||
/// the provider's default model.
|
||||
pub fn model_for<'a>(provider: &'a ProviderDef, explicit: Option<&'a str>) -> Option<&'a str> {
|
||||
explicit
|
||||
.filter(|m| !m.is_empty())
|
||||
.or(provider.default_model.as_deref())
|
||||
}
|
||||
|
||||
/// Provider names accept the same slug rule as agent names: [a-z0-9_-].
|
||||
pub fn is_valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, ProviderDef};
|
||||
|
||||
fn config_with() -> Config {
|
||||
let mut c = Config::default();
|
||||
c.settings.default_provider = Some("openai".to_string());
|
||||
let mut providers = BTreeMap::new();
|
||||
providers.insert(
|
||||
"openai".to_string(),
|
||||
ProviderDef {
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
default_model: Some("gpt-5.2".to_string()),
|
||||
models: vec!["gpt-5.2".to_string(), "gpt-5.1-mini".to_string()],
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
"deepseek".to_string(),
|
||||
ProviderDef {
|
||||
base_url: "https://api.deepseek.com".to_string(),
|
||||
default_model: None,
|
||||
models: vec!["deepseek-chat".to_string()],
|
||||
},
|
||||
);
|
||||
c.settings.providers = Some(providers);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_the_default_provider() {
|
||||
let c = config_with();
|
||||
let (name, def) = default(&c).expect("default provider");
|
||||
assert_eq!(name, "openai");
|
||||
assert_eq!(def.base_url, "https://api.openai.com/v1");
|
||||
assert_eq!(default_name(&c), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_wins_over_the_default() {
|
||||
let c = config_with();
|
||||
let def = get(&c, "deepseek").unwrap();
|
||||
assert_eq!(model_for(def, None), None);
|
||||
assert_eq!(model_for(def, Some("deepseek-reasoner")), Some("deepseek-reasoner"));
|
||||
let openai = get(&c, "openai").unwrap();
|
||||
assert_eq!(model_for(openai, None), Some("gpt-5.2"));
|
||||
assert_eq!(model_for(openai, Some("gpt-5.1-mini")), Some("gpt-5.1-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_missing_provider_is_none() {
|
||||
let mut c = config_with();
|
||||
c.settings.default_provider = Some("nope".to_string());
|
||||
assert!(default(&c).is_none());
|
||||
assert!(default_name(&c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation_follows_the_slug_rule() {
|
||||
assert!(is_valid_name("openai"));
|
||||
assert!(is_valid_name("deep-seek_2"));
|
||||
assert!(!is_valid_name("OpenAI"));
|
||||
assert!(!is_valid_name(""));
|
||||
assert!(!is_valid_name("has space"));
|
||||
}
|
||||
}
|
||||
+43
-2
@@ -58,6 +58,8 @@ pub struct AmCompleter {
|
||||
profile_sub: Vec<&'static str>,
|
||||
/// 'catalog' subcommands (issues #60 #67).
|
||||
catalog_sub: Vec<&'static str>,
|
||||
/// 'providers' subcommands (issue #88).
|
||||
providers_sub: Vec<&'static str>,
|
||||
/// Short descriptions shown next to candidates in the Tab menu
|
||||
/// (Nushell-style).
|
||||
descriptions: BTreeMap<String, String>,
|
||||
@@ -108,6 +110,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("man", "readable man page in the terminal"),
|
||||
("models", "list local models (ollama, llama.cpp, LM Studio)"),
|
||||
("catalog", "manage remote catalogs"),
|
||||
("providers", "manage the LLM provider registry (base URLs, models, default)"),
|
||||
("suggest", "recommend an agent for a request"),
|
||||
("audit", "who changed what, when (config checksums)"),
|
||||
("service", "register an agent as a system service (autostart)"),
|
||||
@@ -233,13 +236,14 @@ impl AmCompleter {
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "suggest", "audit",
|
||||
"profile", "man", "models", "catalog", "providers", "suggest", "audit",
|
||||
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
"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"],
|
||||
providers_sub: vec!["list", "show", "add", "remove", "default"],
|
||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||
gateway_shells: vec![
|
||||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
||||
@@ -286,6 +290,9 @@ impl AmCompleter {
|
||||
"catalog" => {
|
||||
pool = self.catalog_sub.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"providers" => {
|
||||
pool = self.providers_sub.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"completion" => {
|
||||
pool = self.shells.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
@@ -782,7 +789,8 @@ pub fn banner_box(
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
|
||||
" config alias · secret · profile · providers · config · doctor · completion · man · tip"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" models models · models --prune · catalog · suggest · audit".to_string(),
|
||||
@@ -1489,6 +1497,38 @@ fn handle_line(
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"providers" => match rest.first().map(|s| s.as_str()) {
|
||||
Some("show") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Show {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("add") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Add {
|
||||
name: rest[1].clone(),
|
||||
base_url: opt_value("--base-url").unwrap_or_default(),
|
||||
model: opt_value("--model"),
|
||||
models: opt_value("--models"),
|
||||
}),
|
||||
}),
|
||||
Some("remove") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Remove {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("default") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::Default {
|
||||
name: rest[1].clone(),
|
||||
}),
|
||||
}),
|
||||
Some("list") | None => Command::Providers(crate::cli::ProviderArgs {
|
||||
sub: Some(crate::cli::ProvidersCmd::List),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error(crate::i18n::tr("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"suggest" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
||||
@@ -1894,6 +1934,7 @@ pub fn is_am_command(word: &str) -> bool {
|
||||
| "tip"
|
||||
| "models"
|
||||
| "catalog"
|
||||
| "providers"
|
||||
| "suggest"
|
||||
| "audit"
|
||||
| "service"
|
||||
|
||||
Reference in New Issue
Block a user