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
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.1.3"
|
version = "1.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+13
@@ -100,6 +100,19 @@
|
|||||||
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
> onboard/model/auth), avec substitution des placeholders `{model}`,
|
||||||
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
> `{provider}` et `{base_url}` depuis le provider résolu. picoclaw est le
|
||||||
> premier agent du catalogue à les déclarer.
|
> premier agent du catalogue à les déclarer.
|
||||||
|
>
|
||||||
|
> 🔧 **Maintenance v1.1.5 (2026-08-21)** : fix `am ai` / rôles copilot avec
|
||||||
|
> aichat ≥ 0.30 — aichat a changé son format d'agents (le markdown
|
||||||
|
> `agents/<name>.md` ≤ 0.29 est remplacé par une définition
|
||||||
|
> `functions/agents/<name>/index.yaml` + un registre `functions/agents.txt`).
|
||||||
|
> `am setup` / `am setup --roles` écrivent désormais LES DEUX formats
|
||||||
|
> (compatibilité totale) et `am ai` détecte la définition nouvelle génération
|
||||||
|
> en premier (« Unknown agent `am-copilot` » corrigé). Bonus : le contexte
|
||||||
|
> par défaut (dossier courant) est filtré par am (`.git`, `target`,
|
||||||
|
> `node_modules`…, binaires non-UTF-8, `.env*`, > 64 KiB, budget 128 KiB)
|
||||||
|
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
|
||||||
|
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
|
||||||
|
> n'appelle plus l'API, il écho la requête).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.ie \n(.g .ds Aq \(aq
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 1.1.0"
|
.TH am 1 "am 1.1.5"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -250,4 +250,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v1.1.0
|
v1.1.5
|
||||||
|
|||||||
+108
-13
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
||||||
//! context files (default: the current directory). Exec mode generates the
|
//! context files (default: the current directory). Exec mode generates the
|
||||||
//! shell command through aichat in dry-run, classifies it, applies the
|
//! shell command through aichat (`--code` — never executed by aichat),
|
||||||
//! safety policy (settings.shell_ai, dry-run by default) and only executes
|
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
|
||||||
//! after confirmation when required.
|
//! default) and only executes after confirmation when required.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::AiArgs;
|
use crate::cli::AiArgs;
|
||||||
@@ -45,9 +45,11 @@ pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
|||||||
|
|
||||||
let prompt = args.prompt.join(" ");
|
let prompt = args.prompt.join(" ");
|
||||||
// Context files: explicit --files wins, else the current directory
|
// Context files: explicit --files wins, else the current directory
|
||||||
// (issue #96: `aichat -f . "<prompt>"`).
|
// (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() {
|
let files: Vec<String> = if args.files.is_empty() {
|
||||||
vec![".".to_string()]
|
context_files(std::path::Path::new("."))
|
||||||
} else {
|
} else {
|
||||||
args.files
|
args.files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -75,23 +77,92 @@ fn aichat_installed(app: &App) -> bool {
|
|||||||
|| which::which("aichat").is_ok()
|
|| 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
|
/// 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
|
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
|
||||||
/// file is missing the roles are regenerated on the fly, and a generation
|
/// definition is missing the roles are regenerated on the fly, and a
|
||||||
/// failure degrades gracefully (no role). An explicit unknown role is an
|
/// generation failure degrades gracefully (no role). An explicit unknown
|
||||||
/// error.
|
/// role is an error.
|
||||||
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||||
let role = role.unwrap_or("am-copilot");
|
let role = role.unwrap_or("am-copilot");
|
||||||
let dir = crate::roles::aichat_agents_dir();
|
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
|
||||||
let path = dir.join(format!("{role}.md"));
|
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
|
||||||
if path.exists() {
|
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()]);
|
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||||
}
|
}
|
||||||
if role == "am-copilot" {
|
if role == "am-copilot" {
|
||||||
if let Err(e) = crate::roles::generate_all(app) {
|
if let Err(e) = crate::roles::generate_all(app) {
|
||||||
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
||||||
}
|
}
|
||||||
if path.exists() {
|
if present() {
|
||||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||||
}
|
}
|
||||||
app.log.warn(crate::i18n::tr_in(
|
app.log.warn(crate::i18n::tr_in(
|
||||||
@@ -171,7 +242,9 @@ fn exec_mode(
|
|||||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let role = role_args(app, args.role.as_deref())?;
|
// 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(
|
let cmd = crate::shell_ai::generate(
|
||||||
app,
|
app,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -256,4 +329,26 @@ mod tests {
|
|||||||
let s = crate::config::ShellAiSettings::default();
|
let s = crate::config::ShellAiSettings::default();
|
||||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -899,7 +899,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
name: "ai",
|
name: "ai",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
usage: "ai {flags} <prompt...>",
|
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.",
|
about: "Langage naturel → action shell via AIChat (issues #96 #97). Sans --exec : conversationnel (aichat -f <ctx>). Avec --exec : génère la commande (aichat --code — jamais exécutée par aichat), 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"],
|
search_terms: &["shell", "nlp", "natural", "language", "commande", "exec", "langage"],
|
||||||
flags: &[
|
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: "-e", long: "--exec", value: "", desc: "Génère puis exécute la commande shell (après la politique de sécurité)" },
|
||||||
|
|||||||
+120
-23
@@ -1,12 +1,16 @@
|
|||||||
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
|
||||||
//!
|
//!
|
||||||
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
|
//! Generates aichat agent definitions that turn `am ai` into a
|
||||||
//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a
|
|
||||||
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
|
||||||
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
|
||||||
//! by default, English supported) and instructs the model to use the
|
//! by default, English supported) and instructs the model to use the
|
||||||
//! stable `--json` contracts of `am` to inspect the environment.
|
//! stable `--json` contracts of `am` to inspect the environment.
|
||||||
//!
|
//!
|
||||||
|
//! Two layouts are written for compatibility with every aichat version:
|
||||||
|
//! - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||||
|
//! + `functions/agents.txt` (registry), under the aichat config dir;
|
||||||
|
//! - aichat <= 0.29 : legacy markdown agents `agents/<name>.md`.
|
||||||
|
//!
|
||||||
//! The aichat config file is only created when it does not exist yet —
|
//! The aichat config file is only created when it does not exist yet —
|
||||||
//! an existing configuration is never touched (respect of the user setup).
|
//! an existing configuration is never touched (respect of the user setup).
|
||||||
|
|
||||||
@@ -24,20 +28,20 @@ pub const ROLE_NAMES: &[&str] = &[
|
|||||||
"am-orchestrator",
|
"am-orchestrator",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Directory where aichat stores its agent definitions. Candidates are
|
/// Base config directory of aichat. Candidates are probed in order: an
|
||||||
/// probed in order: an existing aichat config wins, otherwise the platform
|
/// existing aichat config wins, otherwise the platform default (XDG on
|
||||||
/// default (XDG on unix, %APPDATA% on Windows).
|
/// unix, %APPDATA% on Windows).
|
||||||
pub fn aichat_agents_dir() -> PathBuf {
|
pub fn aichat_config_dir() -> PathBuf {
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
candidates.push(PathBuf::from(xdg).join("aichat").join("agents"));
|
candidates.push(PathBuf::from(xdg).join("aichat"));
|
||||||
}
|
}
|
||||||
if let Some(home) = crate::config::home_dir() {
|
if let Some(home) = crate::config::home_dir() {
|
||||||
candidates.push(home.join(".config").join("aichat").join("agents"));
|
candidates.push(home.join(".config").join("aichat"));
|
||||||
}
|
}
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
|
candidates.push(PathBuf::from(appdata).join("aichat"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in &candidates {
|
for c in &candidates {
|
||||||
@@ -48,19 +52,38 @@ pub fn aichat_agents_dir() -> PathBuf {
|
|||||||
// No existing aichat config: platform default.
|
// No existing aichat config: platform default.
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
return PathBuf::from(appdata).join("aichat").join("agents");
|
return PathBuf::from(appdata).join("aichat");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::config::home_dir()
|
crate::config::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join(".config")
|
.join(".config")
|
||||||
.join("aichat")
|
.join("aichat")
|
||||||
.join("agents")
|
}
|
||||||
|
|
||||||
|
/// Legacy agents directory (aichat <= 0.29): `agents/<name>.md` files
|
||||||
|
/// with frontmatter. aichat >= 0.30 reads the new definitions instead
|
||||||
|
/// (see [`aichat_agent_def_dir`]); the .md files are still written so
|
||||||
|
/// older aichat versions keep working.
|
||||||
|
pub fn aichat_agents_dir() -> PathBuf {
|
||||||
|
aichat_config_dir().join("agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functions directory (aichat >= 0.30): agent definitions live in
|
||||||
|
/// `functions/agents/<name>/index.yaml` and the agent registry in
|
||||||
|
/// `functions/agents.txt`.
|
||||||
|
pub fn aichat_functions_dir() -> PathBuf {
|
||||||
|
aichat_config_dir().join("functions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory of one agent definition (aichat >= 0.30).
|
||||||
|
pub fn aichat_agent_def_dir(name: &str) -> PathBuf {
|
||||||
|
aichat_functions_dir().join("agents").join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the aichat configuration file (created only if absent).
|
/// Path of the aichat configuration file (created only if absent).
|
||||||
pub fn aichat_config_path() -> PathBuf {
|
pub fn aichat_config_path() -> PathBuf {
|
||||||
aichat_agents_dir().parent().unwrap().join("config.yaml")
|
aichat_config_dir().join("config.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list of `am` commands, embedded in the copilot prompt so the model
|
/// The list of `am` commands, embedded in the copilot prompt so the model
|
||||||
@@ -99,8 +122,8 @@ fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
|
|||||||
/// The bilingual instruction footer shared by every role.
|
/// The bilingual instruction footer shared by every role.
|
||||||
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
const LANG_FOOTER: &str = "\n\n## Langue / Language\nRéponds en français par défaut, dans la langue de la demande si une autre est utilisée.\nAnswer in French by default, or in the language of the request when it differs.\n";
|
||||||
|
|
||||||
/// Build the markdown content of one role.
|
/// The (description, tools, body) of one role.
|
||||||
pub fn role_content(name: &str) -> Option<String> {
|
fn role_parts(name: &str) -> Option<(&'static str, &'static [&'static str], String)> {
|
||||||
let (desc, tools, body) = match name {
|
let (desc, tools, body) = match name {
|
||||||
"am-copilot" => (
|
"am-copilot" => (
|
||||||
"Copilot de agent-manager : guide, catalogue, dépannage",
|
"Copilot de agent-manager : guide, catalogue, dépannage",
|
||||||
@@ -181,25 +204,76 @@ pub fn role_content(name: &str) -> Option<String> {
|
|||||||
),
|
),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let content = format!(
|
Some((desc, tools, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The aichat >= 0.30 agent definition (`index.yaml`): the subset of
|
||||||
|
/// aichat's AgentDefinition that am generates.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct AgentDefinition {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
version: String,
|
||||||
|
instructions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the legacy markdown content of one role (aichat <= 0.29).
|
||||||
|
pub fn role_content(name: &str) -> Option<String> {
|
||||||
|
let (desc, tools, body) = role_parts(name)?;
|
||||||
|
Some(format!(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
frontmatter(name, desc, tools),
|
frontmatter(name, desc, tools),
|
||||||
body,
|
body,
|
||||||
LANG_FOOTER
|
LANG_FOOTER
|
||||||
);
|
))
|
||||||
Some(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write every am-* role into the aichat agents directory (creates the
|
/// The aichat >= 0.30 `index.yaml` definition of one role.
|
||||||
/// directory when missing). Existing files with the same name are
|
pub fn role_definition(name: &str) -> Option<String> {
|
||||||
/// overwritten — they are generated artifacts of am.
|
let (desc, _tools, body) = role_parts(name)?;
|
||||||
|
let def = AgentDefinition {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: desc.to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
instructions: format!("{body}{LANG_FOOTER}"),
|
||||||
|
};
|
||||||
|
serde_yaml::to_string(&def).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write every am-* role into the aichat agent stores (creates the
|
||||||
|
/// directories when missing). Two layouts are written so any aichat
|
||||||
|
/// version works:
|
||||||
|
/// - aichat >= 0.30 : `functions/agents/<name>/index.yaml` (definition)
|
||||||
|
/// + `functions/agents.txt` (registry);
|
||||||
|
/// - aichat <= 0.29 : legacy markdown `agents/<name>.md`.
|
||||||
|
/// Existing files with the same name are overwritten — they are generated
|
||||||
|
/// artifacts of am.
|
||||||
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
pub fn generate_all(_app: &App) -> Result<Vec<PathBuf>> {
|
||||||
let dir = aichat_agents_dir();
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
|
|
||||||
|
// New layout (aichat >= 0.30): index.yaml definitions + agents.txt.
|
||||||
|
let funcs_dir = aichat_functions_dir();
|
||||||
|
let agents_dir = funcs_dir.join("agents");
|
||||||
|
std::fs::create_dir_all(&agents_dir)?;
|
||||||
|
for name in ROLE_NAMES {
|
||||||
|
let def = role_definition(name).expect("known role");
|
||||||
|
let def_path = agents_dir.join(name).join("index.yaml");
|
||||||
|
std::fs::create_dir_all(def_path.parent().expect("parent dir"))?;
|
||||||
|
std::fs::write(&def_path, def)?;
|
||||||
|
written.push(def_path);
|
||||||
|
}
|
||||||
|
let list_path = funcs_dir.join("agents.txt");
|
||||||
|
let mut list = String::from("# generated by `am setup` — aichat >= 0.30 agent registry\n");
|
||||||
|
list.push_str(&format!("{}\n", ROLE_NAMES.join("\n")));
|
||||||
|
std::fs::write(&list_path, list)?;
|
||||||
|
written.push(list_path);
|
||||||
|
|
||||||
|
// Legacy layout (aichat <= 0.29): markdown agents in agents/.
|
||||||
|
let legacy_dir = aichat_agents_dir();
|
||||||
|
std::fs::create_dir_all(&legacy_dir)?;
|
||||||
for name in ROLE_NAMES {
|
for name in ROLE_NAMES {
|
||||||
let content = role_content(name).expect("known role");
|
let content = role_content(name).expect("known role");
|
||||||
let path = dir.join(format!("{name}.md"));
|
let path = legacy_dir.join(format!("{name}.md"));
|
||||||
std::fs::write(&path, content)?;
|
std::fs::write(&path, content)?;
|
||||||
written.push(path);
|
written.push(path);
|
||||||
}
|
}
|
||||||
@@ -266,6 +340,29 @@ mod tests {
|
|||||||
assert!(copilot.contains("setup"), "command list must include setup");
|
assert!(copilot.contains("setup"), "command list must include setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_definition_is_valid_index_yaml() {
|
||||||
|
for name in ROLE_NAMES {
|
||||||
|
let def = role_definition(name).expect("role");
|
||||||
|
assert!(def.starts_with("name:"), "{name}:\n{def}");
|
||||||
|
assert!(def.contains("description:"), "{name}");
|
||||||
|
assert!(def.contains("instructions:"), "{name}");
|
||||||
|
assert!(def.contains("Tu es"), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_def_dir_targets_functions_layout() {
|
||||||
|
let s = aichat_agent_def_dir("am-copilot")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
assert!(s.contains("functions"), "{s}");
|
||||||
|
assert!(s.ends_with("am-copilot"), "{s}");
|
||||||
|
// The registry lives next to the definitions.
|
||||||
|
let registry = aichat_functions_dir().join("agents.txt");
|
||||||
|
assert!(registry.to_string_lossy().ends_with("agents.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agents_dir_prefers_existing_aichat_config() {
|
fn agents_dir_prefers_existing_aichat_config() {
|
||||||
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
// With a home dir, the unix candidate is ~/.config/aichat/agents.
|
||||||
|
|||||||
+10
-3
@@ -7,8 +7,9 @@
|
|||||||
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
//! - conversational mode (no `--exec`): `aichat -f <ctx> "<prompt>"` is
|
||||||
//! spawned in the foreground, exactly as the user would run it;
|
//! spawned in the foreground, exactly as the user would run it;
|
||||||
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
//! - exec mode (`--exec`): aichat is asked to generate the command with
|
||||||
//! `--dry-run` (it prints the command and never runs it), then the
|
//! `--code` (it prints only the command and never runs it — aichat is
|
||||||
//! generated command is classified (safe / risky), a confidence score
|
//! invoked without `--execute`), then the generated command is
|
||||||
|
//! classified (safe / risky), a confidence score
|
||||||
//! is estimated, the configured safety policy is applied (dry-run by
|
//! is estimated, the configured safety policy is applied (dry-run by
|
||||||
//! default), and only then — after confirmation when required — is the
|
//! default), and only then — after confirmation when required — is the
|
||||||
//! command executed through the user's shell.
|
//! command executed through the user's shell.
|
||||||
@@ -283,7 +284,13 @@ pub fn generate(
|
|||||||
role_args: &[String],
|
role_args: &[String],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let agent = require_agent(app, "aichat")?;
|
let agent = require_agent(app, "aichat")?;
|
||||||
let mut extra_args: Vec<String> = vec!["--exec".to_string(), "--dry-run".to_string()];
|
// aichat >= 0.30 renamed --exec to --execute AND changed --dry-run to
|
||||||
|
// skip the API call entirely (it echoes the request), so the old
|
||||||
|
// `--execute --dry-run` no longer yields the generated command. Plain
|
||||||
|
// chat with `--code` extracts and prints only the command (same output
|
||||||
|
// shape, works on every aichat version). aichat NEVER executes here —
|
||||||
|
// am enforces the safety policy before running the command itself.
|
||||||
|
let mut extra_args: Vec<String> = vec!["--code".to_string()];
|
||||||
extra_args.extend(role_args.iter().cloned());
|
extra_args.extend(role_args.iter().cloned());
|
||||||
for f in files {
|
for f in files {
|
||||||
extra_args.push("-f".to_string());
|
extra_args.push("-f".to_string());
|
||||||
|
|||||||
Reference in New Issue
Block a user