feat: epique v1.1.0 — onboarding, copilot & roles aichat (am setup)
release / release (push) Successful in 13m21s
release / macos (push) Canceled after 0s

F1 — Wizard d'onboarding (src/setup.rs) :
- am setup : provider (anthropic, openai, deepseek, google, ollama, custom),
  token masque (rpassword) -> keyring OS (jamais en clair), modele par defaut
  (liste declaree + champ libre), ping API non-bloquant
- Declencheurs non-bloquants : am ai / am ask et banner REPL proposent
  am setup quand aucun provider n'est configure ; re-executable (mode revoir)
- settings.default_provider + providers.* ecrits via persist_setting

F2 — Integration aichat :
- Etape wizard 'Installer le moteur IA (aichat) ? [Y/n]' -> am install aichat
- am ai sans aichat -> 'aichat est manquant. Installer ? [Y/n]'
- install.ps1/install.sh enchainent sur am setup si stdin interactif

F3 — Roles copilot aichat (src/roles.rs) :
- 6 agents generes dans ~/.config/aichat/agents (ou %APPDATA%\aichat\agents) :
  am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator
- Prompts bilingues (FR defaut) privilegiant les contrats --json ; le prompt
  am-copilot embarque la liste des commandes am
- am ai --role <nom> (valide le fichier genere, passe --agent a aichat),
  completion REPL ; config aichat creee seulement si absente
- Câblage complet : cli, dispatch, help, REPL, tip, man (am-setup.1), i18n

