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:
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 '{}'"),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user