- 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
541 lines
20 KiB
Rust
541 lines
20 KiB
Rust
//! 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);
|
|
}
|
|
}
|