281 lines
9.9 KiB
Rust
281 lines
9.9 KiB
Rust
//! 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 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<u8> = 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 <FILE>").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:?}"),
|
|
}
|
|
}
|