Files
agent-manager/src/commands/ai_cmd.rs
T
bruno e637c066fe fix: am ai avec aichat >= 0.30 — rôles index.yaml + agents.txt, contexte filtré, generation --code (v1.1.5)
- roles.rs: aichat 0.30 a remplace les agents markdown agents/<name>.md
  par functions/agents/<name>/index.yaml + registre functions/agents.txt;
  generate_all ecrit les DEUX formats (compat <= 0.29 et >= 0.30)
- ai_cmd.rs: role_args detecte la definition index.yaml en premier
  ('Unknown agent am-copilot' corrige) ; contexte par defaut (dossier
  courant) filtre par am (.git, target, node_modules, binaires non-UTF-8,
  .env*, > 64 KiB, budget 128 KiB, 40 fichiers max) — aichat ne filtre
  rien et echouait sur .git/index ; exec mode utilise le role am-do
  par defaut (commande brute)
- shell_ai.rs: generation via --code au lieu de --execute --dry-run
  (aichat 0.30: --dry-run echo la requete sans appeler l'API)
- version 1.1.5, man pages et ROADMAP maj
2026-08-21 12:22:18 -04:00

355 lines
13 KiB
Rust

//! 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 (`--code` — never executed by aichat),
//! 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::{bail, Context, Result};
use std::process::Command;
pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
// Onboarding hint (v1.1.0 F1) — non-blocking: the AI commands propose
// `am setup` when no provider is configured, then continue anyway.
if crate::setup::needs_setup(app) {
app.log.info(crate::i18n::tr_in(
app.lang(),
"am n'est pas configuré — lancez am setup (provider + token)",
));
}
// F2 (v1.1.0): aichat must be INSTALLED (not only in the catalog) to
// power am ai. Offer the installation when missing.
if !aichat_installed(app) {
let ask_install = !app.cli.yes
&& app.confirm(crate::i18n::tr_in(
app.lang(),
"aichat est manquant. Installer ?",
))?;
if app.cli.yes || ask_install {
app.log.info(crate::i18n::tr_in(app.lang(), "Installation d'aichat…"));
crate::commands::install_cmd::run(app, "aichat", None, false, None, None, false)?;
} else {
bail!(crate::i18n::tr_in(
app.lang(),
"installez aichat: am install aichat — puis réessayez"
));
}
}
let prompt = args.prompt.join(" ");
// Context files: explicit --files wins, else the current directory
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
// walked by am to skip noise/vendored/binary files — aichat itself
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
let files: Vec<String> = if args.files.is_empty() {
context_files(std::path::Path::new("."))
} 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)
}
/// True when the aichat binary is available (installed or on PATH).
/// Uses a direct PATH lookup — the probe cache would mask a shim added
/// mid-test and is meant for agent inventories, not runtime checks.
fn aichat_installed(app: &App) -> bool {
app.state.get("aichat").ok().flatten().is_some()
|| which::which("aichat").is_ok()
}
/// Walk `dir` and collect text files usable as aichat context (the
/// default `--files` when the flag is absent). aichat filters nothing
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
/// - skipped directories: `.git`, vendored/build noise (`target`,
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
/// Then the survivors are sorted and taken while a total budget of
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
/// at 40 files. Empty when nothing qualifies (aichat is called without
/// `-f`).
fn context_files(dir: &std::path::Path) -> Vec<String> {
const MAX_FILES: usize = 40;
const MAX_SIZE: u64 = 64 * 1024;
const TOTAL_BUDGET: u64 = 128 * 1024;
const SKIP_DIRS: &[&str] = &[
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
".cargo", "obj", "out", ".svn", ".hg",
];
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
walk(&path, out);
} else {
if name.starts_with(".env") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if meta.len() > MAX_SIZE {
continue;
}
// UTF-8 sniff on the first bytes — aichat rejects binaries.
let Ok(mut f) = std::fs::File::open(&path) else { continue };
use std::io::Read;
let mut head = Vec::new();
if f.take(8192).read_to_end(&mut head).is_err()
|| std::str::from_utf8(&head).is_err()
{
continue;
}
out.push((path.display().to_string(), meta.len()));
}
}
}
let mut found = Vec::new();
walk(dir, &mut found);
found.sort();
let mut files = Vec::new();
let mut total: u64 = 0;
for (p, len) in found {
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
break;
}
total += len;
files.push(p);
}
files
}
/// Build the aichat `--agent` arguments for the requested role. Without a
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
/// definition is missing the roles are regenerated on the fly, and a
/// generation failure degrades gracefully (no role). An explicit unknown
/// role is an error.
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
let role = role.unwrap_or("am-copilot");
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
let present = || def.exists() || legacy.exists();
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
if role == "am-copilot" {
if let Err(e) = crate::roles::generate_all(app) {
app.log.verbose(&format!("roles generation skipped: {e:#}"));
}
if present() {
return Ok(vec!["--agent".to_string(), role.to_string()]);
}
app.log.warn(crate::i18n::tr_in(
app.lang(),
"rôle 'am-copilot' introuvable — lancez am setup --roles",
));
return Ok(Vec::new());
}
bail!(crate::i18n::tr_in(
app.lang(),
"rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"
)
.replace("{}", role));
}
/// 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();
extra.extend(role_args(app, args.role.as_deref())?);
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"
));
}
// Exec mode asks for the raw command: default to the am-do role
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
let cmd = crate::shell_ai::generate(
app,
prompt,
files,
args.provider.as_deref(),
args.model.as_deref(),
&role,
)?;
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 lang = app.lang();
let mut executed = false;
match decision {
Decision::Abort => {
let msg = if dry_run {
crate::i18n::tr_in(lang, "dry-run : commande non exécutée")
} else {
crate::i18n::tr_in(
lang,
"politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes",
)
};
app.log.info(msg);
}
Decision::Ask => {
let ok = app.confirm(crate::i18n::tr_in(lang, "Exécuter ?"))?;
if ok {
executed = true;
} else {
app.log.info(crate::i18n::tr_in(lang, "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);
}
#[test]
fn context_files_skips_binary_noise_and_secrets() {
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".git")).unwrap();
std::fs::create_dir_all(base.join("target")).unwrap();
std::fs::create_dir_all(base.join("src")).unwrap();
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
let files = context_files(&base);
let names: Vec<String> = files
.iter()
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
let _ = std::fs::remove_dir_all(&base);
}
}