//! 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 { // 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) -> Option { let p = part.trim().to_lowercase(); let agent = agent_of(part, app, prev.as_deref()).or_else(|| prev.clone()); let needs = |cmd: &str| -> Option { 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 { let mut out: Vec = Vec::new(); let mut prev: Option = 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 = 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>> { 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 = 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>, } 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> { let key = query.trim().to_lowercase(); if key.is_empty() { anyhow::bail!("usage: am ask "); } // 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 { 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); } }