v0.4.1 : Phase 1 complete (17/17) - dashboard TUI (#33), favoris/notes/tags (#39), profils d'environnement (#40), completions dynamiques + man pages (#41), packaging officiel + CI (#42), self-update Gitea, state v3, 212 tests
release / linux (push) Failing after 23s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s

This commit is contained in:
2026-08-17 10:50:13 -04:00
parent 4f4cd034b2
commit 56732d672b
93 changed files with 9412 additions and 1270 deletions
+120
View File
@@ -0,0 +1,120 @@
//! Favorites, notes and personal tags (issue #39).
mod common;
use agent_manager::commands::annotations_cmd;
use agent_manager::commands::search_cmd;
use agent_manager::events::{self, EventKind};
use agent_manager::state::{Annotation, InstalledEntry};
fn install(app: &agent_manager::app::App, name: &str) {
let entry = InstalledEntry {
name: name.to_string(),
version: Some("1.0.0".to_string()),
method: "npm".to_string(),
install_dir: format!("/tmp/{name}"),
bins: vec![],
run: name.to_string(),
installed_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
}
#[test]
fn favorite_persists_in_state_and_survives_reload() {
let app = common::test_app("fav", &["list"]);
install(&app, "claude-code");
let code = annotations_cmd::run_favorite(&app, "claude-code").unwrap();
assert_eq!(code, 0);
assert!(annotations_cmd::is_favorite(&app, "claude-code"));
// A fresh StateStore over the same file sees the star (restart).
let store = agent_manager::state::StateStore::new(app.state.path().to_path_buf());
let ann = store.annotation("claude-code").unwrap();
assert!(ann.favorite, "favorite must survive a reload");
let code = annotations_cmd::run_unfavorite(&app, "claude-code").unwrap();
assert_eq!(code, 0);
assert!(!annotations_cmd::is_favorite(&app, "claude-code"));
}
#[test]
fn favorite_is_journalized() {
let app = common::test_app("favlog", &["list"]);
install(&app, "claude-code");
annotations_cmd::run_favorite(&app, "claude-code").unwrap();
let evs = events::read_events(&app.events_dir(), 10);
assert!(
evs.iter().any(|e| e.kind == EventKind::Annotate && e.agent.as_deref() == Some("claude-code")),
"an annotate event must be journaled: {evs:?}"
);
}
#[test]
fn note_is_stored_and_shown() {
let app = common::test_app("note", &["list"]);
install(&app, "claude-code");
let code = annotations_cmd::run_note(
&app,
"claude-code",
&["use".to_string(), "the".to_string(), "sonnet".to_string(), "model".to_string()],
)
.unwrap();
assert_eq!(code, 0);
let ann = app.state.annotation("claude-code").unwrap();
assert_eq!(ann.note.as_deref(), Some("use the sonnet model"));
// Showing the note is a no-error operation too.
assert_eq!(annotations_cmd::run_note(&app, "claude-code", &[]).unwrap(), 0);
}
#[test]
fn tags_are_filterable_in_search() {
let app = common::test_app("tag", &["list"]);
install(&app, "claude-code");
install(&app, "gemini-cli");
annotations_cmd::run_tag(&app, "claude-code", &["python".to_string(), "Frontend".to_string()]).unwrap();
annotations_cmd::run_tag(&app, "gemini-cli", &["python".to_string()]).unwrap();
// Personal tags are lowercased and de-duplicated.
let ann: Annotation = app.state.annotation("claude-code").unwrap();
assert_eq!(ann.tags, vec!["frontend", "python"]);
// search --tag python returns both tagged agents.
assert_eq!(
annotations_cmd::agents_with_tag(&app, "python"),
vec!["claude-code".to_string(), "gemini-cli".to_string()]
);
let code = search_cmd::run(&app, Some(""), None, Some("python")).unwrap();
assert_eq!(code, 0);
// A tag matching nothing is reported cleanly.
let code = search_cmd::run(&app, Some(""), None, Some("nope")).unwrap();
assert_eq!(code, 0);
// untag removes one tag; tags command lists the rest.
annotations_cmd::run_untag(&app, "claude-code", "frontend").unwrap();
let ann = app.state.annotation("claude-code").unwrap();
assert_eq!(ann.tags, vec!["python"]);
let code = annotations_cmd::run_tags(&app, None).unwrap();
assert_eq!(code, 0);
let code = annotations_cmd::run_tags(&app, Some("claude-code")).unwrap();
assert_eq!(code, 0);
}
#[test]
fn tag_requires_at_least_one_word() {
let app = common::test_app("tagempty", &["list"]);
install(&app, "claude-code");
let err = annotations_cmd::run_tag(&app, "claude-code", &[]).unwrap_err().to_string();
assert!(err.contains("usage"), "{err}");
}
#[test]
fn unknown_agent_is_rejected() {
let app = common::test_app("favbad", &["list"]);
let err = annotations_cmd::run_favorite(&app, "does-not-exist").unwrap_err().to_string();
assert!(err.contains("unknown agent"), "{err}");
}
+126
View File
@@ -0,0 +1,126 @@
//! Dynamic completions + man pages (issue #41).
mod common;
use agent_manager::cli::{Cli, Command};
use agent_manager::commands::{agents_cmd, completion_cmd, man_cmd};
use agent_manager::state::InstalledEntry;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
fn generate(shell: Shell, installed: bool) -> String {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
clap_complete::generate(shell, &mut cmd, "am", &mut buf);
if installed {
buf.push(b'\n');
}
let base = String::from_utf8(buf).unwrap();
if !installed {
return base;
}
// Reuse the command's own generation path (with the dynamic layer).
// completion_cmd writes to stdout; emulate by calling its logic through
// the shell snippet functions instead.
let _ = base;
match shell {
Shell::Bash => format!("{}
{}", base, completion_cmd::bash_snippet()),
Shell::Zsh => format!("{}
{}", base, completion_cmd::zsh_snippet()),
Shell::Fish => format!("{}
{}", base, completion_cmd::fish_snippet()),
Shell::PowerShell => {
let marker = "Register-ArgumentCompleter -Native -CommandName 'am' -ScriptBlock {";
let mut text = base.replace(marker, "$amBase = {");
text.push('\n');
text.push_str(completion_cmd::powershell_snippet());
text
}
Shell::Elvish => format!("{}
{}", base, completion_cmd::elvish_snippet()),
_ => base,
}
}
#[test]
fn base_script_has_no_dynamic_layer() {
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell, Shell::Elvish] {
let script = generate(shell, false);
assert!(!script.contains("_agents --installed"), "{shell:?}");
assert!(!script.contains("_am_installed"), "{shell:?}");
}
}
#[test]
fn installed_mode_embeds_the_dynamic_layer() {
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell, Shell::Elvish] {
let script = generate(shell, true);
assert!(script.contains("_agents --installed"), "{shell:?}");
assert!(script.contains("favorite"), "{shell:?}");
}
}
#[test]
fn agents_command_lists_installed_only_in_installed_mode() {
let app = common::test_app("agents", &["list"]);
let entry = InstalledEntry {
name: "claude-code".to_string(),
version: Some("1.0.0".to_string()),
method: "npm".to_string(),
install_dir: "/tmp/cc".to_string(),
bins: vec![],
run: "claude".to_string(),
installed_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let names = agents_cmd::agent_names(&app, true);
assert!(names.contains(&"claude-code".to_string()));
assert!(!names.contains(&"ante".to_string()), "an agent that was never installed must not appear: {names:?}");
let all = agents_cmd::agent_names(&app, false);
assert!(all.contains(&"ante".to_string()), "default mode keeps the whole catalog");
assert_eq!(agents_cmd::run(&app, true).unwrap(), 0);
}
#[test]
fn man_pages_are_written_to_output_dir() {
let app = common::test_app("man", &["man"]);
let dir = std::env::temp_dir().join(format!("am-man-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let code = man_cmd::run(&app, None, Some(&dir)).unwrap();
assert_eq!(code, 0);
assert!(dir.join("am.1").exists(), "am.1 missing");
for name in ["start", "sessions", "stats", "log", "history", "dashboard", "favorite", "profile", "man"] {
assert!(
dir.join(format!("am-{name}.1")).exists(),
"am-{name}.1 missing"
);
}
let text = std::fs::read_to_string(dir.join("am.1")).unwrap();
assert!(text.contains(".TH"), "a roff page must start with .TH: {text}");
assert!(text.contains("dashboard"), "new commands must appear in the page");
let code = man_cmd::run(&app, Some("install"), Some(&dir)).unwrap();
assert_eq!(code, 0);
assert!(dir.join("am-install.1").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_man_command_is_rejected() {
let app = common::test_app("manbad", &["man"]);
let err = man_cmd::run(&app, Some("does-not-exist"), None).unwrap_err().to_string();
assert!(err.contains("unknown command"), "{err}");
}
#[test]
fn cli_parses_completion_installed_flag() {
let cli = Cli::parse_from(["am", "completion", "bash", "--installed"]);
match cli.command {
Some(Command::Completion { installed, .. }) => assert!(installed),
other => panic!("unexpected: {other:?}"),
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Dashboard TUI data + non-TUI modes (issue #33).
mod common;
use agent_manager::commands::dashboard_cmd;
use agent_manager::dashboard;
use agent_manager::state::InstalledEntry;
fn install(app: &agent_manager::app::App, name: &str, pid: Option<u32>) {
let entry = InstalledEntry {
name: name.to_string(),
version: Some("1.0.0".to_string()),
method: "npm".to_string(),
install_dir: format!("/tmp/{name}"),
bins: vec![],
run: name.to_string(),
installed_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: None,
pid,
started_at: pid.map(|_| chrono::Utc::now().to_rfc3339()),
};
app.state.set(&entry).unwrap();
}
#[test]
fn dashboard_collects_overview_and_counts() {
let app = common::test_app("dash", &["dashboard"]);
install(&app, "claude-code", Some(4242));
let data = dashboard::collect(&app);
assert_eq!(data.counts.installed, 1);
assert_eq!(data.counts.running, 0, "pid 4242 is not a live process here");
let row = data.overview.iter().find(|r| r.name == "claude-code").unwrap();
assert_eq!(row.status, "installed");
assert_eq!(row.version.as_deref(), Some("1.0.0"));
}
#[test]
fn dashboard_never_blocks_on_an_empty_journal() {
let app = common::test_app("dashempty", &["dashboard"]);
let data = dashboard::collect(&app);
assert!(data.activity.is_empty());
assert!(data.stats.is_empty());
assert!(data.sessions.is_empty());
assert!(data.projects.is_empty());
}
#[test]
fn dashboard_piped_and_json_modes_return_ok() {
let app = common::test_app("dashpipe", &["dashboard"]);
// In tests stdout is not a terminal: the plain snapshot path runs.
assert_eq!(dashboard_cmd::run(&app).unwrap(), 0);
// JSON mode prints the whole snapshot as one document.
let app = common::test_app("dashjson", &["dashboard", "--json"]);
assert_eq!(dashboard_cmd::run(&app).unwrap(), 0);
}
#[test]
fn dashboard_follows_the_journal() {
let app = common::test_app("dashlog", &["dashboard"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
std::fs::create_dir_all(app.events_dir()).unwrap();
std::fs::write(
&path,
concat!(
"{\"ts\":\"2026-08-01T10:00:00Z\",\"kind\":\"run\",\"agent\":\"a\",\"exit_code\":0}\n",
"{\"ts\":\"2026-08-02T10:00:00Z\",\"kind\":\"install\",\"agent\":\"b\"}\n",
),
)
.unwrap();
let data = dashboard::collect(&app);
assert_eq!(data.activity.len(), 2);
assert_eq!(data.activity[0].kind, "install", "newest first");
assert_eq!(data.stats.len(), 2, "journal feeds the stats tab");
}
+118
View File
@@ -0,0 +1,118 @@
//! Environment profiles (issue #40).
mod common;
use agent_manager::cli::{Cli, Command, ProfileCmd, StartArgs};
use agent_manager::commands::{profile_cmd, run_cmd};
use clap::Parser;
fn app_with_profiles(tag: &str) -> agent_manager::app::App {
let dir = std::env::temp_dir().join(format!("am-profiles-{tag}-{}", std::process::id()));
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\nprofiles:\n dev:\n agent: claude-code\n env:\n DEBUG: \"1\"\n LOG_LEVEL: debug\n args: [--model, sonnet]\n prod:\n env:\n LOG_LEVEL: info\nagents:\n - name: claude-code\n run: claude\n install: {type: npm, package: \"@anthropic-ai/claude-code\"}\n",
)
.unwrap();
let cli = Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = agent_manager::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
}
#[test]
fn profile_list_and_show() {
let app = app_with_profiles("list");
assert_eq!(profile_cmd::run_list(&app).unwrap(), 0);
assert_eq!(profile_cmd::run_show(&app, "dev").unwrap(), 0);
let json = app; // keep clippy quiet about unused paths
let _ = json;
}
#[test]
fn unknown_profile_lists_available_ones() {
let app = app_with_profiles("unknown");
let err = profile_cmd::resolve(&app, "staging").unwrap_err().to_string();
assert!(err.contains("unknown profile 'staging'"), "{err}");
assert!(err.contains("dev") && err.contains("prod"), "{err}");
}
#[test]
fn apply_merges_profile_env_and_args() {
let app = app_with_profiles("apply");
let (args, env) = profile_cmd::apply(
&app,
Some("prod"),
&["--base".to_string()],
&std::collections::BTreeMap::from([("KEEP".to_string(), "1".to_string())]),
)
.unwrap();
assert_eq!(args, vec!["--base"]);
assert_eq!(env.get("LOG_LEVEL").map(String::as_str), Some("info"));
assert_eq!(env.get("KEEP").map(String::as_str), Some("1"));
}
#[test]
fn start_with_unknown_profile_fails_cleanly() {
let app = app_with_profiles("badstart");
let opts = StartArgs {
agent: Some("claude-code".to_string()),
profile: Some("staging".to_string()),
..Default::default()
};
let err = run_cmd::start(&app, &opts).unwrap_err().to_string();
assert!(err.contains("unknown profile"), "{err}");
assert!(err.contains("dev"), "{err}");
}
#[test]
fn start_with_profile_dry_run_succeeds() {
let mut app = app_with_profiles("dry");
// Force dry-run so start_one never spawns.
app.cli.dry_run = true;
let opts = StartArgs {
agent: Some("claude-code".to_string()),
profile: Some("dev".to_string()),
..Default::default()
};
assert_eq!(run_cmd::start(&app, &opts).unwrap(), 0);
}
#[test]
fn start_without_agent_uses_profile_default_agent() {
let mut app = app_with_profiles("default");
app.cli.dry_run = true;
let opts = StartArgs {
agent: None,
profile: Some("dev".to_string()),
..Default::default()
};
assert_eq!(run_cmd::start(&app, &opts).unwrap(), 0);
}
#[test]
fn cli_parses_profile_subcommands() {
let cli = Cli::parse_from(["am", "profile", "list"]);
assert!(matches!(
cli.command,
Some(Command::Profile(ProfileCmd::List))
));
let cli = Cli::parse_from(["am", "profile", "show", "dev"]);
assert!(matches!(
cli.command,
Some(Command::Profile(ProfileCmd::Show { ref name })) if name == "dev"
));
let cli = Cli::parse_from(["am", "start", "claude-code", "--profile", "dev"]);
match cli.command {
Some(Command::Start(a)) => assert_eq!(a.profile.as_deref(), Some("dev")),
other => panic!("unexpected: {other:?}"),
}
}