//! 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 = 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 agents_command_filters_aliases_and_groups_by_launchable_target() { let dir = std::env::temp_dir().join(format!("am-test-alias-group-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let cfg = common::write_config( &dir, r#" agents: - name: claude-code display_name: "Claude Code" category: coding-agent install: type: npm package: "@anthropic-ai/claude-code" run: claude - name: github-copilot display_name: "GitHub Copilot" category: coding-agent install: type: npm package: "@github/copilot" run: copilot - name: aider display_name: "Aider" category: coding-agent install: type: uv package: aider-install run: aider aliases: cc: claude-code copilot: github-copilot groups: dev: [claude-code, aider] empty: [github-copilot] "#, ); let cli = Cli::parse_from([ "am", "--config", cfg.to_str().unwrap(), "list", ]); let mut app = agent_manager::app::App::from_cli(cli).expect("app should build"); common::isolate(&mut app, &dir); let entry = InstalledEntry { name: "claude-code".to_string(), version: Some("1.0.0".to_string()), method: "npm".to_string(), install_dir: dir.join("claude-code").display().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()), "installed agent must appear: {names:?}" ); assert!( names.contains(&"cc".to_string()), "alias pointing to an installed agent must appear: {names:?}" ); assert!( names.contains(&"group:dev".to_string()), "group with at least one installed member must appear: {names:?}" ); assert!( !names.contains(&"github-copilot".to_string()), "agent that is not installed must not appear: {names:?}" ); assert!( !names.contains(&"copilot".to_string()), "alias pointing to a non-installed agent must not appear: {names:?}" ); assert!( !names.contains(&"group:empty".to_string()), "group with no installed members must not appear: {names:?}" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn rebuilt_completer_tracks_install_and_uninstall() { // The REPL completer snapshots the agent pool at startup; the line // editor is rebuilt after install/uninstall, and a rebuilt completer // must reflect the state file: a fresh install shows up under Tab and // an uninstall disappears — without restarting am. let app = common::test_app("refresh", &["list"]); let before = agent_manager::repl::AmCompleter::new(&app); let (_, cands_before) = before.candidates_for("start ", 6); assert!( !cands_before.contains(&"pi".to_string()), "pi must not be proposed before its install: {cands_before:?}" ); app.state .set(&InstalledEntry { name: "pi".to_string(), version: Some("0.84.2".to_string()), method: "binary".to_string(), install_dir: "/tmp/pi".to_string(), bins: vec![], run: "pi".to_string(), installed_at: "2026-08-17T00:00:00Z".to_string(), updated_at: None, pid: None, started_at: None, }) .unwrap(); let after_install = agent_manager::repl::AmCompleter::new(&app); let (_, cands_installed) = after_install.candidates_for("start ", 6); assert!( cands_installed.contains(&"pi".to_string()), "a rebuilt completer must propose a freshly installed agent: {cands_installed:?}" ); app.state.remove("pi").unwrap(); let after_uninstall = agent_manager::repl::AmCompleter::new(&app); let (_, cands_removed) = after_uninstall.candidates_for("start ", 6); assert!( !cands_removed.contains(&"pi".to_string()), "a rebuilt completer must drop an uninstalled agent: {cands_removed:?}" ); } #[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", "tip"] { 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 man_page_renders_to_readable_text() { // The roff source of the real CLI page must render to readable text: // macros and font escapes gone, sections and option terms kept. let cmd = Cli::command(); let man = clap_mangen::Man::new(cmd); let mut buf: Vec = Vec::new(); man.render(&mut buf).unwrap(); let roff = String::from_utf8_lossy(&buf); let text = man_cmd::roff_to_text(&roff, agent_manager::theme::default_theme(), false); assert!(text.contains("▸ NAME"), "NAME section missing:\n{text}"); assert!(text.contains("▸ SYNOPSIS"), "SYNOPSIS section missing"); assert!(text.contains("▸ OPTIONS"), "OPTIONS section missing"); assert!(text.contains("▸ SUBCOMMANDS"), "SUBCOMMANDS section missing"); assert!(!text.contains(".SH"), "macros must be removed: {text}"); assert!(!text.contains("\\fB"), "font escapes must be converted: {text}"); // Option terms appear before their descriptions (term/body order). let pos_config = text.find("--config ").unwrap_or(0); let pos_desc = text.find("Use an alternate configuration file").unwrap_or(0); assert!(pos_config < pos_desc, "term must precede its body:\n{text}"); } #[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:?}"), } }