From 53893f2967f0fe26b0686435928b12f15def04f7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 15:23:28 -0400 Subject: [PATCH] v0.4.3 : persistance du theme actif choisi - le REPL 'theme ' 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 ' reste un one-shot ; config set settings.theme fonctionne egalement - aides mises a jour (banniere, help theme) - tests de persistance + relecture au demarrage, man pages regenerees, version 0.4.3 --- ARCHITECTURE.md | 6 +- Cargo.lock | 2 +- Cargo.toml | 2 +- RELEASING.md | 10 +-- man/am.1 | 4 +- scripts/render-manifests.ps1 | 2 +- src/commands/config_cmd.rs | 117 +++++++++++++++++++++++++++++++++-- src/config.rs | 27 ++++++++ src/help.rs | 6 +- src/repl.rs | 15 +++-- 10 files changed, 166 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce63d2e..2d08aee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. > -> 📦 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-aarch64 ├── am-windows-x86_64.zip -├── am_0.4.2_amd64.deb +├── am_0.4.3_amd64.deb └── ... 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* 🚀 diff --git a/Cargo.lock b/Cargo.lock index 9ccfe6b..84a4111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.4.2" +version = "0.4.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b58dfc4..cb33122 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.4.2" +version = "0.4.3" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/RELEASING.md b/RELEASING.md index ba7bd8b..db91c05 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -62,7 +62,7 @@ doit signaler aucune dépendance manquante. 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 : @@ -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/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz 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 -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) : curl -X POST -H "Authorization: token " \ -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 curl -X POST -H "Authorization: token " \ @@ -95,7 +95,7 @@ archives. Ou via l'API (jeton Gitea requis) : ## 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) diff --git a/man/am.1 b/man/am.1 index 6c9c665..14af47e 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 0.4.2" +.TH am 1 "am 0.4.3" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -174,4 +174,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v0.4.2 +v0.4.3 diff --git a/scripts/render-manifests.ps1 b/scripts/render-manifests.ps1 index 6c3bc9f..a90f92e 100644 --- a/scripts/render-manifests.ps1 +++ b/scripts/render-manifests.ps1 @@ -1,6 +1,6 @@ # Renders the packaging templates (scoop, winget, homebrew, rpm) with the # 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( [Parameter(Mandatory = $true)][string]$Version, [string]$DistDir = "dist" diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index 8ccbe00..e576396 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -103,7 +103,28 @@ pub fn run(app: &App, cmd: &ConfigCmd) -> Result { /// configuration file, then validate the result. pub fn set_value(app: &App, key: &str, value: &str) -> Result { 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 ' 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()))?; let mut root: serde_yaml::Value = serde_yaml::from_str(&text) .with_context(|| format!("{} is not valid YAML", path.display()))?; @@ -128,16 +149,18 @@ pub fn set_value(app: &App, key: &str, value: &str) -> Result { serde_yaml::Value::String(parts.last().unwrap().to_string()), 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()))?; + 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()]); config::load(&cli).map_err(|e| { anyhow!("the new value makes the configuration invalid: {e:#}") })?; - app.log.success(&format!("{key} = {value}")); - Ok(0) + Ok(()) } /// Parse a scalar: booleans and integers keep their type, everything else @@ -235,3 +258,87 @@ fn add_file(app: &App, file: &Path) -> Result { )); 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()); + } +} diff --git a/src/config.rs b/src/config.rs index f4e0a2c..45ad810 100644 --- a/src/config.rs +++ b/src/config.rs @@ -533,6 +533,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.self_update_base_url.is_some() { 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 { s.hooks.insert(k, v); } @@ -764,6 +767,30 @@ agents: 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] fn one_or_many_parses_both() { let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap(); diff --git a/src/help.rs b/src/help.rs index a80f382..8b075ab 100644 --- a/src/help.rs +++ b/src/help.rs @@ -940,7 +940,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "theme", category: "Shell", 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"], flags: &[], subcommands: &[], @@ -950,7 +950,7 @@ pub static HELP_SPECS: &[HelpSpec] = &[ io: None, examples: &[ 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 { @@ -1386,7 +1386,7 @@ pub fn render_general(theme: &Theme, color: bool) -> String { out.push_str(&format!(" {bullet} help - display help about a particular command or agent\n")); out.push_str(&format!(" {bullet} help --find - search through all help commands table\n")); out.push_str(&format!( - " {bullet} am --theme - choose a color theme ({})\n", + " {bullet} theme - switch the color theme (saved) · am --theme for a one-shot ({})\n", crate::theme::names().join(", ") )); out.push('\n'); diff --git a/src/repl.rs b/src/repl.rs index 5b8e73e..4af6b04 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -706,7 +706,7 @@ pub fn banner_box( rows.push(inner(dim(&line, theme, color))); } rows.push(inner(dim( - " 'am --theme ' to switch · settings.theme in the config", + " 'theme ' to switch (saved) · 'am --theme ' for a one-shot", theme, color, ))); @@ -1831,6 +1831,15 @@ fn theme_command(app: &App, arg: Option<&str>) { Some(name) => match crate::theme::find(name) { Some(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(); println!( "{} {}", @@ -1871,9 +1880,7 @@ fn print_theme_status(app: &App) { println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · ")))); println!( " {}", - theme.dimmed( - "'theme ' to switch · '--theme ' or settings.theme to change the default" - ) + theme.dimmed("'theme ' to switch (saved to the config) · 'am --theme ' for a one-shot") ); }