v0.4.3 : persistance du theme actif choisi
release / linux (push) Successful in 13m24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s

- le REPL 'theme <name>' applique ET sauvegarde le theme (settings.theme dans le fichier de config actif, cree s'il manque) via persist_setting
- correction racine : config::merge ignorait settings.theme - la valeur persistee n etait jamais relue au chargement, le theme par defaut etait toujours reapplique apres un redemarrage
- 'am --theme <name>' reste un one-shot ; config set settings.theme <nom> fonctionne egalement
- aides mises a jour (banniere, help theme)
- tests de persistance + relecture au demarrage, man pages regenerees, version 0.4.3
This commit is contained in:
2026-08-17 15:23:28 -04:00
parent 91253b79c5
commit 53893f2967
10 changed files with 166 additions and 25 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
> Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux. > Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux.
> >
> 📦 Version : `0.4.2` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS > 📦 Version : `0.4.3` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
--- ---
@@ -584,7 +584,7 @@ dist/ Binaires précompilés
├── am-linux-x86_64 ├── am-linux-x86_64
├── am-linux-aarch64 ├── am-linux-aarch64
├── am-windows-x86_64.zip ├── am-windows-x86_64.zip
├── am_0.4.2_amd64.deb ├── am_0.4.3_amd64.deb
└── ... └── ...
packaging/ Scripts de packaging packaging/ Scripts de packaging
@@ -685,4 +685,4 @@ flowchart LR
--- ---
*Document généré pour agent-manager v0.4.2* 🚀 *Document généré pour agent-manager v0.4.3* 🚀
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.4.2" version = "0.4.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.4.2" version = "0.4.3"
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"
+5 -5
View File
@@ -62,7 +62,7 @@ doit signaler aucune dépendance manquante.
Une fois toutes les archives dans `dist/` : Une fois toutes les archives dans `dist/` :
scripts/render-manifests.ps1 -Version 0.4.2 scripts/render-manifests.ps1 -Version 0.4.3
Ce script rend, avec la version et les sha256 réels : Ce script rend, avec la version et les sha256 réels :
@@ -73,16 +73,16 @@ Ce script rend, avec la version et les sha256 réels :
- `dist/homebrew/am.rb` → à servir via un tap, ou à soumettre - `dist/homebrew/am.rb` → à servir via un tap, ou à soumettre
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz - `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
dans ~/rpmbuild/SOURCES) dans ~/rpmbuild/SOURCES)
- Paquet deb : `packaging/deb/make-deb.sh 0.4.2` → dist/am_0.4.2_amd64.deb - Paquet deb : `packaging/deb/make-deb.sh 0.4.3` → dist/am_0.4.3_amd64.deb
## 4. Créer la release sur Gitea ## 4. Créer la release sur Gitea
Via l'interface web : Releases > New Release, tag v0.4.2, attacher les Via l'interface web : Releases > New Release, tag v0.4.3, attacher les
archives. Ou via l'API (jeton Gitea requis) : archives. Ou via l'API (jeton Gitea requis) :
curl -X POST -H "Authorization: token <JETON>" \ curl -X POST -H "Authorization: token <JETON>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"tag_name":"v0.4.2","name":"v0.4.2","body":"notes de version"}' \ -d '{"tag_name":"v0.4.3","name":"v0.4.3","body":"notes de version"}' \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
curl -X POST -H "Authorization: token <JETON>" \ curl -X POST -H "Authorization: token <JETON>" \
@@ -95,7 +95,7 @@ archives. Ou via l'API (jeton Gitea requis) :
## 5. Tag git (déclenche aussi le pipeline CI) ## 5. Tag git (déclenche aussi le pipeline CI)
git tag v0.4.2 && git push origin v0.4.2 git tag v0.4.3 && git push origin v0.4.3
## Conventions de nommage (attendues par les installateurs et self-update) ## Conventions de nommage (attendues par les installateurs et self-update)
+2 -2
View File
@@ -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 0.4.2" .TH am 1 "am 0.4.3"
.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
@@ -174,4 +174,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
v0.4.2 v0.4.3
+1 -1
View File
@@ -1,6 +1,6 @@
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the # Renders the packaging templates (scoop, winget, homebrew, rpm) with the
# version and the sha256 of every release archive. # version and the sha256 of every release archive.
# Usage: scripts/render-manifests.ps1 -Version 0.4.2 [-DistDir dist] # Usage: scripts/render-manifests.ps1 -Version 0.4.3 [-DistDir dist]
param( param(
[Parameter(Mandatory = $true)][string]$Version, [Parameter(Mandatory = $true)][string]$Version,
[string]$DistDir = "dist" [string]$DistDir = "dist"
+112 -5
View File
@@ -103,7 +103,28 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
/// configuration file, then validate the result. /// configuration file, then validate the result.
pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> { pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
let path = user_config_path(app)?; let path = user_config_path(app)?;
let text = std::fs::read_to_string(&path) write_dotted_key(&path, key, value)?;
reload_and_validate(&path)?;
app.log.success(&format!("{key} = {value}"));
Ok(0)
}
/// Persist one setting into the configuration file that is actually in
/// effect (the loaded origin), creating the user config file when none
/// exists. Used by the REPL 'theme <name>' command so a switched theme
/// survives a restart.
pub fn persist_setting(app: &App, key: &str, value: &str) -> Result<()> {
let path = match &app.config_origin {
Some(p) => p.clone(),
None => user_config_path(app)?,
};
write_dotted_key(&path, key, value)?;
reload_and_validate(&path)
}
/// Set one dotted key (e.g. settings.default_shell) in a YAML config file.
fn write_dotted_key(path: &Path, key: &str, value: &str) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?; .with_context(|| format!("cannot read {}", path.display()))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&text) let mut root: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("{} is not valid YAML", path.display()))?; .with_context(|| format!("{} is not valid YAML", path.display()))?;
@@ -128,16 +149,18 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result<i32> {
serde_yaml::Value::String(parts.last().unwrap().to_string()), serde_yaml::Value::String(parts.last().unwrap().to_string()),
parsed, parsed,
); );
std::fs::write(&path, serde_yaml::to_string(&root)?) std::fs::write(path, serde_yaml::to_string(&root)?)
.with_context(|| format!("cannot write {}", path.display()))?; .with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
// Validate the new configuration end to end. /// Reload a written config file to make sure the change stays valid.
fn reload_and_validate(path: &Path) -> Result<()> {
let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]); let cli = crate::cli::Cli::parse_from(["am", "--config", path.to_str().unwrap_or_default()]);
config::load(&cli).map_err(|e| { config::load(&cli).map_err(|e| {
anyhow!("the new value makes the configuration invalid: {e:#}") anyhow!("the new value makes the configuration invalid: {e:#}")
})?; })?;
app.log.success(&format!("{key} = {value}")); Ok(())
Ok(0)
} }
/// Parse a scalar: booleans and integers keep their type, everything else /// Parse a scalar: booleans and integers keep their type, everything else
@@ -235,3 +258,87 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
)); ));
Ok(0) Ok(0)
} }
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
/// App whose active config is a file inside a leaked TempDir (the same
/// pattern as profile_cmd's tests: the guard would delete the directory
/// when the helper returns).
fn app_with_config(text: &str) -> (App, PathBuf) {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, text).unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.join("agents");
p.bin_dir = dir.join("agents").join("bin");
p.log_dir = dir.join("logs");
p.state_file = dir.join("state.json");
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
(app, cfg)
}
#[test]
fn persist_setting_writes_into_the_active_config() {
let (app, cfg) = app_with_config("version: \"1.0\"
settings:
theme: default
agents: []
");
persist_setting(&app, "settings.theme", "ocean").unwrap();
let text = std::fs::read_to_string(&cfg).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
assert_eq!(value["settings"]["theme"], "ocean");
}
#[test]
fn persisted_theme_is_used_on_next_start() {
let (app, cfg) = app_with_config("version: \"1.0\"
settings:
theme: default
agents: []
");
persist_setting(&app, "settings.theme", "ocean").unwrap();
// A fresh start reads settings.theme from the config file.
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let reloaded = App::from_cli(cli).unwrap();
assert_eq!(reloaded.theme().name, "ocean");
}
#[test]
fn persist_setting_creates_the_user_config_when_none_exists() {
let (mut app, _cfg) = app_with_config("version: \"1.0\"
agents: []
");
// Simulate a run without any user/local config: no origin.
app.config_origin = None;
persist_setting(&app, "settings.theme", "mono").unwrap();
let user_file = app.paths.config_dir.as_ref().unwrap().join(config::CONFIG_FILE_NAME);
assert!(user_file.is_file(), "user config was not created");
let text = std::fs::read_to_string(&user_file).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap();
assert_eq!(value["settings"]["theme"], "mono");
}
#[test]
fn write_dotted_key_descends_into_nested_mappings() {
let guard = tempfile::tempdir().unwrap();
let path = guard.path().join("c.yaml");
std::fs::write(&path, "version: \"1.0\"
agents: []
").unwrap();
write_dotted_key(&path, "settings.default_shell", "pwsh").unwrap();
let value: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(value["settings"]["default_shell"], "pwsh");
assert!(value["agents"].is_sequence());
}
}
+27
View File
@@ -533,6 +533,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.self_update_base_url.is_some() { if o.self_update_base_url.is_some() {
s.self_update_base_url = o.self_update_base_url; s.self_update_base_url = o.self_update_base_url;
} }
if o.theme.is_some() {
s.theme = o.theme;
}
for (k, v) in o.hooks { for (k, v) in o.hooks {
s.hooks.insert(k, v); s.hooks.insert(k, v);
} }
@@ -764,6 +767,30 @@ agents:
assert!(problems.iter().any(|p| p.contains("'run' is required"))); assert!(problems.iter().any(|p| p.contains("'run' is required")));
} }
#[test]
fn merge_keeps_the_user_theme_setting() {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str(
r#"
version: "1.0"
settings:
theme: ocean
agents: []
"#,
)
.unwrap();
merge(&mut base, overlay);
assert_eq!(base.settings.theme.as_deref(), Some("ocean"));
// The embedded default catalog defines no theme: an overlay without
// one leaves the merged setting untouched.
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str("version: \"1.0\"
agents: []
").unwrap();
merge(&mut base, overlay);
assert_eq!(base.settings.theme, None);
}
#[test] #[test]
fn one_or_many_parses_both() { fn one_or_many_parses_both() {
let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap(); let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap();
+3 -3
View File
@@ -940,7 +940,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
name: "theme", name: "theme",
category: "Shell", category: "Shell",
usage: "theme {flags} [name]", usage: "theme {flags} [name]",
about: "Show the color themes, or switch the active one inside the shell.", about: "Show the color themes, or switch the active one inside the shell (the choice is saved to the config).",
search_terms: &["color", "colors", "palette"], search_terms: &["color", "colors", "palette"],
flags: &[], flags: &[],
subcommands: &[], subcommands: &[],
@@ -950,7 +950,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[
io: None, io: None,
examples: &[ examples: &[
HelpExample { desc: "List the available themes.", code: "theme" }, HelpExample { desc: "List the available themes.", code: "theme" },
HelpExample { desc: "Switch to the ocean theme.", code: "theme ocean" }, HelpExample { desc: "Switch to the ocean theme (kept for the next start).", code: "theme ocean" },
], ],
}, },
HelpSpec { HelpSpec {
@@ -1386,7 +1386,7 @@ pub fn render_general(theme: &Theme, color: bool) -> String {
out.push_str(&format!(" {bullet} help <name> - display help about a particular command or agent\n")); out.push_str(&format!(" {bullet} help <name> - display help about a particular command or agent\n"));
out.push_str(&format!(" {bullet} help --find <text to search> - search through all help commands table\n")); out.push_str(&format!(" {bullet} help --find <text to search> - search through all help commands table\n"));
out.push_str(&format!( out.push_str(&format!(
" {bullet} am --theme <name> - choose a color theme ({})\n", " {bullet} theme <name> - switch the color theme (saved) · am --theme <name> for a one-shot ({})\n",
crate::theme::names().join(", ") crate::theme::names().join(", ")
)); ));
out.push('\n'); out.push('\n');
+11 -4
View File
@@ -706,7 +706,7 @@ pub fn banner_box(
rows.push(inner(dim(&line, theme, color))); rows.push(inner(dim(&line, theme, color)));
} }
rows.push(inner(dim( rows.push(inner(dim(
" 'am --theme <name>' to switch · settings.theme in the config", " 'theme <name>' to switch (saved) · 'am --theme <name>' for a one-shot",
theme, theme,
color, color,
))); )));
@@ -1831,6 +1831,15 @@ fn theme_command(app: &App, arg: Option<&str>) {
Some(name) => match crate::theme::find(name) { Some(name) => match crate::theme::find(name) {
Some(t) => { Some(t) => {
app.set_theme(t); app.set_theme(t);
// Persist the choice (settings.theme in the active config
// file) so it survives an exit and restart.
if let Err(e) =
crate::commands::config_cmd::persist_setting(app, "settings.theme", t.name)
{
app.log.error(format!(
"theme applied for this session, but it could not be saved to the config: {e:#}"
));
}
let color = app.color(); let color = app.color();
println!( println!(
"{} {}", "{} {}",
@@ -1871,9 +1880,7 @@ fn print_theme_status(app: &App) {
println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · ")))); println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · "))));
println!( println!(
" {}", " {}",
theme.dimmed( theme.dimmed("'theme <name>' to switch (saved to the config) · 'am --theme <name>' for a one-shot")
"'theme <name>' to switch · '--theme <name>' or settings.theme to change the default"
)
); );
} }