Tests : 450 verts (roles + setup unitaires, ai_test ajustes locale-agnostic)
v1.1.0 ; ROADMAP axe 14 + maintenance note ; README Premier lancement
This commit is contained in:
2026-08-20 23:09:21 -04:00
parent 50f515e406
commit d71c3dd34d
20 changed files with 870 additions and 12 deletions
+275
View File
@@ -0,0 +1,275 @@
//! am roles — the aichat copilot layer (épique v1.1.0, F3).
//!
//! Generates aichat agent files (`~/.config/aichat/agents/am-*.md` — or
//! `%APPDATA%\aichat\agents\` on Windows) that turn `am ai` into a
//! contextual copilot: guide & catalog, secured OS operator, dev helper,
//! raw execution, analyst, orchestrator. Each prompt is bilingual (French
//! by default, English supported) and instructs the model to use the
//! stable `--json` contracts of `am` to inspect the environment.
//!
//! The aichat config file is only created when it does not exist yet —
//! an existing configuration is never touched (respect of the user setup).
use crate::app::App;
use anyhow::Result;
use std::path::{Path, PathBuf};
/// The six copilot roles shipped by `am setup` / `am setup --roles`.
pub const ROLE_NAMES: &[&str] = &[
"am-copilot",
"am-operator",
"am-dev",
"am-do",
"am-analyst",
"am-orchestrator",
];
/// Directory where aichat stores its agent definitions. Candidates are
/// probed in order: an existing aichat config wins, otherwise the platform
/// default (XDG on unix, %APPDATA% on Windows).
pub fn aichat_agents_dir() -> PathBuf {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(xdg).join("aichat").join("agents"));
}
if let Some(home) = crate::config::home_dir() {
candidates.push(home.join(".config").join("aichat").join("agents"));
}
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.push(PathBuf::from(appdata).join("aichat").join("agents"));
}
}
for c in &candidates {
if c.exists() {
return c.clone();
}
}
// No existing aichat config: platform default.
if cfg!(windows) {
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("aichat").join("agents");
}
}
crate::config::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("aichat")
.join("agents")
}
/// Path of the aichat configuration file (created only if absent).
pub fn aichat_config_path() -> PathBuf {
aichat_agents_dir().parent().unwrap().join("config.yaml")
}
/// The list of `am` commands, embedded in the copilot prompt so the model
/// always knows what the CLI can do (kept in sync with `am help commands`).
pub fn command_list() -> String {
let mut out = String::from(
"list, status, sessions, stats, top, report, projects, timeline, log, logs, history, ",
);
out.push_str(
"init, alias, secret, open, watch, search, info, install, uninstall, update, start, stop, ",
);
out.push_str(
"restart, run, doctor, config, completion, self-update, self-uninstall, export, import, ",
);
out.push_str(
"shell, theme, lang, tip, dashboard, favorite, unfavorite, note, tag, untag, tags, ",
);
out.push_str(
"profile, man, models, catalog, providers, suggest, ask, ai, registry, audit, service, ",
);
out.push_str("schedule, monitor, web, serve, sync, migrate, playbook, lab, plugins, setup");
out
}
fn frontmatter(name: &str, description: &str, tools: &[&str]) -> String {
let mut out = String::from("---\n");
out.push_str(&format!("name: {name}\n"));
out.push_str(&format!("description: {description}\n"));
if !tools.is_empty() {
out.push_str(&format!("tools: [{}]\n", tools.join(", ")));
}
out.push_str("---\n");
out
}
/// 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";
/// Build the markdown content of one role.
pub fn role_content(name: &str) -> Option<String> {
let (desc, tools, body) = match name {
"am-copilot" => (
"Copilot de agent-manager : guide, catalogue, dépannage",
&[][..],
format!(
"Tu es le copilot de **agent-manager (am)**, le gestionnaire d'agents IA locaux.\n\
Tu aides l'utilisateur à exploiter am : catalogue, agents, installation, REPL.\n\
\n\
Commandes am disponibles : {}\n\
\n\
Règles :\n\
- Privilégie les sorties --json pour inspecter l'environnement : am list --json, am status --json, am info <agent>, am search <mot>.\n\
- Pour comprendre l'état : am doctor, am stats --json, am log --kind error.\n\
- Propose TOUJOURS la commande exacte à taper, dans un bloc de code.\n\
- Si une commande est incertaine, dis-le et propose am help <cmd>.",
command_list()
),
),
"am-operator" => (
"Opérateur système sécurisé : commandes shell, fichiers, processus",
&["shell"][..],
"Tu es un opérateur système SÉCURISÉ. Tu transformes les demandes en commandes shell précises.\n\
\n\
Règles de sécurité (non négociables) :\n\
- N'exécute jamais directement : propose la commande, laisse l'utilisateur ou am ai --exec l'exécuter après confirmation.\n\
- Signale clairement les commandes RISKY (rm -rf, dd, mkfs, chmod -R 777, git push --force...) et propose une alternative plus sûre quand elle existe.\n\
- Adapte-toi à l'OS détecté (Windows/cmd/powershell vs Linux/macOS/bash).\n\
- Pour lister/trier/compresser des fichiers, propose la commande la plus simple possible.".to_string(),
),
"am-dev" => (
"Assistant développeur : code, git, tests, revue",
&[][..],
"Tu es un assistant développeur qui travaille AVEC am (agent-manager).\n\
\n\
Tu aides sur :\n\
- Refactoring, debugging, écriture de tests, revue de code (diff).\n\
- Git : am ne gère pas git directement — propose les commandes git exactes.\n\
- Suivi de projets : am projects --json, am timeline --json pour le contexte d'activité.\n\
- Choix d'agent : am search <tâche> pour recommander un coding agent du catalogue, puis am install / am run.\n\
\n\
Réponds avec du code concret et des commandes exactes.".to_string(),
),
"am-do" => (
"Exécution pure : génère la commande brute pour am ai --exec",
&["shell"][..],
"Tu es le moteur d'exécution de am ai --exec.\n\
\n\
Réponds UNIQUEMENT par la commande shell exacte à exécuter, sur une seule ligne si possible.\n\
- Aucune explication avant/après : seulement la commande.\n\
- Si la demande est dangereuse, réponds par la commande la plus conservatrice qui satisfait la demande.\n\
- Adapte-toi à l'OS détecté.".to_string(),
),
"am-analyst" => (
"Analyste : coûts, logs, statistiques, santé du parc d'agents",
&[][..],
"Tu es l'analyste de la flotte d'agents gérée par am.\n\
\n\
Sources de données (toujours en --json) :\n\
- Coûts : am stats --costs --json, am top --json\n\
- Logs & erreurs : am log --kind error --limit 50 --json, am logs <agent> --lines 50\n\
- Activité : am timeline --json, am sessions --json, am report --period week\n\
- Santé : am doctor --json, am status --json\n\
\n\
Produis des synthèses courtes avec les chiffres clés, et propose des actions concrètes.".to_string(),
),
"am-orchestrator" => (
"Orchestrateur : groupes, lab, benchmarks, automatisation",
&[][..],
"Tu es l'orchestrateur des agents am.\n\
\n\
Tu aides à :\n\
- Lancer des groupes : am start group:dev, am start group:dev --parallel\n\
- Benchmarker : am lab --agents a,b --task <fichier> --json\n\
- Automatiser : am schedule add <cmd...>, am service install <agent>, am playbook <fichier>\n\
- Planifier des runs et interpréter les résultats (am lab --json).\n\
\n\
Propose toujours des commandes exactes et des benchmarks comparables.".to_string(),
),
_ => return None,
};
let content = format!(
"{}{}{}",
frontmatter(name, desc, tools),
body,
LANG_FOOTER
);
Some(content)
}
/// Write every am-* role into the aichat agents directory (creates the
/// directory when missing). Existing files with the same name are
/// overwritten — they are generated artifacts of am.
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();
for name in ROLE_NAMES {
let content = role_content(name).expect("known role");
let path = dir.join(format!("{name}.md"));
std::fs::write(&path, content)?;
written.push(path);
}
Ok(written)
}
/// Create the aichat config file (provider + default model) ONLY when it
/// does not exist. An existing configuration is left untouched.
pub fn ensure_config_file(app: &App) -> Result<bool> {
let path = aichat_config_path();
if path.exists() {
return Ok(false);
}
let provider = crate::providers::default_name(&app.config);
let model = provider.and_then(|p| crate::providers::get(&app.config, p))
.and_then(|def| def.default_model.clone());
let mut body = String::from("# generated by `am setup` — first run only\n");
if let Some(p) = provider {
body.push_str(&format!("model_provider: {p}\n"));
}
if let Some(m) = model {
body.push_str(&format!("model: {m}\n"));
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, body)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generates_all_six_roles_with_frontmatter() {
for name in ROLE_NAMES {
let content = role_content(name).expect("role");
assert!(content.starts_with("---\n"), "{name}");
assert!(content.contains(&format!("name: {name}")), "{name}");
assert!(content.contains("description:"), "{name}");
// Bilingual footer present.
assert!(content.contains("Réponds en français"), "{name}");
}
}
#[test]
fn shell_tools_only_on_execution_roles() {
let operator = role_content("am-operator").unwrap();
assert!(operator.contains("tools: [shell]"));
let copilot = role_content("am-copilot").unwrap();
assert!(!copilot.contains("tools:"), "copilot must not have tools");
}
#[test]
fn unknown_role_returns_none() {
assert!(role_content("am-inconnu").is_none());
}
#[test]
fn copilot_prompt_embeds_the_command_list() {
let copilot = role_content("am-copilot").unwrap();
assert!(copilot.contains("am list --json"));
assert!(copilot.contains("setup"), "command list must include setup");
}
#[test]
fn agents_dir_prefers_existing_aichat_config() {
// With a home dir, the unix candidate is ~/.config/aichat/agents.
let dir = aichat_agents_dir();
assert!(dir.ends_with("agents"), "{dir:?}");
}
}