- 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
355 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|