//! 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 { // 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 . ""`). 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 = 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 { 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> { let role = role.unwrap_or("am-copilot"); // aichat >= 0.30 reads functions/agents//index.yaml; the legacy // agents/.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 "" 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 { let mut extra: Vec = 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 { 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 = 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); } }