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
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user