feat: v1.0 — am ask : langage naturel → commande am, règles locales FR/EN hors-ligne, raffinement LLM optionnel via le registre providers (token keyring, jamais de données du journal), cache des traductions, confirmation avant exécution, désactivable (closes #78)

- src/ask.rs : traducteur par règles (verbes FR/EN, pronoms, nicknames d'agents, séquences «et/puis»), cache ask_cache.json, POST /chat/completions du provider résolu avec Bearer du keyring, validation des commandes retournées, dégradation silencieuse sans token
- settings.ask {enabled (défaut true), provider, model} + doc config.yaml ; CLI/REPL/help/man/i18n
- 7 tests : règles (installe claude et lance-le → 2 commandes), cache, LLM via serveur tiny_http (token Bearer, zéro donnée du journal), dégradation, désactivation
This commit is contained in:
2026-08-19 22:01:00 -04:00
parent 7cab1a0b5c
commit 12eb09b01a
12 changed files with 652 additions and 3 deletions
+1 -1
View File
@@ -476,7 +476,7 @@ alerte).
|---|---|---|
| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M |
| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L |
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
| [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L |
| [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L |
| [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL |
+8
View File
@@ -49,6 +49,14 @@ settings:
# telemetry:
# enabled: false
# endpoint: https://exemple.tld/v1/ping # optionnel : envoi batch périodique
# am ask (#78) : langage naturel → commande am. Les règles locales marchent
# toujours, hors-ligne, sans dépendance. Le raffinement LLM optionnel passe
# par le registre providers (base_url + token du keyring + modèle) — seul
# le texte de la requête est envoyé, jamais les données du journal.
# ask:
# enabled: true # false désactive entièrement am ask
# provider: deepseek # optionnel (défaut: settings.default_provider)
# model: deepseek-chat # optionnel (défaut: modèle par défaut du provider)
# 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é (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans
+19
View File
@@ -0,0 +1,19 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-ask 1 "ask "
.SH NAME
ask \- Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.SH SYNOPSIS
\fBask\fR [\fB\-\-yes\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIREQUEST\fR>
.SH DESCRIPTION
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.SH OPTIONS
.TP
\fB\-\-yes\fR
Skip the confirmation prompt
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIREQUEST\fR>
The request in natural language
+3
View File
@@ -127,6 +127,9 @@ Manage the LLM provider registry: base URLs, models, default provider
am\-suggest(1)
Suggest agents matching a query, boosted by real usage (issue #61)
.TP
am\-ask(1)
Ask a natural\-language request and get the matching am command(s) (issue #78): local rules first, optional LLM refinement, confirmation before execution
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
+540
View File
@@ -0,0 +1,540 @@
//! am ask — natural language → am commands (issue #78).
//!
//! A local rule-based translator (offline, zero dependency, always works),
//! an optional LLM refinement through the provider registry (settings.ask),
//! and a translation cache for offline reuse. Every execution is confirmed
//! before it runs, and no journal data is ever sent to the provider — only
//! the user's own query.
use crate::app::App;
use crate::commands::execute_command;
use crate::config::AskSettings;
use crate::secrets::SecretStore;
use anyhow::{anyhow, Result};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// System prompt of the refinement call: the model must answer with one am
/// command per line, nothing else.
const SYSTEM_PROMPT: &str = "You translate a user request into commands of the 'am' CLI \
(agent-manager). Reply with ONE 'am' command per line — no text, no backticks, no \
explanation. Known commands: install, uninstall, update, start, stop, restart, run, \
list, status, ps, logs, history, stats, top, report, doctor, config, providers, \
secret, suggest, search, info, open, watch, sync, web, monitor, schedule, ask. \
Examples: «installe claude et lance-le» -> install claude-code\nstart claude-code ; \
«qui tourne ?» -> ps.";
const CACHE_MAX_ENTRIES: usize = 200;
// ---------------------------------------------------------------------------
// Rules (offline, bilingual FR/EN)
// ---------------------------------------------------------------------------
/// Known agent nicknames resolved before scanning the catalog.
const NICKNAMES: &[(&str, &str)] = &[
("claude", "claude-code"),
("claude code", "claude-code"),
("codex", "codex"),
("agentty", "agentty"),
("dsh", "deepseek-harness"),
("deepseek", "deepseek-harness"),
("opencode", "opencode"),
("aider", "aider"),
("gptme", "gptme"),
("goose", "goose"),
("openclaw", "openclaw"),
("jcode", "jcode"),
];
fn agent_of(part: &str, app: &App, prev: Option<&str>) -> Option<String> {
// Pronouns reuse the previous agent of the sentence.
let p = part.trim().to_lowercase();
let pronouns = [
"-le", "-la", "-les", "-l", "le", "la", "les", "l'", "it", "him", "her", "them", "celui-ci",
"celui la", "celui-là", "the agent", "l'agent",
];
if pronouns.iter().any(|pr| p.contains(pr)) {
if let Some(prev) = prev {
return Some(prev.to_string());
}
}
// Nicknames first (claude before scanning for "claude-code").
for (nick, target) in NICKNAMES {
if p.contains(nick) {
return Some(target.to_string());
}
}
// Exact catalog names, longest first so "claude-code" wins over "claude".
let mut names: Vec<&str> = app.config.agents.iter().map(|a| a.name.as_str()).collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
for name in names {
if p.contains(name) {
return Some(name.to_string());
}
}
None
}
fn translate_part(part: &str, app: &App, prev: &mut Option<String>) -> Option<String> {
let p = part.trim().to_lowercase();
let agent = agent_of(part, app, prev.as_deref()).or_else(|| prev.clone());
let needs = |cmd: &str| -> Option<String> {
let a = agent.clone().or_else(|| agent_of(part, app, prev.as_deref()));
match a {
Some(a) => Some(format!("{cmd} {a}")),
None => Some(format!("{cmd} --all")), // "tout mettre à jour" etc.
}
};
let cmd = if p.contains("desinstalle") || p.contains("désinstalle")
|| p.contains("uninstall") || p.contains("supprime")
{
needs("uninstall")
} else if p.contains("mets a jour") || p.contains("mets à jour") || p.contains("maj ")
|| p.contains("mise a jour") || p.contains("update") || p.contains("upgrade")
{
if agent.is_some() {
needs("update")
} else {
Some("update --all".to_string())
}
} else if p.contains("installe") || p.contains("install") || p.contains("setup")
|| p.contains("instal")
{
needs("install")
} else if p.contains("arrete") || p.contains("arrête") || p.contains("stop")
|| p.contains("kill") || p.contains("tue") || p.contains("eteins") || p.contains("éteins")
{
needs("stop")
} else if p.contains("relance") || p.contains("redemarre") || p.contains("redémarre")
|| p.contains("restart") || p.contains("reboot")
{
needs("restart")
} else if p.contains("lance") || p.contains("start") || p.contains("demarre")
|| p.contains("démarre") || p.contains("ouvre") || p.contains("execute") || p.contains("exécute")
{
match agent {
Some(a) => Some(format!("start {a}")),
None => Some("start".to_string()),
}
} else if p.contains("historique") || p.contains("history") {
Some("history".to_string())
} else if p.contains("statistique") || p.contains("statistique") || p.contains("stats") {
Some("stats".to_string())
} else if p.contains("qui tourne") || p.contains("processus") || p.contains("ps ") {
Some("ps".to_string())
} else if p.contains("sante") || p.contains("santé") || p.contains("doctor")
|| p.contains("diagnostic") || p.contains("checkup") || p.contains("verifie tout")
{
Some("doctor".to_string())
} else if p.contains("provider") || p.contains("fournisseur") {
Some("providers list".to_string())
} else if p.contains("secret") {
Some("secret list".to_string())
} else if p.contains("config") {
Some("config show".to_string())
} else if p.contains("tout") && p.contains("liste") {
Some("list --all".to_string())
} else if p.contains("liste") || p.contains("list") || p.contains("lister")
|| p.contains("catalogue") || p.contains("agents")
{
Some("list".to_string())
} else if p.contains("journal") || p.contains("log") {
Some("logs".to_string())
} else if p.contains("aide") || p.contains("help") {
Some("help".to_string())
} else {
None
};
if let Some(c) = &cmd {
// Keep the agent context for the following pronouns.
if let Some(a) = agent_of(part, app, prev.as_deref()) {
*prev = Some(a);
}
}
cmd
}
/// Rule-based translation of a full query into am commands.
fn rules_translate(query: &str, app: &App) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut prev: Option<String> = None;
// Sentence separators (FR + EN): each chunk becomes one command.
for chunk in query.split(|c| matches!(c, ',' | ';' | '.')) {
for part in chunk.splitn(2, |c| c == ':' || c == ';') {
let part = part.trim();
if part.is_empty() {
continue;
}
// Split on conjunctions, keeping the order.
let mut pieces: Vec<String> = Vec::new();
let mut current = String::new();
for word in part.split_whitespace() {
if matches!(
word.to_lowercase().as_str(),
"et" | "puis" | "and" | "then" | "ensuite" | "alors" | "&" | "+"
) {
if !current.trim().is_empty() {
pieces.push(current.trim().to_string());
}
current.clear();
} else {
current.push_str(word);
current.push(' ');
}
}
if !current.trim().is_empty() {
pieces.push(current.trim().to_string());
}
for piece in pieces {
if let Some(cmd) = translate_part(&piece, app, &mut prev) {
if !out.contains(&cmd) {
out.push(cmd);
}
}
}
}
}
out
}
// ---------------------------------------------------------------------------
// Optional LLM refinement (issue #78) — via the provider registry (#88)
// ---------------------------------------------------------------------------
/// ask is enabled by default (local rules work out of the box, issue #78);
/// an explicit `ask.enabled: false` turns the whole command off.
fn ask_enabled(app: &App) -> bool {
app.config
.settings
.ask
.as_ref()
.map(|a| a.enabled)
.unwrap_or(true)
}
/// The optional LLM refinement settings: (provider, model), both None when
/// no provider is configured (rules-only mode).
fn ask_settings(app: &App) -> Option<(Option<&str>, Option<&str>)> {
if !ask_enabled(app) {
return None;
}
let a = app.config.settings.ask.as_ref();
Some((
a.and_then(|x| x.provider.as_deref()).filter(|p| !p.is_empty()),
a.and_then(|x| x.model.as_deref()).filter(|m| !m.is_empty()),
))
}
fn is_known_command(first_word: &str) -> bool {
matches!(
first_word,
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "list" | "status" | "ps" | "logs" | "history" | "stats" | "top"
| "report" | "doctor" | "config" | "providers" | "secret" | "suggest"
| "search" | "info" | "open" | "watch" | "sync" | "web" | "monitor"
| "schedule" | "ask" | "lab" | "playbook"
)
}
/// Ask the provider registry to translate the query. Returns None when no
/// provider is configured (rules-only mode). Only the user's query is sent —
/// never journal data.
fn llm_translate(app: &App, query: &str) -> Result<Option<Vec<String>>> {
let Some((provider, model)) = ask_settings(app) else {
return Ok(None);
};
let provider_name = provider.or_else(|| crate::providers::default_name(&app.config));
let Some(provider_name) = provider_name else {
return Ok(None);
};
let Some(def) = crate::providers::get(&app.config, provider_name) else {
return Ok(None);
};
let model = model.or(def.default_model.as_deref());
let Some(model) = model else {
return Ok(None);
};
let token = crate::secrets::store()
.get(&crate::secrets::key_for_provider_token(provider_name))
.ok()
.flatten();
let endpoint = format!("{}/chat/completions", def.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"temperature": 0,
"max_tokens": 256,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": query},
],
});
let mut req = ureq::post(&endpoint)
.set("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(20));
if let Some(t) = &token {
req = req.set("Authorization", &format!("Bearer {t}"));
}
let resp = req
.send_string(&body.to_string())
.map_err(|e| anyhow!("{e}"))?;
let json: serde_json::Value = serde_json::from_reader(resp.into_reader())?;
let content = json
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
.ok_or_else(|| anyhow!("réponse du fournisseur illisible"))?;
let commands: Vec<String> = content
.lines()
.map(|l| l.trim().trim_start_matches("am ").trim().to_string())
.filter(|l| !l.is_empty())
.filter(|l| {
let first = l.split_whitespace().next().unwrap_or("");
is_known_command(first)
})
.collect();
if commands.is_empty() {
anyhow::bail!("le fournisseur n'a produit aucune commande valide");
}
Ok(Some(commands))
}
// ---------------------------------------------------------------------------
// Cache (offline reuse, issue #78)
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Serialize, Deserialize)]
struct AskCache {
entries: BTreeMap<String, Vec<String>>,
}
fn cache_path(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or(Path::new("."))
.join("ask_cache.json")
}
fn cache_load(app: &App) -> AskCache {
std::fs::read_to_string(cache_path(app))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn cache_save(app: &App, cache: &AskCache) {
if let Ok(json) = serde_json::to_string_pretty(cache) {
let _ = std::fs::write(cache_path(app), json);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Translate a natural-language query into am commands (rules first, then
/// the optional LLM, both cached for offline reuse).
pub fn translate(app: &App, query: &str) -> Result<Vec<String>> {
let key = query.trim().to_lowercase();
if key.is_empty() {
anyhow::bail!("usage: am ask <demande en langage naturel>");
}
// 1. Cache hit — works offline, even after a previous LLM translation.
let mut cache = cache_load(app);
if let Some(hit) = cache.entries.get(&key) {
return Ok(hit.clone());
}
// 2. Local rules (zero dependency).
let rules = rules_translate(query, app);
if !rules.is_empty() {
cache.entries.insert(key.clone(), rules.clone());
cache_save(app, &cache);
return Ok(rules);
}
// 3. Optional LLM refinement through the provider registry. Failures
// (no token, provider unreachable, malformed answer) degrade to the
// rules-only result instead of blocking the command.
match llm_translate(app, query) {
Ok(Some(llm)) => {
cache.entries.insert(key.clone(), llm.clone());
cache_save(app, &cache);
return Ok(llm);
}
Ok(None) => {}
Err(e) => {
app.log
.verbose(&format!("ask LLM refinement skipped: {e:#}"));
}
}
Ok(Vec::new())
}
/// am ask entry point: translate, show, confirm, execute.
pub fn run(app: &App, query: &str, yes: bool) -> Result<i32> {
if !ask_enabled(app) {
app.log.error(crate::i18n::tr(
"am ask est désactivé — settings.ask.enabled: true pour l'activer",
));
return Ok(1);
}
let commands = translate(app, query)?;
if commands.is_empty() {
app.log.info(crate::i18n::tr(
"je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»",
));
return Ok(0);
}
for c in &commands {
println!(" am {c}");
}
if !yes && !app.confirm(&crate::i18n::tr("exécuter ces commandes ?"))? {
app.log.info(crate::i18n::tr("annulé"));
return Ok(0);
}
for c in &commands {
let tokens = shell_words::split(c)?;
let mut argv = vec!["am".to_string()];
argv.extend(tokens);
let cli = crate::cli::Cli::parse_from(argv);
let Some(cmd) = cli.command else {
continue;
};
let code = execute_command(app, &cmd)?;
if code != 0 {
return Ok(code);
}
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use clap::Parser;
fn test_app(ask_yaml: &str) -> App {
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,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{ask_yaml}agents: []\n"
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = crate::app::App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.state_file = dir.join("state.json");
app.paths = p;
app
}
#[test]
fn rules_install_and_launch_claude() {
let app = test_app("");
let cmds = translate(&app, "installe claude et lance-le").unwrap();
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
}
#[test]
fn rules_cover_common_requests() {
let app = test_app("");
assert_eq!(translate(&app, "liste les agents").unwrap(), vec!["list"]);
assert_eq!(translate(&app, "arrête codex").unwrap(), vec!["stop codex"]);
assert_eq!(
translate(&app, "mets à jour tout").unwrap(),
vec!["update --all"]
);
assert_eq!(translate(&app, "qui tourne ?").unwrap(), vec!["ps"]);
assert_eq!(
translate(&app, "installe deepseek-harness").unwrap(),
vec!["install deepseek-harness"]
);
}
#[test]
fn rules_unknown_query_is_empty() {
let app = test_app("");
assert!(translate(&app, "quelle est la météo demain ?").unwrap().is_empty());
}
#[test]
fn cache_reuses_translations_offline() {
let app = test_app("");
let cmds = translate(&app, "installe claude et lance-le").unwrap();
assert_eq!(cmds.len(), 2);
// Second pass: served from the cache file (rules are deterministic,
// but the cache also carries previous LLM translations offline).
let cache = cache_load(&app);
assert!(cache.entries.contains_key("installe claude et lance-le"));
}
#[test]
fn llm_refinement_uses_the_registry_and_never_sends_journal_data() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let app = test_app(&format!(
" default_provider: openai\n ask:\n enabled: true\n provider: openai\n providers:\n openai:\n base_url: http://127.0.0.1:{port}\n default_model: gpt-5.2\n"
));
// Store a fake token in the keyring namespace of the provider.
let store = crate::secrets::store();
let key = crate::secrets::key_for_provider_token("openai");
store.set(&key, "sk-test-token").unwrap();
let handle = std::thread::spawn(move || {
let mut req = server
.recv_timeout(std::time::Duration::from_secs(10))
.expect("server recv failed")
.expect("the ask call must reach the provider");
assert_eq!(req.url(), "/chat/completions");
let mut body = String::new();
req.as_reader().read_to_string(&mut body).unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["model"], "gpt-5.2");
assert_eq!(json["temperature"], 0);
let user_msg = json["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "user")
.unwrap()["content"]
.as_str()
.unwrap()
.to_string();
let auth = req.headers().iter().find(|h| h.field.equiv("Authorization")).map(|h| h.value.as_str().to_string());
let _ = req.respond(tiny_http::Response::from_string(
r#"{"choices":[{"message":{"content":"install claude-code\nstart claude-code"}}]}"#,
));
(user_msg, auth)
});
// The rules do NOT cover this query — the LLM refinement is reached.
let cmds = translate(&app, "donne-moi un rapport de la semaine").unwrap();
assert_eq!(cmds, vec!["install claude-code", "start claude-code"]);
let (user_msg, auth) = handle.join().unwrap();
// The provider receives the query and the keyring token — nothing else.
assert_eq!(user_msg, "donne-moi un rapport de la semaine");
assert_eq!(auth.as_deref(), Some("Bearer sk-test-token"));
store.remove(&key).unwrap();
}
#[test]
fn llm_failure_degrades_to_rules_only() {
// No provider configured explicitly: the embedded default (anthropic)
// would 401 without a token — translate() must degrade silently to
// the rules-only result instead of blocking.
let app = test_app("");
let cmds = translate(&app, "une requête hors règles").unwrap();
assert!(cmds.is_empty());
}
#[test]
fn disabled_ask_is_refused() {
let app = test_app(" ask:\n enabled: false\n");
// run() reports the refusal with exit code 1 (not an error).
assert_eq!(run(&app, "installe claude", true).unwrap(), 1);
}
}
+11
View File
@@ -200,6 +200,17 @@ pub enum Command {
#[arg(value_name = "QUERY", num_args = 1.., required = true)]
words: Vec<String>,
},
/// Ask a natural-language request and get the matching am command(s)
/// (issue #78): local rules first, optional LLM refinement, confirmation
/// before execution.
Ask {
/// The request in natural language
#[arg(value_name = "REQUEST", num_args = 1.., required = true)]
query: Vec<String>,
/// Skip the confirmation prompt
#[arg(long)]
yes: bool,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
+1
View File
@@ -102,6 +102,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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::Ask { query, yes } => crate::ask::run(app, &query.join(" "), *yes),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
+32
View File
@@ -140,6 +140,11 @@ pub struct Settings {
/// Nothing is collected or sent while `enabled` is false (the default).
#[serde(default)]
pub telemetry: Option<TelemetrySettings>,
/// am ask (issue #78): natural language → am command. The local rules
/// are always available; the optional LLM refinement uses the provider
/// registry. `enabled: false` turns the whole command off.
#[serde(default)]
pub ask: Option<AskSettings>,
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
@@ -167,6 +172,30 @@ pub struct TelemetrySettings {
pub endpoint: Option<String>,
}
/// am ask settings (issue #78): the LLM refinement is optional — without a
/// provider, the local rule-based translator still works, offline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AskSettings {
/// Master switch; false disables `am ask` entirely (issue #78).
pub enabled: bool,
/// Provider of the registry used for the refinement call. Falls back
/// to settings.default_provider when unset. None = rules only.
pub provider: Option<String>,
/// Model for the refinement call. Falls back to the provider default.
pub model: Option<String>,
}
impl Default for AskSettings {
fn default() -> Self {
AskSettings {
enabled: true,
provider: None,
model: None,
}
}
}
/// One entry of the LLM provider registry (issue #88).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
@@ -872,6 +901,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.telemetry.is_some() {
s.telemetry = o.telemetry;
}
if o.ask.is_some() {
s.ask = o.ask;
}
if o.plugins.is_some() {
s.plugins = o.plugins;
}
+19
View File
@@ -851,6 +851,25 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
],
},
HelpSpec {
name: "ask",
category: "Commands",
usage: "ask {flags} <demande...>",
about: "Traduit une demande en langage naturel en commande(s) am : règles locales hors-ligne, puis raffinement LLM optionnel via le registre providers, cache des traductions, confirmation avant exécution (issue #78).",
search_terms: &["nlp", "natural", "language", "traduire", "langage"],
flags: &[
HelpFlag { short: "", long: "--yes", value: "", desc: "Skip the confirmation prompt" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "demande", typ: "string", desc: "La demande en langage naturel, ex: «installe claude et lance-le»" },
],
io: None,
examples: &[
HelpExample { desc: "Installer puis lancer un agent.", code: "ask installe claude et lance-le" },
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
],
},
HelpSpec {
name: "start",
category: "Commands",
+6 -1
View File
@@ -159,7 +159,12 @@ pub const CATALOG: &[(&str, &str)] = &[
("date", "date"),
("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"),
("am ask est désactivé — settings.ask.enabled: true pour l'activer", "am ask is disabled — set settings.ask.enabled: true to enable it"),
("je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»", "I did not understand — examples: «installe claude et lance-le», «liste les agents», «arrête codex»"),
("exécuter ces commandes ?", "run these commands?"),
("annulé", "cancelled"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest un agent pour du 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 '{}'"),
+1
View File
@@ -13,6 +13,7 @@
pub mod app;
pub mod agent_config;
pub mod ask;
pub mod automation;
pub mod backup;
pub mod catalog;
+11 -1
View File
@@ -236,7 +236,7 @@ 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", "providers", "suggest", "audit",
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "audit",
"service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
@@ -1551,6 +1551,16 @@ fn handle_line(
Command::Suggest {
words: rest.to_vec(),
}
}
"ask" => {
if rest.is_empty() {
app.log.error(crate::i18n::tr("usage: ask <demande> — ex: ask installe claude et lance-le"));
return Ok(false);
}
Command::Ask {
query: rest.to_vec(),
yes: flag("--yes"),
}
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {