diff --git a/ROADMAP.md b/ROADMAP.md index 0276b9c..4678d60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/config.yaml b/config.yaml index d875219..2e5e88b 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/man/am-ask.1 b/man/am-ask.1 new file mode 100644 index 0000000..4314495 --- /dev/null +++ b/man/am-ask.1 @@ -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 diff --git a/man/am.1 b/man/am.1 index 36f175a..2fec9ab 100644 --- a/man/am.1 +++ b/man/am.1 @@ -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 diff --git a/src/ask.rs b/src/ask.rs new file mode 100644 index 0000000..1db59a2 --- /dev/null +++ b/src/ask.rs @@ -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 { + // 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); + } +} diff --git a/src/cli.rs b/src/cli.rs index a50f287..16d3986 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -200,6 +200,17 @@ pub enum Command { #[arg(value_name = "QUERY", num_args = 1.., required = true)] words: Vec, }, + /// 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, + /// 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) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 33c7333..032dbf3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -102,6 +102,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { 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, diff --git a/src/config.rs b/src/config.rs index 1a78753..dbaaa33 100644 --- a/src/config.rs +++ b/src/config.rs @@ -140,6 +140,11 @@ pub struct Settings { /// Nothing is collected or sent while `enabled` is false (the default). #[serde(default)] pub telemetry: Option, + /// 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, /// Plugin scripts (issue #75): default timeout and enable list. #[serde(default)] pub plugins: Option, @@ -167,6 +172,30 @@ pub struct TelemetrySettings { pub endpoint: Option, } +/// 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, + /// Model for the refinement call. Falls back to the provider default. + pub model: Option, +} + +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; } diff --git a/src/help.rs b/src/help.rs index 0b98adb..4da2aa5 100644 --- a/src/help.rs +++ b/src/help.rs @@ -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} ", + 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", diff --git a/src/i18n.rs b/src/i18n.rs index bfa7f93..01efddf 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -159,7 +159,12 @@ pub const CATALOG: &[(&str, &str)] = &[ ("date", "date"), ("début", "start"), ("fin", "end"), - ("usage: suggest — ex: suggest un agent pour du Python", "usage: suggest — 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 — ex: ask installe claude et lance-le", "usage: ask — e.g. ask installe claude et lance-le"), + ("usage: suggest — ex: suggest un agent pour du Python", "usage: suggest — e.g. suggest un agent pour du Python"), // ---- providers (#88) --------------------------------------------------- ("aucun provider enregistré — voir: am providers add --base-url ", "no providers registered — see: am providers add --base-url "), ("provider '{}' inconnu", "unknown provider '{}'"), diff --git a/src/lib.rs b/src/lib.rs index eb7f020..fff830d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod app; pub mod agent_config; +pub mod ask; pub mod automation; pub mod backup; pub mod catalog; diff --git a/src/repl.rs b/src/repl.rs index 2ef2c55..6eb1292 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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 — 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 {