feat: am ai — langage naturel vers action shell securisee via AIChat (issues #96 #97)
release / release (push) Successful in 13m12s
release / macos (push) Canceled after 0s

- Commande am ai (alias CLI: am shell) : conversationnel par defaut
  (aichat -f <ctx> "<prompt>"), mode --exec avec generation aichat --dry-run
- Securite (issue #97) : classification safe/risky + certainite affichee,
  politique dry-run par defaut (settings.shell_ai.default_safety:
  dry-run | confirm | auto), confirmation y/N pour les commandes risky,
  --yes pour executer, --dry-run force la simulation
- Flags : --exec/-e, --files/-f (defaut: dossier courant), --provider,
  --model (registre providers -> env aichat + modele, token via keyring)
- Execution via le shell utilisateur (default_shell > SHELL > COMSPEC)
- Journalisation : EventKind::ShellAi (mode, risk, certainty, executed)
- Module src/shell_ai.rs (classification, politique, generation, execution)
- Câblage complet : cli, dispatch, help, REPL (parseur+completer+banner),
  tip, man pages (am-ai.1), i18n EN, config.yaml (bloc shell_ai)
- Tests : 7 unitaires (classification, certainty, extraction, decision)
  + 4 integration (shim aichat.cmd : dry-run par defaut, --yes, mode
  conversationnel, mode confirm) — 339 tests verts
- v1.0.5 ; ROADMAP axe 13.3 coche (6 lignes), epic #93 clôturee

closes #93
closes #96
closes #97
This commit is contained in:
2026-08-20 12:07:38 -04:00
parent 02517735be
commit 313645eea2
19 changed files with 1032 additions and 15 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "1.0.4"
version = "1.0.5"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "1.0.4"
version = "1.0.5"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+1
View File
@@ -79,6 +79,7 @@ plateforme, il compile automatiquement depuis les sources.
| am search <mot-clé> | recherche fuzzy : tolère les fautes de frappe, classe par pertinence, propose « vouliez-vous dire » |
| am suggest <requête> | recommande un agent pour une demande en langage naturel (tags + usage réel) |
| am ask "<demande>" | langage naturel → commande(s) am : règles locales hors-ligne, raffinement LLM optionnel (settings.ask), confirmation avant exécution — ex: am ask "installe claude et lance-le" |
| am ai "<demande>" [--exec] [--files <path>] | langage naturel → action shell via AIChat (alias: am shell) : conversationnel par défaut ; --exec génère la commande, la classe safe/risky et applique la politique de sécurité (settings.shell_ai, dry-run par défaut — --yes pour exécuter) |
| am info <agent> | fiche détaillée (installation, dépendances, site…) |
| am status [agent] | état, version, PID, logs |
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
+18 -9
View File
@@ -1,7 +1,7 @@
# 🗺️ ROADMAP agent-manager — vers le « super outil »
> **Version du document : 2.4** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.4
> **Version du document : 2.5** · Statut : phases 0 à 3 livrées
> (v0.3.0 → v1.0.1), maintenance v1.0.2 → v1.0.5
> Base : analyse du code **v0.2.7** (Rust, 45+ tests, zéro dépendance runtime)
>
> ✅ **Phase 0 livrée en v0.3.0 (2026-08-17)** : issues #2 à #16 clôturées,
@@ -38,6 +38,15 @@
> **shell-ai** ajoutés (issues #94 #95) : AIChat (alias `ai`, binaire GitHub
> Release), ShellGPT (pip), Fabric (go), Shell AI (npm/Ollama), AI CLI
> (binaire, politique de sécurité risk/certainty). Catalogue : 77 agents.
>
> 🔧 **Maintenance v1.0.5 (2026-08-20)** : **commande `am ai` livrée**
> (issues #96 #97) — langage naturel → action shell via AIChat : mode
> conversationnel (aichat -f <ctx>), mode --exec avec pipeline de sécurité
> (génération aichat --dry-run, classification safe/risky + certainité,
> politique dry-run par défaut configurable settings.shell_ai, confirmation
> y/N pour les commandes risky, exécution via le shell de l'utilisateur),
> flags --files/--provider/--model/--yes, alias CLI `am shell`, événements
> `shell_ai` journalisés. Épique Axe 13 (#93) clôturée.
---
@@ -403,12 +412,12 @@ aichat -f . "résume les données de ces fichiers JSON"
| Fonctionnalité | Description | Effort | Phase |
|---|---|---|---|
| ✅ **Catalogue : ajouter AIChat** | Définition `AgentDef` + alias `ai` → `aichat` — livré en v1.0.4 (issues #94 #95) | S | P1 |
| **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte | S | P3 |
| **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e`) | S | P3 |
| **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte | S | P3 |
| **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; confirmation si modification | M | P3 |
| **Provider configurable** | Réutiliser le registre `providers.rs` (cheap model par défaut) | M | P3 |
| **Alias intégrés** | `am shell`, `am do` comme synonymes | S | P3 |
| ✅ **Commande `am ai <prompt>`** | Lancer AIChat avec le dossier courant comme contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--exec` / `-e`** | Active le mode exécution directe (`aichat -e` via aichat --dry-run + exécution sécurisée par am) — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Flag `--files <path>`** | Passer fichiers/dossiers spécifiques en contexte — livré en v1.0.5 (issue #96) | S | P3 |
| ✅ **Sécurité : `--dry-run` par défaut** | Simuler avant exécution ; classification safe/risky + confirmation — livré en v1.0.5 (issue #97, settings.shell_ai) | M | P3 |
| ✅ **Provider configurable** | Réutiliser le registre `providers.rs` (--provider/--model → env aichat + modèle) — livré en v1.0.5 (issue #96) | M | P3 |
| ✅ **Alias intégrés** | `am shell` comme synonyme CLI de `am ai` — livré en v1.0.5 (issue #96) | S | P3 |
| **Extensions sœurs** | `am summarize`, `am explain`, `am fix` (voir ci-dessous) | M | P3 |
---
@@ -619,4 +628,4 @@ configuration manuelle (tokens au keyring, provider/modèle par défaut).
Phases livrées : 0 (v0.3.0), 1 (v0.4.1), 2 (v0.6.0), v0.7.0 (providers,
2026-08-19), 3 (v1.0.0, #76–#80, 2026-08-20) ; maintenance v1.0.1/v1.0.2
(self-update décompression, REPL), v1.0.3 (détection externe), v1.0.4
(catalogue shell AI, #94 #95).*
(catalogue shell AI, #94 #95), v1.0.5 (commande am ai, #93 #96 #97).*
+11
View File
@@ -64,6 +64,17 @@ settings:
# sources:
# - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml
# author: bruno
# am ai (#96 #97) : politique de sécurité Shell AI. dry-run par défaut —
# aucune commande modifiante n'est exécutée sans confirmation explicite
# (--yes). `confirm` demande validation pour les commandes risky ;
# `auto` exécute tout (déconseillé). Les motifs supplémentaires s'ajoutent
# aux patterns intégrés (rm -rf, dd if, mkfs, chmod -R 777, ...).
# shell_ai:
# default_safety: dry-run # dry-run | confirm | auto
# risky_patterns:
# - "rm -rf"
# - "> /dev"
# - "dd if"
# Profil sandbox par agent (#79) : commandes autorisées (binaires), répertoires
# de travail et politique réseau. Désactivé par défaut (mode non sandboxé).
# Les tentatives refusées sont journalisées (events sandbox) pour l'audit.
+28
View File
@@ -0,0 +1,28 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-ai 1 "ai "
.SH NAME
ai \- Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH SYNOPSIS
\fBai\fR [\fB\-e\fR|\fB\-\-exec\fR] [\fB\-f\fR|\fB\-\-files\fR] [\fB\-\-provider\fR] [\fB\-\-model\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIPROMPT\fR>
.SH DESCRIPTION
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.SH OPTIONS
.TP
\fB\-e\fR, \fB\-\-exec\fR
Execute the generated shell command (after the safety policy)
.TP
\fB\-f\fR, \fB\-\-files\fR \fI<PATH>\fR
Files or directories passed as context (repeatable; default: the current directory)
.TP
\fB\-\-provider\fR \fI<ID>\fR
Provider of the registry used by aichat (env vars + model)
.TP
\fB\-\-model\fR \fI<MODEL>\fR
Force a model (aichat \-\-model)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
<\fIPROMPT\fR>
The request in natural language
+5 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 1.0.3"
.TH am 1 "am 1.0.5"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -133,6 +133,9 @@ Suggest agents matching a query, boosted by real usage (issue #61)
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\-ai(1)
Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
.TP
am\-start(1)
Start an agent (foreground by default, or detached with \-\-background)
.TP
@@ -244,4 +247,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v1.0.3
v1.0.5
+32
View File
@@ -214,6 +214,9 @@ pub enum Command {
#[arg(long)]
yes: bool,
},
/// Langage naturel → action shell via AIChat (issues #96 #97); alias: shell
#[command(alias = "shell")]
Ai(AiArgs),
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -700,6 +703,35 @@ pub struct WebArgs {
pub no_open: bool,
}
/// Arguments of am ai (issues #96 #97): natural language → shell action
/// via AIChat. The global flags --dry-run and --yes/-y apply as well.
#[derive(Args, Debug, Clone, Default)]
pub struct AiArgs {
/// The request in natural language
#[arg(value_name = "PROMPT", num_args = 1.., required = true)]
pub prompt: Vec<String>,
/// Execute the generated shell command (after the safety policy)
#[arg(short = 'e', long, action = ArgAction::SetTrue)]
pub exec: bool,
/// Files or directories passed as context (repeatable; default: the
/// current directory)
#[arg(short = 'f', long = "files", value_name = "PATH", action = ArgAction::Append)]
pub files: Vec<std::path::PathBuf>,
/// Provider of the registry used by aichat (env vars + model)
#[arg(long, value_name = "ID")]
pub provider: Option<String>,
/// Force a model (aichat --model)
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// REPL-only: --yes given on the REPL line (the CLI global -y/--yes is
/// handled by clap directly).
#[arg(skip)]
pub yes: bool,
/// REPL-only: --dry-run given on the REPL line.
#[arg(skip)]
pub dry_run: bool,
}
/// Arguments of am serve (issue #80).
#[derive(Args, Debug, Clone, Default)]
pub struct ServeArgs {
+184
View File
@@ -0,0 +1,184 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
//! context files (default: the current directory). Exec mode generates the
//! shell command through aichat in dry-run, classifies it, applies the
//! safety policy (settings.shell_ai, dry-run by default) and only executes
//! after confirmation when required.
use crate::app::App;
use crate::cli::AiArgs;
use crate::commands::{require_agent, resolve_exec};
use crate::events::{Event, EventKind};
use crate::shell_ai::{Decision, SafetyMode};
use anyhow::{Context, Result};
use std::process::Command;
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
let prompt = args.prompt.join(" ");
// Context files: explicit --files wins, else the current directory
// (issue #96: `aichat -f . "<prompt>"`).
let files: Vec<String> = if args.files.is_empty() {
vec![".".to_string()]
} else {
args.files
.iter()
.map(|p| p.display().to_string())
.collect()
};
// REPL per-line flags combine with the CLI globals.
let yes = app.cli.yes || args.yes;
let dry_run = app.cli.dry_run || args.dry_run;
let agent = require_agent(app, "aichat")?;
if !args.exec {
return chat_mode(app, agent, &prompt, &files, args, dry_run);
}
exec_mode(app, &prompt, &files, args, yes, dry_run)
}
/// Conversational mode: aichat -f <ctx> "<prompt>" in the foreground, with
/// the provider/model environment applied when requested.
fn chat_mode(
app: &App,
agent: &crate::config::AgentDef,
prompt: &str,
files: &[String],
args: &AiArgs,
dry_run: bool,
) -> Result<i32> {
let mut extra: Vec<String> = Vec::new();
for f in files {
extra.push("-f".to_string());
extra.push(f.clone());
}
extra.push(prompt.to_string());
let (p_args, p_env) =
crate::shell_ai::provider_env(app, args.provider.as_deref(), args.model.as_deref())?;
extra.extend(p_args);
if dry_run {
app.log.dry(&format!(
"would run aichat {} (conversationnel)",
extra.join(" ")
));
return Ok(0);
}
let exec = resolve_exec(app, agent, &extra, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {}",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec!["mode=chat".to_string(), "executed=true".to_string()]),
);
Ok(status.code().unwrap_or(1))
}
/// Exec mode: generate the command (aichat --dry-run), classify it, apply
/// the safety policy, then execute through the shell when allowed.
fn exec_mode(
app: &App,
prompt: &str,
files: &[String],
args: &AiArgs,
yes: bool,
dry_run: bool,
) -> Result<i32> {
if dry_run {
app.log.dry(&format!(
"would generate & classify via aichat (exec) — commande non exécutée"
));
}
let cmd = crate::shell_ai::generate(
app,
prompt,
files,
args.provider.as_deref(),
args.model.as_deref(),
)?;
let settings = app
.config
.settings
.shell_ai
.clone()
.unwrap_or_default();
let risk = crate::shell_ai::classify(&cmd, &settings.risky_patterns);
let certainty = crate::shell_ai::estimate_certainty(&cmd, risk);
let mode = settings.safety_mode();
// Show the generated command and its classification (issue #97 UX).
println!("🔒 Commande générée : {}", cmd);
println!(" Risk : {}", risk.as_str());
println!(" Certainty: {}%", certainty);
println!(
" Safety : {} (settings.shell_ai.default_safety; --yes pour exécuter)",
mode.as_str()
);
let decision = crate::shell_ai::decide(mode, risk, dry_run, yes);
let mut executed = false;
match decision {
Decision::Abort => {
let msg = if dry_run {
"dry-run : commande non exécutée"
} else {
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes"
};
app.log.info(crate::i18n::tr(msg));
}
Decision::Ask => {
let ok = app.confirm(crate::i18n::tr("Exécuter ?"))?;
if ok {
executed = true;
} else {
app.log.info(crate::i18n::tr("annulé"));
}
}
Decision::Execute => {
executed = true;
}
}
let code = if executed {
app.log.info(&format!("exécution: {}", cmd));
let code = crate::shell_ai::execute(app, &cmd)?;
code
} else {
0
};
app.emit(
&Event::now(EventKind::ShellAi)
.with_agent("aichat".to_string())
.with_args(vec![
"mode=exec".to_string(),
format!("risk={}", risk.as_str()),
format!("certainty={certainty}"),
format!("executed={executed}"),
]),
);
Ok(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_dry_run_is_the_default_policy() {
// The embedded config does not set shell_ai -> dry-run default.
let s = crate::config::ShellAiSettings::default();
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
}
+2
View File
@@ -1,6 +1,7 @@
//! Command implementations and dispatch.
pub mod agents_cmd;
pub mod ai_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
@@ -106,6 +107,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
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::Ai(args) => ai_cmd::run(app, args),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
+12
View File
@@ -109,6 +109,18 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("--yes", "exécute la commande sans confirmation")],
example: "ask installe claude et lance-le",
},
TipEntry {
usage: "ai <demande> [--exec] [--files <path>]",
about: "langage naturel → action shell via AIChat (issues #96 #97) : génère avec aichat, classe safe/risky, dry-run par défaut",
options: &[
("--exec", "génère PUIS exécute la commande (après la politique de sécurité)"),
("-f, --files", "fichiers/dossiers en contexte (défaut: dossier courant)"),
("--provider", "provider du registre (clé du keyring + modèle)"),
("--model", "forcer un modèle"),
("--yes", "exécute sans confirmation"),
],
example: "ai --exec \"compresse les fichiers JSON en un zip\"",
},
TipEntry {
usage: "models",
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
+35
View File
@@ -162,6 +162,10 @@ pub struct Settings {
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
/// am ai security settings (issue #97): default safety policy and
/// extra risky command patterns for the Shell AI command.
#[serde(default)]
pub shell_ai: Option<ShellAiSettings>,
}
/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never
@@ -200,6 +204,34 @@ impl Default for AskSettings {
}
}
/// am ai security settings (issue #97): safety policy and risky patterns
/// for the Shell AI command. The default policy is `dry-run` — nothing is
/// executed without an explicit confirmation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ShellAiSettings {
/// Default safety policy of `am ai --exec`:
/// `dry-run` (show only), `confirm` (ask for risky commands) or
/// `auto` (execute everything). Default: dry-run.
pub default_safety: Option<String>,
/// Extra command substrings classified as risky (in addition to the
/// built-in patterns: rm -rf, dd if, mkfs, chmod -R 777, ...).
#[serde(default)]
pub risky_patterns: Vec<String>,
}
impl ShellAiSettings {
/// The effective default safety mode (unknown values fall back to
/// dry-run — the safe choice).
pub fn safety_mode(&self) -> crate::shell_ai::SafetyMode {
match self.default_safety.as_deref() {
Some("auto") => crate::shell_ai::SafetyMode::Auto,
Some("confirm") => crate::shell_ai::SafetyMode::Confirm,
_ => crate::shell_ai::SafetyMode::DryRun,
}
}
}
/// Community registry settings (issue #77).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -969,6 +1001,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.default_provider.is_some() {
s.default_provider = o.default_provider;
}
if o.shell_ai.is_some() {
s.shell_ai = o.shell_ai;
}
// Providers merge key by key (issue #88): an overlay adds or replaces
// one provider without wiping the others. An explicit `null` in the
// overlay DELETES the provider (the standard YAML overlay pattern) so a
+4
View File
@@ -52,6 +52,9 @@ pub enum EventKind {
Provider,
/// Sandbox refusal journalized for audit (issue #79).
Sandbox,
/// am ai — Shell AI action: generated, classified and/or executed
/// (issues #96 #97).
ShellAi,
}
impl EventKind {
@@ -79,6 +82,7 @@ impl EventKind {
EventKind::Lab => "lab",
EventKind::Provider => "provider",
EventKind::Sandbox => "sandbox",
EventKind::ShellAi => "shell_ai",
}
}
}
+26
View File
@@ -876,6 +876,32 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" },
],
},
HelpSpec {
name: "ai",
category: "Commands",
usage: "ai {flags} <prompt...>",
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --dry-run), la classe safe/risky, applique la politique de sécurité (dry-run par défaut — settings.shell_ai) puis exécute après confirmation. Alias CLI : am shell.",
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
flags: &[
HelpFlag { short: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
HelpFlag { short: "-f", long: "--files", value: "PATH", desc: "Fichier ou dossier passé en contexte (répétable ; défaut : dossier courant)" },
HelpFlag { short: "", long: "--provider", value: "ID", desc: "Provider du registre utilisé par aichat (variables d'env + modèle)" },
HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Forcer un modèle (aichat --model)" },
HelpFlag { short: "-y", long: "--yes", value: "", desc: "Exécuter sans confirmation (global)" },
HelpFlag { short: "", long: "--dry-run", value: "", desc: "Simuler : génère et classe la commande sans rien exécuter (global)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "prompt", typ: "string", desc: "La demande en langage naturel, ex: «liste les fichiers JSON du dossier courant»" },
],
io: None,
examples: &[
HelpExample { desc: "Mode conversationnel (défaut).", code: "ai liste tous les fichiers JSON et extrait les clés uniques" },
HelpExample { desc: "Générer la commande sans l'exécuter (dry-run par défaut).", code: "ai --exec \"compresse les fichiers JSON en un zip\"" },
HelpExample { desc: "Exécuter après confirmation explicite.", code: "ai --exec \"supprime les fichiers .tmp\" --yes" },
HelpExample { desc: "Passer un dossier en contexte.", code: "ai --files ./data \"résume les données de ces fichiers\"" },
],
},
HelpSpec {
name: "registry",
category: "Commands",
+6
View File
@@ -163,6 +163,12 @@ pub const CATALOG: &[(&str, &str)] = &[
("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"),
("Exécuter ?", "Execute?"),
("dry-run : commande non exécutée", "dry-run: command not executed"),
("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"),
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
("installation refusée — source non fiable", "installation refused — untrusted source"),
+1
View File
@@ -14,6 +14,7 @@
pub mod app;
pub mod agent_config;
pub mod ask;
pub mod shell_ai;
pub mod automation;
pub mod backup;
pub mod catalog;
+56 -2
View File
@@ -114,6 +114,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("providers", "manage the LLM provider registry (base URLs, models, default)"),
("suggest", "recommend an agent for a request"),
("ask", "natural language → am commands (local rules first, optional LLM refinement)"),
("ai", "natural language → shell action via AIChat (--exec to run, dry-run by default)"),
("audit", "who changed what, when (config checksums)"),
("service", "register an agent as a system service (autostart)"),
("schedule", "plan am commands (daily) and check the fleet health"),
@@ -239,7 +240,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", "ask", "registry", "audit",
"profile", "man", "models", "catalog", "providers", "suggest", "ask", "ai", "registry", "audit",
"service", "schedule", "monitor", "web", "serve", "sync", "migrate", "playbook", "lab", "plugins",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
@@ -799,7 +800,7 @@ pub fn banner_box(
.to_string(),
));
rows.push(inner(
" models models · models --prune · catalog · suggest · ask · audit".to_string(),
" models models · models --prune · catalog · suggest · ask · ai · audit".to_string(),
));
rows.push(inner(
" automate service install · schedule add · doctor --watch · monitor · sync · migrate · playbook".to_string(),
@@ -1603,6 +1604,58 @@ fn handle_line(
yes: flag("--yes"),
}
},
"ai" => {
let mut exec = false;
let mut yes = false;
let mut dry_run = false;
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut provider: Option<String> = None;
let mut model: Option<String> = None;
let mut prompt: Vec<String> = Vec::new();
let mut i = 0;
while i < rest.len() {
match rest[i].as_str() {
"--exec" | "-e" => exec = true,
"--yes" | "-y" => yes = true,
"--dry-run" => dry_run = true,
"--files" | "-f" => {
i += 1;
if i < rest.len() {
files.push(std::path::PathBuf::from(&rest[i]));
}
}
"--provider" => {
i += 1;
if i < rest.len() {
provider = Some(rest[i].clone());
}
}
"--model" => {
i += 1;
if i < rest.len() {
model = Some(rest[i].clone());
}
}
other => prompt.push(other.to_string()),
}
i += 1;
}
if prompt.is_empty() {
app.log.error(crate::i18n::tr(
"usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"",
));
return Ok(false);
}
Command::Ai(crate::cli::AiArgs {
prompt,
exec,
files,
provider,
model,
yes,
dry_run,
})
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
@@ -2019,6 +2072,7 @@ pub fn is_am_command(word: &str) -> bool {
| "providers"
| "suggest"
| "ask"
| "ai"
| "audit"
| "service"
| "schedule"
+453
View File
@@ -0,0 +1,453 @@
//! am ai — langage naturel → action shell (issues #96 #97).
//!
//! The Shell AI command turns a natural-language request into a shell
//! action using AIChat (the `aichat` catalog agent) as the generation
//! engine:
//!
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
//! spawned in the foreground, exactly as the user would run it;
//! - exec mode (`--exec`): aichat is asked to generate the command with
//! `--dry-run` (it prints the command and never runs it), then the
//! generated command is classified (safe / risky), a confidence score
//! is estimated, the configured safety policy is applied (dry-run by
//! default), and only then — after confirmation when required — is the
//! command executed through the user's shell.
//!
//! Nothing is ever executed without an explicit confirmation: the default
//! policy is `dry-run`, overridden by `--yes` (execute without asking),
//! the `settings.shell_ai.default_safety` setting (dry-run | confirm |
//! auto) or the explicit `--dry-run` flag (never execute, show only).
use crate::app::App;
use crate::commands::{require_agent, resolve_exec};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeMap;
use std::process::Command;
/// Safety policies of `am ai --exec` (issue #97).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SafetyMode {
/// Show the generated command only — nothing is executed unless the
/// user passes `--yes`.
DryRun,
/// Execute safe commands directly; ask before risky ones.
Confirm,
/// Execute everything without asking (explicitly enabled by the user).
Auto,
}
impl SafetyMode {
pub fn as_str(&self) -> &'static str {
match self {
SafetyMode::DryRun => "dry-run",
SafetyMode::Confirm => "confirm",
SafetyMode::Auto => "auto",
}
}
}
/// Risk class of a generated shell command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Risk {
Safe,
Risky,
}
impl Risk {
pub fn as_str(&self) -> &'static str {
match self {
Risk::Safe => "safe",
Risk::Risky => "risky",
}
}
}
/// Built-in substrings that mark a command as risky (issue #97). The user
/// can extend this list with `settings.shell_ai.risky_patterns`.
pub const BUILTIN_RISKY_PATTERNS: &[&str] = &[
"rm -rf",
"rm -fr",
"rm -r -f",
"dd if=",
"mkfs.",
"fdisk",
"parted",
"gdisk",
":(){",
"chmod -R 777",
"chmod 777 /",
"chown -R",
"> /dev/sd",
">/dev/sd",
"sudo rm",
"git push --force",
"git push -f",
"drop database",
"drop table",
"truncate table",
"shutdown",
"reboot",
"poweroff",
"kill -9",
"pkill -9",
"killall",
"init 0",
"init 6",
"mv / ",
"rm /",
"format c:",
"del /f /s",
"rd /s",
"cipher /w",
"curl ... | sh",
"curl ... | bash",
"wget ... | sh",
];
/// Classify a generated command against the built-in patterns plus the
/// user-configured ones (`settings.shell_ai.risky_patterns`).
pub fn classify(cmd: &str, extra_patterns: &[String]) -> Risk {
let lower = cmd.to_lowercase();
let hits = BUILTIN_RISKY_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_lowercase()))
|| extra_patterns.iter().any(|p| lower.contains(&p.to_lowercase()));
if hits {
Risk::Risky
} else {
Risk::Safe
}
}
/// A coarse confidence heuristic (0-100) shown to the user. Risky or
/// compound commands are scored lower; simple, read-only commands score
/// higher. It is an estimate, never a guarantee.
pub fn estimate_certainty(cmd: &str, risk: Risk) -> u8 {
let mut score: i32 = 92;
if risk == Risk::Risky {
score -= 25;
}
// Compound commands chain several effects — harder to predict.
for sep in ["&&", "||", ";\n", "\n", " | ", " 2>"] {
if cmd.contains(sep) {
score -= 6;
}
}
// Redirections and pipes move data around.
if cmd.contains('>') || cmd.contains('<') {
score -= 5;
}
if cmd.contains("sudo") {
score -= 8;
}
score.clamp(40, 99) as u8
}
/// Decide what to do with a generated command under the effective policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// Print and abort (dry-run or declined confirmation).
Abort,
/// Ask the user (y/N) before executing.
Ask,
/// Execute directly.
Execute,
}
/// Apply the safety policy: explicit `--dry-run` flag > `--yes` flag >
/// configured default mode (fallback: dry-run).
pub fn decide(
mode: SafetyMode,
risk: Risk,
dry_run_flag: bool,
yes_flag: bool,
) -> Decision {
if dry_run_flag {
return Decision::Abort;
}
if yes_flag {
return Decision::Execute;
}
match mode {
SafetyMode::Auto => Decision::Execute,
SafetyMode::Confirm => {
if risk == Risk::Risky {
Decision::Ask
} else {
Decision::Execute
}
}
SafetyMode::DryRun => Decision::Abort,
}
}
/// Extract the shell command from aichat's `--dry-run` output: strips a
/// fenced code block if present, otherwise uses the trimmed output as-is.
pub fn extract_command(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
// Fenced block: ```bash ... ``` (or ```sh, ```powershell, ```cmd ...)
let mut lines = trimmed.lines();
if lines.next().is_some_and(|l| l.trim_start().starts_with("```")) {
let body: Vec<&str> = lines
.take_while(|l| !l.trim().starts_with("```"))
.collect();
let cmd = body.join("\n").trim().to_string();
if !cmd.is_empty() {
return Some(cmd);
}
}
Some(trimmed.to_string())
}
/// Map a provider of the registry to the environment variables AIChat
/// understands, and resolve the model to pass with `--model`. The token is
/// resolved from the OS keyring via the standard `@secret` mechanism and
/// never appears on the command line (issue #89).
pub fn provider_env(
app: &App,
provider: Option<&str>,
model: Option<&str>,
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
// Without any flag, aichat keeps its own configuration (its config file
// and API keys) — the registry is only applied on explicit request.
if provider.is_none() && model.is_none() {
return Ok((Vec::new(), BTreeMap::new()));
}
let Some((pname, def, resolved_model)) =
crate::providers::resolve_for(&app.config, None, None, provider, model)
else {
if provider.is_some() {
let known = crate::providers::names(&app.config);
let hint = if known.is_empty() {
"aucun (am providers add <nom> --base-url <url>)".to_string()
} else {
known.join(", ")
};
bail!(crate::tr_fmt!(
"provider '{}' inconnu — providers enregistrés: {}",
provider.unwrap_or(""),
hint
));
}
return Ok((Vec::new(), BTreeMap::new()));
};
let mut args: Vec<String> = Vec::new();
let mut env: BTreeMap<String, String> = BTreeMap::new();
// Known providers map to AIChat's standard API-key variables; custom
// provider names fall back to the openai-compatible channel.
let key_var = match pname {
"anthropic" => Some("ANTHROPIC_API_KEY"),
"openai" => Some("OPENAI_API_KEY"),
"deepseek" => Some("DEEPSEEK_API_KEY"),
"google" => Some("GOOGLE_API_KEY"),
"groq" => Some("GROQ_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
"xai" | "grok" => Some("XAI_API_KEY"),
"ollama" => None, // local — no key; aichat knows its default endpoint
_ => Some("OPENAI_API_KEY"),
};
if let Some(var) = key_var {
env.insert(var.to_string(), "@secret".to_string());
if !matches!(pname, "openai" | "ollama" | "anthropic" | "deepseek" | "google" | "groq" | "openrouter" | "xai" | "grok") {
// Custom endpoints ride the openai-compatible channel.
env.insert("OPENAI_API_BASE".to_string(), def.base_url.clone());
}
}
if let Some(m) = resolved_model {
args.push("--model".to_string());
args.push(m.to_string());
}
let warnings = crate::secrets::resolve_env_secrets(
&crate::secrets::store(),
"aichat",
Some(pname),
&mut env,
);
for w in warnings {
app.log.warn(&w);
}
Ok((args, env))
}
/// Generate a shell command for `prompt` using aichat in dry-run mode.
/// Returns the generated command (never executed by aichat).
pub fn generate(
app: &App,
prompt: &str,
files: &[String],
provider: Option<&str>,
model: Option<&str>,
) -> Result<String> {
let agent = require_agent(app, "aichat")?;
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
for f in files {
extra_args.push("-f".to_string());
extra_args.push(f.clone());
}
extra_args.push(prompt.to_string());
let (p_args, p_env) = provider_env(app, provider, model)?;
extra_args.extend(p_args);
let exec = resolve_exec(app, agent, &extra_args, &p_env)?;
app.log.verbose(&format!(
"shell-ai: {} {} (dry-run generation)",
exec.program,
exec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let out = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.output()
.with_context(|| format!("failed to run {}", exec.program))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
bail!(crate::tr_fmt!(
"aichat n'a pas généré de commande (exit {}){}",
out.status.code().unwrap_or(-1),
if err.is_empty() { String::new() } else { format!(": {err}") }
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
extract_command(&stdout).ok_or_else(|| anyhow!(crate::i18n::tr(
"aichat n'a retourné aucune commande (dry-run)"
)))
}
/// Execute a generated command through the user's shell (default_shell >
/// $SHELL > $COMSPEC > cmd). The command is passed as a single argument.
pub fn execute(app: &App, command: &str) -> Result<i32> {
let spec = crate::shell::resolve_default(
app.config.settings.default_shell.as_deref(),
std::env::var_os("SHELL"),
std::env::var_os("COMSPEC"),
);
app.log.verbose(&format!(
"shell-ai: executing via {} {}",
spec.program,
spec.args.join(" ")
));
let (prog, prefix) = crate::runner::resolve_program(spec.program);
let mut args = prefix;
args.extend(spec.args.iter().map(|s| s.to_string()));
args.push(command.to_string());
let status = Command::new(&prog)
.args(&args)
.status()
.with_context(|| format!("failed to run {}", spec.program))?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_safe_and_risky() {
assert_eq!(classify("ls -la", &[]), Risk::Safe);
assert_eq!(classify("echo hello", &[]), Risk::Safe);
assert_eq!(classify("find . -name '*.json'", &[]), Risk::Safe);
assert_eq!(classify("rm -rf /tmp/x", &[]), Risk::Risky);
assert_eq!(classify("sudo rm -rf /var/log", &[]), Risk::Risky);
assert_eq!(classify("dd if=/dev/zero of=/dev/sda", &[]), Risk::Risky);
assert_eq!(classify("git push --force origin main", &[]), Risk::Risky);
assert_eq!(classify("drop database prod;", &[]), Risk::Risky);
}
#[test]
fn classifies_extra_user_patterns() {
let extra = vec!["killall node".to_string()];
assert_eq!(classify("killall node", &extra), Risk::Risky);
assert_eq!(classify("killall nodejs", &extra), Risk::Risky);
assert_eq!(classify("node --version", &extra), Risk::Safe);
}
#[test]
fn certainty_stays_in_bounds_and_penalizes_risk() {
let safe = estimate_certainty("ls -la", Risk::Safe);
let risky = estimate_certainty("rm -rf /", Risk::Risky);
assert!(safe > risky, "{safe} should be > {risky}");
assert!((40..=99).contains(&safe));
assert!((40..=99).contains(&risky));
let compound = estimate_certainty("rm -rf /tmp/a && echo ok", Risk::Risky);
assert!(compound < risky || compound == risky);
}
#[test]
fn extract_command_handles_fences_and_plain() {
assert_eq!(
extract_command("```bash\nrm *.tmp\n```"),
Some("rm *.tmp".to_string())
);
assert_eq!(
extract_command("```sh\necho hello\n```\n"),
Some("echo hello".to_string())
);
assert_eq!(
extract_command("rm *.tmp\n"),
Some("rm *.tmp".to_string())
);
assert_eq!(extract_command(" \n "), None);
}
#[test]
fn decision_matrix() {
// dry-run (default): abort, even with --yes overriding to execute.
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Safe, false, false),
Decision::Abort
);
assert_eq!(
decide(SafetyMode::DryRun, Risk::Risky, false, true),
Decision::Execute
);
// explicit --dry-run wins over --yes.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, true, true),
Decision::Abort
);
// confirm: ask only for risky.
assert_eq!(
decide(SafetyMode::Confirm, Risk::Safe, false, false),
Decision::Execute
);
assert_eq!(
decide(SafetyMode::Confirm, Risk::Risky, false, false),
Decision::Ask
);
// auto: execute everything.
assert_eq!(
decide(SafetyMode::Auto, Risk::Risky, false, false),
Decision::Execute
);
}
#[test]
fn safety_mode_parsing_falls_back_to_dry_run() {
use crate::config::ShellAiSettings;
let s = ShellAiSettings {
default_safety: Some("confirm".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::Confirm);
let s = ShellAiSettings {
default_safety: Some("n'importe quoi".to_string()),
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
let s = ShellAiSettings {
default_safety: None,
risky_patterns: vec![],
};
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Integration tests for `am ai` (issues #96 #97): the command resolves
//! the `aichat` catalog agent, so a fake `aichat.cmd` shim is placed on
//! PATH and emits a canned command. The safety pipeline (dry-run default,
//! classification, confirmation, --yes) is exercised end to end through
//! the real command dispatch.
mod common;
use agent_manager::cli::Cli;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
/// Serialize PATH mutation and process spawning between parallel tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
static COUNTER: AtomicU32 = AtomicU32::new(0);
const AICHAT_DEF: &str = r#"
agents:
- name: aichat
display_name: AIChat
description: fake shim for tests
category: shell-ai
install:
type: binary
repo: sigoden/aichat
binary: aichat
run: aichat
installable: false
"#;
fn fresh_dir(tag: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-ai-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Write a fake `aichat.cmd` into `dir` and prepend `dir` to PATH.
fn fake_aichat(dir: &PathBuf, body: &str) {
std::fs::write(dir.join("aichat.cmd"), body).unwrap();
let mut paths = vec![dir.clone()];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
}
/// Build the App for an `am ai` invocation with the aichat shim on PATH.
fn app_for(shim_body: &str, settings: &str, args: &[&str]) -> (agent_manager::app::App, PathBuf) {
let dir = fresh_dir("app");
fake_aichat(&dir, shim_body);
let cfg = common::write_config(&dir, &format!("{settings}{AICHAT_DEF}"));
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = agent_manager::app::App::from_cli(cli).expect("app should build");
common::isolate(&mut app, &dir);
(app, dir)
}
fn run(args: &[&str], shim_body: &str, settings: &str) -> (String, String, i32) {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (app, dir) = app_for(shim_body, settings, args);
let cmd = app.cli.command.as_ref().expect("a command was parsed");
let code = agent_manager::commands::execute_command(&app, cmd).unwrap_or_else(|e| {
eprintln!("ERR: {e:#}");
1
});
let log = std::fs::read_to_string(app.paths.log_dir.join("agent-manager.log"))
.unwrap_or_default();
// Events journal lives next to state.json (isolated dir).
let mut events = String::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with("events-") && name.ends_with(".jsonl") {
events.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
}
}
}
(log, events, code)
}
#[test]
fn ai_exec_dry_run_is_the_default_and_never_executes() {
let (log, events, code) = run(
&["ai", "--exec", "supprime tout"],
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("non exécutée"),
"dry-run policy must abort, log: {log}"
);
assert!(!log.contains("exécution:"), "nothing must run, log: {log}");
assert!(
events.contains("shell_ai") && events.contains("executed=false"),
"journal must record the aborted exec, events: {events}"
);
}
#[test]
fn ai_exec_with_yes_executes_through_the_shell() {
// Shim emits a harmless command; --yes skips the policy gate.
let (log, events, code) = run(
&["ai", "--exec", "dis bonjour", "--yes"],
"@echo off\r\necho echo hello-from-shim\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("exécution: echo hello-from-shim"),
"the generated command must run, log: {log}"
);
assert!(
events.contains("executed=true"),
"journal must record the execution, events: {events}"
);
}
#[test]
fn ai_conversational_passes_prompt_and_context_to_aichat() {
// Shim echoes its arguments; conversational mode passes -f . + prompt.
let (log, events, code) = run(
&["ai", "liste les fichiers"],
"@echo off\r\necho %*\r\n",
"",
);
assert_eq!(code, 0);
assert!(
log.contains("shell-ai:") || events.contains("mode=chat"),
"conversational mode must be journalized, log: {log} events: {events}"
);
assert!(events.contains("mode=chat"), "events: {events}");
}
#[test]
fn ai_exec_respects_configured_confirm_mode() {
// default_safety: confirm + risky command => the confirmation prompt is
// reached; without stdin input the prompt is declined (empty answer).
let (log, events, code) = run(
&["ai", "--exec", "supprime"],
"@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n",
" shell_ai:\n default_safety: confirm\n",
);
assert_eq!(code, 0);
assert!(
log.contains("annulé") || log.contains("cancelled") || log.contains("non exécutée"),
"declined confirmation must abort, log: {log}"
);
assert!(!log.contains("exécution:"), "log: {log}");
assert!(events.contains("executed=false"), "events: {events}");
}