feat: phase 2 — i18n messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt, REPL lang) (closes #74)
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.5.4"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.5.4"
|
||||
version = "0.5.5"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
@@ -210,6 +210,7 @@ Disponibles avant **ou après** la sous-commande.
|
||||
| -v, --verbose | affiche chaque commande exécutée |
|
||||
| -q, --quiet | uniquement les erreurs |
|
||||
| --no-color | désactive les couleurs |
|
||||
| --lang <fr\|en> | langue des messages : fr (défaut) ou en — AM_LANG/LANG lus sinon |
|
||||
| -c, --config <fichier> | configuration alternative |
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -455,7 +455,7 @@ moins de 2 secondes.
|
||||
| [#71](https://git.dracodev.net/Projets/agent-manager/issues/71) | ✅ Lien agent ↔ modèle — am run --model | M |
|
||||
| [#72](https://git.dracodev.net/Projets/agent-manager/issues/72) | ✅ doctor vérifie ollama / llama-server comme n'importe quel outil | S |
|
||||
| [#73](https://git.dracodev.net/Projets/agent-manager/issues/73) | ✅ am models prune — purge des modèles inutilisés | S |
|
||||
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | i18n — messages EN/FR | L |
|
||||
| [#74](https://git.dracodev.net/Projets/agent-manager/issues/74) | ✅ i18n — messages EN/FR (--lang, AM_LANG/LANG, catalogue tr/tr_fmt) | L |
|
||||
| [#75](https://git.dracodev.net/Projets/agent-manager/issues/75) | ✅ Plugin scripts (hooks avancés, intégration CI) | M |
|
||||
|
||||
Critère de sortie du jalon : parc auto-supervisé (crash = redémarrage +
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 0.5.4"
|
||||
.TH am 1 "am 0.5.5"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
|
||||
\fBam\fR [\fB\-c\fR|\fB\-\-config\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-q\fR|\fB\-\-quiet\fR] [\fB\-y\fR|\fB\-\-yes\fR] [\fB\-\-dry\-run\fR] [\fB\-\-json\fR] [\fB\-\-no\-color\fR] [\fB\-\-theme\fR] [\fB\-\-lang\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
agent\-manager (am) — a CLI to manage local AI coding agents.
|
||||
.PP
|
||||
@@ -38,6 +38,19 @@ Disable ANSI colors
|
||||
\fB\-\-theme\fR \fI<THEME>\fR
|
||||
Color theme for the output (see \*(Aqam help theme\*(Aq for the list)
|
||||
.TP
|
||||
\fB\-\-lang\fR \fI<LANG>\fR
|
||||
Output language: fr (default) or en (issue #74)
|
||||
.br
|
||||
|
||||
.br
|
||||
\fIPossible values:\fR
|
||||
.RS 14
|
||||
.IP \(bu 2
|
||||
fr
|
||||
.IP \(bu 2
|
||||
en
|
||||
.RE
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help (see a summary with \*(Aq\-h\*(Aq)
|
||||
.TP
|
||||
@@ -216,4 +229,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v0.5.4
|
||||
v0.5.5
|
||||
|
||||
+18
@@ -21,6 +21,9 @@ pub struct App {
|
||||
pub catalog: Catalog,
|
||||
pub state: StateStore,
|
||||
pub log: Logger,
|
||||
/// Active output language (issue #74); mirrored in i18n::active() so the
|
||||
/// REPL 'lang' command can switch it at runtime.
|
||||
lang: Cell<crate::i18n::Lang>,
|
||||
/// Active output color theme (switchable at runtime with the REPL
|
||||
/// 'theme <name>' command).
|
||||
theme: Cell<&'static Theme>,
|
||||
@@ -84,6 +87,8 @@ impl App {
|
||||
loaded.config.groups.clone(),
|
||||
);
|
||||
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
|
||||
let lang = crate::i18n::resolve(cli.lang);
|
||||
crate::i18n::set_lang(lang);
|
||||
Ok(Self {
|
||||
cli,
|
||||
config: loaded.config,
|
||||
@@ -92,6 +97,7 @@ impl App {
|
||||
catalog,
|
||||
state,
|
||||
log,
|
||||
lang: Cell::new(lang),
|
||||
theme: Cell::new(theme),
|
||||
})
|
||||
}
|
||||
@@ -116,6 +122,18 @@ impl App {
|
||||
self.theme.get()
|
||||
}
|
||||
|
||||
/// Active output language (issue #74).
|
||||
pub fn lang(&self) -> crate::i18n::Lang {
|
||||
self.lang.get()
|
||||
}
|
||||
|
||||
/// Switch the active language (REPL 'lang' command); the i18n global is
|
||||
/// kept in sync so tr()/tr_fmt! follow immediately.
|
||||
pub fn set_lang(&self, lang: crate::i18n::Lang) {
|
||||
self.lang.set(lang);
|
||||
crate::i18n::set_lang(lang);
|
||||
}
|
||||
|
||||
/// Switch the active theme (used by the REPL 'theme <name>' command).
|
||||
pub fn set_theme(&self, theme: &'static Theme) {
|
||||
self.theme.set(theme);
|
||||
|
||||
@@ -54,6 +54,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, value_name = "THEME")]
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Output language: fr (default) or en (issue #74)
|
||||
#[arg(long, global = true, value_enum, value_name = "LANG")]
|
||||
pub lang: Option<crate::i18n::LangArg>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn generate(dir: &Path, force: bool, template: Option<&str>) -> Result<PathB
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let stack = if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
crate::i18n::tr("aucune détectée").to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
};
|
||||
@@ -82,7 +82,7 @@ pub fn run(app: &App, force: bool, template: Option<&str>) -> Result<i32> {
|
||||
"wrote {} (stack: {})",
|
||||
path.display(),
|
||||
if ctx.stack.is_empty() {
|
||||
"aucune détectée".to_string()
|
||||
crate::i18n::tr("aucune détectée").to_string()
|
||||
} else {
|
||||
ctx.stack.join(", ")
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
let scheduled = schedule_locked(&locked);
|
||||
if !scheduled.is_empty() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
"{} {}",
|
||||
"locked:".yellow(),
|
||||
scheduled.len()
|
||||
crate::tr_fmt!(
|
||||
"{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
||||
scheduled.len()
|
||||
)
|
||||
);
|
||||
}
|
||||
if scheduled.len() != locked.len() {
|
||||
println!(
|
||||
"{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
"{} {}",
|
||||
"deferred:".cyan(),
|
||||
locked.len() - scheduled.len()
|
||||
crate::tr_fmt!(
|
||||
"{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
||||
locked.len() - scheduled.len()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +119,25 @@ pub fn report_md(app: &App, days: u32) -> String {
|
||||
}
|
||||
let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0);
|
||||
let top_rows = top(app, None).unwrap_or_default();
|
||||
let mut md = format!(
|
||||
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
|
||||
duration
|
||||
);
|
||||
// Issue #74: the report follows the active language.
|
||||
let md = if app.lang().is_en() {
|
||||
format!(
|
||||
"# agent-manager report — last {days} days\n\n- Launches: {total_launches}\n- Failures: {failures}\n- Changes (install/update/uninstall): {changes}\n- Cumulative duration: {}s\n- Recorded sessions: {sessions}\n",
|
||||
duration
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n",
|
||||
duration
|
||||
)
|
||||
};
|
||||
let mut md = md;
|
||||
if !top_rows.is_empty() {
|
||||
md.push_str("\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n");
|
||||
md.push_str(if app.lang().is_en() {
|
||||
"\n## Top agents\n\n| Agent | Launches | Duration | Failures |\n|---|---|---|---|\n"
|
||||
} else {
|
||||
"\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n"
|
||||
});
|
||||
for r in &top_rows {
|
||||
md.push_str(&format!(
|
||||
"| {} | {} | {}s | {} |\n",
|
||||
@@ -137,8 +150,15 @@ pub fn report_md(app: &App, days: u32) -> String {
|
||||
if !rows.is_empty() {
|
||||
let t = crate::costs::totals(&rows);
|
||||
md.push_str(&format!(
|
||||
"\n## Coûts estimés\n\n- Sessions facturées : {}\n- Tokens : {} in / {} out\n- Coût estimé : ${:.4}\n",
|
||||
t.sessions, t.tokens_in, t.tokens_out, t.cost_usd
|
||||
"{}\n\n- {}: {}\n- {}: {} in / {} out\n- {}: ${:.4}\n",
|
||||
if app.lang().is_en() { "\n## Estimated costs" } else { "\n## Coûts estimés" },
|
||||
if app.lang().is_en() { "Billed sessions" } else { "Sessions facturées" },
|
||||
t.sessions,
|
||||
if app.lang().is_en() { "Tokens" } else { "Tokens" },
|
||||
t.tokens_in,
|
||||
t.tokens_out,
|
||||
if app.lang().is_en() { "Estimated cost" } else { "Coût estimé" },
|
||||
t.cost_usd
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,13 +111,18 @@ pub fn run(app: &App, query: &str) -> Result<i32> {
|
||||
));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{query}' — vouliez-vous dire : {} ?",
|
||||
"{} — {} : {} ?",
|
||||
crate::tr_fmt!("no agent matches '{}'", query),
|
||||
crate::i18n::tr("vouliez-vous dire"),
|
||||
suggestions.join(", ")
|
||||
));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
println!("suggestions for \"{query}\":");
|
||||
println!(
|
||||
"{} \"{query}\":",
|
||||
crate::i18n::tr("suggestions for")
|
||||
);
|
||||
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
|
||||
+34
-10
@@ -435,7 +435,11 @@ pub fn render(app: &App, random: bool) -> String {
|
||||
};
|
||||
if random {
|
||||
let (title, body) = random_tip();
|
||||
return format!("💡 {} — {}\n", p.acc(title), body);
|
||||
return format!(
|
||||
"💡 {} — {}\n",
|
||||
p.acc(crate::i18n::tr_in(app.lang(), title)),
|
||||
crate::i18n::tr_in(app.lang(), body)
|
||||
);
|
||||
}
|
||||
let mut out = String::new();
|
||||
out.push_str(&header(app, &p));
|
||||
@@ -444,7 +448,7 @@ pub fn render(app: &App, random: bool) -> String {
|
||||
out.push_str(§ion_rule(app, &p, s.icon, s.title));
|
||||
out.push('\n');
|
||||
for e in s.entries {
|
||||
out.push_str(&entry(&p, e));
|
||||
out.push_str(&entry(&p, app.lang(), e));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -453,11 +457,18 @@ pub fn render(app: &App, random: bool) -> String {
|
||||
let (title, body) = daily_tip();
|
||||
out.push_str(§ion_rule(app, &p, "💡", "Le saviez-vous ?"));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(" {} — {}\n", p.acc(title), body));
|
||||
out.push_str(&format!(
|
||||
" {} — {}\n",
|
||||
p.acc(crate::i18n::tr_in(app.lang(), title)),
|
||||
crate::i18n::tr_in(app.lang(), body)
|
||||
));
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
" {}\n",
|
||||
p.dim("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'")
|
||||
p.dim(crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'"
|
||||
))
|
||||
));
|
||||
out
|
||||
}
|
||||
@@ -528,17 +539,26 @@ fn header(app: &App, p: &Painter) -> String {
|
||||
/// Section separator: " 🚀 Lancer & piloter ────────".
|
||||
fn section_rule(app: &App, p: &Painter, icon: &str, title: &str) -> String {
|
||||
let w = page_width(app);
|
||||
let label = format!(" {icon} {title} ");
|
||||
let label = format!(" {icon} {} ", crate::i18n::tr_in(app.lang(), title));
|
||||
let dashes = "─".repeat(w.saturating_sub(label.chars().count()).max(1));
|
||||
format!("{}{}", p.hdr(&label), p.frame(&dashes))
|
||||
}
|
||||
|
||||
/// One cheat-sheet entry: usage line, options, example.
|
||||
fn entry(p: &Painter, e: &TipEntry) -> String {
|
||||
fn entry(p: &Painter, lang: crate::i18n::Lang, e: &TipEntry) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(" {} {} {}\n", p.inf("▸"), p.acc(e.usage), e.about));
|
||||
out.push_str(&format!(
|
||||
" {} {} {}\n",
|
||||
p.inf("▸"),
|
||||
p.acc(e.usage),
|
||||
crate::i18n::tr_in(lang, e.about)
|
||||
));
|
||||
for (flag, desc) in e.options {
|
||||
out.push_str(&format!(" {} {}\n", p.dim(flag), desc));
|
||||
out.push_str(&format!(
|
||||
" {} {}\n",
|
||||
p.dim(flag),
|
||||
crate::i18n::tr_in(lang, desc)
|
||||
));
|
||||
}
|
||||
if !e.example.is_empty() {
|
||||
out.push_str(&format!(" {} {}\n", p.dim("❯"), p.val(e.example)));
|
||||
@@ -593,8 +613,12 @@ fn most_used_section(app: &App, p: &Painter) -> String {
|
||||
out.push('\n');
|
||||
if top.is_empty() {
|
||||
out.push_str(&format!(
|
||||
" {} — lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici.\n",
|
||||
p.dim("aucune commande enregistrée")
|
||||
" {} — {}\n",
|
||||
p.dim(crate::i18n::tr_in(app.lang(), "aucune commande enregistrée")),
|
||||
crate::i18n::tr_in(
|
||||
app.lang(),
|
||||
"lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici"
|
||||
)
|
||||
));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,12 @@ const GLOBAL_FLAGS: &[HelpFlag] = &[
|
||||
value: "THEME",
|
||||
desc: "Color theme for the output (see 'am help theme' for the list)",
|
||||
},
|
||||
HelpFlag {
|
||||
short: "",
|
||||
long: "--lang",
|
||||
value: "LANG",
|
||||
desc: "Output language: fr (default) or en (issue #74)",
|
||||
},
|
||||
];
|
||||
|
||||
const START_FLAGS: &[HelpFlag] = &[
|
||||
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
//! Internationalization (issue #74): locale selection and message lookup.
|
||||
//!
|
||||
//! The catalog uses the gettext convention: the **French message is the key**.
|
||||
//! `tr()` returns its argument unchanged in French mode (zero behavior change
|
||||
//! for the default locale), and the English translation when one exists in
|
||||
//! the catalog. A message without an entry falls back to the French text, so
|
||||
//! the CLI never shows raw keys.
|
||||
//!
|
||||
//! Locale resolution order (first match wins):
|
||||
//! 1. the `--lang` global flag (`am --lang en <cmd>`),
|
||||
//! 2. the `AM_LANG` environment variable,
|
||||
//! 3. the `LANG` / `LC_ALL` / `LC_MESSAGES` environment variables,
|
||||
//! 4. the default: French.
|
||||
//!
|
||||
//! Locale values are parsed leniently (`en`, `EN`, `en-US`, `en_US` … all
|
||||
//! select English); anything unrecognized falls back to the default. Adding
|
||||
//! another language only requires a new `Lang` variant and one more column in
|
||||
//! the catalog entries.
|
||||
//!
|
||||
//! The `--json` contracts are untouched: only display strings go through the
|
||||
//! catalog, never serialized field names or event kinds.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// The two supported output languages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Fr,
|
||||
En,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Fr => "fr",
|
||||
Lang::En => "en",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_en(self) -> bool {
|
||||
matches!(self, Lang::En)
|
||||
}
|
||||
|
||||
/// Human-readable label, in the language itself.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Lang::Fr => "français",
|
||||
Lang::En => "english",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValueEnum so clap accepts `--lang fr|en` and rejects unknown values.
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LangArg {
|
||||
Fr,
|
||||
En,
|
||||
}
|
||||
|
||||
impl From<LangArg> for Lang {
|
||||
fn from(v: LangArg) -> Self {
|
||||
match v {
|
||||
LangArg::Fr => Lang::Fr,
|
||||
LangArg::En => Lang::En,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active locale, as an atomic (the REPL can switch it at runtime).
|
||||
static ACTIVE: AtomicU8 = AtomicU8::new(0); // 0 = Fr, 1 = En
|
||||
|
||||
/// Current locale.
|
||||
pub fn active() -> Lang {
|
||||
match ACTIVE.load(Ordering::Relaxed) {
|
||||
1 => Lang::En,
|
||||
_ => Lang::Fr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active locale (startup from `--lang`/env, or the REPL `lang`
|
||||
/// command).
|
||||
pub fn set_lang(lang: Lang) {
|
||||
ACTIVE.store(if lang.is_en() { 1 } else { 0 }, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Resolve the locale: explicit flag, then `AM_LANG`, then `LANG`/`LC_ALL`/
|
||||
/// `LC_MESSAGES`, then the French default.
|
||||
pub fn resolve(flag: Option<LangArg>) -> Lang {
|
||||
if let Some(l) = flag {
|
||||
return l.into();
|
||||
}
|
||||
if let Ok(v) = std::env::var("AM_LANG") {
|
||||
if let Some(l) = parse(&v) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
if let Some(l) = parse(&v) {
|
||||
return l;
|
||||
}
|
||||
}
|
||||
}
|
||||
Lang::Fr
|
||||
}
|
||||
|
||||
/// Lenient parsing: "en", "EN", "en-US", "en_US.UTF-8", "fr-FR" …
|
||||
fn parse(value: &str) -> Option<Lang> {
|
||||
let v = value.trim().to_ascii_lowercase();
|
||||
if v.starts_with("en") {
|
||||
return Some(Lang::En);
|
||||
}
|
||||
if v.starts_with("fr") {
|
||||
return Some(Lang::Fr);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// One entry per message: `(French message, English translation)`. The French
|
||||
// text is the stable key (gettext convention): it is fixed by the call site
|
||||
// and doubles as the fallback. Placeholders (`{}`, `{name}`) must appear in
|
||||
// the same order in both languages — a test enforces it.
|
||||
|
||||
pub const CATALOG: &[(&str, &str)] = &[
|
||||
// ---- messages généraux -------------------------------------------------
|
||||
("aucun agent installé ou en cours d'exécution", "no installed or running agents"),
|
||||
("aucun agent installé", "no installed agents"),
|
||||
("aucun résultat", "no results"),
|
||||
("aucun", "none"),
|
||||
("inconnu", "unknown"),
|
||||
("disponible", "available"),
|
||||
("activé", "enabled"),
|
||||
("désactivé", "disabled"),
|
||||
("oui", "yes"),
|
||||
("non", "no"),
|
||||
("terminé", "finished"),
|
||||
("échec", "failed"),
|
||||
("interrompu", "interrupted"),
|
||||
("en cours", "running"),
|
||||
("installé", "installed"),
|
||||
("non installé", "not installed"),
|
||||
("externe", "external"),
|
||||
("non installable", "not installable"),
|
||||
("erreur", "error"),
|
||||
("avertissement", "warning"),
|
||||
("information", "info"),
|
||||
("confirmer", "confirm"),
|
||||
("annuler", "cancel"),
|
||||
("version", "version"),
|
||||
("chemin", "path"),
|
||||
("projet", "project"),
|
||||
("durée", "duration"),
|
||||
("statut", "status"),
|
||||
("session", "session"),
|
||||
("date", "date"),
|
||||
("début", "start"),
|
||||
("fin", "end"),
|
||||
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest an agent for Python"),
|
||||
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
|
||||
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
|
||||
("vouliez-vous dire", "did you mean"),
|
||||
("aucune détectée", "none detected"),
|
||||
("aucune commande enregistrée", "no recorded commands"),
|
||||
("lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici", "start the am shell and type a few commands: your habits will appear here"),
|
||||
("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'", "am help <command> for details · am tip --random for a single tip · inside the am shell, just type 'tip'"),
|
||||
("Le saviez-vous ?", "Did you know?"),
|
||||
("Vos commandes les plus utilisées", "Your most used commands"),
|
||||
("Lancer & piloter", "Run & control"),
|
||||
("Installer & maintenir", "Install & maintain"),
|
||||
("Observer", "Observe"),
|
||||
("Personnaliser", "Customize"),
|
||||
("Boîte à outils", "Toolbox"),
|
||||
("démarre un agent (premier plan par défaut)", "starts an agent (foreground by default)"),
|
||||
("arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", "stops a background agent (SIGTERM then SIGKILL)"),
|
||||
("redémarre avec les mêmes options (agent, groupe, profil…)", "restarts with the same options (agent, group, profile…)"),
|
||||
("supervise l'agent et le relance s'il meurt", "supervises the agent and restarts it if it dies"),
|
||||
("exécute la commande de l'agent directement (sans gestion de processus)", "runs the agent command directly (no process management)"),
|
||||
("agents installés (managed + external détectés sur le PATH)", "installed agents (managed + external detected on PATH)"),
|
||||
("cherche dans le catalogue (nom, description, catégorie, tags)", "searches the catalog (name, description, category, tags)"),
|
||||
("recommande un agent pour une demande en langage naturel (tags + usage réel)", "recommends an agent for a natural-language request (tags + real usage)"),
|
||||
("inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", "inventory of local models (ollama, llama.cpp, LM Studio)"),
|
||||
("rafraîchit le catalogue officiel (diff + confirmation)", "refreshes the official catalog (diff + confirmation)"),
|
||||
("qui a modifié quoi, quand (checksums config + journal d'événements)", "who changed what, when (config checksums + event journal)"),
|
||||
("agent en service système (systemd / launchd / tâche Windows) avec démarrage auto", "registers the agent as a system service (systemd / launchd / Windows task) with autostart"),
|
||||
("planifie une commande am quotidienne (ex: update --all le matin)", "schedules a daily am command (e.g. update --all in the morning)"),
|
||||
("vérifications de l'environnement en continu, alerte dès qu'un problème apparaît", "continuous environment checks, alerts as soon as a problem appears"),
|
||||
("démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)", "starts a whole group (ordered + health wait by default, --parallel without waiting)"),
|
||||
("annule la dernière mise à jour (backup automatique avant chaque update)", "rolls back the last update (automatic backup before every update)"),
|
||||
("génère une config projet pré-remplie (web, python, rust, cli)", "generates a pre-filled project config (web, python, rust, cli)"),
|
||||
("exporte une session : métadonnées + commandes + extrait de log (JSON)", "exports a session: metadata + commands + log excerpt (JSON)"),
|
||||
("installe l'agent et ses dépendances (Node, Python, Rust…)", "installs the agent and its dependencies (Node, Python, Rust…)"),
|
||||
("met à jour un agent vers la dernière version disponible", "updates an agent to the latest available version"),
|
||||
("supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)", "removes the files and the state entry (completion refreshes)"),
|
||||
("état de tous les agents (running / installed / external)", "state of every agent (running / installed / external)"),
|
||||
("lit la fin du journal de l'agent", "reads the end of the agent log"),
|
||||
("TUI en direct : activité, stats, sessions, projets", "live TUI: activity, stats, sessions, projects"),
|
||||
("historique des sessions · statistiques · agents les plus utilisés", "session history · statistics · most used agents"),
|
||||
("TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils", "real-time TUI of managed processes (CPU, memory, uptime) with threshold alerts"),
|
||||
("pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", "pushes the state (state.json, journal, history) into the configured git repo (sync_repo)"),
|
||||
("export/import d'un bundle de transfert machine A → B (config + état + historique + backups)", "export/import of a machine-transfer bundle A → B (config + state + history + backups)"),
|
||||
("exporte une plage d'historique en playbook YAML (état_dir/playbooks/)", "exports a history range as a YAML playbook (state_dir/playbooks/)"),
|
||||
("benchmark : même tâche sur plusieurs agents (durée, exit, coût)", "benchmark: the same task on several agents (duration, exit, cost)"),
|
||||
("scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)", "event extension scripts (JSON contract stdin/stdout, timeout)"),
|
||||
("une vue chronologique de toute l'activité", "a chronological view of all activity"),
|
||||
("crée un raccourci (ex. cc → claude-code)", "creates a shortcut (e.g. cc → claude-code)"),
|
||||
("profils d'environnement réutilisables (env + args)", "reusable environment profiles (env + args)"),
|
||||
("configuration effective (catalogue fusionné, alias, groupes…)", "effective configuration (merged catalog, aliases, groups…)"),
|
||||
("change la palette du shell, des tableaux et de cette page", "changes the palette of the shell, the tables and this page"),
|
||||
("stocke un secret dans le keyring de l'OS, jamais en clair", "stores a secret in the OS keyring, never in plaintext"),
|
||||
("annotez vos agents : étoile, note libre, tags personnels", "annotate your agents: star, free note, personal tags"),
|
||||
("diagnostique l'environnement et répare les problèmes courants", "diagnoses the environment and fixes common problems"),
|
||||
("sauvegarde config + état, puis restauration", "backs up config + state, then restores"),
|
||||
("complétion dynamique : agents installés, alias et groupes au Tab", "dynamic completion: installed agents, aliases and groups on Tab"),
|
||||
("page de manuel pour une commande", "manual page for a command"),
|
||||
("cherche une commande dans toute l'aide", "searches every help page for a command"),
|
||||
("met à jour agent-manager lui-même", "updates agent-manager itself"),
|
||||
("détaché en arrière-plan, sortie dans le log", "detached in the background, output goes to the log"),
|
||||
("variable d'environnement (répétable)", "environment variable (repeatable)"),
|
||||
("profil d'environnement (env + args)", "environment profile (env + args)"),
|
||||
("kill immédiat, sans délai de grâce", "immediate kill, no grace period"),
|
||||
("délai de grâce avant le kill (défaut : 5 s)", "grace period before the kill (default: 5 s)"),
|
||||
("relance automatique avec backoff", "automatic restart with backoff"),
|
||||
("notification bureau à chaque relance", "desktop notification on every restart"),
|
||||
("catalogue complet, y compris non installés", "the whole catalog, including uninstalled agents"),
|
||||
("uniquement les agents actifs", "only active agents"),
|
||||
("name, version ou status", "name, version or status"),
|
||||
("filtre par catégorie", "filter by category"),
|
||||
("sortie machine-readable", "machine-readable output"),
|
||||
("purge les modèles inutilisés", "prunes unused models"),
|
||||
("simulation sans rien supprimer", "simulation, deletes nothing"),
|
||||
("seuil d'inutilisation (défaut : 30)", "unused threshold (default: 30)"),
|
||||
("ajoute un catalogue d'équipe par URL", "adds a team catalog by URL"),
|
||||
("rapport machine-readable", "machine-readable report"),
|
||||
("état du service", "service status"),
|
||||
("retire le service", "removes the service"),
|
||||
("liste les planifications", "lists the schedules"),
|
||||
("supprime une planification", "removes a schedule"),
|
||||
("répare automatiquement ce qui peut l'être", "automatically repairs what can be repaired"),
|
||||
("exécute l'agent dans son profil conteneur (docker/podman)", "runs the agent inside its container profile (docker/podman)"),
|
||||
("liste les points de restauration", "lists the restore points"),
|
||||
("restaure un point précis", "restores a specific point"),
|
||||
("liste les templates disponibles", "lists the available templates"),
|
||||
("destination (défaut ./am-session-<id>.json)", "destination (default ./am-session-<id>.json)"),
|
||||
("purge les sessions terminées au-delà de N jours", "purges sessions finished more than N days ago"),
|
||||
("choisit la méthode (index ou type:npm, binary…)", "selects the method (index or type:npm, binary…)"),
|
||||
("réinstalle même si déjà présent", "reinstalls even if already present"),
|
||||
("tous les agents installés d'un coup", "every installed agent at once"),
|
||||
("supprime aussi les logs et la définition de la config", "also removes the logs and the config definition"),
|
||||
("suit le log en direct", "follows the log live"),
|
||||
("nombre de lignes (défaut : 20)", "number of lines (default: 20)"),
|
||||
("7d, 30d, 90d ou all", "7d, 30d, 90d or all"),
|
||||
("tokens + coût estimé par agent (issue #49)", "tokens + estimated cost per agent (issue #49)"),
|
||||
("rafraîchissement (défaut 2s)", "refresh interval (default 2s)"),
|
||||
("flux JSON pour les scripts", "JSON stream for scripts"),
|
||||
("message de commit personnalisé", "custom commit message"),
|
||||
("pousse automatiquement à la fermeture du REPL", "pushes automatically when the REPL closes"),
|
||||
("crée agent-manager-migrate.amx", "creates agent-manager-migrate.amx"),
|
||||
("chemin du bundle", "bundle path"),
|
||||
("fichier playbook", "playbook file"),
|
||||
("rejoue pas à pas avec confirmation", "replays step by step with confirmation"),
|
||||
("exécution concurrente", "concurrent execution"),
|
||||
("rapport comparatif stable (scripts/CI)", "stable comparison report (scripts/CI)"),
|
||||
("exécute un plugin sur un événement de test (CI)", "runs a plugin against a test event (CI)"),
|
||||
("activité à partir d'une date", "activity from a given date"),
|
||||
("fichier de destination (défaut : am-export.json)", "destination file (default: am-export.json)"),
|
||||
("vérifie seulement si une version existe", "only checks whether a version exists"),
|
||||
("exécution séquentielle", "sequential execution"),
|
||||
("kill de l'arbre de processus au timeout", "kills the process tree on timeout"),
|
||||
("Tab complète vos agents", "Tab completes your agents"),
|
||||
("dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.", "in the am shell, type 'start ' then Tab: installed agents, aliases and groups show up — and the list refreshes after every install/uninstall."),
|
||||
("!! relance la dernière commande", "!! re-runs the last command"),
|
||||
("l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.", "the shell history accepts !!, !N and !prefix to re-run a command without retyping it."),
|
||||
("Le shell am double le shell système", "The am shell doubles as the system shell"),
|
||||
("tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.", "anything that is not an am command is run by your shell (pwsh, cmd, bash…): no need to leave to launch a tool."),
|
||||
("--dry-run simule sans rien changer", "--dry-run simulates without changing anything"),
|
||||
("testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.", "test 'install', 'update' or 'uninstall' safely: am shows what it would do, without touching the disk."),
|
||||
("Les groupes lancent plusieurs agents", "Groups launch several agents"),
|
||||
("'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.", "'start group:dev' launches the whole group at once; create your own in the config."),
|
||||
("Cherchez dans toute l'aide", "Search all the help"),
|
||||
("'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.", "'am help --find <word>' scans the descriptions, usages and examples of every command."),
|
||||
("--json pour les scripts", "--json for scripts"),
|
||||
("quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.", "almost every command accepts --json and outputs structured data, perfect for your scripts."),
|
||||
("Les alias raccourcissent tout", "Aliases shorten everything"),
|
||||
("'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.", "'alias add cc claude-code' then 'start cc': a short name for every agent."),
|
||||
("export/import sauvegardent votre monde", "export/import back up your world"),
|
||||
("config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.", "config, state, annotations and profiles go into a JSON file, restorable with 'import'."),
|
||||
("Les secrets vivent dans le keyring", "Secrets live in the keyring"),
|
||||
("'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.", "'secret set API_KEY --agent pi' stores the value in the OS keychain, never in plaintext in the config."),
|
||||
("Le thème suit votre humeur", "The theme follows your mood"),
|
||||
("'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", "'theme ocean', 'theme dracula'… the palette of the am shell, the tables and this page changes instantly."),
|
||||
("doctor répare", "doctor repairs"),
|
||||
("'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", "'am doctor --fix' checks the environment and fixes common problems (missing folders, corrupted state…)."),
|
||||
];
|
||||
|
||||
/// Look up the English translation of a French message. Returns None when the
|
||||
/// message is not in the catalog (the caller falls back to the French text).
|
||||
pub fn lookup_en(fr: &str) -> Option<&'static str> {
|
||||
CATALOG
|
||||
.iter()
|
||||
.find(|(k, _)| *k == fr)
|
||||
.map(|(_, en)| *en)
|
||||
}
|
||||
|
||||
/// Translate a message for the active locale. In French mode the input is
|
||||
/// returned unchanged; in English mode the catalog translation is used, with
|
||||
/// the French text as fallback when the message is not translated yet.
|
||||
pub fn tr<'a>(key: &'a str) -> &'a str {
|
||||
tr_in(active(), key)
|
||||
}
|
||||
|
||||
/// Translate a message for an explicit locale.
|
||||
pub fn tr_in<'a>(lang: Lang, key: &'a str) -> &'a str {
|
||||
match lang {
|
||||
Lang::Fr => key,
|
||||
Lang::En => lookup_en(key).unwrap_or(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a localized message: `tr_fmt!("agent '{}' installé", name)`.
|
||||
/// The English translation keeps the same placeholders (enforced by test).
|
||||
/// Edition 2021 forbids non-literal format strings, so the formatting is
|
||||
/// done by a small runtime `{}`-replacer; catalog entries therefore use
|
||||
/// positional `{}` placeholders only (no `{name}`, `{:.1}`, …).
|
||||
#[macro_export]
|
||||
macro_rules! tr_fmt {
|
||||
($key:literal $(, $arg:expr)*) => {
|
||||
$crate::i18n::tr_fmt($key, &[$(&$arg as &dyn ::std::fmt::Display),*])
|
||||
};
|
||||
}
|
||||
|
||||
/// Format a message for the active locale, replacing every `{}` with the
|
||||
/// Display rendering of the matching argument.
|
||||
pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
match active() {
|
||||
Lang::Fr => fmt_args(key, args),
|
||||
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `{}` placeholders (positional) with the Display of each argument.
|
||||
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
let mut out = String::with_capacity(template.len() + 16);
|
||||
let mut rest = template;
|
||||
let mut i = 0;
|
||||
while let Some(pos) = rest.find("{}") {
|
||||
out.push_str(&rest[..pos]);
|
||||
if let Some(arg) = args.get(i) {
|
||||
out.push_str(&arg.to_string());
|
||||
}
|
||||
i += 1;
|
||||
rest = &rest[pos + 2..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Env-var tests mutate the process environment and would race when run
|
||||
/// in parallel; serialize them.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn placeholders(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let after = &rest[i..];
|
||||
let close = after.find('}').map(|j| j + 1).unwrap_or(after.len());
|
||||
out.push(after[..close].to_string());
|
||||
rest = &after[close..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_locale_values() {
|
||||
assert_eq!(parse("en"), Some(Lang::En));
|
||||
assert_eq!(parse("EN"), Some(Lang::En));
|
||||
assert_eq!(parse("en-US"), Some(Lang::En));
|
||||
assert_eq!(parse("en_US.UTF-8"), Some(Lang::En));
|
||||
assert_eq!(parse("fr"), Some(Lang::Fr));
|
||||
assert_eq!(parse("fr-FR"), Some(Lang::Fr));
|
||||
assert_eq!(parse("de"), None);
|
||||
assert_eq!(parse(""), None);
|
||||
assert_eq!(parse(" en "), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_wins_over_env() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("AM_LANG", "en");
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
let flag = Some(LangArg::En);
|
||||
assert_eq!(resolve(flag), Lang::En);
|
||||
let flag = Some(LangArg::Fr);
|
||||
assert_eq!(resolve(flag), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn am_lang_env_beats_lang_env() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("AM_LANG", "en");
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
assert_eq!(resolve(None), Lang::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_env_falls_back_to_default() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("AM_LANG");
|
||||
std::env::set_var("LANG", "en_GB");
|
||||
assert_eq!(resolve(None), Lang::En);
|
||||
std::env::set_var("LANG", "fr_FR");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
std::env::remove_var("LANG");
|
||||
std::env::remove_var("LC_ALL");
|
||||
std::env::remove_var("LC_MESSAGES");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_env_falls_back_to_default() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("AM_LANG");
|
||||
std::env::set_var("LANG", "de_DE");
|
||||
assert_eq!(resolve(None), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_returns_input_in_french() {
|
||||
set_lang(Lang::Fr);
|
||||
assert_eq!(tr("aucun agent installé"), "aucun agent installé");
|
||||
assert_eq!(tr("message non catalogué"), "message non catalogué");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_translates_in_english_and_falls_back() {
|
||||
set_lang(Lang::En);
|
||||
assert_eq!(tr("aucun agent installé"), "no installed agents");
|
||||
// Not in the catalog: the French text is the fallback, never a key.
|
||||
assert_eq!(tr("message non catalogué"), "message non catalogué");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tr_fmt_formats_both_locales() {
|
||||
set_lang(Lang::Fr);
|
||||
assert_eq!(crate::tr_fmt!("agent '{}' installé", "cc"), "agent 'cc' installé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_args_replaces_positional_placeholders() {
|
||||
assert_eq!(fmt_args("a {} b", &[&"x", &"y"]), "a x b");
|
||||
assert_eq!(fmt_args("{}", &[&42]), "42");
|
||||
assert_eq!(fmt_args("no placeholders", &[]), "no placeholders");
|
||||
assert_eq!(fmt_args("{} {}", &[&1]), "1 ");
|
||||
assert_eq!(fmt_args("", &[&1]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_is_consistent() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (fr, en) in CATALOG {
|
||||
assert!(!fr.is_empty(), "empty french key");
|
||||
assert!(!en.is_empty(), "empty english translation for {fr:?}");
|
||||
assert!(seen.insert(*fr), "duplicate catalog key: {fr:?}");
|
||||
// Placeholders must line up between the two languages.
|
||||
assert_eq!(
|
||||
placeholders(fr),
|
||||
placeholders(en),
|
||||
"placeholder mismatch for {fr:?}"
|
||||
);
|
||||
// Catalog entries only use positional {} placeholders: the
|
||||
// runtime formatter (tr_fmt) cannot honour {name} or {:.1}.
|
||||
assert!(
|
||||
!fr.contains('{') || placeholders(fr).iter().all(|p| p == "{}"),
|
||||
"{fr:?}: only '{{}}' placeholders are allowed in the catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_placeholder_in_fr_is_present_in_en() {
|
||||
for (fr, en) in CATALOG {
|
||||
for ph in placeholders(fr) {
|
||||
assert!(
|
||||
en.contains(&ph),
|
||||
"{fr:?}: placeholder {ph} missing in {en:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub mod events;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod i18n;
|
||||
pub mod installers;
|
||||
pub mod lab;
|
||||
pub mod models;
|
||||
@@ -85,6 +86,9 @@ fn real_main() -> i32 {
|
||||
return code;
|
||||
}
|
||||
let cli = cli::Cli::parse();
|
||||
// Issue #74: resolve the output language before anything else so even
|
||||
// config errors are localized; the REPL can switch it at runtime.
|
||||
i18n::set_lang(i18n::resolve(cli.lang));
|
||||
match app::App::from_cli(cli) {
|
||||
Ok(app) => {
|
||||
// Issue #53: enforce the session retention at every startup
|
||||
|
||||
+79
-15
@@ -120,6 +120,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("plugins", "list the event plugins and test one (JSON contract on stdin/stdout)"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("theme", "show or switch the color theme"),
|
||||
("lang", "show or switch the output language (fr/en)"),
|
||||
("tip", "cheat sheet of the most useful commands"),
|
||||
("ls", "list files and directories (Nushell-style)"),
|
||||
("dir", "list files and directories (same as ls)"),
|
||||
@@ -229,7 +230,7 @@ impl AmCompleter {
|
||||
commands: vec![
|
||||
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme", "lang",
|
||||
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
|
||||
"profile", "man", "models", "catalog", "suggest", "audit",
|
||||
"service", "schedule", "monitor", "sync", "migrate", "playbook", "lab", "plugins",
|
||||
@@ -296,6 +297,9 @@ impl AmCompleter {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
"lang" => {
|
||||
pool = vec!["fr".to_string(), "en".to_string()];
|
||||
}
|
||||
"cd" | "chdir" => {
|
||||
// Complete directories only — dive one level per Tab.
|
||||
let cwd = current_dir();
|
||||
@@ -637,6 +641,7 @@ pub fn banner_box(
|
||||
session: &ShellSession,
|
||||
session_id: &str,
|
||||
cwd: &str,
|
||||
lang: &str,
|
||||
theme: &Theme,
|
||||
color: bool,
|
||||
) -> String {
|
||||
@@ -791,7 +796,7 @@ pub fn banner_box(
|
||||
" system self-update · self-uninstall · export · import".to_string(),
|
||||
));
|
||||
rows.push(inner(
|
||||
" shell ls · dir · cd · ps · where · get · shell · theme · help · version · exit"
|
||||
" shell ls · dir · cd · ps · where · get · shell · theme · lang · help · version · exit"
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
@@ -799,9 +804,10 @@ pub fn banner_box(
|
||||
rows.push(blank());
|
||||
rows.push(inner(dim(
|
||||
&format!(
|
||||
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · /help for commands",
|
||||
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · lang {} · /help for commands",
|
||||
session.current.name,
|
||||
theme.name
|
||||
theme.name,
|
||||
lang,
|
||||
),
|
||||
theme,
|
||||
color,
|
||||
@@ -831,6 +837,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
|
||||
session,
|
||||
session_id,
|
||||
¤t_dir_string(),
|
||||
app.lang().code(),
|
||||
theme,
|
||||
color,
|
||||
);
|
||||
@@ -1255,6 +1262,10 @@ fn handle_input(
|
||||
theme_command(app, None);
|
||||
return Ok(false);
|
||||
}
|
||||
"lang" | "/lang" => {
|
||||
lang_command(app, None);
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// History expansion: !! !N !prefix ^old^new (re-execution, issue #27).
|
||||
@@ -1274,6 +1285,7 @@ fn handle_input(
|
||||
match cmd {
|
||||
"shell" => shell_command(session, arg),
|
||||
"theme" => theme_command(app, arg),
|
||||
"lang" => lang_command(app, arg),
|
||||
"help" => match arg {
|
||||
Some(topic) => {
|
||||
if let Err(e) = crate::help::print_command(app, topic) {
|
||||
@@ -1284,7 +1296,7 @@ fn handle_input(
|
||||
},
|
||||
_ => app
|
||||
.log
|
||||
.error(&format!("unknown command '/{cmd}' — type '/help'")),
|
||||
.error(crate::tr_fmt!("unknown command '/{}' — type '/help'", cmd)),
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -1354,6 +1366,11 @@ fn handle_line(
|
||||
theme_command(app, rest.first().map(|s| s.as_str()));
|
||||
return Ok(false);
|
||||
}
|
||||
// lang | lang <fr|en>: show the output language or switch it (issue #74).
|
||||
if cmd == "lang" {
|
||||
lang_command(app, rest.first().map(|s| s.as_str()));
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Session directory change: kept in the am process, not a child shell.
|
||||
if matches!(cmd, "cd" | "chdir") {
|
||||
@@ -1450,7 +1467,7 @@ fn handle_line(
|
||||
}),
|
||||
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
|
||||
_ => {
|
||||
app.log.error("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list");
|
||||
app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
@@ -1467,13 +1484,13 @@ fn handle_line(
|
||||
}),
|
||||
Some("list") => Command::Catalog(crate::cli::CatalogCmd::List),
|
||||
_ => {
|
||||
app.log.error("usage: catalog update | catalog add <url> | catalog list");
|
||||
app.log.error(crate::i18n::tr("usage: catalog update | catalog add <url> | catalog list"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
"suggest" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error("usage: suggest <requête> — ex: suggest un agent pour du Python");
|
||||
app.log.error(crate::i18n::tr("usage: suggest <requête> — ex: suggest un agent pour du Python"));
|
||||
return Ok(false);
|
||||
}
|
||||
Command::Suggest {
|
||||
@@ -1490,7 +1507,7 @@ fn handle_line(
|
||||
}),
|
||||
Some("list") | None => Command::Alias(crate::cli::AliasCmd::List),
|
||||
_ => {
|
||||
app.log.error("usage: alias add <name> <target> | alias remove <name> | alias list");
|
||||
app.log.error(crate::i18n::tr("usage: alias add <name> <target> | alias remove <name> | alias list"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
@@ -1525,7 +1542,8 @@ fn handle_line(
|
||||
Some("list") | None => Command::Profile(crate::cli::ProfileCmd::List),
|
||||
other => {
|
||||
app.log.error(&format!(
|
||||
"usage: profile list | profile show <name>{}",
|
||||
"{}{}",
|
||||
crate::i18n::tr("usage: profile list | profile show <name>"),
|
||||
other.map(|o| format!(" (unknown 'profile {o}')")).unwrap_or_default()
|
||||
));
|
||||
return Ok(false);
|
||||
@@ -1611,7 +1629,7 @@ fn handle_line(
|
||||
},
|
||||
"playbook" => {
|
||||
if rest.is_empty() {
|
||||
app.log.error("usage: playbook <file> — ex: playbook deploy.yaml");
|
||||
app.log.error(crate::i18n::tr("usage: playbook <file> — ex: playbook deploy.yaml"));
|
||||
return Ok(false);
|
||||
}
|
||||
Command::Playbook {
|
||||
@@ -1747,7 +1765,7 @@ fn handle_line(
|
||||
id: rest[1].clone(),
|
||||
}),
|
||||
_ => {
|
||||
app.log.error("usage: schedule add <cmd...> | schedule list | schedule remove <id>");
|
||||
app.log.error(crate::i18n::tr("usage: schedule add <cmd...> | schedule list | schedule remove <id>"));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
@@ -1793,7 +1811,7 @@ fn handle_line(
|
||||
},
|
||||
other => {
|
||||
app.log
|
||||
.error(&format!("unknown command '{other}' — type 'help'"));
|
||||
.error(crate::tr_fmt!("unknown command '{}' — type 'help'", other));
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
@@ -1808,7 +1826,7 @@ fn handle_line(
|
||||
fn is_shell_command(word: &str) -> bool {
|
||||
matches!(
|
||||
word,
|
||||
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "help" | "version" | "exit"
|
||||
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "lang" | "help" | "version" | "exit"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2032,7 +2050,7 @@ fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||
let code = session.run(line)?;
|
||||
if code != 0 {
|
||||
app.log
|
||||
.error(&format!("command exited with code {code}"));
|
||||
.error(crate::tr_fmt!("command exited with code {}", code));
|
||||
}
|
||||
app.emit(
|
||||
&Event::now(EventKind::Shell)
|
||||
@@ -2130,6 +2148,48 @@ fn print_theme_status(app: &App) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Show or switch the output language (issue #74). With no argument the
|
||||
/// current locale is shown; with 'fr' or 'en' it is switched for the rest
|
||||
/// of the session (the i18n global follows immediately).
|
||||
fn lang_command(app: &App, arg: Option<&str>) {
|
||||
match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => print_lang_status(app),
|
||||
Some(code) => match code.to_ascii_lowercase().as_str() {
|
||||
"fr" | "french" | "français" => {
|
||||
app.set_lang(crate::i18n::Lang::Fr);
|
||||
println!("language: {}", crate::i18n::Lang::Fr.label());
|
||||
}
|
||||
"en" | "english" | "anglais" => {
|
||||
app.set_lang(crate::i18n::Lang::En);
|
||||
println!("language: {}", crate::i18n::Lang::En.label());
|
||||
}
|
||||
other => app.log.error(format!(
|
||||
"unknown language '{other}' — use 'lang fr' or 'lang en'"
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the active language and the switch hint.
|
||||
fn print_lang_status(app: &App) {
|
||||
let lang = app.lang();
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
let hdr = |s: &str| -> String {
|
||||
if color {
|
||||
theme.hdr(s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
println!("{}", hdr("Language"));
|
||||
println!(" ◉ {} ({})", theme.acc(lang.label()), lang.code());
|
||||
println!(
|
||||
" {}",
|
||||
theme.dimmed("'lang <fr|en>' to switch (this session) · 'am --lang <fr|en>' at startup")
|
||||
);
|
||||
}
|
||||
|
||||
fn print_shell_status(session: &ShellSession) {
|
||||
let others: Vec<&str> = session
|
||||
.available
|
||||
@@ -2296,6 +2356,7 @@ mod tests {
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
"fr",
|
||||
crate::theme::default_theme(),
|
||||
false,
|
||||
);
|
||||
@@ -2333,6 +2394,7 @@ mod tests {
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
"fr",
|
||||
crate::theme::default_theme(),
|
||||
false,
|
||||
);
|
||||
@@ -2360,6 +2422,7 @@ mod tests {
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
"fr",
|
||||
ocean,
|
||||
true,
|
||||
);
|
||||
@@ -2463,6 +2526,7 @@ mod tests {
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
"fr",
|
||||
ocean,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! i18n (issue #74): locale selection, English output, stable JSON contracts.
|
||||
//!
|
||||
//! The tests spawn the real am binary (CARGO_BIN_EXE_am) so the assertions
|
||||
//! cover the actual command output, not just the library layer.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::i18n::{self, Lang};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Run the real am binary with args and a temp config; returns
|
||||
/// (stdout, stderr, exit code).
|
||||
fn am(args: &[&str], config: &PathBuf) -> (String, String, i32) {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_am"));
|
||||
cmd.arg("--config").arg(config).args(args);
|
||||
let out = cmd.output().expect("am binary should run");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
String::from_utf8_lossy(&out.stderr).to_string(),
|
||||
out.status.code().unwrap_or(-1),
|
||||
)
|
||||
}
|
||||
|
||||
/// A minimal valid config in a fresh temp dir (no user config leakage).
|
||||
fn temp_config(tag: &str) -> (PathBuf, PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"am-i18n-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
(cfg, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_flag_selects_english_for_tip_content() {
|
||||
let (cfg, _dir) = temp_config("flag");
|
||||
// Explicit French: the tip page is French.
|
||||
let (fr, _, _) = am(&["--lang", "fr", "tip"], &cfg);
|
||||
assert!(fr.contains("Lancer & piloter"), "fr tip missing: {fr}");
|
||||
// --lang en: the same page is translated.
|
||||
let (en, _, code) = am(&["--lang", "en", "tip"], &cfg);
|
||||
assert_eq!(code, 0);
|
||||
assert!(en.contains("Run & control"), "en tip missing: {en}");
|
||||
assert!(!en.contains("Lancer & piloter"), "untranslated section: {en}");
|
||||
// The flag works after the subcommand too (global clap flag).
|
||||
let (en2, _, _) = am(&["tip", "--lang", "en"], &cfg);
|
||||
assert!(en2.contains("Run & control"), "en tip (post) missing: {en2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn am_lang_env_selects_english() {
|
||||
let (cfg, _dir) = temp_config("env");
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_am"));
|
||||
cmd.arg("--config").arg(&cfg).arg("tip");
|
||||
cmd.env("AM_LANG", "en");
|
||||
let out = cmd.output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(text.contains("Run & control"), "AM_LANG=en ignored: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_env_values_are_parsed_leniently() {
|
||||
// The host machine may export LANG/AM_LANG (git-bash sets LANG=en_GB):
|
||||
// clear them so the default resolution is exercised deterministically.
|
||||
std::env::remove_var("AM_LANG");
|
||||
std::env::remove_var("LANG");
|
||||
std::env::remove_var("LC_ALL");
|
||||
std::env::remove_var("LC_MESSAGES");
|
||||
assert_eq!(i18n::resolve(None), Lang::Fr); // no env -> default French
|
||||
std::env::set_var("AM_LANG", "en_US.UTF-8");
|
||||
assert_eq!(i18n::resolve(None), Lang::En);
|
||||
std::env::remove_var("AM_LANG");
|
||||
// The default follows the host LANG when set (spec: locale selon env).
|
||||
std::env::set_var("LANG", "fr_FR.UTF-8");
|
||||
assert_eq!(i18n::resolve(None), Lang::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_remembers_the_selected_language() {
|
||||
let app = common::test_app("applang", &["--lang", "en", "list"]);
|
||||
assert_eq!(app.lang(), Lang::En);
|
||||
assert!(i18n::active().is_en());
|
||||
let app_fr = common::test_app("applangfr", &["--lang", "fr", "list"]);
|
||||
assert_eq!(app_fr.lang(), Lang::Fr);
|
||||
assert!(!i18n::active().is_en());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_contract_is_identical_across_languages() {
|
||||
// Issue #74 acceptance: --json field names and values are untouched by
|
||||
// the language switch — only display strings are translated.
|
||||
let (cfg, _dir) = temp_config("json");
|
||||
let (fr, _, code_fr) = am(&["list", "--json"], &cfg);
|
||||
let (en, _, code_en) = am(&["list", "--json", "--lang", "en"], &cfg);
|
||||
assert_eq!(code_fr, 0);
|
||||
assert_eq!(code_en, 0);
|
||||
assert_eq!(fr, en, "list --json differs across languages");
|
||||
// Same for status --json (structured detail).
|
||||
let (fr_s, _, _) = am(&["status", "--json"], &cfg);
|
||||
let (en_s, _, _) = am(&["status", "--json", "--lang", "en"], &cfg);
|
||||
assert_eq!(fr_s, en_s, "status --json differs across languages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_reports_stack_in_english() {
|
||||
let (cfg, dir) = temp_config("init");
|
||||
let work = dir.join("proj");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_am"))
|
||||
.arg("--config")
|
||||
.arg(&cfg)
|
||||
.args(["--lang", "en", "init", "--force"])
|
||||
.current_dir(&work)
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
text.contains("none detected"),
|
||||
"EN init message missing: {text}"
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -8,7 +8,9 @@ use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn tip_page_renders_header_sections_and_options() {
|
||||
let app = common::test_app("tip", &["tip"]);
|
||||
// Issue #74: the page follows the locale; force French here so the
|
||||
// assertions are deterministic whatever the host LANG says.
|
||||
let app = common::test_app("tip", &["--lang", "fr", "tip"]);
|
||||
let text = tip_cmd::render(&app, false);
|
||||
assert!(text.contains("TIP"), "header missing:\n{text}");
|
||||
assert!(text.contains("antisèche"), "subtitle missing:\n{text}");
|
||||
|
||||
@@ -45,6 +45,9 @@ fn report_md_contains_numbers_and_table() {
|
||||
];
|
||||
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
|
||||
let app = app_with_events("report", &refs);
|
||||
// Issue #74: the report follows the app language; force French so the
|
||||
// assertions are deterministic whatever the host LANG says.
|
||||
app.set_lang(agent_manager::i18n::Lang::Fr);
|
||||
let md = stats_cmd::report_md(&app, 7);
|
||||
assert!(md.contains("# Rapport agent-manager"), "md: {md}");
|
||||
assert!(md.contains("Lancements : 2"));
|
||||
|
||||
Reference in New Issue
Block a user