From 8ee3ddf3dbd032034ab996a16cbee5fe291f498f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 15 Aug 2026 11:26:34 -0400 Subject: [PATCH] agent-manager v0.2.0 : gestionnaire d'agents IA locaux - CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1 --- .gitignore | 2 + Cargo.lock | 1689 ++++++++++++++++++++++++++ Cargo.toml | 40 + EXAMPLES.md | 287 +++++ LICENSE | 21 + README.md | 433 +++++++ RELEASING.md | 53 + completions/_am | 970 +++++++++++++++ completions/_am.ps1 | 573 +++++++++ completions/am.bash | 1175 ++++++++++++++++++ completions/am.elv | 514 ++++++++ completions/am.fish | 290 +++++ config.yaml | 980 +++++++++++++++ examples/raw/config-path.txt | 4 + examples/raw/config-validate.txt | 1 + examples/raw/doctor.txt | 22 + examples/raw/help.txt | 56 + examples/raw/info-cc.txt | 12 + examples/raw/info-claude-code.txt | 12 + examples/raw/install-devin.txt | 3 + examples/raw/install-dryrun.txt | 4 + examples/raw/list-all.txt | 74 ++ examples/raw/list-json.txt | 1082 +++++++++++++++++ examples/raw/list.txt | 13 + examples/raw/repl-piped.txt | 36 + examples/raw/run-pi-dryrun.txt | 1 + examples/raw/search-empty.txt | 3 + examples/raw/search-rust.txt | 11 + examples/raw/start-group-dryrun.txt | 3 + examples/raw/status-all.txt | 13 + examples/raw/status-claude-code.txt | 6 + examples/raw/stop-notinstalled.txt | 1 + examples/raw/update-notinstalled.txt | 3 + examples/raw/version.txt | 1 + install.ps1 | 81 ++ install.sh | 83 ++ src/app.rs | 162 +++ src/catalog.rs | 159 +++ src/cli.rs | 217 ++++ src/commands/completion_cmd.rs | 13 + src/commands/config_cmd.rs | 182 +++ src/commands/doctor_cmd.rs | 43 + src/commands/export_import.rs | 135 ++ src/commands/info_cmd.rs | 197 +++ src/commands/install_cmd.rs | 152 +++ src/commands/list_cmd.rs | 151 +++ src/commands/mod.rs | 213 ++++ src/commands/run_cmd.rs | 187 +++ src/commands/search_cmd.rs | 62 + src/commands/self_update.rs | 78 ++ src/commands/status_cmd.rs | 179 +++ src/commands/uninstall_cmd.rs | 113 ++ src/commands/update_cmd.rs | 104 ++ src/config.rs | 739 +++++++++++ src/deps.rs | 208 ++++ src/doctor.rs | 229 ++++ src/download.rs | 470 +++++++ src/installers/binary.rs | 103 ++ src/installers/cargo.rs | 76 ++ src/installers/git.rs | 159 +++ src/installers/golang.rs | 32 + src/installers/mod.rs | 296 +++++ src/installers/npm.rs | 115 ++ src/installers/pipuv.rs | 130 ++ src/installers/script.rs | 68 ++ src/lib.rs | 62 + src/main.rs | 8 + src/output.rs | 273 +++++ src/probe.rs | 325 +++++ src/process.rs | 221 ++++ src/repl.rs | 436 +++++++ src/runner.rs | 186 +++ src/state.rs | 178 +++ src/version.rs | 158 +++ tests/common/mod.rs | 71 ++ tests/config_test.rs | 91 ++ tests/doctor_test.rs | 37 + tests/dry_run_test.rs | 88 ++ tests/process_test.rs | 66 + 79 files changed, 15724 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 EXAMPLES.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 RELEASING.md create mode 100644 completions/_am create mode 100644 completions/_am.ps1 create mode 100644 completions/am.bash create mode 100644 completions/am.elv create mode 100644 completions/am.fish create mode 100644 config.yaml create mode 100644 examples/raw/config-path.txt create mode 100644 examples/raw/config-validate.txt create mode 100644 examples/raw/doctor.txt create mode 100644 examples/raw/help.txt create mode 100644 examples/raw/info-cc.txt create mode 100644 examples/raw/info-claude-code.txt create mode 100644 examples/raw/install-devin.txt create mode 100644 examples/raw/install-dryrun.txt create mode 100644 examples/raw/list-all.txt create mode 100644 examples/raw/list-json.txt create mode 100644 examples/raw/list.txt create mode 100644 examples/raw/repl-piped.txt create mode 100644 examples/raw/run-pi-dryrun.txt create mode 100644 examples/raw/search-empty.txt create mode 100644 examples/raw/search-rust.txt create mode 100644 examples/raw/start-group-dryrun.txt create mode 100644 examples/raw/status-all.txt create mode 100644 examples/raw/status-claude-code.txt create mode 100644 examples/raw/stop-notinstalled.txt create mode 100644 examples/raw/update-notinstalled.txt create mode 100644 examples/raw/version.txt create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 src/app.rs create mode 100644 src/catalog.rs create mode 100644 src/cli.rs create mode 100644 src/commands/completion_cmd.rs create mode 100644 src/commands/config_cmd.rs create mode 100644 src/commands/doctor_cmd.rs create mode 100644 src/commands/export_import.rs create mode 100644 src/commands/info_cmd.rs create mode 100644 src/commands/install_cmd.rs create mode 100644 src/commands/list_cmd.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/run_cmd.rs create mode 100644 src/commands/search_cmd.rs create mode 100644 src/commands/self_update.rs create mode 100644 src/commands/status_cmd.rs create mode 100644 src/commands/uninstall_cmd.rs create mode 100644 src/commands/update_cmd.rs create mode 100644 src/config.rs create mode 100644 src/deps.rs create mode 100644 src/doctor.rs create mode 100644 src/download.rs create mode 100644 src/installers/binary.rs create mode 100644 src/installers/cargo.rs create mode 100644 src/installers/git.rs create mode 100644 src/installers/golang.rs create mode 100644 src/installers/mod.rs create mode 100644 src/installers/npm.rs create mode 100644 src/installers/pipuv.rs create mode 100644 src/installers/script.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/output.rs create mode 100644 src/probe.rs create mode 100644 src/process.rs create mode 100644 src/repl.rs create mode 100644 src/runner.rs create mode 100644 src/state.rs create mode 100644 src/version.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/config_test.rs create mode 100644 tests/doctor_test.rs create mode 100644 tests/dry_run_test.rs create mode 100644 tests/process_test.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a4595e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..26e54d7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1689 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "agent-manager" +version = "0.2.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "clap_complete", + "colored", + "flate2", + "rustyline", + "semver", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "shell-words", + "tar", + "tempfile", + "ureq", + "wait-timeout", + "which", + "zip", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "libc", + "log", + "memchr", + "nix", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6cd41e8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "agent-manager" +version = "0.2.0" +edition = "2021" +description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." +license = "MIT" +default-run = "am" + +[[bin]] +name = "am" +path = "src/main.rs" + +[lib] +name = "agent_manager" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive", "wrap_help"] } +clap_complete = "4" +colored = "2" +flate2 = "1" +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +rustyline = { version = "14", default-features = false, features = ["with-file-history"] } +sha2 = "0.10" +shell-words = "1" +tar = "0.4" +tempfile = "3" +ureq = { version = "2", default-features = false, features = ["tls"] } +wait-timeout = "0.2" +which = "7" +zip = "0.6" + +[profile.release] +lto = "thin" +strip = true diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..4c9e813 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,287 @@ +# Exemples d'utilisation + +Sorties réelles capturées sur Windows 11 (binaire debug). Les couleurs ANSI +sont désactivées automatiquement quand la sortie est redirigée ; les +exécutables présents sur cette machine sont détectés comme agents « externes » +(version détectée via --version, sondée en parallèle et mise en cache). + +## Version et aide + + agent-manager (am) — a CLI to manage local AI coding agents. + + Install, uninstall, start, stop, restart and update AI agents from a YAML + catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from + GitHub Releases, install scripts, and git repositories. All state lives under + your user directories; nothing pollutes the system. + + Usage: am [OPTIONS] [COMMAND] + + Commands: + list List installed agents (default); use --all for the whole catalog + start Start an agent (foreground by default, or detached with --background) + stop Stop a background agent (SIGTERM, then SIGKILL after the timeout) + restart Restart an agent: stop, then start with the same options + status Show the state of one agent, or of every installed agent + install Install an agent and its dependencies + uninstall Uninstall an agent and clean its files + update Update an installed agent to the latest available version + search Search the catalog by keyword (name, description, category, tags) + info Show detailed information about one agent + config Manage the configuration file + doctor Check the environment: tools, config validity, paths, permissions + run Run the agent command directly with the given arguments (no process management) + completion Generate a shell completion script + self-update Update agent-manager itself from GitHub Releases + export Export the configuration and installation state (backup) + + +## Lister les agents + +Par défaut, list n'affiche que les agents installés : ceux gérés par +agent-manager et ceux détectés sur le PATH (externes). + + NAME VERSION STATUS SOURCE PATH + ------------------------------------------------ + Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi… + Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe + Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen… + jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe + Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe + LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA… + NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr… + + +Avec --all, le catalogue complet apparaît avec l'état de chacun. + + NAME VERSION STATUS SOURCE PATH + ------------------------------------------------------------- + agentty — not-installed - — + Aider — not-installed - — + Amazon Q Dev CLI — not-installed - — + Amp — not-installable - — + Ante — not-installed - — + Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi… + cc-haha — not-installable - — + Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe + Claurst — not-installed - — + Claw Code — not-installed - — + + +## Rechercher + + NAME CATEGORY INSTALLABLE DESCRIPTION + ------------------------------------------------- + Claw Code coding-agent yes Réécriture clean-room Rust/Python de l'architec… + jcode coding-agent yes Agent TUI Rust optimisé RAM et latence de démar… + Zap coding-agent yes Agent TUI Rust skill-first, injecte seulement l… + Smelt coding-agent yes Agent TUI Rust multi-provider, 4 modes (Normal/… + Claurst coding-agent yes Claude Code réécrit en Rust idiomatique, avec d… + ZeroClaw assistant yes Runtime d'agent AI Rust autonome, architecture … + IronClaw assistant yes OpenClaw réécrit en Rust par NEAR AI. Sandbox W… + Moltis assistant yes Alternative Rust-native à OpenClaw, zéro code u… + Ante proprietary yes Binaire Rust 15 MB (research preview), architec… + + + NAME CATEGORY INSTALLABLE DESCRIPTION + ---------------------------------------- + no agent matches 'zzzz' (categories: assistant, coding-agent, desktop, local-first, proprietary, saas) + + +## Fiche détaillée + + Claude Code + slug: claude-code + description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent. + category: coding-agent + website: https://github.com/anthropics/claude-code + tags: anthropic, assistant, source-available, mainstream + status: found on PATH (external — not managed) + run: claude + install methods: + 1) type=npm package=@anthropic-ai/claude-code + dependencies: + - node >= 18.0.0 — https://nodejs.org + + +## État d'un agent / de tous les agents installés + + Claude Code (claude-code) + status: external + version: 2.1.112 + method: — + path: C:\Users\bruno\.local\bin\claude.exe + run: claude + + + NAME VERSION STATUS PID PATH + ------------------------------------------- + Atomic Agent — external — C:\Users\bruno\AppData\Local\atomic-agent\atomi… + Claude Code 2.1.112 external — C:\Users\bruno\.local\bin\claude.exe + Hermes Agent 2026.8.3 external — C:\Users\bruno\AppData\Local\hermes\hermes-agen… + jcode 0.76.0 external — C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe + Kimi CLI 0.31.1 external — C:\Users\bruno\.kimi-code\bin\kimi.exe + LazyCodex 11.1.0 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + + +## Simulation d'installation (--dry-run --verbose) + +Aucune écriture : les commandes qui seraient exécutées sont affichées +(dépendance cargo vérifiée, puis cargo install dans le répertoire local). + + [dry-run] would run: cargo --version + installing Smelt (type=cargo package=smelt)… + [dry-run] would run: cargo install --root C:\Users\bruno\AppData\Local\agent-manager\agents\smelt smelt + dry run complete — nothing was changed + + +Installer un agent non installable (SaaS) produit une erreur claire : + + error: agent 'devin' is not installable locally: SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API) + + [exit code: 1] + + +## Exécution et groupes + + [dry-run] would run C:\Users\bruno\scoop\apps\nodejs22\current\bin\pi.cmd + + + [dry-run] would start C:\Users\bruno\.local\bin\claude.exe (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\claude-code.log) + [dry-run] would start aider (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\aider.log) + [dry-run] would start codex (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\codex.log) + + +## Arrêt et mise à jour d'un agent non géré + + agent 'claude-code' is not managed by agent-manager; nothing to stop + + + error: agent 'claude-code' is not installed by agent-manager — use 'am install claude-code' + + [exit code: 1] + + +## Sortie JSON (pour les scripts) + + [ + { + "name": "agentty", + "display_name": "agentty", + "category": "coding-agent", + "description": "Agent terminal C++26, binaire statique 13.6 MB, cold start sub-milliseconde, zéro dépendance runtime, sandbox par défaut.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "aider", + + +## Configuration + + no user config found — using the embedded default catalog + user config directory: C:\Users\bruno\AppData\Roaming\agent-manager + user config file: C:\Users\bruno\AppData\Roaming\agent-manager\config.yaml + local config file: agent-manager.yaml (current directory) + + + configuration is valid (72 agents) + + +## Doctor + + CHECK STATUS DETAIL + ----------------------------- + git ok C:\Users\bruno\scoop\apps\git\current\cmd\git.e… + sh ok C:\Users\bruno\scoop\shims\sh.exe (v5.3.15) + curl ok C:\Windows\system32\curl.exe (v8.21.0) + node ok C:\Users\bruno\scoop\apps\nodejs22\current\node… + npm ok C:\Users\bruno\scoop\apps\nodejs22\current\bin\… + python3 ok C:\Users\bruno\scoop\shims\python3.exe (v3.13.1… + python ok C:\Users\bruno\AppData\Local\Microsoft\WindowsA… + cargo ok C:\Users\bruno\scoop\apps\rustup\current\.cargo… + go ok C:\Users\bruno\scoop\shims\go.exe (v1.26.5) + bun ok C:\Users\bruno\scoop\shims\bun.exe (v1.3.14) + uv ok C:\Users\bruno\scoop\shims\uv.exe (v0.12.2) + docker ok C:\Program Files\Docker\Docker\resources\bin\do… + config warn no user config file found; using the embedded d… + config-schema ok 72 agents defined + state ok 0 installed agent(s) + install-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen… + bin-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen… + log-dir ok C:\Users\bruno\AppData\Local\agent-manager\logs + bin-on-path warn C:\Users\bruno\AppData\Local\agent-manager\agen… + 4 problem(s) found — 'am doctor --fix' repairs what it can + + +## Shell interactif + +am lancé sans arguments ouvre un shell interactif avec complétion par la +touche Tab (commandes, noms d'agents, alias, group:xxx, options) et +historique persistant (fichier history.txt dans le répertoire d'état) : + + $ am + agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit' + am> inst # complète en "install" + am> install cla # propose claude-code, claurst, ... + am> start group: # propose group:dev, group:local, group:claw, ... + am> exit + +## Installation réelle de bout en bout (npm) + +Session complète avec un agent de démonstration défini dans un fichier de +config temporaire (paquet npm cowsay) : installation, statut, exécution, +erreur propre au démarrage en arrière-plan, mise à jour, désinstallation +avec purge. + + $ am install cowsay --yes + installing Cowsay (type=npm package=cowsay)… + installed Cowsay (v1.6.0) in .../agents/cowsay + start it with: am start cowsay + + $ am status cowsay + Cowsay (cowsay) + status: installed + version: 1.6.0 + method: npm + path: .../agents/cowsay/cowsay.cmd + run: cowsay + installed: 2026-08-15T14:39:26+00:00 + + $ am run cowsay bonjour + ________________ + < cowsay bonjour > + ---------------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || + + $ am start cowsay --background + error: agent 'cowsay' exited immediately after start (status exit code: 0); log: .../cowsay.log + ________ + < cowsay > + -------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || + + $ am update cowsay + Cowsay is up to date (v1.6.0) + + $ am uninstall cowsay --purge --yes + removed 'cowsay' from .../cowsay.yaml (backup: .../cowsay.yaml.bak) + uninstalled Cowsay (files removed, config purged) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..620c366 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 agent-manager contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..78f794e --- /dev/null +++ b/README.md @@ -0,0 +1,433 @@ +# agent-manager (am) + +Gestionnaire en ligne de commande, écrit en **Rust**, pour les **agents IA de +coding installés localement** : lister, installer, démarrer, arrêter, +redémarrer, mettre à jour et désinstaller — avec gestion automatique des +dépendances (Node.js, Python, Go, Rust, Bun, etc.) et un catalogue YAML de +~70 agents prêts à l'emploi. + +--- + +## Sommaire + +1. [Fonctionnalités](#fonctionnalités) +2. [Installation](#installation) +3. [Démarrage rapide](#démarrage-rapide) +4. [Commandes](#commandes) +5. [Options globales](#options-globales) +6. [Fichier de configuration](#fichier-de-configuration) +7. [Ajouter un agent](#ajouter-un-agent) +8. [Alias, groupes et mode interactif](#alias-groupes-et-mode-interactif) +9. [Gestion des processus](#gestion-des-processus) +10. [Sécurité](#sécurité) +11. [Tests](#tests) +12. [Complétions shell](#complétions-shell) +13. [Structure du projet](#structure-du-projet) +14. [Limites et feuille de route](#limites-et-feuille-de-route) + +--- + +## Fonctionnalités + +- **Catalogue intégré** : ~70 agents connus (Claude Code, Codex, Gemini CLI, + OpenCode, Aider, jcode, Prime Agent, l'écosystème OpenClaw, les SaaS, etc.), + chacun avec sa méthode d'installation et ses dépendances. +- **9 méthodes d'installation** : + + | type | description | + |------|-------------| + | npm / bun | paquet global installé dans un préfixe local (jamais en -g système) | + | pip / uv | virtualenv privé par agent, sans toucher au site-packages système | + | cargo | cargo install --root | + | go | go install avec GOBIN local | + | curl | script d'installation téléchargé puis exécuté séparément (jamais de curl pipé vers sh) | + | binary| binaire précompilé depuis GitHub Releases ou URL directe, checksum vérifié, extraction zip/tar.gz | + | git | clone + build (make, cargo build --release, npm install…) puis copie du binaire ou shim | + +- **Gestion des processus** : avant-plan (stdio hérités) ou arrière-plan + (détaché, PID enregistré, sortie vers un log) ; arrêt propre SIGTERM puis + SIGKILL après un délai configurable. +- **Base d'état locale** (state.json) : nom, version, chemin, binaires, PID, + dates — plus la **détection d'agents externes** via le PATH. Les sondes de + version sont exécutées **en parallèle** (timeout 4 s) et **mises en cache** + (index du PATH et versions) : un am list à chaud prend ~50 ms. +- **Dépendances** : vérification avant installation (node >= 18, etc.) avec + messages actionnables ; installation automatique possible quand une commande + d'installation est connue. +- **Sécurité** : mode --dry-run universel, confirmations (--yes pour les + sauter), validation des URL (http/https/file), vérification sha256, + journalisation horodatée de toutes les commandes exécutées. +- **Confort développeur** : sortie JSON stable (--json), couleurs + (--no-color), mode verbeux, complétions bash/zsh/fish/powershell/elvish, + alias, groupes (am start group:dev), mode interactif, export/import, + doctor et self-update. + +--- + +## Installation + +### En une ligne (recommandé) + +**Windows** (PowerShell) : + + powershell -ExecutionPolicy Bypass -Command "irm https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.ps1 | iex" + +**Linux / macOS / WSL** : + + curl -fsSL https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.sh | sh + +Le script télécharge le binaire précompilé de la **dernière release** du dépôt +Gitea, l'installe dans un répertoire utilisateur et l'ajoute au PATH. Si aucun +binaire ne correspond à votre plateforme, il compile automatiquement depuis +les sources (cargo install --git). **Relancez la même commande pour mettre à +jour.** Les scripts sont versionnés dans le dépôt (install.ps1, install.sh) — +vous pouvez les lire avant de les exécuter. + +### Depuis les sources + +Prérequis : Rust 1.70+ (le programme lui-même n'a **aucune** dépendance +runtime ; les outils comme node/python ne sont nécessaires que pour les agents +qui les utilisent). + + # 1. cloner et compiler (binaire dans target/release/am) + git clone https://git.dracodev.net/Projets/agent-manager.git + cd agent-manager + cargo build --release + + # 2. installer dans ~/.cargo/bin (sur le PATH) + cargo install --path . + + # 3. vérifier + am --version + am doctor + +> Le binaire s'appelle **am** (agent-manager est le nom du paquet). + +### Ajouter les binaires des agents au PATH + +Les agents installés par am vivent dans +~/.local/share/agent-manager/agents/bin (Linux), +%LOCALAPPDATA%\agent-manager\agents\bin (Windows) ou +~/Library/Application Support/agent-manager/agents/bin (macOS). +Ajoutez ce répertoire au PATH pour pouvoir lancer les agents directement : + + # Linux/macOS — dans ~/.bashrc ou ~/.zshrc + export PATH="$HOME/.local/share/agent-manager/agents/bin:$PATH" + +am doctor vous le rappelle si le répertoire n'est pas sur le PATH. + +--- + +## Démarrage rapide + + am list # agents installés (gérés + externes) + am list --all # tout le catalogue, avec l'état de chacun + am search rust # recherche par mot-clé + am info jcode # fiche détaillée + + am install claude-code # npm + vérification de node >= 18 + am install aider --method pip # choisir la méthode 2 (pip) au lieu de uv + am install smelt --dry-run # simuler sans rien changer + + am start claude-code # avant-plan (Ctrl-C pour quitter) + am start claude-code --background --env ANTHROPIC_MODEL=opus + am status claude-code + am stop claude-code # SIGTERM puis SIGKILL après 5 s + am restart codex --background + am update claude-code + am uninstall smelt --purge + + am run pi "explique ce fichier" # exécution directe, sans gestion de process + am doctor --fix # vérifie l'environnement et répare + +--- + +## Commandes + +| Commande | Description | +|----------|-------------| +| am list [--all] [--category ] | agents installés (défaut) ; --all pour tout le catalogue | +| am start [--background] [--args ...] [--env K=V] [--notify] | démarre l'agent (avant-plan par défaut) | +| am stop [--force] [--timeout ] | arrête un agent d'arrière-plan (PID enregistré) | +| am restart [--background] [--force] | arrête puis redémarre | +| am status [agent] | état d'un agent, ou de tous les agents installés | +| am install [--method ] [--force] | installe l'agent et ses dépendances | +| am uninstall [--purge] | désinstalle et nettoie (--purge : logs + entrée de config) | +| am update [--all] | met à jour vers la dernière version connue | +| am search [--category ] | recherche dans le catalogue | +| am info | description, méthodes d'installation, commande, dépendances, site | +| am config show / path / edit / validate / add | gestion de la configuration | +| am doctor [--fix] | outils présents, validité de la config, chemins, permissions | +| am run [args...] | exécute la commande de l'agent directement | +| am completion | script de complétion (bash, zsh, fish, powershell, elvish) | +| am self-update [--check] | met à jour agent-manager depuis GitHub Releases | +| am export / am import | sauvegarde/restauration de la config + état | +| am (sans arguments) | shell interactif | + +**Statuts affichés par list/status** : running (PID actif), installed (géré +par am), external (trouvé sur le PATH, non géré), not-installed, +not-installable (SaaS/desktop). + +--- + +## Options globales + +Toutes les options globales fonctionnent avant **ou après** la sous-commande. + +| Option | Description | +|--------|-------------| +| -c, --config | fichier de configuration alternatif | +| -v, --verbose | affiche chaque commande exécutée et les détails | +| -q, --quiet | uniquement les erreurs | +| -y, --yes | répond oui à toutes les confirmations | +| --dry-run | simule l'action (aucune écriture, aucun téléchargement) | +| --json | sortie JSON stable sur stdout (les messages vont sur stderr) | +| --no-color | désactive les couleurs ANSI | +| -h, --help / -V, --version | aide / version | + +--- + +## Fichier de configuration + +### Emplacements (par ordre de priorité) + +1. --config (ou la variable d'environnement AGENT_MANAGER_CONFIG) +2. ./agent-manager.yaml (répertoire courant) +3. ~/.config/agent-manager/config.yaml + (Windows : %APPDATA%\agent-manager\config.yaml) +4. catalogue par défaut embarqué dans le binaire + +Le fichier trouvé **surcharge/complète** le catalogue embarqué : un agent +défini par l'utilisateur remplace celui du même nom ; les autres s'ajoutent. + +### Schéma (extrait) + + version: "1.0" + + settings: + install_dir: null # défaut: ~/.local/share/agent-manager/agents + log_dir: null # défaut: ~/.local/state/agent-manager/logs + default_shell: null # shell pour les scripts d'installation + auto_install_deps: true # proposer d'installer les dépendances manquantes + confirm_before_run: true # confirmer avant d'exécuter un script d'installation + stop_timeout_secs: 5 # délai SIGTERM -> SIGKILL + self_update_repo: null # "owner/repo" pour am self-update + + aliases: + cc: claude-code # am start cc == am start claude-code + + groups: + dev: [claude-code, aider, codex] + + include: # modularité : autres fichiers (chemins relatifs) + - agents-perso.yaml + + agents: + - name: claude-code + display_name: "Claude Code" + description: "..." + category: coding-agent + website: https://github.com/anthropics/claude-code + install: + type: npm + package: "@anthropic-ai/claude-code" + # args: ["--omit=optional"] # arguments supplémentaires + # env: { FOO: bar } # environnement de l'installeur + # methods: [...] # méthodes alternatives (--method) + # post_install: ["claude setup"] # commandes après installation + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: claude + args: [] # arguments par défaut + env: {} # variables d'environnement par défaut + tags: [anthropic] + installable: true # false = listé mais non installable (SaaS/desktop) + note: null # explication affichée avec installable: false + platforms: [] # linux | macos | windows (vide = tous) + +Les chemins acceptent ~ et les variables d'environnement ($HOME, ${VAR}). +La configuration est **validée au démarrage** avec des messages précis +(agent 'x': 'run' is required when installable, champs inconnus rejetés, +doublons détectés). + +--- + +## Ajouter un agent + +Créez ~/.config/agent-manager/config.yaml (am config edit) ou un fichier +inclus (am config add mes-agents.yaml) puis : + + version: "1.0" + agents: + # paquet npm + - name: mon-agent-npm + display_name: "Mon Agent" + install: { type: npm, package: mon-agent } + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: mon-agent + + # paquet pip (virtualenv privé) + - name: mon-agent-python + display_name: "Mon Agent Python" + install: { type: pip, package: mon-agent-python } + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: mon-agent + + # binaire GitHub Releases (asset choisi selon plateforme/arch) + - name: mon-binaire + display_name: "Mon Binaire" + install: + type: binary + repo: owner/repo + binary: mon-binaire # nom de l'exécutable attendu + checksum: null # ou "sha256:..." + run: mon-binaire + + # binaire via URL directe + - name: mon-binaire-2 + display_name: "Mon Binaire 2" + install: + type: binary + url: "https://example.com/dl/mon-bin.tar.gz" + run: mon-bin + + # script d'installation (téléchargé PUIS exécuté, jamais curl|sh) + - name: mon-script + display_name: "Mon Script" + install: + type: curl + script: "https://example.com/install.sh" + run: mon-script + platforms: [linux, macos] + + # dépôt git + build + - name: mon-git + display_name: "Mon Git" + install: + type: git + repo: "https://github.com/owner/repo" + build: ["cargo", "build", "--release"] # ou "make build" (chaîne) + binary_path: target/release/mon-git # copié dans le bin dir + run: mon-git + + # application desktop / SaaS : listée mais non installable + - name: mon-saas + display_name: "Mon SaaS" + installable: false + note: "SaaS — voir https://example.com" + +Puis : am config validate, am install mon-agent-npm, etc. + +--- + +## Alias, groupes et mode interactif + + am start cc # alias -> claude-code + am start group:dev # démarre tout le groupe "dev" en arrière-plan + am stop group:dev # arrête tout le groupe + am # shell interactif (rustyline) : + # am> inst # complétion Tab : commandes, agents, alias, + # am> install jcode # group:xxx, options (--background, ...) + # am> start jcode --background + # am> exit + +Le shell interactif offre la **complétion par Tab** (commandes, noms +d'agents, alias, groupes, options) et un **historique persistant** +(flèches haut/bas, fichier history.txt dans le répertoire d'état). + +--- + +## Gestion des processus + +- **Avant-plan** (défaut) : la commande hérite de votre terminal ; am attend + sa fin et propage son code de sortie. Ctrl-C interrompt l'agent. +- **Arrière-plan** (--background) : processus détaché (nouveau groupe de + processus), PID enregistré dans state.json, stdout/stderr redirigés vers + ~/.local/state/agent-manager/logs/.log. Si l'agent crashe au + démarrage, am affiche la fin du log et échoue. +- **Arrêt** : am stop envoie SIGTERM (Windows : taskkill), attend + stop_timeout_secs, puis SIGKILL (taskkill /F /T). --force court-circuite + la phase gracieuse. Les PID périmés sont nettoyés automatiquement. +- Toutes les commandes exécutées sont journalisées avec horodatage dans + /agent-manager.log (--verbose les affiche aussi). + +--- + +## Sécurité + +- Les scripts d'installation (curl) sont **téléchargés puis exécutés + séparément** — jamais de curl pipé directement vers sh. --verbose affiche + leur contenu, confirm_before_run demande confirmation. +- --dry-run simule n'importe quelle action sans rien écrire. +- URL limitées à https, http et file (tests locaux). +- Checksums sha256 vérifiés quand ils sont fournis dans la config. +- Rien n'est exécuté avec des privilèges élevés ; tout vit dans les + répertoires utilisateur. Les archives sont extraites avec protection + contre le path traversal (zip-slip). + +--- + +## Tests + + cargo test # tests unitaires + intégration (config, versions, + # dry-run, processus, doctor, installateurs) + cargo test -- --nocapture + +Les tests d'installation utilisent des *mocks* et le mode dry-run : aucun +réseau, aucune installation réelle. Les tests de processus spawnent +sleep/ping et vérifient le cycle démarrage → détection → arrêt. + +--- + +## Complétions shell + + am completion bash > completions/am.bash # fournies dans completions/ + am completion zsh > completions/_am + am completion fish > completions/am.fish + am completion powershell > completions/_am.ps1 + + # bash + source completions/am.bash + # zsh + fpath+=(completions) && compinit + # fish + cp completions/am.fish ~/.config/fish/completions/am.fish + +--- + +## Structure du projet + + src/ + main.rs # point d'entrée (binaire am) + lib.rs # crate bibliothèque (testable) + cli.rs # définition clap des commandes + app.rs # contexte applicatif (config, chemins, état, log) + config.rs # schéma YAML, chargement, includes, fusion, validation + catalog.rs # catalogue fusionné : lookup, alias, groupes, recherche + state.rs # base locale JSON (installations, PID) + runner.rs # exécution de commandes (réelle / mock / dry-run) + process.rs # spawn détaché, détection, SIGTERM/SIGKILL, logs + download.rs # téléchargement, checksums, releases GitHub, archives + deps.rs # vérification des dépendances (node >= 18, ...) + doctor.rs # diagnostic d'environnement + réparations + output.rs # logger horodaté, tableaux, JSON, couleurs + installers/ # npm, bun, pip, uv, cargo, go, curl, binary, git + commands/ # une implémentation par commande + repl.rs # mode interactif + config.yaml # catalogue par défaut (~70 agents, embarqué) + tests/ # tests d'intégration + completions/ # scripts de complétion générés + +--- + +## Limites et feuille de route + +- Les applications desktop (Warp, openyak, Kun, cc-haha) et les SaaS (Devin, + Amp, Junie, Cortex, Tabnine, Mentat) sont **listés** avec + installable: false et un lien — ils ne sont pas installables localement. +- La mise à jour compare la version installée à la dernière connue (registry + npm/PyPI/crates.io, tag GitHub) puis réinstalle. +- Pistes futures : plugins d'installation dynamiques, notifications Windows + natives, intégration tmux, tests d'intégration avec des registres simulés. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..aa60d25 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,53 @@ +# Publier une release + +Les installateurs one-liner (`install.sh` et `install.ps1`) téléchargent +les binaires attachés à la **dernière release** du dépôt Gitea +(https://git.dracodev.net/Projets/agent-manager). Sans binaire pour la +plateforme, ils retombent sur `cargo install --git` (plus lent mais toujours +fonctionnel). + +## 1. Bumper la version + +Dans Cargo.toml (ex. `0.2.0`), puis : + + cargo build --release # binaire Windows (am.exe) sur Windows + # sur une machine Linux : + cargo build --release # binaire Linux (am) + +## 2. Créer les archives des binaires + +Windows (PowerShell) : + + Compress-Archive target\release\am.exe am-windows-x86_64.zip + +Linux : + + tar -czf am-linux-x86_64.tar.gz -C target/release am + +## 3. Créer la release sur Gitea + +Via l'interface web : Releases > New Release, tag v0.2.0, attacher les +archives ci-dessus. + +Ou via l'API (jeton Gitea requis) : + + curl -X POST -H "Authorization: token " \ + -H "Content-Type: application/json" \ + -d '{"tag_name":"v0.2.0","name":"v0.2.0","body":"notes de version"}' \ + https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases + + curl -X POST -H "Authorization: token " \ + -F "attachment=@am-windows-x86_64.zip" \ + https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases//assets?name=am-windows-x86_64.zip + + curl -X POST -H "Authorization: token " \ + -F "attachment=@am-linux-x86_64.tar.gz" \ + https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases//assets?name=am-linux-x86_64.tar.gz + +## 4. Tag git + + git tag v0.2.0 && git push origin v0.2.0 + +Les conventions de nommage attendues par les installateurs : +- am-windows-x86_64.zip (contient am.exe) +- am-linux-x86_64.tar.gz (contient am) diff --git a/completions/_am b/completions/_am new file mode 100644 index 0000000..5998350 --- /dev/null +++ b/completions/_am @@ -0,0 +1,970 @@ +#compdef am + +autoload -U is-at-least + +_am() { + typeset -A opt_args + typeset -a _arguments_options + local ret=1 + + if is-at-least 5.2; then + _arguments_options=(-s -S -C) + else + _arguments_options=(-s -C) + fi + + local context curcontext="$curcontext" state line + _arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +'-V[Print version]' \ +'--version[Print version]' \ +":: :_am_commands" \ +"*::: :->am" \ +&& ret=0 + case $state in + (am) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:am-command-$line[1]:" + case $line[1] in + (list) +_arguments "${_arguments_options[@]}" : \ +'--category=[Only show agents of this category]:CATEGORY:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--installed-only[Only show agents that are installed (managed or external)]' \ +'--all[Include hidden agents as well]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(start) +_arguments "${_arguments_options[@]}" : \ +'*--args=[Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)]::ARGS:_default' \ +'*--env=[Set an environment variable for the agent (repeatable)\: --env KEY=VALUE]:KEY=VALUE:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'(-f --foreground)-b[Run detached in the background; output goes to the agent log file]' \ +'(-f --foreground)--background[Run detached in the background; output goes to the agent log file]' \ +'-f[Run in the foreground, attached to this terminal (the default)]' \ +'--foreground[Run in the foreground, attached to this terminal (the default)]' \ +'--notify[Send a desktop notification once the agent has started]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name, alias, or group\::_default' \ +&& ret=0 +;; +(stop) +_arguments "${_arguments_options[@]}" : \ +'--timeout=[Grace period in seconds before killing (default\: from config, 5s)]:SECS:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--force[Kill immediately instead of terminating gracefully]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name, alias, or group\::_default' \ +&& ret=0 +;; +(restart) +_arguments "${_arguments_options[@]}" : \ +'*--args=[Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)]::ARGS:_default' \ +'*--env=[Set an environment variable for the agent (repeatable)\: --env KEY=VALUE]:KEY=VALUE:_default' \ +'--timeout=[Grace period in seconds before killing (default\: from config, 5s)]:SECS:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'(-f --foreground)-b[Run detached in the background; output goes to the agent log file]' \ +'(-f --foreground)--background[Run detached in the background; output goes to the agent log file]' \ +'-f[Run in the foreground, attached to this terminal (the default)]' \ +'--foreground[Run in the foreground, attached to this terminal (the default)]' \ +'--notify[Send a desktop notification once the agent has started]' \ +'--force[Kill immediately instead of terminating gracefully]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name, alias, or group\::_default' \ +&& ret=0 +;; +(status) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::agent -- Agent name or alias (omit to show all installed agents):_default' \ +&& ret=0 +;; +(install) +_arguments "${_arguments_options[@]}" : \ +'--method=[Select the install method (index, or type\: npm, pip, uv, cargo, go, bun, curl, binary, git)]:METHOD:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--force[Reinstall even if already installed]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name or alias:_default' \ +&& ret=0 +;; +(uninstall) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--purge[Also remove logs and the agent entry from the user config file]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name or alias:_default' \ +&& ret=0 +;; +(update) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--all[Update every installed managed agent]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::agent -- Agent name or alias (required unless --all):_default' \ +&& ret=0 +;; +(search) +_arguments "${_arguments_options[@]}" : \ +'--category=[Restrict to this category]:CATEGORY:_default' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':keyword -- Keyword to search for (case-insensitive substring):_default' \ +&& ret=0 +;; +(info) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name or alias:_default' \ +&& ret=0 +;; +(config) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +":: :_am__subcmd__config_commands" \ +"*::: :->config" \ +&& ret=0 + + case $state in + (config) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:am-config-command-$line[1]:" + case $line[1] in + (show) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(path) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(edit) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(validate) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(add) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':file -- YAML (or JSON) file defining agents to include:_files' \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +":: :_am__subcmd__config__subcmd__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:am-config-help-command-$line[1]:" + case $line[1] in + (show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(path) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(edit) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(validate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(add) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +;; +(doctor) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--fix[Attempt to repair problems (create directories, fix the state file)]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(run) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name or alias:_default' \ +'*::args -- Arguments passed through to the agent command:_default' \ +&& ret=0 +;; +(completion) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':shell -- Shell to generate completions for:(bash elvish fish powershell zsh)' \ +&& ret=0 +;; +(self-update) +_arguments "${_arguments_options[@]}" : \ +'--to=[Download the new binary to this path instead of replacing the current one]:FILE:_files' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'--check[Only check whether a newer version exists]' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(export) +_arguments "${_arguments_options[@]}" : \ +'--output=[Output file (default\: am-export.json)]:FILE:_files' \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +&& ret=0 +;; +(import) +_arguments "${_arguments_options[@]}" : \ +'-c+[Use an alternate configuration file]:FILE:_files' \ +'--config=[Use an alternate configuration file]:FILE:_files' \ +'-v[Verbose output\: show every command executed and its details]' \ +'--verbose[Verbose output\: show every command executed and its details]' \ +'(-v --verbose)-q[Quiet mode\: only errors are printed]' \ +'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \ +'-y[Answer yes to every confirmation prompt]' \ +'--yes[Answer yes to every confirmation prompt]' \ +'--dry-run[Simulate the action without changing anything]' \ +'--json[Emit machine-readable JSON on stdout (for scripts)]' \ +'--no-color[Disable ANSI colors]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':file -- File exported by the export command:_files' \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +":: :_am__subcmd__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:am-help-command-$line[1]:" + case $line[1] in + (list) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(start) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(stop) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(restart) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(status) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(install) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(uninstall) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(update) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(search) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(info) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(config) +_arguments "${_arguments_options[@]}" : \ +":: :_am__subcmd__help__subcmd__config_commands" \ +"*::: :->config" \ +&& ret=0 + + case $state in + (config) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:am-help-config-command-$line[1]:" + case $line[1] in + (show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(path) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(edit) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(validate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(add) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; +(doctor) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(run) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(completion) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(self-update) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(export) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(import) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +} + +(( $+functions[_am_commands] )) || +_am_commands() { + local commands; commands=( +'list:List agents\: installed ones, or every agent known to the catalog' \ +'start:Start an agent (foreground by default, or detached with --background)' \ +'stop:Stop a background agent (SIGTERM, then SIGKILL after the timeout)' \ +'restart:Restart an agent\: stop, then start with the same options' \ +'status:Show the state of one agent, or of every installed agent' \ +'install:Install an agent and its dependencies' \ +'uninstall:Uninstall an agent and clean its files' \ +'update:Update an installed agent to the latest available version' \ +'search:Search the catalog by keyword (name, description, category, tags)' \ +'info:Show detailed information about one agent' \ +'config:Manage the configuration file' \ +'doctor:Check the environment\: tools, config validity, paths, permissions' \ +'run:Run the agent command directly with the given arguments (no process management)' \ +'completion:Generate a shell completion script' \ +'self-update:Update agent-manager itself from GitHub Releases' \ +'export:Export the configuration and installation state (backup)' \ +'import:Import a previously exported configuration and state' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'am commands' commands "$@" +} +(( $+functions[_am__subcmd__completion_commands] )) || +_am__subcmd__completion_commands() { + local commands; commands=() + _describe -t commands 'am completion commands' commands "$@" +} +(( $+functions[_am__subcmd__config_commands] )) || +_am__subcmd__config_commands() { + local commands; commands=( +'show:Print the effective (merged) configuration as YAML' \ +'path:Print the location of the configuration file(s)' \ +'edit:Open the user configuration file in your editor' \ +'validate:Validate the configuration file(s)' \ +'add:Add another agent definition file to the user configuration' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'am config commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__add_commands] )) || +_am__subcmd__config__subcmd__add_commands() { + local commands; commands=() + _describe -t commands 'am config add commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__edit_commands] )) || +_am__subcmd__config__subcmd__edit_commands() { + local commands; commands=() + _describe -t commands 'am config edit commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help_commands] )) || +_am__subcmd__config__subcmd__help_commands() { + local commands; commands=( +'show:Print the effective (merged) configuration as YAML' \ +'path:Print the location of the configuration file(s)' \ +'edit:Open the user configuration file in your editor' \ +'validate:Validate the configuration file(s)' \ +'add:Add another agent definition file to the user configuration' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'am config help commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__add_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__add_commands() { + local commands; commands=() + _describe -t commands 'am config help add commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__edit_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__edit_commands() { + local commands; commands=() + _describe -t commands 'am config help edit commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__help_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__help_commands() { + local commands; commands=() + _describe -t commands 'am config help help commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__path_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__path_commands() { + local commands; commands=() + _describe -t commands 'am config help path commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__show_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'am config help show commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__help__subcmd__validate_commands] )) || +_am__subcmd__config__subcmd__help__subcmd__validate_commands() { + local commands; commands=() + _describe -t commands 'am config help validate commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__path_commands] )) || +_am__subcmd__config__subcmd__path_commands() { + local commands; commands=() + _describe -t commands 'am config path commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__show_commands] )) || +_am__subcmd__config__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'am config show commands' commands "$@" +} +(( $+functions[_am__subcmd__config__subcmd__validate_commands] )) || +_am__subcmd__config__subcmd__validate_commands() { + local commands; commands=() + _describe -t commands 'am config validate commands' commands "$@" +} +(( $+functions[_am__subcmd__doctor_commands] )) || +_am__subcmd__doctor_commands() { + local commands; commands=() + _describe -t commands 'am doctor commands' commands "$@" +} +(( $+functions[_am__subcmd__export_commands] )) || +_am__subcmd__export_commands() { + local commands; commands=() + _describe -t commands 'am export commands' commands "$@" +} +(( $+functions[_am__subcmd__help_commands] )) || +_am__subcmd__help_commands() { + local commands; commands=( +'list:List agents\: installed ones, or every agent known to the catalog' \ +'start:Start an agent (foreground by default, or detached with --background)' \ +'stop:Stop a background agent (SIGTERM, then SIGKILL after the timeout)' \ +'restart:Restart an agent\: stop, then start with the same options' \ +'status:Show the state of one agent, or of every installed agent' \ +'install:Install an agent and its dependencies' \ +'uninstall:Uninstall an agent and clean its files' \ +'update:Update an installed agent to the latest available version' \ +'search:Search the catalog by keyword (name, description, category, tags)' \ +'info:Show detailed information about one agent' \ +'config:Manage the configuration file' \ +'doctor:Check the environment\: tools, config validity, paths, permissions' \ +'run:Run the agent command directly with the given arguments (no process management)' \ +'completion:Generate a shell completion script' \ +'self-update:Update agent-manager itself from GitHub Releases' \ +'export:Export the configuration and installation state (backup)' \ +'import:Import a previously exported configuration and state' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'am help commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__completion_commands] )) || +_am__subcmd__help__subcmd__completion_commands() { + local commands; commands=() + _describe -t commands 'am help completion commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config_commands] )) || +_am__subcmd__help__subcmd__config_commands() { + local commands; commands=( +'show:Print the effective (merged) configuration as YAML' \ +'path:Print the location of the configuration file(s)' \ +'edit:Open the user configuration file in your editor' \ +'validate:Validate the configuration file(s)' \ +'add:Add another agent definition file to the user configuration' \ + ) + _describe -t commands 'am help config commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config__subcmd__add_commands] )) || +_am__subcmd__help__subcmd__config__subcmd__add_commands() { + local commands; commands=() + _describe -t commands 'am help config add commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config__subcmd__edit_commands] )) || +_am__subcmd__help__subcmd__config__subcmd__edit_commands() { + local commands; commands=() + _describe -t commands 'am help config edit commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config__subcmd__path_commands] )) || +_am__subcmd__help__subcmd__config__subcmd__path_commands() { + local commands; commands=() + _describe -t commands 'am help config path commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config__subcmd__show_commands] )) || +_am__subcmd__help__subcmd__config__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'am help config show commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__config__subcmd__validate_commands] )) || +_am__subcmd__help__subcmd__config__subcmd__validate_commands() { + local commands; commands=() + _describe -t commands 'am help config validate commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__doctor_commands] )) || +_am__subcmd__help__subcmd__doctor_commands() { + local commands; commands=() + _describe -t commands 'am help doctor commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__export_commands] )) || +_am__subcmd__help__subcmd__export_commands() { + local commands; commands=() + _describe -t commands 'am help export commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__help_commands] )) || +_am__subcmd__help__subcmd__help_commands() { + local commands; commands=() + _describe -t commands 'am help help commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__import_commands] )) || +_am__subcmd__help__subcmd__import_commands() { + local commands; commands=() + _describe -t commands 'am help import commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__info_commands] )) || +_am__subcmd__help__subcmd__info_commands() { + local commands; commands=() + _describe -t commands 'am help info commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__install_commands] )) || +_am__subcmd__help__subcmd__install_commands() { + local commands; commands=() + _describe -t commands 'am help install commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__list_commands] )) || +_am__subcmd__help__subcmd__list_commands() { + local commands; commands=() + _describe -t commands 'am help list commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__restart_commands] )) || +_am__subcmd__help__subcmd__restart_commands() { + local commands; commands=() + _describe -t commands 'am help restart commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__run_commands] )) || +_am__subcmd__help__subcmd__run_commands() { + local commands; commands=() + _describe -t commands 'am help run commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__search_commands] )) || +_am__subcmd__help__subcmd__search_commands() { + local commands; commands=() + _describe -t commands 'am help search commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__self-update_commands] )) || +_am__subcmd__help__subcmd__self-update_commands() { + local commands; commands=() + _describe -t commands 'am help self-update commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__start_commands] )) || +_am__subcmd__help__subcmd__start_commands() { + local commands; commands=() + _describe -t commands 'am help start commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__status_commands] )) || +_am__subcmd__help__subcmd__status_commands() { + local commands; commands=() + _describe -t commands 'am help status commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__stop_commands] )) || +_am__subcmd__help__subcmd__stop_commands() { + local commands; commands=() + _describe -t commands 'am help stop commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__uninstall_commands] )) || +_am__subcmd__help__subcmd__uninstall_commands() { + local commands; commands=() + _describe -t commands 'am help uninstall commands' commands "$@" +} +(( $+functions[_am__subcmd__help__subcmd__update_commands] )) || +_am__subcmd__help__subcmd__update_commands() { + local commands; commands=() + _describe -t commands 'am help update commands' commands "$@" +} +(( $+functions[_am__subcmd__import_commands] )) || +_am__subcmd__import_commands() { + local commands; commands=() + _describe -t commands 'am import commands' commands "$@" +} +(( $+functions[_am__subcmd__info_commands] )) || +_am__subcmd__info_commands() { + local commands; commands=() + _describe -t commands 'am info commands' commands "$@" +} +(( $+functions[_am__subcmd__install_commands] )) || +_am__subcmd__install_commands() { + local commands; commands=() + _describe -t commands 'am install commands' commands "$@" +} +(( $+functions[_am__subcmd__list_commands] )) || +_am__subcmd__list_commands() { + local commands; commands=() + _describe -t commands 'am list commands' commands "$@" +} +(( $+functions[_am__subcmd__restart_commands] )) || +_am__subcmd__restart_commands() { + local commands; commands=() + _describe -t commands 'am restart commands' commands "$@" +} +(( $+functions[_am__subcmd__run_commands] )) || +_am__subcmd__run_commands() { + local commands; commands=() + _describe -t commands 'am run commands' commands "$@" +} +(( $+functions[_am__subcmd__search_commands] )) || +_am__subcmd__search_commands() { + local commands; commands=() + _describe -t commands 'am search commands' commands "$@" +} +(( $+functions[_am__subcmd__self-update_commands] )) || +_am__subcmd__self-update_commands() { + local commands; commands=() + _describe -t commands 'am self-update commands' commands "$@" +} +(( $+functions[_am__subcmd__start_commands] )) || +_am__subcmd__start_commands() { + local commands; commands=() + _describe -t commands 'am start commands' commands "$@" +} +(( $+functions[_am__subcmd__status_commands] )) || +_am__subcmd__status_commands() { + local commands; commands=() + _describe -t commands 'am status commands' commands "$@" +} +(( $+functions[_am__subcmd__stop_commands] )) || +_am__subcmd__stop_commands() { + local commands; commands=() + _describe -t commands 'am stop commands' commands "$@" +} +(( $+functions[_am__subcmd__uninstall_commands] )) || +_am__subcmd__uninstall_commands() { + local commands; commands=() + _describe -t commands 'am uninstall commands' commands "$@" +} +(( $+functions[_am__subcmd__update_commands] )) || +_am__subcmd__update_commands() { + local commands; commands=() + _describe -t commands 'am update commands' commands "$@" +} + +if [ "$funcstack[1]" = "_am" ]; then + _am "$@" +else + compdef _am am +fi diff --git a/completions/_am.ps1 b/completions/_am.ps1 new file mode 100644 index 0000000..9e497a7 --- /dev/null +++ b/completions/_am.ps1 @@ -0,0 +1,573 @@ + +using namespace System.Management.Automation +using namespace System.Management.Automation.Language + +Register-ArgumentCompleter -Native -CommandName 'am' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $command = @( + 'am' + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i] + if ($element -isnot [StringConstantExpressionAst] -or + $element.StringConstantType -ne [StringConstantType]::BareWord -or + $element.Value.StartsWith('-') -or + $element.Value -eq $wordToComplete) { + break + } + $element.Value + }) -join ';' + + $completions = @(switch ($command) { + 'am' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') + [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List agents: installed ones, or every agent known to the catalog') + [CompletionResult]::new('start', 'start', [CompletionResultType]::ParameterValue, 'Start an agent (foreground by default, or detached with --background)') + [CompletionResult]::new('stop', 'stop', [CompletionResultType]::ParameterValue, 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)') + [CompletionResult]::new('restart', 'restart', [CompletionResultType]::ParameterValue, 'Restart an agent: stop, then start with the same options') + [CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Show the state of one agent, or of every installed agent') + [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install an agent and its dependencies') + [CompletionResult]::new('uninstall', 'uninstall', [CompletionResultType]::ParameterValue, 'Uninstall an agent and clean its files') + [CompletionResult]::new('update', 'update', [CompletionResultType]::ParameterValue, 'Update an installed agent to the latest available version') + [CompletionResult]::new('search', 'search', [CompletionResultType]::ParameterValue, 'Search the catalog by keyword (name, description, category, tags)') + [CompletionResult]::new('info', 'info', [CompletionResultType]::ParameterValue, 'Show detailed information about one agent') + [CompletionResult]::new('config', 'config', [CompletionResultType]::ParameterValue, 'Manage the configuration file') + [CompletionResult]::new('doctor', 'doctor', [CompletionResultType]::ParameterValue, 'Check the environment: tools, config validity, paths, permissions') + [CompletionResult]::new('run', 'run', [CompletionResultType]::ParameterValue, 'Run the agent command directly with the given arguments (no process management)') + [CompletionResult]::new('completion', 'completion', [CompletionResultType]::ParameterValue, 'Generate a shell completion script') + [CompletionResult]::new('self-update', 'self-update', [CompletionResultType]::ParameterValue, 'Update agent-manager itself from GitHub Releases') + [CompletionResult]::new('export', 'export', [CompletionResultType]::ParameterValue, 'Export the configuration and installation state (backup)') + [CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import a previously exported configuration and state') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'am;list' { + [CompletionResult]::new('--category', '--category', [CompletionResultType]::ParameterName, 'Only show agents of this category') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--installed-only', '--installed-only', [CompletionResultType]::ParameterName, 'Only show agents that are installed (managed or external)') + [CompletionResult]::new('--all', '--all', [CompletionResultType]::ParameterName, 'Include hidden agents as well') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;start' { + [CompletionResult]::new('--args', '--args', [CompletionResultType]::ParameterName, 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)') + [CompletionResult]::new('--env', '--env', [CompletionResultType]::ParameterName, 'Set an environment variable for the agent (repeatable): --env KEY=VALUE') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-b', '-b', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file') + [CompletionResult]::new('--background', '--background', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file') + [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)') + [CompletionResult]::new('--foreground', '--foreground', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)') + [CompletionResult]::new('--notify', '--notify', [CompletionResultType]::ParameterName, 'Send a desktop notification once the agent has started') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;stop' { + [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'Grace period in seconds before killing (default: from config, 5s)') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Kill immediately instead of terminating gracefully') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;restart' { + [CompletionResult]::new('--args', '--args', [CompletionResultType]::ParameterName, 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)') + [CompletionResult]::new('--env', '--env', [CompletionResultType]::ParameterName, 'Set an environment variable for the agent (repeatable): --env KEY=VALUE') + [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'Grace period in seconds before killing (default: from config, 5s)') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-b', '-b', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file') + [CompletionResult]::new('--background', '--background', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file') + [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)') + [CompletionResult]::new('--foreground', '--foreground', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)') + [CompletionResult]::new('--notify', '--notify', [CompletionResultType]::ParameterName, 'Send a desktop notification once the agent has started') + [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Kill immediately instead of terminating gracefully') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;status' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;install' { + [CompletionResult]::new('--method', '--method', [CompletionResultType]::ParameterName, 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Reinstall even if already installed') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;uninstall' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--purge', '--purge', [CompletionResultType]::ParameterName, 'Also remove logs and the agent entry from the user config file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;update' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--all', '--all', [CompletionResultType]::ParameterName, 'Update every installed managed agent') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;search' { + [CompletionResult]::new('--category', '--category', [CompletionResultType]::ParameterName, 'Restrict to this category') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;info' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML') + [CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)') + [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor') + [CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)') + [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'am;config;show' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config;path' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config;edit' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config;validate' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config;add' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;config;help' { + [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML') + [CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)') + [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor') + [CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)') + [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'am;config;help;show' { + break + } + 'am;config;help;path' { + break + } + 'am;config;help;edit' { + break + } + 'am;config;help;validate' { + break + } + 'am;config;help;add' { + break + } + 'am;config;help;help' { + break + } + 'am;doctor' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--fix', '--fix', [CompletionResultType]::ParameterName, 'Attempt to repair problems (create directories, fix the state file)') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;run' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;completion' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;self-update' { + [CompletionResult]::new('--to', '--to', [CompletionResultType]::ParameterName, 'Download the new binary to this path instead of replacing the current one') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--check', '--check', [CompletionResultType]::ParameterName, 'Only check whether a newer version exists') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;export' { + [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output file (default: am-export.json)') + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;import' { + [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed') + [CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt') + [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything') + [CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)') + [CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } + 'am;help' { + [CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List agents: installed ones, or every agent known to the catalog') + [CompletionResult]::new('start', 'start', [CompletionResultType]::ParameterValue, 'Start an agent (foreground by default, or detached with --background)') + [CompletionResult]::new('stop', 'stop', [CompletionResultType]::ParameterValue, 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)') + [CompletionResult]::new('restart', 'restart', [CompletionResultType]::ParameterValue, 'Restart an agent: stop, then start with the same options') + [CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Show the state of one agent, or of every installed agent') + [CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install an agent and its dependencies') + [CompletionResult]::new('uninstall', 'uninstall', [CompletionResultType]::ParameterValue, 'Uninstall an agent and clean its files') + [CompletionResult]::new('update', 'update', [CompletionResultType]::ParameterValue, 'Update an installed agent to the latest available version') + [CompletionResult]::new('search', 'search', [CompletionResultType]::ParameterValue, 'Search the catalog by keyword (name, description, category, tags)') + [CompletionResult]::new('info', 'info', [CompletionResultType]::ParameterValue, 'Show detailed information about one agent') + [CompletionResult]::new('config', 'config', [CompletionResultType]::ParameterValue, 'Manage the configuration file') + [CompletionResult]::new('doctor', 'doctor', [CompletionResultType]::ParameterValue, 'Check the environment: tools, config validity, paths, permissions') + [CompletionResult]::new('run', 'run', [CompletionResultType]::ParameterValue, 'Run the agent command directly with the given arguments (no process management)') + [CompletionResult]::new('completion', 'completion', [CompletionResultType]::ParameterValue, 'Generate a shell completion script') + [CompletionResult]::new('self-update', 'self-update', [CompletionResultType]::ParameterValue, 'Update agent-manager itself from GitHub Releases') + [CompletionResult]::new('export', 'export', [CompletionResultType]::ParameterValue, 'Export the configuration and installation state (backup)') + [CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import a previously exported configuration and state') + [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') + break + } + 'am;help;list' { + break + } + 'am;help;start' { + break + } + 'am;help;stop' { + break + } + 'am;help;restart' { + break + } + 'am;help;status' { + break + } + 'am;help;install' { + break + } + 'am;help;uninstall' { + break + } + 'am;help;update' { + break + } + 'am;help;search' { + break + } + 'am;help;info' { + break + } + 'am;help;config' { + [CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML') + [CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)') + [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor') + [CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)') + [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration') + break + } + 'am;help;config;show' { + break + } + 'am;help;config;path' { + break + } + 'am;help;config;edit' { + break + } + 'am;help;config;validate' { + break + } + 'am;help;config;add' { + break + } + 'am;help;doctor' { + break + } + 'am;help;run' { + break + } + 'am;help;completion' { + break + } + 'am;help;self-update' { + break + } + 'am;help;export' { + break + } + 'am;help;import' { + break + } + 'am;help;help' { + break + } + }) + + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText +} diff --git a/completions/am.bash b/completions/am.bash new file mode 100644 index 0000000..ea2d2ac --- /dev/null +++ b/completions/am.bash @@ -0,0 +1,1175 @@ +_am() { + local i cur prev opts cmd + COMPREPLY=() + if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then + cur="$2" + else + cur="${COMP_WORDS[COMP_CWORD]}" + fi + prev="$3" + cmd="" + opts="" + + for i in "${COMP_WORDS[@]:0:COMP_CWORD}" + do + case "${cmd},${i}" in + ",$1") + cmd="am" + ;; + am,completion) + cmd="am__subcmd__completion" + ;; + am,config) + cmd="am__subcmd__config" + ;; + am,doctor) + cmd="am__subcmd__doctor" + ;; + am,export) + cmd="am__subcmd__export" + ;; + am,help) + cmd="am__subcmd__help" + ;; + am,import) + cmd="am__subcmd__import" + ;; + am,info) + cmd="am__subcmd__info" + ;; + am,install) + cmd="am__subcmd__install" + ;; + am,list) + cmd="am__subcmd__list" + ;; + am,restart) + cmd="am__subcmd__restart" + ;; + am,run) + cmd="am__subcmd__run" + ;; + am,search) + cmd="am__subcmd__search" + ;; + am,self-update) + cmd="am__subcmd__self__subcmd__update" + ;; + am,start) + cmd="am__subcmd__start" + ;; + am,status) + cmd="am__subcmd__status" + ;; + am,stop) + cmd="am__subcmd__stop" + ;; + am,uninstall) + cmd="am__subcmd__uninstall" + ;; + am,update) + cmd="am__subcmd__update" + ;; + am__subcmd__config,add) + cmd="am__subcmd__config__subcmd__add" + ;; + am__subcmd__config,edit) + cmd="am__subcmd__config__subcmd__edit" + ;; + am__subcmd__config,help) + cmd="am__subcmd__config__subcmd__help" + ;; + am__subcmd__config,path) + cmd="am__subcmd__config__subcmd__path" + ;; + am__subcmd__config,show) + cmd="am__subcmd__config__subcmd__show" + ;; + am__subcmd__config,validate) + cmd="am__subcmd__config__subcmd__validate" + ;; + am__subcmd__config__subcmd__help,add) + cmd="am__subcmd__config__subcmd__help__subcmd__add" + ;; + am__subcmd__config__subcmd__help,edit) + cmd="am__subcmd__config__subcmd__help__subcmd__edit" + ;; + am__subcmd__config__subcmd__help,help) + cmd="am__subcmd__config__subcmd__help__subcmd__help" + ;; + am__subcmd__config__subcmd__help,path) + cmd="am__subcmd__config__subcmd__help__subcmd__path" + ;; + am__subcmd__config__subcmd__help,show) + cmd="am__subcmd__config__subcmd__help__subcmd__show" + ;; + am__subcmd__config__subcmd__help,validate) + cmd="am__subcmd__config__subcmd__help__subcmd__validate" + ;; + am__subcmd__help,completion) + cmd="am__subcmd__help__subcmd__completion" + ;; + am__subcmd__help,config) + cmd="am__subcmd__help__subcmd__config" + ;; + am__subcmd__help,doctor) + cmd="am__subcmd__help__subcmd__doctor" + ;; + am__subcmd__help,export) + cmd="am__subcmd__help__subcmd__export" + ;; + am__subcmd__help,help) + cmd="am__subcmd__help__subcmd__help" + ;; + am__subcmd__help,import) + cmd="am__subcmd__help__subcmd__import" + ;; + am__subcmd__help,info) + cmd="am__subcmd__help__subcmd__info" + ;; + am__subcmd__help,install) + cmd="am__subcmd__help__subcmd__install" + ;; + am__subcmd__help,list) + cmd="am__subcmd__help__subcmd__list" + ;; + am__subcmd__help,restart) + cmd="am__subcmd__help__subcmd__restart" + ;; + am__subcmd__help,run) + cmd="am__subcmd__help__subcmd__run" + ;; + am__subcmd__help,search) + cmd="am__subcmd__help__subcmd__search" + ;; + am__subcmd__help,self-update) + cmd="am__subcmd__help__subcmd__self__subcmd__update" + ;; + am__subcmd__help,start) + cmd="am__subcmd__help__subcmd__start" + ;; + am__subcmd__help,status) + cmd="am__subcmd__help__subcmd__status" + ;; + am__subcmd__help,stop) + cmd="am__subcmd__help__subcmd__stop" + ;; + am__subcmd__help,uninstall) + cmd="am__subcmd__help__subcmd__uninstall" + ;; + am__subcmd__help,update) + cmd="am__subcmd__help__subcmd__update" + ;; + am__subcmd__help__subcmd__config,add) + cmd="am__subcmd__help__subcmd__config__subcmd__add" + ;; + am__subcmd__help__subcmd__config,edit) + cmd="am__subcmd__help__subcmd__config__subcmd__edit" + ;; + am__subcmd__help__subcmd__config,path) + cmd="am__subcmd__help__subcmd__config__subcmd__path" + ;; + am__subcmd__help__subcmd__config,show) + cmd="am__subcmd__help__subcmd__config__subcmd__show" + ;; + am__subcmd__help__subcmd__config,validate) + cmd="am__subcmd__help__subcmd__config__subcmd__validate" + ;; + *) + ;; + esac + done + + case "${cmd}" in + am) + opts="-c -v -q -y -h -V --config --verbose --quiet --yes --dry-run --json --no-color --help --version list start stop restart status install uninstall update search info config doctor run completion self-update export import help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__completion) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help bash elvish fish powershell zsh" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help show path edit validate add help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__add) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__edit) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help) + opts="show path edit validate add help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__add) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__edit) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__path) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__help__subcmd__validate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__path) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__show) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__config__subcmd__validate) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__doctor) + opts="-c -v -q -y -h --fix --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__export) + opts="-c -v -q -y -h --output --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --output) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help) + opts="list start stop restart status install uninstall update search info config doctor run completion self-update export import help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__completion) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config) + opts="show path edit validate add" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config__subcmd__add) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config__subcmd__edit) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config__subcmd__path) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__config__subcmd__validate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__doctor) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__export) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__import) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__info) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__install) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__list) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__restart) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__run) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__search) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__self__subcmd__update) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__start) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__status) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__stop) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__uninstall) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__help__subcmd__update) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__import) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__info) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__install) + opts="-c -v -q -y -h --method --force --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --method) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__list) + opts="-c -v -q -y -h --installed-only --category --all --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --category) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__restart) + opts="-b -f -c -v -q -y -h --background --foreground --args --env --notify --force --timeout --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --args) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --env) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --timeout) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__run) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__search) + opts="-c -v -q -y -h --category --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --category) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__self__subcmd__update) + opts="-c -v -q -y -h --check --to --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --to) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__start) + opts="-b -f -c -v -q -y -h --background --foreground --args --env --notify --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --args) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --env) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__status) + opts="-c -v -q -y -h --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__stop) + opts="-c -v -q -y -h --force --timeout --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --timeout) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__uninstall) + opts="-c -v -q -y -h --purge --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + am__subcmd__update) + opts="-c -v -q -y -h --all --config --verbose --quiet --yes --dry-run --json --no-color --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --config) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -c) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + esac +} + +if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then + complete -F _am -o nosort -o bashdefault -o default am +else + complete -F _am -o bashdefault -o default am +fi diff --git a/completions/am.elv b/completions/am.elv new file mode 100644 index 0000000..0bdc5ab --- /dev/null +++ b/completions/am.elv @@ -0,0 +1,514 @@ + +use builtin; +use str; + +set edit:completion:arg-completer[am] = {|@words| + fn spaces {|n| + builtin:repeat $n ' ' | str:join '' + } + fn cand {|text desc| + edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc + } + var command = 'am' + for word $words[1..-1] { + if (str:has-prefix $word '-') { + break + } + set command = $command';'$word + } + var completions = [ + &'am'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help (see more with ''--help'')' + cand --help 'Print help (see more with ''--help'')' + cand -V 'Print version' + cand --version 'Print version' + cand list 'List agents: installed ones, or every agent known to the catalog' + cand start 'Start an agent (foreground by default, or detached with --background)' + cand stop 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)' + cand restart 'Restart an agent: stop, then start with the same options' + cand status 'Show the state of one agent, or of every installed agent' + cand install 'Install an agent and its dependencies' + cand uninstall 'Uninstall an agent and clean its files' + cand update 'Update an installed agent to the latest available version' + cand search 'Search the catalog by keyword (name, description, category, tags)' + cand info 'Show detailed information about one agent' + cand config 'Manage the configuration file' + cand doctor 'Check the environment: tools, config validity, paths, permissions' + cand run 'Run the agent command directly with the given arguments (no process management)' + cand completion 'Generate a shell completion script' + cand self-update 'Update agent-manager itself from GitHub Releases' + cand export 'Export the configuration and installation state (backup)' + cand import 'Import a previously exported configuration and state' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'am;list'= { + cand --category 'Only show agents of this category' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --installed-only 'Only show agents that are installed (managed or external)' + cand --all 'Include hidden agents as well' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;start'= { + cand --args 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' + cand --env 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -b 'Run detached in the background; output goes to the agent log file' + cand --background 'Run detached in the background; output goes to the agent log file' + cand -f 'Run in the foreground, attached to this terminal (the default)' + cand --foreground 'Run in the foreground, attached to this terminal (the default)' + cand --notify 'Send a desktop notification once the agent has started' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;stop'= { + cand --timeout 'Grace period in seconds before killing (default: from config, 5s)' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --force 'Kill immediately instead of terminating gracefully' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;restart'= { + cand --args 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' + cand --env 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' + cand --timeout 'Grace period in seconds before killing (default: from config, 5s)' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -b 'Run detached in the background; output goes to the agent log file' + cand --background 'Run detached in the background; output goes to the agent log file' + cand -f 'Run in the foreground, attached to this terminal (the default)' + cand --foreground 'Run in the foreground, attached to this terminal (the default)' + cand --notify 'Send a desktop notification once the agent has started' + cand --force 'Kill immediately instead of terminating gracefully' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;status'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;install'= { + cand --method 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --force 'Reinstall even if already installed' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;uninstall'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --purge 'Also remove logs and the agent entry from the user config file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;update'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --all 'Update every installed managed agent' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;search'= { + cand --category 'Restrict to this category' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;info'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + cand show 'Print the effective (merged) configuration as YAML' + cand path 'Print the location of the configuration file(s)' + cand edit 'Open the user configuration file in your editor' + cand validate 'Validate the configuration file(s)' + cand add 'Add another agent definition file to the user configuration' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'am;config;show'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config;path'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config;edit'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config;validate'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config;add'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;config;help'= { + cand show 'Print the effective (merged) configuration as YAML' + cand path 'Print the location of the configuration file(s)' + cand edit 'Open the user configuration file in your editor' + cand validate 'Validate the configuration file(s)' + cand add 'Add another agent definition file to the user configuration' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'am;config;help;show'= { + } + &'am;config;help;path'= { + } + &'am;config;help;edit'= { + } + &'am;config;help;validate'= { + } + &'am;config;help;add'= { + } + &'am;config;help;help'= { + } + &'am;doctor'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --fix 'Attempt to repair problems (create directories, fix the state file)' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;run'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;completion'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;self-update'= { + cand --to 'Download the new binary to this path instead of replacing the current one' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand --check 'Only check whether a newer version exists' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;export'= { + cand --output 'Output file (default: am-export.json)' + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;import'= { + cand -c 'Use an alternate configuration file' + cand --config 'Use an alternate configuration file' + cand -v 'Verbose output: show every command executed and its details' + cand --verbose 'Verbose output: show every command executed and its details' + cand -q 'Quiet mode: only errors are printed' + cand --quiet 'Quiet mode: only errors are printed' + cand -y 'Answer yes to every confirmation prompt' + cand --yes 'Answer yes to every confirmation prompt' + cand --dry-run 'Simulate the action without changing anything' + cand --json 'Emit machine-readable JSON on stdout (for scripts)' + cand --no-color 'Disable ANSI colors' + cand -h 'Print help' + cand --help 'Print help' + } + &'am;help'= { + cand list 'List agents: installed ones, or every agent known to the catalog' + cand start 'Start an agent (foreground by default, or detached with --background)' + cand stop 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)' + cand restart 'Restart an agent: stop, then start with the same options' + cand status 'Show the state of one agent, or of every installed agent' + cand install 'Install an agent and its dependencies' + cand uninstall 'Uninstall an agent and clean its files' + cand update 'Update an installed agent to the latest available version' + cand search 'Search the catalog by keyword (name, description, category, tags)' + cand info 'Show detailed information about one agent' + cand config 'Manage the configuration file' + cand doctor 'Check the environment: tools, config validity, paths, permissions' + cand run 'Run the agent command directly with the given arguments (no process management)' + cand completion 'Generate a shell completion script' + cand self-update 'Update agent-manager itself from GitHub Releases' + cand export 'Export the configuration and installation state (backup)' + cand import 'Import a previously exported configuration and state' + cand help 'Print this message or the help of the given subcommand(s)' + } + &'am;help;list'= { + } + &'am;help;start'= { + } + &'am;help;stop'= { + } + &'am;help;restart'= { + } + &'am;help;status'= { + } + &'am;help;install'= { + } + &'am;help;uninstall'= { + } + &'am;help;update'= { + } + &'am;help;search'= { + } + &'am;help;info'= { + } + &'am;help;config'= { + cand show 'Print the effective (merged) configuration as YAML' + cand path 'Print the location of the configuration file(s)' + cand edit 'Open the user configuration file in your editor' + cand validate 'Validate the configuration file(s)' + cand add 'Add another agent definition file to the user configuration' + } + &'am;help;config;show'= { + } + &'am;help;config;path'= { + } + &'am;help;config;edit'= { + } + &'am;help;config;validate'= { + } + &'am;help;config;add'= { + } + &'am;help;doctor'= { + } + &'am;help;run'= { + } + &'am;help;completion'= { + } + &'am;help;self-update'= { + } + &'am;help;export'= { + } + &'am;help;import'= { + } + &'am;help;help'= { + } + ] + $completions[$command] +} diff --git a/completions/am.fish b/completions/am.fish new file mode 100644 index 0000000..39674f4 --- /dev/null +++ b/completions/am.fish @@ -0,0 +1,290 @@ +# Print an optspec for argparse to handle cmd's options that are independent of any subcommand. +function __fish_am_global_optspecs + string join \n c/config= v/verbose q/quiet y/yes dry-run json no-color h/help V/version +end + +function __fish_am_needs_command + # Figure out if the current invocation already has a command. + set -l cmd (commandline -opc) + set -e cmd[1] + argparse -s (__fish_am_global_optspecs) -- $cmd 2>/dev/null + or return + if set -q argv[1] + # Also print the command, so this can be used to figure out what it is. + echo $argv[1] + return 1 + end + return 0 +end + +function __fish_am_using_subcommand + set -l cmd (__fish_am_needs_command) + test -z "$cmd" + and return 1 + contains -- $cmd[1] $argv +end + +complete -c am -n "__fish_am_needs_command" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_needs_command" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_needs_command" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_needs_command" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_needs_command" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_needs_command" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_needs_command" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_needs_command" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c am -n "__fish_am_needs_command" -s V -l version -d 'Print version' +complete -c am -n "__fish_am_needs_command" -f -a "list" -d 'List agents: installed ones, or every agent known to the catalog' +complete -c am -n "__fish_am_needs_command" -f -a "start" -d 'Start an agent (foreground by default, or detached with --background)' +complete -c am -n "__fish_am_needs_command" -f -a "stop" -d 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)' +complete -c am -n "__fish_am_needs_command" -f -a "restart" -d 'Restart an agent: stop, then start with the same options' +complete -c am -n "__fish_am_needs_command" -f -a "status" -d 'Show the state of one agent, or of every installed agent' +complete -c am -n "__fish_am_needs_command" -f -a "install" -d 'Install an agent and its dependencies' +complete -c am -n "__fish_am_needs_command" -f -a "uninstall" -d 'Uninstall an agent and clean its files' +complete -c am -n "__fish_am_needs_command" -f -a "update" -d 'Update an installed agent to the latest available version' +complete -c am -n "__fish_am_needs_command" -f -a "search" -d 'Search the catalog by keyword (name, description, category, tags)' +complete -c am -n "__fish_am_needs_command" -f -a "info" -d 'Show detailed information about one agent' +complete -c am -n "__fish_am_needs_command" -f -a "config" -d 'Manage the configuration file' +complete -c am -n "__fish_am_needs_command" -f -a "doctor" -d 'Check the environment: tools, config validity, paths, permissions' +complete -c am -n "__fish_am_needs_command" -f -a "run" -d 'Run the agent command directly with the given arguments (no process management)' +complete -c am -n "__fish_am_needs_command" -f -a "completion" -d 'Generate a shell completion script' +complete -c am -n "__fish_am_needs_command" -f -a "self-update" -d 'Update agent-manager itself from GitHub Releases' +complete -c am -n "__fish_am_needs_command" -f -a "export" -d 'Export the configuration and installation state (backup)' +complete -c am -n "__fish_am_needs_command" -f -a "import" -d 'Import a previously exported configuration and state' +complete -c am -n "__fish_am_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c am -n "__fish_am_using_subcommand list" -l category -d 'Only show agents of this category' -r +complete -c am -n "__fish_am_using_subcommand list" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand list" -l installed-only -d 'Only show agents that are installed (managed or external)' +complete -c am -n "__fish_am_using_subcommand list" -l all -d 'Include hidden agents as well' +complete -c am -n "__fish_am_using_subcommand list" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand list" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand list" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand list" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand list" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand list" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand list" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand start" -l args -d 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' -r +complete -c am -n "__fish_am_using_subcommand start" -l env -d 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' -r +complete -c am -n "__fish_am_using_subcommand start" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand start" -s b -l background -d 'Run detached in the background; output goes to the agent log file' +complete -c am -n "__fish_am_using_subcommand start" -s f -l foreground -d 'Run in the foreground, attached to this terminal (the default)' +complete -c am -n "__fish_am_using_subcommand start" -l notify -d 'Send a desktop notification once the agent has started' +complete -c am -n "__fish_am_using_subcommand start" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand start" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand start" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand start" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand start" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand start" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand start" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand stop" -l timeout -d 'Grace period in seconds before killing (default: from config, 5s)' -r +complete -c am -n "__fish_am_using_subcommand stop" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand stop" -l force -d 'Kill immediately instead of terminating gracefully' +complete -c am -n "__fish_am_using_subcommand stop" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand stop" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand stop" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand stop" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand stop" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand stop" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand stop" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand restart" -l args -d 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' -r +complete -c am -n "__fish_am_using_subcommand restart" -l env -d 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' -r +complete -c am -n "__fish_am_using_subcommand restart" -l timeout -d 'Grace period in seconds before killing (default: from config, 5s)' -r +complete -c am -n "__fish_am_using_subcommand restart" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand restart" -s b -l background -d 'Run detached in the background; output goes to the agent log file' +complete -c am -n "__fish_am_using_subcommand restart" -s f -l foreground -d 'Run in the foreground, attached to this terminal (the default)' +complete -c am -n "__fish_am_using_subcommand restart" -l notify -d 'Send a desktop notification once the agent has started' +complete -c am -n "__fish_am_using_subcommand restart" -l force -d 'Kill immediately instead of terminating gracefully' +complete -c am -n "__fish_am_using_subcommand restart" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand restart" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand restart" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand restart" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand restart" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand restart" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand restart" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand status" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand status" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand status" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand status" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand status" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand status" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand status" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand status" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand install" -l method -d 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)' -r +complete -c am -n "__fish_am_using_subcommand install" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand install" -l force -d 'Reinstall even if already installed' +complete -c am -n "__fish_am_using_subcommand install" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand install" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand install" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand install" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand install" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand install" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand install" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand uninstall" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand uninstall" -l purge -d 'Also remove logs and the agent entry from the user config file' +complete -c am -n "__fish_am_using_subcommand uninstall" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand uninstall" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand uninstall" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand uninstall" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand uninstall" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand uninstall" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand uninstall" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand update" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand update" -l all -d 'Update every installed managed agent' +complete -c am -n "__fish_am_using_subcommand update" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand update" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand update" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand update" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand update" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand update" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand update" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand search" -l category -d 'Restrict to this category' -r +complete -c am -n "__fish_am_using_subcommand search" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand search" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand search" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand search" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand search" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand search" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand search" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand search" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand info" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand info" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand info" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand info" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand info" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand info" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand info" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand info" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "show" -d 'Print the effective (merged) configuration as YAML' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "path" -d 'Print the location of the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "edit" -d 'Open the user configuration file in your editor' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "validate" -d 'Validate the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "add" -d 'Add another agent definition file to the user configuration' +complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "show" -d 'Print the effective (merged) configuration as YAML' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "path" -d 'Print the location of the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "edit" -d 'Open the user configuration file in your editor' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "validate" -d 'Validate the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "add" -d 'Add another agent definition file to the user configuration' +complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c am -n "__fish_am_using_subcommand doctor" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand doctor" -l fix -d 'Attempt to repair problems (create directories, fix the state file)' +complete -c am -n "__fish_am_using_subcommand doctor" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand doctor" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand doctor" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand doctor" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand doctor" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand doctor" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand doctor" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand run" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand run" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand run" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand run" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand run" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand run" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand run" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand run" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand completion" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand completion" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand completion" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand completion" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand completion" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand completion" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand completion" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand completion" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand self-update" -l to -d 'Download the new binary to this path instead of replacing the current one' -r -F +complete -c am -n "__fish_am_using_subcommand self-update" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand self-update" -l check -d 'Only check whether a newer version exists' +complete -c am -n "__fish_am_using_subcommand self-update" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand self-update" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand self-update" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand self-update" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand self-update" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand self-update" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand self-update" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand export" -l output -d 'Output file (default: am-export.json)' -r -F +complete -c am -n "__fish_am_using_subcommand export" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand export" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand export" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand export" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand export" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand export" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand export" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand export" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand import" -s c -l config -d 'Use an alternate configuration file' -r -F +complete -c am -n "__fish_am_using_subcommand import" -s v -l verbose -d 'Verbose output: show every command executed and its details' +complete -c am -n "__fish_am_using_subcommand import" -s q -l quiet -d 'Quiet mode: only errors are printed' +complete -c am -n "__fish_am_using_subcommand import" -s y -l yes -d 'Answer yes to every confirmation prompt' +complete -c am -n "__fish_am_using_subcommand import" -l dry-run -d 'Simulate the action without changing anything' +complete -c am -n "__fish_am_using_subcommand import" -l json -d 'Emit machine-readable JSON on stdout (for scripts)' +complete -c am -n "__fish_am_using_subcommand import" -l no-color -d 'Disable ANSI colors' +complete -c am -n "__fish_am_using_subcommand import" -s h -l help -d 'Print help' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "list" -d 'List agents: installed ones, or every agent known to the catalog' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "start" -d 'Start an agent (foreground by default, or detached with --background)' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "stop" -d 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "restart" -d 'Restart an agent: stop, then start with the same options' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "status" -d 'Show the state of one agent, or of every installed agent' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "install" -d 'Install an agent and its dependencies' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "uninstall" -d 'Uninstall an agent and clean its files' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "update" -d 'Update an installed agent to the latest available version' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "search" -d 'Search the catalog by keyword (name, description, category, tags)' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "info" -d 'Show detailed information about one agent' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "config" -d 'Manage the configuration file' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "doctor" -d 'Check the environment: tools, config validity, paths, permissions' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "run" -d 'Run the agent command directly with the given arguments (no process management)' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "completion" -d 'Generate a shell completion script' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "self-update" -d 'Update agent-manager itself from GitHub Releases' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "export" -d 'Export the configuration and installation state (backup)' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "import" -d 'Import a previously exported configuration and state' +complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "show" -d 'Print the effective (merged) configuration as YAML' +complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "path" -d 'Print the location of the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "edit" -d 'Open the user configuration file in your editor' +complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "validate" -d 'Validate the configuration file(s)' +complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "add" -d 'Add another agent definition file to the user configuration' diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6fe7c1d --- /dev/null +++ b/config.yaml @@ -0,0 +1,980 @@ +# ============================================================================ +# agent-manager — default configuration (embedded in the binary) +# ============================================================================ +# This is the base catalog. User configuration files +# (./agent-manager.yaml, ~/.config/agent-manager/config.yaml, --config ) +# override or extend it. Run 'am config show' to see the effective config. +# +# Install types: npm | pip | uv | cargo | go | bun | curl | binary | git +# ============================================================================ + +version: "1.0" + +# --- Global settings (null = platform default) ------------------------------- +settings: + install_dir: null # default: ~/.local/share/agent-manager/agents + log_dir: null # default: ~/.local/state/agent-manager/logs + default_shell: null # default: sh on Unix + auto_install_deps: true # offer to install missing dependencies + confirm_before_run: true # confirm before running install scripts + stop_timeout_secs: 5 # SIGTERM grace period before SIGKILL + self_update_repo: null # e.g. "you/agent-manager" for am self-update + +# --- Command aliases ---------------------------------------------------------- +aliases: + cc: claude-code + gemini: gemini-cli + gh-copilot: github-copilot + q: amazon-q + droid: factory-droid + gjc: gajae-code + tiny: tiny-agents + continue: continue-cli + +# --- Groups (start/stop/restart several agents together) ---------------------- +groups: + dev: [claude-code, aider, codex] + local: [atomic-agent, nanocoder, gptme, goose] + claw: [openclaw, zeroclaw, picoclaw] + mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code] + +# ============================================================================ +# Agents +# ============================================================================ +agents: + # ---------------------------------------------------------------- installés + - name: claude-code + display_name: "Claude Code" + description: "Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent." + category: coding-agent + website: https://github.com/anthropics/claude-code + install: + type: npm + package: "@anthropic-ai/claude-code" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: claude + tags: [anthropic, assistant, source-available, mainstream] + + - name: hermes-agent + display_name: "Hermes Agent" + description: "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp)." + category: assistant + website: https://github.com/NousResearch/hermes-agent + install: + type: curl + script: "https://hermes-agent.nousresearch.com/install.sh" + dependencies: + - { name: curl, install_hint: "https://curl.se" } + - { name: sh, install_hint: "un shell POSIX" } + run: hermes + platforms: [linux, macos] + tags: [nous-research, memory, skills, multi-channel] + + - name: atomic-agent + display_name: "Atomic Agent" + description: "Agent local-first optimisé pour les modèles locaux (llama.cpp/Ollama). Contrôle navigateur (CDP) + OS, 14 skills intégrés (docker, github, notion, obsidian, pdf, ffmpeg...)." + category: local-first + website: https://github.com/AtomicBot-ai/atomic-agent + install: + type: binary + repo: AtomicBot-ai/atomic-agent + binary: atomic-agent + run: atomic-agent + tags: [local, ollama, browser, skills] + + - name: pi + display_name: "Pi" + description: "Harness terminal minimal et adaptable (pi-mono). API LLM unifiée, TUI, skills et MCP. C'est la base sur laquelle Prime Agent est construit." + category: coding-agent + website: https://github.com/badlogic/pi-mono + install: + type: npm + package: "@earendil-works/pi-coding-agent" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: pi + tags: [harness, mcp, tui] + + - name: tiny-agents + display_name: "Tiny Agents" + description: "Squad d'agents composables basés sur l'Inference Client HuggingFace + MCP. Chaque agent est défini par un agent.json." + category: coding-agent + website: https://huggingface.co/datasets/tiny-agents/tiny-agents + install: + type: pip + package: tiny-agents + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: tiny-agents + note: "usage : tiny-agents run " + tags: [huggingface, mcp, experimental] + + - name: nemoclaw + display_name: "NemoClaw" + description: "CLI NVIDIA pour provisionner et gérer des sandboxes sécurisées (OpenShell runtime) exécutant des agents. Politiques réseau, filesystem et process." + category: coding-agent + website: https://github.com/NVIDIA/NemoClaw + install: + type: npm + package: nemoclaw + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: nemoclaw + tags: [nvidia, sandbox, docker] + + # --------------------------------------------------------------- Prime Agent + - name: prime-agent + display_name: "Prime Agent" + description: "Agent open-source (MIT) de Prime Intellect. RLM (Recursive Language Model) — contexte traité comme variables, outils comme appels de fonction dans un REPL IPython persistant. Construit sur pi." + category: coding-agent + website: https://github.com/PrimeIntellect-ai/prime-agent + install: + type: curl + script: "https://app.primeintellect.ai/prime-agent/install.sh" + dependencies: + - { name: curl, install_hint: "https://curl.se" } + - { name: sh, install_hint: "un shell POSIX" } + run: prime-agent + platforms: [linux, macos] + tags: [rlm, daemon, self-improving] + + # --------------------------------------------------- Poids lourds open source + - name: opencode + display_name: "OpenCode" + description: "Agent terminal 75+ providers, intégration LSP, design privacy-first." + category: coding-agent + website: https://github.com/anomalyco/opencode + install: + type: npm + package: opencode-ai + methods: + - type: curl + script: "https://opencode.ai/install" + note: "installateur officiel (bash)" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: opencode + tags: [multi-provider, lsp, privacy] + + - name: claw-code + display_name: "Claw Code" + description: "Réécriture clean-room Rust/Python de l'architecture Claude Code. Le repo le plus rapide de l'histoire de GitHub à atteindre 100K stars." + category: coding-agent + website: https://github.com/ultraworkers/claw-code + install: + type: binary + repo: ultraworkers/claw-code + binary: claw-code + run: claw-code + note: "projet en mode musée — maintenance 100 % automatisée" + tags: [rust, clean-room, fork] + + - name: gemini-cli + display_name: "Gemini CLI" + description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche." + category: coding-agent + website: https://github.com/google-gemini/gemini-cli + install: + type: npm + package: "@google/gemini-cli" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: gemini + tags: [google, mainstream] + + - name: codex + display_name: "Codex CLI" + description: "Agent local officiel OpenAI, lecture/édition/exécution de code, TUI interactif (Apache-2.0)." + category: coding-agent + website: https://github.com/openai/codex + install: + type: npm + package: "@openai/codex" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: codex + tags: [openai, mainstream] + + - name: openhands + display_name: "OpenHands" + description: "Environnement de développement agentique open-source (ex-OpenDevin), avec entrées CLI et web." + category: coding-agent + website: https://github.com/All-Hands-AI/OpenHands + install: + type: pip + package: openhands + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: openhands + note: "une image Docker officielle existe aussi (allhandsai/openhands)" + tags: [agentic-dev, web-ui] + + - name: cline + display_name: "Cline CLI" + description: "Agent autonome multi-modèle pour planning, édition de fichiers, exécution de commandes et browser use." + category: coding-agent + website: https://github.com/cline/cline + install: + type: npm + package: "@cline/cline" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: cline + tags: [multi-model, planning, browser] + + - name: goose + display_name: "Goose" + description: "Agent local et extensible, conçu pour tourner on-device, intégration MCP." + category: local-first + website: https://github.com/aaif-goose/goose + install: + type: npm + package: "@aaif-goose/goose" + methods: + - type: binary + repo: aaif-goose/goose + binary: goose + note: "binaire depuis les releases GitHub" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: goose + tags: [local, mcp, on-device] + + - name: aider + display_name: "Aider" + description: "Pair-programming par diffs/patches, excellent support git et multi-fichiers. Le vétéran du genre." + category: coding-agent + website: https://github.com/Aider-AI/aider + install: + type: uv + package: aider-install + methods: + - type: pip + package: aider-install + note: "via pip (plus lent)" + post_install: ["aider-install"] + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: aider + note: "aider-install installe aider dans ~/.local (style pipx)" + tags: [diffs, git, pair-programming] + + - name: continue-cli + display_name: "Continue CLI" + description: "Extension terminal multi-modèle, focus local/privacy." + category: coding-agent + website: https://github.com/continuedev/continue + install: + type: npm + package: "@continuedev/continue" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: continue + tags: [multi-model, privacy] + + - name: deep-agents-code + display_name: "Deep Agents Code" + description: "Agent coding terminal officiel LangChain (Deep Agents SDK). TUI, opérations fichiers, shell, subagents, mode headless, human-in-the-loop." + category: coding-agent + website: https://github.com/langchain-ai/deepagents + install: + type: pip + package: deepagents-code + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: deepagents-code + tags: [langchain, subagents] + + - name: crush + display_name: "Crush" + description: "TUI agentic Go signé Charmbracelet, multi-provider, LSP-aware, riche UI terminal." + category: coding-agent + website: https://github.com/charmbracelet/crush + install: + type: go + package: github.com/charmbracelet/crush@latest + dependencies: + - { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" } + run: crush + tags: [go, tui, lsp] + + - name: qwen-code + display_name: "Qwen Code" + description: "Agent CLI officiel Alibaba pour les modèles Qwen coder (Apache-2.0)." + category: coding-agent + website: https://github.com/QwenLM/qwen-code + install: + type: npm + package: "@qwen-code/qwen-code" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: qwen-code + tags: [alibaba, qwen] + + - name: grok-build + display_name: "Grok Build" + description: "Harness et TUI de coding officiel xAI, fullscreen, interactif à la souris (Apache-2.0)." + category: coding-agent + website: https://github.com/xai-org/grok-build + install: + type: npm + package: "@xai-org/grok-build" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: grok-build + platforms: [linux, macos] + tags: [xai, tui] + + - name: kimi-cli + display_name: "Kimi CLI" + description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0)." + category: coding-agent + website: https://github.com/MoonshotAI/kimi-cli + install: + type: npm + package: "@moonshotai/kimi-cli" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: kimi + tags: [moonshot, skills, mcp] + + - name: plandex + display_name: "Plandex" + description: "Agent CLI plan-first pour construire des features multi-fichiers avec étapes structurées et 2M tokens de contexte." + category: coding-agent + website: https://github.com/plandex-ai/plandex + install: + type: curl + script: "https://plandex.ai/install.sh" + dependencies: + - { name: curl, install_hint: "https://curl.se" } + - { name: sh, install_hint: "un shell POSIX" } + run: plandex + platforms: [linux, macos] + note: "développement au ralenti (dernier commit oct. 2025)" + tags: [plan-first, large-context] + + - name: swe-agent + display_name: "SWE-agent" + description: "Agent pour résoudre de vrais issues de repo et tâches PR (workflows SWE-bench)." + category: coding-agent + website: https://github.com/SWE-agent/SWE-agent + install: + type: pip + package: swe-agent + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: sweagent + tags: [swe-bench, issues] + + - name: open-interpreter + display_name: "Open Interpreter" + description: "Outil terminal qui exécute du code et des actions — faire des choses sur ma machine." + category: coding-agent + website: https://github.com/OpenInterpreter/open-interpreter + install: + type: pip + package: open-interpreter + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: interpreter + tags: [code-execution] + + - name: mimo-code + display_name: "MiMo Code" + description: "Agent terminal officiel Xiaomi. TUI + mode non-interactif, MCP, skills, hooks, git worktrees, sessions reprenables." + category: coding-agent + website: https://github.com/XiaomiMiMo/MiMo-Code + install: + type: npm + package: "@xiaomi-mimo/cli" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: mimo + tags: [xiaomi, worktrees] + + - name: smol-developer + display_name: "Smol Developer" + description: "Agent développeur embarquable qui génère des codebases entières depuis un prompt." + category: coding-agent + website: https://github.com/smol-ai/developer + install: + type: pip + package: smol-developer + dependencies: + - { name: python, min_version: "3.8.0", install_hint: "https://python.org" } + run: smol-developer + note: "projet inactif (dernier commit sept. 2023)" + tags: [codegen, pioneer] + + - name: groq-code-cli + display_name: "Groq Code CLI" + description: "CLI léger propulsé par l'inférence ultra-rapide de Groq, outils/commandes extensibles." + category: coding-agent + website: https://github.com/build-with-groq/groq-code-cli + install: + type: npm + package: groq-code-cli + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: groq-code + tags: [groq, fast] + + # ------------------------------------------------------- Trésors open source + - name: jcode + display_name: "jcode" + description: "Agent TUI Rust optimisé RAM et latence de démarrage (~28 MB/session). Mémoire agent, swarm mode, browser automation, MCP, 40+ providers avec OAuth intégré." + category: coding-agent + website: https://github.com/1jehuang/jcode + install: + type: binary + repo: 1jehuang/jcode + binary: jcode + run: jcode + tags: [rust, swarm, tui] + + - name: agentty + display_name: "agentty" + description: "Agent terminal C++26, binaire statique 13.6 MB, cold start sub-milliseconde, zéro dépendance runtime, sandbox par défaut." + category: coding-agent + website: https://github.com/1ay1/agentty + install: + type: binary + repo: 1ay1/agentty + binary: agentty + run: agentty + tags: [c++, static, sandbox] + + - name: nullclaw + display_name: "NullClaw" + description: "Plus petit agent compatible OpenClaw, en Zig. 678 KB, ~1MB RAM, <2ms startup, 23+ providers, 18 channels." + category: assistant + website: https://github.com/nullclaw/nullclaw + install: + type: binary + repo: nullclaw/nullclaw + binary: nullclaw + run: nullclaw + tags: [zig, tiny, openclaw] + + - name: nanocoder + display_name: "Nanocoder" + description: "Agent CLI local-first communautaire. BYO model (Ollama, OpenRouter, API OpenAI-compatible), tool calling natif + fallback XML, MCP, tools personnalisés." + category: local-first + website: https://github.com/Nano-Collective/nanocoder + install: + type: uv + package: nanocoder + methods: + - type: pip + package: nanocoder + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: nanocoder + tags: [local, ollama, byo-model] + + - name: gptme + display_name: "gptme" + description: "Agent dans ton terminal avec agents persistants auto-modifiants, mémoire git-backed. Exécute du code, édite des fichiers, browse le web." + category: coding-agent + website: https://github.com/gptme/gptme + install: + type: uv + package: gptme + methods: + - type: pip + package: gptme + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: gptme + tags: [persistent, memory] + + - name: devon + display_name: "Devon" + description: "Pair programmer open-source avec TUI, planification, exécution et debugging autonomes dans les workflows git." + category: coding-agent + website: https://github.com/entropy-research/Devon + install: + type: pip + package: devon-agent + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: devon + note: "projet inactif (dernier commit juil. 2024)" + tags: [tui, autonomous] + + - name: ra-aid + display_name: "RA.Aid" + description: "Agent de coding autonome sur LangGraph avec pipeline research -> plan -> implement, intégration aider optionnelle." + category: coding-agent + website: https://github.com/ai-christianson/RA.Aid + install: + type: pip + package: ra-aid + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: ra-aid + note: "développement au ralenti (juin 2025)" + tags: [langgraph, pipeline] + + - name: letta-code + display_name: "Letta Code" + description: "Agent CLI memory-first (ex-MemGPT). Mémoire persistante entre sessions, model-agnostique, skill learning, context repositories." + category: coding-agent + website: https://github.com/letta-ai/letta-code + install: + type: pip + package: letta-code + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: letta-code + tags: [memory, memgpt] + + - name: dvalincode + display_name: "DvalinCode" + description: "Agent local-first provider-neutre orienté gouvernance : policy engine, egress réseau forcé, audit trail hash-chaîné, approval diff inline, Web GUI embarqué." + category: local-first + website: https://github.com/arthurpanhku/dvalincode + install: + type: binary + repo: arthurpanhku/dvalincode + binary: dvalincode + run: dvalincode + tags: [governance, audit] + + - name: san + display_name: "San" + description: "Runtime Go provider-agnostique pour agents spécialisés. Exécute les skills/plugins/MCP Claude Code sans modification, 9+ providers, mémoire auto-évolutive." + category: coding-agent + website: https://github.com/genai-io/san + install: + type: binary + repo: genai-io/san + binary: san + run: san + tags: [go, skills, runtime] + + - name: zap + display_name: "Zap" + description: "Agent TUI Rust skill-first, injecte seulement le contexte nécessaire. Code-indexé SQLite, MCP, modèles locaux via LM Studio." + category: coding-agent + website: https://github.com/zap-coding-agent/zap-coding-agent + install: + type: binary + repo: zap-coding-agent/zap-coding-agent + binary: zap + run: zap + tags: [rust, tui, sqlite] + + - name: smelt + display_name: "Smelt" + description: "Agent TUI Rust multi-provider, 4 modes (Normal/Plan/Apply/Yolo), permissions granulaires, subagents parallèles, keybinds vim, mode headless." + category: coding-agent + website: https://github.com/leonardcser/smelt + install: + type: cargo + package: smelt + dependencies: + - { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" } + run: smelt + tags: [rust, tui, vim] + + - name: grinta + display_name: "Grinta" + description: "Agent terminal local-first pour exécution autonome long-horizon : état durable, récupération, gestion de contexte, orchestration d'outils, LSP/DAP." + category: local-first + website: https://github.com/josephsenior/Grinta-Coding-Agent + install: + type: pip + package: grinta + dependencies: + - { name: python, min_version: "3.10.0", install_hint: "https://python.org" } + run: grinta + tags: [long-horizon, lsp] + + - name: waveloom + display_name: "Waveloom" + description: "Agent Go avec TUI Bubble Tea, DeepSeek V4 prompt caching, 4 niveaux de compaction de contexte, 3 modes subagent (Fork/Cold/Explore)." + category: coding-agent + website: https://github.com/Menfre01/waveloom + install: + type: binary + repo: Menfre01/waveloom + binary: waveloom + run: waveloom + tags: [go, tui, deepseek] + + - name: every-code + display_name: "Every Code" + description: "Fork de Codex CLI avec validation, automation, intégration browser, multi-agents, theming et orchestration multi-provider (OpenAI/Claude/Gemini)." + category: coding-agent + website: https://github.com/just-every/code + install: + type: git + repo: "https://github.com/just-every/code" + build: ["npm", "install"] + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: "every code" + tags: [fork, codex, multi-provider] + + - name: free-code + display_name: "Free Code" + description: "Fork de Claude Code sans télémétrie, guardrails retirés, toutes les features expérimentales activées." + category: coding-agent + website: https://github.com/paoloanzn/free-code + install: + type: git + repo: "https://github.com/paoloanzn/free-code" + build: ["npm", "install"] + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: free-code + tags: [fork, no-telemetry] + + - name: claurst + display_name: "Claurst" + description: "Claude Code réécrit en Rust idiomatique, avec découvertes du leak source (KAIROS, buddy system)." + category: coding-agent + website: https://github.com/Kuberwastaken/claurst + install: + type: git + repo: "https://github.com/Kuberwastaken/claurst" + build: ["cargo", "build", "--release"] + binary_path: target/release/claurst + dependencies: + - { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" } + run: claurst + tags: [rust, rewrite] + + - name: mistral-vibe + display_name: "Mistral Vibe" + description: "Assistant CLI Mistral pour interaction conversationnelle et éditions de repo." + category: coding-agent + website: https://github.com/mistralai/mistral-vibe + install: + type: npm + package: "@mistralai/mistral-vibe" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: mistral-vibe + tags: [mistral] + + - name: amazon-q + display_name: "Amazon Q Dev CLI" + description: "Chat terminal agentic AWS pour build, debugging et DevOps en langage naturel." + category: coding-agent + website: https://github.com/aws/amazon-q-developer-cli + install: + type: npm + package: "@aws/amazon-q-developer-cli" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: q + tags: [aws, devops] + + - name: lazycodex + display_name: "LazyCodex" + description: "Harness d'agent pour codebases complexes — package OmO comme distribution Codex. Mémoire projet, planning, exécution et complétion vérifiée à l'intérieur de Codex. L'équivalent de LazyVim pour Codex." + category: coding-agent + website: https://github.com/code-yeongyu/lazycodex + install: + type: npm + package: lazycodex-ai + post_install: ["lazycodex-ai", "install"] + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: "npx lazycodex-ai" + note: "s'intègre dans Codex (nécessite @openai/codex)" + tags: [harness, codex, plan] + + - name: gajae-code + display_name: "Gajae-Code" + description: "Harness de coding externe (gjc) — se connecte au plan que tu paies déjà (Claude, Codex, Cursor, Copilot...). Workflow plan-gated, réponses aux questions depuis le téléphone." + category: coding-agent + website: https://github.com/Yeachan-Heo/gajae-code + install: + type: npm + package: gajae-code + methods: + - type: bun + package: gajae-code + note: "via bun (plus rapide)" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: gjc + tags: [harness, subscription, telegram] + + # ------------------------------------------------------------ Local-first + - name: openyak + display_name: "openyak" + description: "Agent desktop local-first, zéro compte, zéro télémétrie. Modèles locaux via Ollama/Rapid-MLX ou BYO." + category: desktop + website: https://github.com/openyak/openyak + installable: false + note: "application desktop — téléchargez-la depuis https://github.com/openyak/openyak/releases" + tags: [desktop, ollama] + + - name: kun + display_name: "Kun" + description: "Workspace local-first desktop GUI + TUI pour coding, écriture, design, recherche et automation." + category: desktop + website: https://github.com/KunAgent/Kun + installable: false + note: "application desktop — https://github.com/KunAgent/Kun/releases" + tags: [desktop, workspace] + + - name: ipollowork + display_name: "iPolloWork" + description: "Workspace AI local-first source-available, alternative à Codex et Claude Code pour code, fichiers, docs, sites web." + category: local-first + website: https://github.com/Devin-AXIS/iPolloWork + install: + type: git + repo: "https://github.com/Devin-AXIS/iPolloWork" + run: ipollowork + note: "étapes de build spécifiques au repo — consultez son README" + tags: [workspace, source-available] + + - name: cc-haha + display_name: "cc-haha" + description: "Workspace desktop local-first cross-platform pour Claude Code/agents : multi-agent, git worktrees, diffs, skills marketplace." + category: desktop + website: https://github.com/NanmiCoder/cc-haha + installable: false + note: "application desktop — https://github.com/NanmiCoder/cc-haha/releases" + tags: [desktop, multi-agent] + + - name: claw-empire + display_name: "claw-empire" + description: "Simulateur de bureau d'agents AI local-first — orchestre CLI, OAuth et API depuis un CEO Desk." + category: local-first + website: https://github.com/GreenSheep01201/claw-empire + install: + type: git + repo: "https://github.com/GreenSheep01201/claw-empire" + run: claw-empire + note: "étapes de build spécifiques au repo — consultez son README" + tags: [simulator, orchestration] + + # ----------------------------------------------------- Écosystème OpenClaw + - name: openclaw + display_name: "OpenClaw" + description: "L'assistant personnel AI local d'origine. CLI avec onboarding, skills, outils, multi-channel (WhatsApp/Slack/Discord)." + category: assistant + website: https://github.com/openclaw/openclaw + install: + type: npm + package: "@openclaw/openclaw" + dependencies: + - { name: node, min_version: "22.0.0", install_hint: "https://nodejs.org" } + run: openclaw + tags: [assistant, multi-channel, skills] + + - name: nanobot + display_name: "nanobot" + description: "Réécriture ultra-légère (~4000 lignes Python) d'OpenClaw. Tool use, mémoire persistante, tâches planifiées, Telegram/Discord/WhatsApp." + category: assistant + website: https://github.com/HKUDS/nanobot + install: + type: git + repo: "https://github.com/HKUDS/nanobot" + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: "python -m nanobot" + tags: [python, lightweight] + + - name: zeroclaw + display_name: "ZeroClaw" + description: "Runtime d'agent AI Rust autonome, architecture pluggable trait-driven, tourne sur matériel minimal (<5MB RAM), exécution sandboxée, recherche hybride." + category: assistant + website: https://github.com/zeroclaw-labs/zeroclaw + install: + type: binary + repo: zeroclaw-labs/zeroclaw + binary: zeroclaw + run: zeroclaw + tags: [rust, minimal, sandbox] + + - name: nanoclaw + display_name: "NanoClaw" + description: "Alternative légère security-first à OpenClaw, agents en containers Apple/Docker, sandbox, basé sur Anthropic Agents SDK." + category: assistant + website: https://github.com/gavrielc/nanoclaw + install: + type: git + repo: "https://github.com/gavrielc/nanoclaw" + dependencies: + - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } + run: nanoclaw + tags: [security, sandbox] + + - name: picoclaw + display_name: "PicoClaw" + description: "Assistant personnel AI ultra-léger en Go, <10MB RAM, tourne sur du matériel à 10 dollars." + category: assistant + website: https://github.com/sipeed/picoclaw + install: + type: git + repo: "https://github.com/sipeed/picoclaw" + dependencies: + - { name: go, min_version: "1.21.0", install_hint: "https://go.dev/dl" } + run: picoclaw + tags: [go, tiny] + + - name: ironclaw + display_name: "IronClaw" + description: "OpenClaw réécrit en Rust par NEAR AI. Sandbox WASM, permissions capability-based, défense anti prompt-injection." + category: assistant + website: https://github.com/nearai/ironclaw + install: + type: git + repo: "https://github.com/nearai/ironclaw" + dependencies: + - { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" } + run: ironclaw + tags: [rust, wasm, security] + + - name: moltis + display_name: "Moltis" + description: "Alternative Rust-native à OpenClaw, zéro code unsafe, 2300+ tests, voice I/O intégré, MCP, multi-channel." + category: assistant + website: https://github.com/moltis-org/moltis + install: + type: git + repo: "https://github.com/moltis-org/moltis" + dependencies: + - { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" } + run: moltis + tags: [rust, voice, mcp] + + # ---------------------------------------------------------- Propriétaires + - name: cursor-cli + display_name: "Cursor CLI" + description: "Agent CLI officiel Cursor, shell mode, headless/CI, agents parallèles, multi-modèles." + category: proprietary + website: https://cursor.com/cli + install: + type: npm + package: cursor-agent + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: cursor + note: "paquet npm communautaire — l'officiel est livré avec l'app Cursor" + tags: [cursor, proprietary] + + - name: warp + display_name: "Warp" + description: "Terminal moderne avec mode agent AI intégré — comprend les tâches, exécute les commandes, édite les fichiers." + category: desktop + website: https://github.com/warpdotdev/Warp + installable: false + note: "application desktop — téléchargez-la depuis https://warp.dev" + tags: [terminal, desktop] + + - name: github-copilot + display_name: "GitHub Copilot CLI" + description: "CLI agentic GitHub pour workflows repo/PR/issue, suggestions de commandes, automation headless." + category: proprietary + website: https://github.com/github/copilot-cli + install: + type: npm + package: "@github/copilot" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: copilot + tags: [github, copilot] + + - name: devin + display_name: "Devin" + description: "Ingénieur logiciel AI autonome de Cognition, accès shell/browser complet, code auto-réparant, collaboration PR." + category: saas + website: https://devin.ai + installable: false + note: "SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API)" + tags: [saas, autonomous] + + - name: factory-droid + display_name: "Factory Droid" + description: "Agent CLI multi-modèle de Factory, #1 sur Terminal-Bench, droids spécialisés, mode headless CI." + category: proprietary + website: https://github.com/Factory-AI/factory + install: + type: npm + package: "@factory-ai/factory" + dependencies: + - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } + run: droid + tags: [factory, benchmark, ci] + + - name: amp + display_name: "Amp" + description: "Agent AI de Sourcegraph pour implémenter des tâches sur de vraies codebases." + category: saas + website: https://sourcegraph.com/amp + installable: false + note: "SaaS — via https://sourcegraph.com/amp" + tags: [saas, sourcegraph] + + - name: junie-cli + display_name: "Junie CLI" + description: "Agent CLI LLM-agnostique JetBrains (EAP), GPT-5/Claude/Gemini/Grok, plan mode, headless CI/CD." + category: saas + website: https://junie.jetbrains.com + installable: false + note: "SaaS — via https://junie.jetbrains.com (installation gérée par JetBrains)" + tags: [saas, jetbrains] + + - name: cortex-code-cli + display_name: "Cortex Code CLI" + description: "Agent CLI data-native de Snowflake pour pipelines, analytics et apps AI avec gouvernance enterprise." + category: saas + website: https://www.snowflake.com/en/product/cortex-code/ + installable: false + note: "SaaS — via Snowflake (https://www.snowflake.com)" + tags: [saas, snowflake, data] + + - name: tabnine-cli + display_name: "Tabnine CLI" + description: "Assistant coding AI agentic de Tabnine, distribué en container Docker, licence enterprise." + category: saas + website: https://docs.tabnine.com/main/getting-started/tabnine-cli + installable: false + note: "distribué en container Docker (licence enterprise) — voir https://docs.tabnine.com" + tags: [saas, docker] + + - name: mentat-cli + display_name: "Mentat CLI" + description: "Agent CLI cloud-native pour gérer des agents Mentat distants depuis le terminal, détection auto repo/branch." + category: saas + website: https://mentat.ai/docs/cli + installable: false + note: "SaaS — via https://mentat.ai/docs/cli" + tags: [saas, remote-agents] + + - name: pool + display_name: "Pool" + description: "Agent terminal de Poolside propulsé par les modèles Laguna, TUI + headless (pool exec), ACP client/server." + category: proprietary + website: https://github.com/poolsideai/pool + install: + type: binary + repo: poolsideai/pool + binary: pool + run: pool + note: "EULA propriétaire" + tags: [poolside, laguna] + + - name: ante + display_name: "Ante" + description: "Binaire Rust 15 MB (research preview), architecture client-daemon, TUI + headless, inférence GGUF offline embarquée (llama.cpp), 12+ providers." + category: proprietary + website: https://github.com/AntigmaLabs/ante-preview + install: + type: binary + repo: AntigmaLabs/ante-preview + binary: ante + run: ante + tags: [offline, gguf, daemon] + diff --git a/examples/raw/config-path.txt b/examples/raw/config-path.txt new file mode 100644 index 0000000..f5e7c7f --- /dev/null +++ b/examples/raw/config-path.txt @@ -0,0 +1,4 @@ +no user config found — using the embedded default catalog +user config directory: C:\Users\bruno\AppData\Roaming\agent-manager +user config file: C:\Users\bruno\AppData\Roaming\agent-manager\config.yaml +local config file: agent-manager.yaml (current directory) diff --git a/examples/raw/config-validate.txt b/examples/raw/config-validate.txt new file mode 100644 index 0000000..9b46f12 --- /dev/null +++ b/examples/raw/config-validate.txt @@ -0,0 +1 @@ +configuration is valid (72 agents) diff --git a/examples/raw/doctor.txt b/examples/raw/doctor.txt new file mode 100644 index 0000000..d92b14a --- /dev/null +++ b/examples/raw/doctor.txt @@ -0,0 +1,22 @@ +CHECK STATUS DETAIL +----------------------------- +git ok C:\Users\bruno\scoop\apps\git\current\cmd\git.e… +sh ok C:\Users\bruno\scoop\shims\sh.exe (v5.3.15) +curl ok C:\Windows\system32\curl.exe (v8.21.0) +node ok C:\Users\bruno\scoop\apps\nodejs22\current\node… +npm ok C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +python3 ok C:\Users\bruno\scoop\shims\python3.exe (v3.13.1… +python ok C:\Users\bruno\AppData\Local\Microsoft\WindowsA… +cargo ok C:\Users\bruno\scoop\apps\rustup\current\.cargo… +go ok C:\Users\bruno\scoop\shims\go.exe (v1.26.5) +bun ok C:\Users\bruno\scoop\shims\bun.exe (v1.3.14) +uv ok C:\Users\bruno\scoop\shims\uv.exe (v0.12.2) +docker ok C:\Program Files\Docker\Docker\resources\bin\do… +config warn no user config file found; using the embedded d… +config-schema ok 72 agents defined +state ok 0 installed agent(s) +install-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen… +bin-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen… +log-dir ok C:\Users\bruno\AppData\Local\agent-manager\logs +bin-on-path warn C:\Users\bruno\AppData\Local\agent-manager\agen… +4 problem(s) found — 'am doctor --fix' repairs what it can diff --git a/examples/raw/help.txt b/examples/raw/help.txt new file mode 100644 index 0000000..11952fd --- /dev/null +++ b/examples/raw/help.txt @@ -0,0 +1,56 @@ +agent-manager (am) — a CLI to manage local AI coding agents. + +Install, uninstall, start, stop, restart and update AI agents from a YAML +catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from +GitHub Releases, install scripts, and git repositories. All state lives under +your user directories; nothing pollutes the system. + +Usage: am [OPTIONS] [COMMAND] + +Commands: + list List installed agents (default); use --all for the whole catalog + start Start an agent (foreground by default, or detached with --background) + stop Stop a background agent (SIGTERM, then SIGKILL after the timeout) + restart Restart an agent: stop, then start with the same options + status Show the state of one agent, or of every installed agent + install Install an agent and its dependencies + uninstall Uninstall an agent and clean its files + update Update an installed agent to the latest available version + search Search the catalog by keyword (name, description, category, tags) + info Show detailed information about one agent + config Manage the configuration file + doctor Check the environment: tools, config validity, paths, permissions + run Run the agent command directly with the given arguments (no process management) + completion Generate a shell completion script + self-update Update agent-manager itself from GitHub Releases + export Export the configuration and installation state (backup) + import Import a previously exported configuration and state + help Print this message or the help of the given subcommand(s) + +Options: + -c, --config + Use an alternate configuration file + + -v, --verbose + Verbose output: show every command executed and its details + + -q, --quiet + Quiet mode: only errors are printed + + -y, --yes + Answer yes to every confirmation prompt + + --dry-run + Simulate the action without changing anything + + --json + Emit machine-readable JSON on stdout (for scripts) + + --no-color + Disable ANSI colors + + -h, --help + Print help (see a summary with '-h') + + -V, --version + Print version diff --git a/examples/raw/info-cc.txt b/examples/raw/info-cc.txt new file mode 100644 index 0000000..4973792 --- /dev/null +++ b/examples/raw/info-cc.txt @@ -0,0 +1,12 @@ +Claude Code + slug: claude-code + description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent. + category: coding-agent + website: https://github.com/anthropics/claude-code + tags: anthropic, assistant, source-available, mainstream + status: found on PATH (external — not managed) + run: claude + install methods: + 1) type=npm package=@anthropic-ai/claude-code + dependencies: + - node >= 18.0.0 — https://nodejs.org diff --git a/examples/raw/info-claude-code.txt b/examples/raw/info-claude-code.txt new file mode 100644 index 0000000..4973792 --- /dev/null +++ b/examples/raw/info-claude-code.txt @@ -0,0 +1,12 @@ +Claude Code + slug: claude-code + description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent. + category: coding-agent + website: https://github.com/anthropics/claude-code + tags: anthropic, assistant, source-available, mainstream + status: found on PATH (external — not managed) + run: claude + install methods: + 1) type=npm package=@anthropic-ai/claude-code + dependencies: + - node >= 18.0.0 — https://nodejs.org diff --git a/examples/raw/install-devin.txt b/examples/raw/install-devin.txt new file mode 100644 index 0000000..a641545 --- /dev/null +++ b/examples/raw/install-devin.txt @@ -0,0 +1,3 @@ +error: agent 'devin' is not installable locally: SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API) + +[exit code: 1] \ No newline at end of file diff --git a/examples/raw/install-dryrun.txt b/examples/raw/install-dryrun.txt new file mode 100644 index 0000000..f0bc30a --- /dev/null +++ b/examples/raw/install-dryrun.txt @@ -0,0 +1,4 @@ +[dry-run] would run: cargo --version +installing Smelt (type=cargo package=smelt)… +[dry-run] would run: cargo install --root C:\Users\bruno\AppData\Local\agent-manager\agents\smelt smelt +dry run complete — nothing was changed diff --git a/examples/raw/list-all.txt b/examples/raw/list-all.txt new file mode 100644 index 0000000..20bf87a --- /dev/null +++ b/examples/raw/list-all.txt @@ -0,0 +1,74 @@ +NAME VERSION STATUS SOURCE PATH +------------------------------------------------------------- +agentty — not-installed - — +Aider — not-installed - — +Amazon Q Dev CLI — not-installed - — +Amp — not-installable - — +Ante — not-installed - — +Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi… +cc-haha — not-installable - — +Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe +Claurst — not-installed - — +Claw Code — not-installed - — +claw-empire — not-installed - — +Cline CLI — not-installed - — +Codex CLI — not-installed - — +Continue CLI — not-installed - — +Cortex Code CLI — not-installable - — +Crush — not-installed - — +Cursor CLI — not-installed - — +Deep Agents Code — not-installed - — +Devin — not-installable - — +Devon — not-installed - — +DvalinCode — not-installed - — +Every Code — not-installed - — +Factory Droid — not-installed - — +Free Code — not-installed - — +Gajae-Code — not-installed - — +Gemini CLI — not-installed - — +GitHub Copilot CLI — not-installed - — +Goose — not-installed - — +gptme — not-installed - — +Grinta — not-installed - — +Grok Build — not-installed - — +Groq Code CLI — not-installed - — +Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen… +iPolloWork — not-installed - — +IronClaw — not-installed - — +jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe +Junie CLI — not-installable - — +Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe +Kun — not-installable - — +LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Letta Code — not-installed - — +Mentat CLI — not-installable - — +MiMo Code — not-installed - — +Mistral Vibe — not-installed - — +Moltis — not-installed - — +nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA… +NanoClaw — not-installed - — +Nanocoder — not-installed - — +NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +NullClaw — not-installed - — +Open Interpreter — not-installed - — +OpenClaw — not-installed - — +OpenCode — not-installed - — +OpenHands — not-installed - — +openyak — not-installable - — +Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +PicoClaw — not-installed - — +Plandex — not-installed - — +Pool — not-installed - — +Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Qwen Code — not-installed - — +RA.Aid — not-installed - — +San — not-installed - — +Smelt — not-installed - — +Smol Developer — not-installed - — +SWE-agent — not-installed - — +Tabnine CLI — not-installable - — +Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr… +Warp — not-installable - — +Waveloom — not-installed - — +Zap — not-installed - — +ZeroClaw — not-installed - — diff --git a/examples/raw/list-json.txt b/examples/raw/list-json.txt new file mode 100644 index 0000000..456921e --- /dev/null +++ b/examples/raw/list-json.txt @@ -0,0 +1,1082 @@ +[ + { + "name": "agentty", + "display_name": "agentty", + "category": "coding-agent", + "description": "Agent terminal C++26, binaire statique 13.6 MB, cold start sub-milliseconde, zéro dépendance runtime, sandbox par défaut.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "aider", + "display_name": "Aider", + "category": "coding-agent", + "description": "Pair-programming par diffs/patches, excellent support git et multi-fichiers. Le vétéran du genre.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "aider-install installe aider dans ~/.local (style pipx)" + }, + { + "name": "amazon-q", + "display_name": "Amazon Q Dev CLI", + "category": "coding-agent", + "description": "Chat terminal agentic AWS pour build, debugging et DevOps en langage naturel.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "amp", + "display_name": "Amp", + "category": "saas", + "description": "Agent AI de Sourcegraph pour implémenter des tâches sur de vraies codebases.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "SaaS — via https://sourcegraph.com/amp" + }, + { + "name": "ante", + "display_name": "Ante", + "category": "proprietary", + "description": "Binaire Rust 15 MB (research preview), architecture client-daemon, TUI + headless, inférence GGUF offline embarquée (llama.cpp), 12+ providers.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "atomic-agent", + "display_name": "Atomic Agent", + "category": "local-first", + "description": "Agent local-first optimisé pour les modèles locaux (llama.cpp/Ollama). Contrôle navigateur (CDP) + OS, 14 skills intégrés (docker, github, notion, obsidian, pdf, ffmpeg...).", + "status": "external", + "version": null, + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\AppData\\Local\\atomic-agent\\atomic-agent.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "cc-haha", + "display_name": "cc-haha", + "category": "desktop", + "description": "Workspace desktop local-first cross-platform pour Claude Code/agents : multi-agent, git worktrees, diffs, skills marketplace.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "application desktop — https://github.com/NanmiCoder/cc-haha/releases" + }, + { + "name": "claude-code", + "display_name": "Claude Code", + "category": "coding-agent", + "description": "Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent.", + "status": "external", + "version": "2.1.112", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\.local\\bin\\claude.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "claurst", + "display_name": "Claurst", + "category": "coding-agent", + "description": "Claude Code réécrit en Rust idiomatique, avec découvertes du leak source (KAIROS, buddy system).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "claw-code", + "display_name": "Claw Code", + "category": "coding-agent", + "description": "Réécriture clean-room Rust/Python de l'architecture Claude Code. Le repo le plus rapide de l'histoire de GitHub à atteindre 100K stars.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "projet en mode musée — maintenance 100 % automatisée" + }, + { + "name": "claw-empire", + "display_name": "claw-empire", + "category": "local-first", + "description": "Simulateur de bureau d'agents AI local-first — orchestre CLI, OAuth et API depuis un CEO Desk.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "étapes de build spécifiques au repo — consultez son README" + }, + { + "name": "cline", + "display_name": "Cline CLI", + "category": "coding-agent", + "description": "Agent autonome multi-modèle pour planning, édition de fichiers, exécution de commandes et browser use.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "codex", + "display_name": "Codex CLI", + "category": "coding-agent", + "description": "Agent local officiel OpenAI, lecture/édition/exécution de code, TUI interactif (Apache-2.0).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "continue-cli", + "display_name": "Continue CLI", + "category": "coding-agent", + "description": "Extension terminal multi-modèle, focus local/privacy.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "cortex-code-cli", + "display_name": "Cortex Code CLI", + "category": "saas", + "description": "Agent CLI data-native de Snowflake pour pipelines, analytics et apps AI avec gouvernance enterprise.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "SaaS — via Snowflake (https://www.snowflake.com)" + }, + { + "name": "crush", + "display_name": "Crush", + "category": "coding-agent", + "description": "TUI agentic Go signé Charmbracelet, multi-provider, LSP-aware, riche UI terminal.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "cursor-cli", + "display_name": "Cursor CLI", + "category": "proprietary", + "description": "Agent CLI officiel Cursor, shell mode, headless/CI, agents parallèles, multi-modèles.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "paquet npm communautaire — l'officiel est livré avec l'app Cursor" + }, + { + "name": "deep-agents-code", + "display_name": "Deep Agents Code", + "category": "coding-agent", + "description": "Agent coding terminal officiel LangChain (Deep Agents SDK). TUI, opérations fichiers, shell, subagents, mode headless, human-in-the-loop.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "devin", + "display_name": "Devin", + "category": "saas", + "description": "Ingénieur logiciel AI autonome de Cognition, accès shell/browser complet, code auto-réparant, collaboration PR.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API)" + }, + { + "name": "devon", + "display_name": "Devon", + "category": "coding-agent", + "description": "Pair programmer open-source avec TUI, planification, exécution et debugging autonomes dans les workflows git.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "projet inactif (dernier commit juil. 2024)" + }, + { + "name": "dvalincode", + "display_name": "DvalinCode", + "category": "local-first", + "description": "Agent local-first provider-neutre orienté gouvernance : policy engine, egress réseau forcé, audit trail hash-chaîné, approval diff inline, Web GUI embarqué.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "every-code", + "display_name": "Every Code", + "category": "coding-agent", + "description": "Fork de Codex CLI avec validation, automation, intégration browser, multi-agents, theming et orchestration multi-provider (OpenAI/Claude/Gemini).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "factory-droid", + "display_name": "Factory Droid", + "category": "proprietary", + "description": "Agent CLI multi-modèle de Factory, #1 sur Terminal-Bench, droids spécialisés, mode headless CI.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "free-code", + "display_name": "Free Code", + "category": "coding-agent", + "description": "Fork de Claude Code sans télémétrie, guardrails retirés, toutes les features expérimentales activées.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "gajae-code", + "display_name": "Gajae-Code", + "category": "coding-agent", + "description": "Harness de coding externe (gjc) — se connecte au plan que tu paies déjà (Claude, Codex, Cursor, Copilot...). Workflow plan-gated, réponses aux questions depuis le téléphone.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "gemini-cli", + "display_name": "Gemini CLI", + "category": "coding-agent", + "description": "Agent terminal officiel Google (Apache-2.0), outils repo + recherche.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "github-copilot", + "display_name": "GitHub Copilot CLI", + "category": "proprietary", + "description": "CLI agentic GitHub pour workflows repo/PR/issue, suggestions de commandes, automation headless.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "goose", + "display_name": "Goose", + "category": "local-first", + "description": "Agent local et extensible, conçu pour tourner on-device, intégration MCP.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "gptme", + "display_name": "gptme", + "category": "coding-agent", + "description": "Agent dans ton terminal avec agents persistants auto-modifiants, mémoire git-backed. Exécute du code, édite des fichiers, browse le web.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "grinta", + "display_name": "Grinta", + "category": "local-first", + "description": "Agent terminal local-first pour exécution autonome long-horizon : état durable, récupération, gestion de contexte, orchestration d'outils, LSP/DAP.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "grok-build", + "display_name": "Grok Build", + "category": "coding-agent", + "description": "Harness et TUI de coding officiel xAI, fullscreen, interactif à la souris (Apache-2.0).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "groq-code-cli", + "display_name": "Groq Code CLI", + "category": "coding-agent", + "description": "CLI léger propulsé par l'inférence ultra-rapide de Groq, outils/commandes extensibles.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "hermes-agent", + "display_name": "Hermes Agent", + "category": "assistant", + "description": "Agent Nous Research multi-modèle (300+ providers). Mémoire persistante, création automatique de skills, exécution de code sandboxée, reach multi-plateforme (Telegram/Slack/Discord/WhatsApp).", + "status": "external", + "version": "2026.8.3", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\AppData\\Local\\hermes\\hermes-agent\\venv\\Scripts\\hermes.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "ipollowork", + "display_name": "iPolloWork", + "category": "local-first", + "description": "Workspace AI local-first source-available, alternative à Codex et Claude Code pour code, fichiers, docs, sites web.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "étapes de build spécifiques au repo — consultez son README" + }, + { + "name": "ironclaw", + "display_name": "IronClaw", + "category": "assistant", + "description": "OpenClaw réécrit en Rust par NEAR AI. Sandbox WASM, permissions capability-based, défense anti prompt-injection.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "jcode", + "display_name": "jcode", + "category": "coding-agent", + "description": "Agent TUI Rust optimisé RAM et latence de démarrage (~28 MB/session). Mémoire agent, swarm mode, browser automation, MCP, 40+ providers avec OAuth intégré.", + "status": "external", + "version": "0.76.0", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\AppData\\Local\\jcode\\bin\\jcode.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "junie-cli", + "display_name": "Junie CLI", + "category": "saas", + "description": "Agent CLI LLM-agnostique JetBrains (EAP), GPT-5/Claude/Gemini/Grok, plan mode, headless CI/CD.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "SaaS — via https://junie.jetbrains.com (installation gérée par JetBrains)" + }, + { + "name": "kimi-cli", + "display_name": "Kimi CLI", + "category": "coding-agent", + "description": "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0).", + "status": "external", + "version": "0.31.1", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\.kimi-code\\bin\\kimi.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "kun", + "display_name": "Kun", + "category": "desktop", + "description": "Workspace local-first desktop GUI + TUI pour coding, écriture, design, recherche et automation.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "application desktop — https://github.com/KunAgent/Kun/releases" + }, + { + "name": "lazycodex", + "display_name": "LazyCodex", + "category": "coding-agent", + "description": "Harness d'agent pour codebases complexes — package OmO comme distribution Codex. Mémoire projet, planning, exécution et complétion vérifiée à l'intérieur de Codex. L'équivalent de LazyVim pour Codex.", + "status": "external", + "version": "11.1.0", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\scoop\\apps\\nodejs22\\current\\bin\\npx.cmd", + "running": false, + "pid": null, + "installable": true, + "note": "s'intègre dans Codex (nécessite @openai/codex)" + }, + { + "name": "letta-code", + "display_name": "Letta Code", + "category": "coding-agent", + "description": "Agent CLI memory-first (ex-MemGPT). Mémoire persistante entre sessions, model-agnostique, skill learning, context repositories.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "mentat-cli", + "display_name": "Mentat CLI", + "category": "saas", + "description": "Agent CLI cloud-native pour gérer des agents Mentat distants depuis le terminal, détection auto repo/branch.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "SaaS — via https://mentat.ai/docs/cli" + }, + { + "name": "mimo-code", + "display_name": "MiMo Code", + "category": "coding-agent", + "description": "Agent terminal officiel Xiaomi. TUI + mode non-interactif, MCP, skills, hooks, git worktrees, sessions reprenables.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "mistral-vibe", + "display_name": "Mistral Vibe", + "category": "coding-agent", + "description": "Assistant CLI Mistral pour interaction conversationnelle et éditions de repo.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "moltis", + "display_name": "Moltis", + "category": "assistant", + "description": "Alternative Rust-native à OpenClaw, zéro code unsafe, 2300+ tests, voice I/O intégré, MCP, multi-channel.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "nanobot", + "display_name": "nanobot", + "category": "assistant", + "description": "Réécriture ultra-légère (~4000 lignes Python) d'OpenClaw. Tool use, mémoire persistante, tâches planifiées, Telegram/Discord/WhatsApp.", + "status": "external", + "version": null, + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "nanoclaw", + "display_name": "NanoClaw", + "category": "assistant", + "description": "Alternative légère security-first à OpenClaw, agents en containers Apple/Docker, sandbox, basé sur Anthropic Agents SDK.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "nanocoder", + "display_name": "Nanocoder", + "category": "local-first", + "description": "Agent CLI local-first communautaire. BYO model (Ollama, OpenRouter, API OpenAI-compatible), tool calling natif + fallback XML, MCP, tools personnalisés.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "nemoclaw", + "display_name": "NemoClaw", + "category": "coding-agent", + "description": "CLI NVIDIA pour provisionner et gérer des sandboxes sécurisées (OpenShell runtime) exécutant des agents. Politiques réseau, filesystem et process.", + "status": "external", + "version": null, + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\scoop\\apps\\nodejs22\\current\\bin\\nemoclaw.cmd", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "nullclaw", + "display_name": "NullClaw", + "category": "assistant", + "description": "Plus petit agent compatible OpenClaw, en Zig. 678 KB, ~1MB RAM, <2ms startup, 23+ providers, 18 channels.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "open-interpreter", + "display_name": "Open Interpreter", + "category": "coding-agent", + "description": "Outil terminal qui exécute du code et des actions — faire des choses sur ma machine.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "openclaw", + "display_name": "OpenClaw", + "category": "assistant", + "description": "L'assistant personnel AI local d'origine. CLI avec onboarding, skills, outils, multi-channel (WhatsApp/Slack/Discord).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "opencode", + "display_name": "OpenCode", + "category": "coding-agent", + "description": "Agent terminal 75+ providers, intégration LSP, design privacy-first.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "openhands", + "display_name": "OpenHands", + "category": "coding-agent", + "description": "Environnement de développement agentique open-source (ex-OpenDevin), avec entrées CLI et web.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "une image Docker officielle existe aussi (allhandsai/openhands)" + }, + { + "name": "openyak", + "display_name": "openyak", + "category": "desktop", + "description": "Agent desktop local-first, zéro compte, zéro télémétrie. Modèles locaux via Ollama/Rapid-MLX ou BYO.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "application desktop — téléchargez-la depuis https://github.com/openyak/openyak/releases" + }, + { + "name": "pi", + "display_name": "Pi", + "category": "coding-agent", + "description": "Harness terminal minimal et adaptable (pi-mono). API LLM unifiée, TUI, skills et MCP. C'est la base sur laquelle Prime Agent est construit.", + "status": "external", + "version": "0.84.1", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\scoop\\apps\\nodejs22\\current\\bin\\pi.cmd", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "picoclaw", + "display_name": "PicoClaw", + "category": "assistant", + "description": "Assistant personnel AI ultra-léger en Go, <10MB RAM, tourne sur du matériel à 10 dollars.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "plandex", + "display_name": "Plandex", + "category": "coding-agent", + "description": "Agent CLI plan-first pour construire des features multi-fichiers avec étapes structurées et 2M tokens de contexte.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "développement au ralenti (dernier commit oct. 2025)" + }, + { + "name": "pool", + "display_name": "Pool", + "category": "proprietary", + "description": "Agent terminal de Poolside propulsé par les modèles Laguna, TUI + headless (pool exec), ACP client/server.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "EULA propriétaire" + }, + { + "name": "prime-agent", + "display_name": "Prime Agent", + "category": "coding-agent", + "description": "Agent open-source (MIT) de Prime Intellect. RLM (Recursive Language Model) — contexte traité comme variables, outils comme appels de fonction dans un REPL IPython persistant. Construit sur pi.", + "status": "external", + "version": "0.7.2", + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\scoop\\apps\\nodejs22\\current\\bin\\prime-agent.cmd", + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "qwen-code", + "display_name": "Qwen Code", + "category": "coding-agent", + "description": "Agent CLI officiel Alibaba pour les modèles Qwen coder (Apache-2.0).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "ra-aid", + "display_name": "RA.Aid", + "category": "coding-agent", + "description": "Agent de coding autonome sur LangGraph avec pipeline research -> plan -> implement, intégration aider optionnelle.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "développement au ralenti (juin 2025)" + }, + { + "name": "san", + "display_name": "San", + "category": "coding-agent", + "description": "Runtime Go provider-agnostique pour agents spécialisés. Exécute les skills/plugins/MCP Claude Code sans modification, 9+ providers, mémoire auto-évolutive.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "smelt", + "display_name": "Smelt", + "category": "coding-agent", + "description": "Agent TUI Rust multi-provider, 4 modes (Normal/Plan/Apply/Yolo), permissions granulaires, subagents parallèles, keybinds vim, mode headless.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "smol-developer", + "display_name": "Smol Developer", + "category": "coding-agent", + "description": "Agent développeur embarquable qui génère des codebases entières depuis un prompt.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": "projet inactif (dernier commit sept. 2023)" + }, + { + "name": "swe-agent", + "display_name": "SWE-agent", + "category": "coding-agent", + "description": "Agent pour résoudre de vrais issues de repo et tâches PR (workflows SWE-bench).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "tabnine-cli", + "display_name": "Tabnine CLI", + "category": "saas", + "description": "Assistant coding AI agentic de Tabnine, distribué en container Docker, licence enterprise.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "distribué en container Docker (licence enterprise) — voir https://docs.tabnine.com" + }, + { + "name": "tiny-agents", + "display_name": "Tiny Agents", + "category": "coding-agent", + "description": "Squad d'agents composables basés sur l'Inference Client HuggingFace + MCP. Chaque agent est défini par un agent.json.", + "status": "external", + "version": null, + "source": "external", + "method": null, + "path": "C:\\Users\\bruno\\scoop\\apps\\python313\\current\\Scripts\\tiny-agents.exe", + "running": false, + "pid": null, + "installable": true, + "note": "usage : tiny-agents run " + }, + { + "name": "warp", + "display_name": "Warp", + "category": "desktop", + "description": "Terminal moderne avec mode agent AI intégré — comprend les tâches, exécute les commandes, édite les fichiers.", + "status": "not-installable", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": false, + "note": "application desktop — téléchargez-la depuis https://warp.dev" + }, + { + "name": "waveloom", + "display_name": "Waveloom", + "category": "coding-agent", + "description": "Agent Go avec TUI Bubble Tea, DeepSeek V4 prompt caching, 4 niveaux de compaction de contexte, 3 modes subagent (Fork/Cold/Explore).", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "zap", + "display_name": "Zap", + "category": "coding-agent", + "description": "Agent TUI Rust skill-first, injecte seulement le contexte nécessaire. Code-indexé SQLite, MCP, modèles locaux via LM Studio.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + }, + { + "name": "zeroclaw", + "display_name": "ZeroClaw", + "category": "assistant", + "description": "Runtime d'agent AI Rust autonome, architecture pluggable trait-driven, tourne sur matériel minimal (<5MB RAM), exécution sandboxée, recherche hybride.", + "status": "not-installed", + "version": null, + "source": "-", + "method": null, + "path": null, + "running": false, + "pid": null, + "installable": true, + "note": null + } +] diff --git a/examples/raw/list.txt b/examples/raw/list.txt new file mode 100644 index 0000000..2825649 --- /dev/null +++ b/examples/raw/list.txt @@ -0,0 +1,13 @@ +NAME VERSION STATUS SOURCE PATH +------------------------------------------------ +Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi… +Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe +Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen… +jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe +Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe +LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA… +NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr… diff --git a/examples/raw/repl-piped.txt b/examples/raw/repl-piped.txt new file mode 100644 index 0000000..234d552 --- /dev/null +++ b/examples/raw/repl-piped.txt @@ -0,0 +1,36 @@ +agent-manager (am) — manage local AI coding agents + +Usage: am [OPTIONS] [COMMAND] + +Commands: + list List agents: installed ones, or every agent known to the catalog + start Start an agent (foreground by default, or detached with --background) + stop Stop a background agent (SIGTERM, then SIGKILL after the timeout) + restart Restart an agent: stop, then start with the same options + status Show the state of one agent, or of every installed agent + install Install an agent and its dependencies + uninstall Uninstall an agent and clean its files + update Update an installed agent to the latest available version + search Search the catalog by keyword (name, description, category, tags) + info Show detailed information about one agent + config Manage the configuration file + doctor Check the environment: tools, config validity, paths, permissions + run Run the agent command directly with the given arguments (no process management) + completion Generate a shell completion script + self-update Update agent-manager itself from GitHub Releases + export Export the configuration and installation state (backup) + import Import a previously exported configuration and state + help Print this message or the help of the given subcommand(s) + +Options: + -c, --config Use an alternate configuration file + -v, --verbose Verbose output: show every command executed and its details + -q, --quiet Quiet mode: only errors are printed + -y, --yes Answer yes to every confirmation prompt + --dry-run Simulate the action without changing anything + --json Emit machine-readable JSON on stdout (for scripts) + --no-color Disable ANSI colors + -h, --help Print help (see more with '--help') + -V, --version Print version + +exitcode=2 diff --git a/examples/raw/run-pi-dryrun.txt b/examples/raw/run-pi-dryrun.txt new file mode 100644 index 0000000..0b942e6 --- /dev/null +++ b/examples/raw/run-pi-dryrun.txt @@ -0,0 +1 @@ +[dry-run] would run C:\Users\bruno\scoop\apps\nodejs22\current\bin\pi.cmd diff --git a/examples/raw/search-empty.txt b/examples/raw/search-empty.txt new file mode 100644 index 0000000..73f27b5 --- /dev/null +++ b/examples/raw/search-empty.txt @@ -0,0 +1,3 @@ +NAME CATEGORY INSTALLABLE DESCRIPTION +---------------------------------------- +no agent matches 'zzzz' (categories: assistant, coding-agent, desktop, local-first, proprietary, saas) diff --git a/examples/raw/search-rust.txt b/examples/raw/search-rust.txt new file mode 100644 index 0000000..6152299 --- /dev/null +++ b/examples/raw/search-rust.txt @@ -0,0 +1,11 @@ +NAME CATEGORY INSTALLABLE DESCRIPTION +------------------------------------------------- +Claw Code coding-agent yes Réécriture clean-room Rust/Python de l'architec… +jcode coding-agent yes Agent TUI Rust optimisé RAM et latence de démar… +Zap coding-agent yes Agent TUI Rust skill-first, injecte seulement l… +Smelt coding-agent yes Agent TUI Rust multi-provider, 4 modes (Normal/… +Claurst coding-agent yes Claude Code réécrit en Rust idiomatique, avec d… +ZeroClaw assistant yes Runtime d'agent AI Rust autonome, architecture … +IronClaw assistant yes OpenClaw réécrit en Rust par NEAR AI. Sandbox W… +Moltis assistant yes Alternative Rust-native à OpenClaw, zéro code u… +Ante proprietary yes Binaire Rust 15 MB (research preview), architec… diff --git a/examples/raw/start-group-dryrun.txt b/examples/raw/start-group-dryrun.txt new file mode 100644 index 0000000..a9af90e --- /dev/null +++ b/examples/raw/start-group-dryrun.txt @@ -0,0 +1,3 @@ +[dry-run] would start C:\Users\bruno\.local\bin\claude.exe (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\claude-code.log) +[dry-run] would start aider (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\aider.log) +[dry-run] would start codex (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\codex.log) diff --git a/examples/raw/status-all.txt b/examples/raw/status-all.txt new file mode 100644 index 0000000..42aad2c --- /dev/null +++ b/examples/raw/status-all.txt @@ -0,0 +1,13 @@ +NAME VERSION STATUS PID PATH +------------------------------------------- +Atomic Agent — external — C:\Users\bruno\AppData\Local\atomic-agent\atomi… +Claude Code 2.1.112 external — C:\Users\bruno\.local\bin\claude.exe +Hermes Agent 2026.8.3 external — C:\Users\bruno\AppData\Local\hermes\hermes-agen… +jcode 0.76.0 external — C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe +Kimi CLI 0.31.1 external — C:\Users\bruno\.kimi-code\bin\kimi.exe +LazyCodex 11.1.0 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +nanobot — external — C:\Users\bruno\AppData\Local\Microsoft\WindowsA… +NemoClaw — external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Pi 0.84.1 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Prime Agent 0.7.2 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\… +Tiny Agents — external — C:\Users\bruno\scoop\apps\python313\current\Scr… diff --git a/examples/raw/status-claude-code.txt b/examples/raw/status-claude-code.txt new file mode 100644 index 0000000..a16ca98 --- /dev/null +++ b/examples/raw/status-claude-code.txt @@ -0,0 +1,6 @@ +Claude Code (claude-code) + status: external + version: 2.1.112 + method: — + path: C:\Users\bruno\.local\bin\claude.exe + run: claude diff --git a/examples/raw/stop-notinstalled.txt b/examples/raw/stop-notinstalled.txt new file mode 100644 index 0000000..32799d3 --- /dev/null +++ b/examples/raw/stop-notinstalled.txt @@ -0,0 +1 @@ +agent 'claude-code' is not managed by agent-manager; nothing to stop diff --git a/examples/raw/update-notinstalled.txt b/examples/raw/update-notinstalled.txt new file mode 100644 index 0000000..25a0654 --- /dev/null +++ b/examples/raw/update-notinstalled.txt @@ -0,0 +1,3 @@ +error: agent 'claude-code' is not installed by agent-manager — use 'am install claude-code' + +[exit code: 1] \ No newline at end of file diff --git a/examples/raw/version.txt b/examples/raw/version.txt new file mode 100644 index 0000000..fefafe9 --- /dev/null +++ b/examples/raw/version.txt @@ -0,0 +1 @@ +am 0.1.0 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..b576f72 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,81 @@ +# agent-manager (am) — installateur Windows (PowerShell 5.1+) +# +# One-liner : +# powershell -ExecutionPolicy Bypass -Command "irm https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.ps1 | iex" +# +# Télécharge le binaire de la dernière release Gitea ; à défaut, compile via +# cargo install --git. Relancez le script pour mettre à jour. +param( + [string]$BaseUrl = "https://git.dracodev.net/Projets/agent-manager", + [string]$ApiUrl = "https://git.dracodev.net/api/v1/repos/Projets/agent-manager", + [string]$InstallDir = "" +) +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +# PowerShell 5.1 : forcer TLS 1.2 pour les serveurs modernes +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + +function Fail([string]$msg) { Write-Host "erreur: $msg" -ForegroundColor Red; exit 1 } + +function Install-FromSource { + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + Fail "pas de binaire precompile et cargo est absent - installez Rust (https://rustup.rs) puis relancez" + } + Write-Host "Installation depuis les sources (cargo install --git $BaseUrl.git) ..." + cargo install --git "$BaseUrl.git" --locked + if ($LASTEXITCODE -ne 0) { Fail "cargo install a echoue" } + $bin = Join-Path $env:USERPROFILE ".cargoinam.exe" + Write-Host "" + Write-Host "OK - am est installe dans $bin" + Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)" + exit 0 +} + +$arch = if ($env:PROCESSOR_ARCHITECTURE -match "ARM") { "arm64" } else { "x86_64" } +$binDir = if ($InstallDir) { $InstallDir } else { Join-Path $env:LOCALAPPDATA "agent-managerin" } + +if ($arch -eq "x86_64") { + $ok = $false + try { + $release = Invoke-RestMethod -Uri "$ApiUrl/releases/latest" -UseBasicParsing -TimeoutSec 20 + $tag = $release.tag_name + $asset = "am-windows-x86_64.zip" + $url = "$BaseUrl/releases/download/$tag/$asset" + $tmp = Join-Path $env:TEMP "am-$tag.zip" + Write-Host "Telechargement de $url ..." + Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing -TimeoutSec 300 + $tmpDir = Join-Path $env:TEMP "am-$tag" + Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $tmp -DestinationPath $tmpDir -Force + $exe = Join-Path $tmpDir "am.exe" + if (Test-Path $exe) { + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + Copy-Item $exe (Join-Path $binDir "am.exe") -Force + Remove-Item $tmp -Force -ErrorAction SilentlyContinue + Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + $ok = $true + } else { throw "am.exe absent de l'archive" } + } catch { + Write-Host "Binaire precompile indisponible ($($_.Exception.Message)) - installation depuis les sources ..." + } + if (-not $ok) { Install-FromSource } +} else { + Write-Host "Architecture $arch : pas de binaire precompile - installation depuis les sources ..." + Install-FromSource +} + +# Ajout au PATH utilisateur (permanent) uniquement pour le dossier par defaut +if (-not $InstallDir) { + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($userPath -notlike "*$binDir*") { + [Environment]::SetEnvironmentVariable("Path", "$binDir;$userPath", "User") + Write-Host "Ajout de $binDir au PATH utilisateur (rouvrez votre terminal pour en profiter)" + } +} +$env:Path = "$binDir;$env:Path" +& (Join-Path $binDir "am.exe") --version +Write-Host "" +Write-Host "OK - 'am' est installe. Essayez :" +Write-Host " am list" +Write-Host " am doctor" +Write-Host "Mise a jour : relancez ce script." diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..dd7eaed --- /dev/null +++ b/install.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# agent-manager (am) — installateur Linux / macOS / WSL +# +# One-liner : +# curl -fsSL https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.sh | sh +# +# Télécharge le binaire de la dernière release Gitea ; à défaut, compile via +# cargo install --git. Relancez le script pour mettre à jour. +set -eu + +BASE_URL="${AM_BASE_URL:-https://git.dracodev.net/Projets/agent-manager}" +API_URL="${AM_API_URL:-https://git.dracodev.net/api/v1/repos/Projets/agent-manager}" +INSTALL_DIR="${AM_INSTALL_DIR:-$HOME/.local/bin}" +GIT_URL="$BASE_URL.git" + +fail() { echo "erreur: $1" >&2; exit 1; } + +arch=$(uname -m) +case "$arch" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) fail "architecture non supportée: $arch" ;; +esac + +install_from_source() { + if ! command -v cargo >/dev/null 2>&1; then + fail "pas de binaire précompilé pour $arch et cargo est absent — installez Rust (https://rustup.rs) puis relancez" + fi + echo "Installation depuis les sources (cargo install --git $GIT_URL)…" + cargo install --git "$GIT_URL" --locked + echo "" + echo "OK — am est installé dans ~/.cargo/bin/am" + echo "Essayez : am list | am doctor (mise à jour : relancez ce script)" + exit 0 +} + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +tag="" +if command -v curl >/dev/null 2>&1; then + release=$(curl -fsSL --connect-timeout 15 "$API_URL/releases/latest" 2>/dev/null || true) + tag=$(printf '%s' "$release" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 | cut -d'"' -f4) +fi + +installed="" +if [ -n "$tag" ] && command -v curl >/dev/null 2>&1; then + asset="am-linux-$arch.tar.gz" + url="$BASE_URL/releases/download/$tag/$asset" + echo "Téléchargement de $url …" + if curl -fsSL --connect-timeout 20 -o "$tmpdir/am.tar.gz" "$url"; then + tar -xzf "$tmpdir/am.tar.gz" -C "$tmpdir" 2>/dev/null || true + if [ -f "$tmpdir/am" ]; then + mkdir -p "$INSTALL_DIR" + cp "$tmpdir/am" "$INSTALL_DIR/am" + chmod +x "$INSTALL_DIR/am" + installed=1 + fi + fi +fi + +if [ -z "$installed" ]; then + echo "Binaire précompilé indisponible — installation depuis les sources…" + install_from_source +fi + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + echo "Astuce : $INSTALL_DIR n'est pas dans votre PATH" + if [ -f "$HOME/.profile" ] && ! grep -qF "$INSTALL_DIR" "$HOME/.profile"; then + echo "export PATH="$INSTALL_DIR:\$PATH"" >> "$HOME/.profile" + echo "Ajouté à ~/.profile (reconnectez-vous pour en profiter)" + fi + ;; +esac + +"$INSTALL_DIR/am" --version || true +echo "" +echo "OK — 'am' est installé. Essayez :" +echo " am list" +echo " am doctor" +echo "Mise à jour : relancez ce script." diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..99f706e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,162 @@ +//! Application context: configuration, catalog, state, paths, logger and +//! shared flags. + +use crate::catalog::Catalog; +use crate::cli::Cli; +use crate::config::{self, Config, Settings}; +use crate::output::Logger; +use crate::state::StateStore; +use anyhow::{Context, Result}; +use std::io::{IsTerminal, Write}; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct App { + pub cli: Cli, + pub config: Config, + pub config_origin: Option, + pub paths: Paths, + pub catalog: Catalog, + pub state: StateStore, + pub log: Logger, +} + +#[derive(Debug, Clone)] +pub struct Paths { + pub install_dir: PathBuf, + pub bin_dir: PathBuf, + pub log_dir: PathBuf, + pub state_file: PathBuf, + pub probe_cache_file: PathBuf, + pub config_dir: Option, +} + +impl Paths { + pub fn from_settings(settings: &Settings) -> Self { + let install_dir = settings + .install_dir + .as_deref() + .map(config::expand_path) + .unwrap_or_else(|| data_dir().join("agents")); + let log_dir = settings + .log_dir + .as_deref() + .map(config::expand_path) + .unwrap_or_else(|| state_dir().join("logs")); + let bin_dir = install_dir.join("bin"); + let state_file = state_dir().join("state.json"); + let probe_cache_file = state_dir().join("probe-cache.json"); + let config_dir = config::user_config_dir(); + Self { + install_dir, + bin_dir, + log_dir, + state_file, + probe_cache_file, + config_dir, + } + } + + /// Create every directory the manager needs. + pub fn ensure(&self) -> Result<()> { + std::fs::create_dir_all(&self.install_dir) + .with_context(|| format!("cannot create {}", self.install_dir.display()))?; + std::fs::create_dir_all(&self.bin_dir)?; + std::fs::create_dir_all(&self.log_dir)?; + Ok(()) + } +} + +impl App { + pub fn from_cli(cli: Cli) -> Result { + let loaded = config::load(&cli)?; + let paths = Paths::from_settings(&loaded.config.settings); + let log = Logger::new(paths.log_dir.clone(), cli.verbose, cli.quiet, cli.no_color); + let state = StateStore::new(paths.state_file.clone()); + let catalog = Catalog::new( + loaded.config.agents.clone(), + loaded.config.aliases.clone(), + loaded.config.groups.clone(), + ); + Ok(Self { + cli, + config: loaded.config, + config_origin: loaded.origin, + paths, + catalog, + state, + log, + }) + } + + pub fn dry_run(&self) -> bool { + self.cli.dry_run + } + + pub fn json(&self) -> bool { + self.cli.json + } + + pub fn yes(&self) -> bool { + self.cli.yes + } + + pub fn color(&self) -> bool { + !self.cli.no_color && std::io::stdout().is_terminal() + } + + /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. + pub fn confirm(&self, question: &str) -> Result { + if self.cli.yes { + return Ok(true); + } + eprint!("{question} [y/N] "); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin().read_line(&mut line).ok(); + let answer = line.trim().to_lowercase(); + Ok(matches!(answer.as_str(), "y" | "yes" | "o" | "oui" | "true")) + } +} + +/// Root directory for agent data (overridable with AGENT_MANAGER_DATA, +/// mostly used by tests). +pub fn data_dir() -> PathBuf { + if let Some(d) = std::env::var_os("AGENT_MANAGER_DATA") { + return PathBuf::from(d); + } + if cfg!(windows) { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(|| config::home_dir().map(|h| h.join(".local").join("share"))); + return base + .map(|b| b.join("agent-manager")) + .unwrap_or_else(|| PathBuf::from(".agent-manager")); + } + if cfg!(target_os = "macos") { + return config::home_dir() + .map(|h| h.join("Library").join("Application Support").join("agent-manager")) + .unwrap_or_else(|| PathBuf::from(".agent-manager")); + } + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| config::home_dir().map(|h| h.join(".local").join("share"))); + base.map(|b| b.join("agent-manager")) + .unwrap_or_else(|| PathBuf::from(".agent-manager")) +} + +/// Root directory for runtime state (logs, state file). +pub fn state_dir() -> PathBuf { + if let Some(d) = std::env::var_os("AGENT_MANAGER_STATE") { + return PathBuf::from(d); + } + if cfg!(target_os = "linux") { + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| config::home_dir().map(|h| h.join(".local").join("state"))); + if let Some(b) = base { + return b.join("agent-manager"); + } + } + data_dir() +} diff --git a/src/catalog.rs b/src/catalog.rs new file mode 100644 index 0000000..a048db3 --- /dev/null +++ b/src/catalog.rs @@ -0,0 +1,159 @@ +//! The merged agent catalog: lookup, aliases, groups and search. + +use crate::config::AgentDef; +use std::collections::{BTreeMap, HashMap}; + +#[derive(Debug, Clone, Default)] +pub struct Catalog { + agents: Vec, + by_name: HashMap, + aliases: BTreeMap, + groups: BTreeMap>, +} + +impl Catalog { + pub fn new( + agents: Vec, + aliases: BTreeMap, + groups: BTreeMap>, + ) -> Self { + let mut by_name = HashMap::new(); + for (i, a) in agents.iter().enumerate() { + by_name.insert(a.name.clone(), i); + } + Self { + agents, + by_name, + aliases, + groups, + } + } + + pub fn agents(&self) -> &[AgentDef] { + &self.agents + } + + pub fn len(&self) -> usize { + self.agents.len() + } + + pub fn is_empty(&self) -> bool { + self.agents.is_empty() + } + + pub fn get(&self, name: &str) -> Option<&AgentDef> { + self.by_name.get(name).map(|&i| &self.agents[i]) + } + + /// Resolve an agent name or an alias to the agent definition. + pub fn resolve(&self, name_or_alias: &str) -> Option<&AgentDef> { + if let Some(target) = self.aliases.get(name_or_alias) { + return self.get(target); + } + self.get(name_or_alias) + } + + pub fn alias_target(&self, alias: &str) -> Option<&str> { + self.aliases.get(alias).map(|s| s.as_str()) + } + + /// Members of a group, in definition order (missing names are skipped). + pub fn group_members(&self, group: &str) -> Vec<&AgentDef> { + let mut out = Vec::new(); + if let Some(names) = self.groups.get(group) { + for n in names { + if let Some(a) = self.get(n) { + out.push(a); + } + } + } + out + } + + pub fn has_group(&self, group: &str) -> bool { + self.groups.contains_key(group) + } + + /// Is this token a group selector ("group:")? + pub fn parse_group_selector(s: &str) -> Option<&str> { + s.strip_prefix("group:") + } + + /// Case-insensitive search over name, display name, description, + /// category, tags and note. + pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> { + let kw = keyword.to_lowercase(); + self.agents + .iter() + .filter(|a| { + if a.hidden { + return false; + } + if let Some(cat) = category { + if a.category.as_deref() != Some(cat) { + return false; + } + } + let hay = format!( + "{} {} {} {} {} {}", + a.name, + a.title(), + a.description.as_deref().unwrap_or(""), + a.category.as_deref().unwrap_or(""), + a.tags.join(" "), + a.note.as_deref().unwrap_or("") + ) + .to_lowercase(); + hay.contains(&kw) + }) + .collect() + } + + /// Sorted list of all categories present in the catalog. + pub fn categories(&self) -> Vec { + let mut cats: Vec = self + .agents + .iter() + .filter_map(|a| a.category.clone()) + .collect(); + cats.sort(); + cats.dedup(); + cats + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn catalog() -> Catalog { + let cfg: Config = serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap(); + Catalog::new(cfg.agents, cfg.aliases, cfg.groups) + } + + #[test] + fn resolves_aliases() { + let cat = catalog(); + let via_alias = cat.resolve("cc").map(|a| a.name.clone()); + assert_eq!(via_alias.as_deref(), Some("claude-code")); + } + + #[test] + fn resolves_groups() { + let cat = catalog(); + assert!(cat.has_group("dev")); + let members = cat.group_members("dev"); + assert!(!members.is_empty()); + assert!(members.iter().any(|a| a.name == "claude-code")); + } + + #[test] + fn searches() { + let cat = catalog(); + let hits = cat.search("anthropic", None); + assert!(hits.iter().any(|a| a.name == "claude-code")); + let cats = cat.categories(); + assert!(cats.contains(&"coding-agent".to_string())); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..8ed2f78 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,217 @@ +//! Command line interface definition (clap v4, derive API). + +use clap::{ArgAction, Args, Parser, Subcommand}; +use clap_complete::Shell; +use std::path::PathBuf; + +/// Manage local AI coding agents: install, start, stop, update. +#[derive(Parser, Debug, Clone)] +#[command( + name = "am", + bin_name = "am", + version, + about = "agent-manager (am) — manage local AI coding agents", + long_about = "agent-manager (am) — a CLI to manage local AI coding agents. + +Install, uninstall, start, stop, restart and update AI agents from a YAML +catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from +GitHub Releases, install scripts, and git repositories. All state lives under +your user directories; nothing pollutes the system." +)] +pub struct Cli { + /// Use an alternate configuration file + #[arg(short = 'c', long, global = true, value_name = "FILE")] + pub config: Option, + + /// Verbose output: show every command executed and its details + #[arg(short = 'v', long, global = true, action = ArgAction::SetTrue)] + pub verbose: bool, + + /// Quiet mode: only errors are printed + #[arg(short = 'q', long, global = true, conflicts_with = "verbose", action = ArgAction::SetTrue)] + pub quiet: bool, + + /// Answer yes to every confirmation prompt + #[arg(short = 'y', long, global = true, action = ArgAction::SetTrue)] + pub yes: bool, + + /// Simulate the action without changing anything + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub dry_run: bool, + + /// Emit machine-readable JSON on stdout (for scripts) + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub json: bool, + + /// Disable ANSI colors + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub no_color: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// List installed agents (default); use --all for the whole catalog + List { + /// Show every agent known to the catalog, not only the installed ones + #[arg(long)] + all: bool, + /// Only show agents of this category + #[arg(long, value_name = "CATEGORY")] + category: Option, + }, + /// Start an agent (foreground by default, or detached with --background) + Start(StartArgs), + /// Stop a background agent (SIGTERM, then SIGKILL after the timeout) + Stop { + /// Agent name, alias, or group: + agent: String, + /// Kill immediately instead of terminating gracefully + #[arg(long)] + force: bool, + /// Grace period in seconds before killing (default: from config, 5s) + #[arg(long, value_name = "SECS")] + timeout: Option, + }, + /// Restart an agent: stop, then start with the same options + Restart { + #[command(flatten)] + start: StartArgs, + /// Kill immediately instead of terminating gracefully + #[arg(long)] + force: bool, + /// Grace period in seconds before killing (default: from config, 5s) + #[arg(long, value_name = "SECS")] + timeout: Option, + }, + /// Show the state of one agent, or of every installed agent + Status { + /// Agent name or alias (omit to show all installed agents) + agent: Option, + }, + /// Install an agent and its dependencies + Install { + /// Agent name or alias + agent: String, + /// Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git) + #[arg(long, value_name = "METHOD")] + method: Option, + /// Reinstall even if already installed + #[arg(long)] + force: bool, + }, + /// Uninstall an agent and clean its files + Uninstall { + /// Agent name or alias + agent: String, + /// Also remove logs and the agent entry from the user config file + #[arg(long)] + purge: bool, + }, + /// Update an installed agent to the latest available version + Update { + /// Agent name or alias (required unless --all) + agent: Option, + /// Update every installed managed agent + #[arg(long)] + all: bool, + }, + /// Search the catalog by keyword (name, description, category, tags) + Search { + /// Keyword to search for (case-insensitive substring) + keyword: String, + /// Restrict to this category + #[arg(long, value_name = "CATEGORY")] + category: Option, + }, + /// Show detailed information about one agent + Info { + /// Agent name or alias + agent: String, + }, + /// Manage the configuration file + #[command(subcommand)] + Config(ConfigCmd), + /// Check the environment: tools, config validity, paths, permissions + Doctor { + /// Attempt to repair problems (create directories, fix the state file) + #[arg(long)] + fix: bool, + }, + /// Run the agent command directly with the given arguments (no process management) + Run { + /// Agent name or alias + agent: String, + /// Arguments passed through to the agent command + #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")] + args: Vec, + }, + /// Generate a shell completion script + Completion { + /// Shell to generate completions for + #[arg(value_enum, value_name = "SHELL")] + shell: Shell, + }, + /// Update agent-manager itself from GitHub Releases + SelfUpdate { + /// Only check whether a newer version exists + #[arg(long)] + check: bool, + /// Download the new binary to this path instead of replacing the current one + #[arg(long, value_name = "FILE")] + to: Option, + }, + /// Export the configuration and installation state (backup) + Export { + /// Output file (default: am-export.json) + #[arg(long, value_name = "FILE", default_value = "am-export.json")] + output: PathBuf, + }, + /// Import a previously exported configuration and state + Import { + /// File exported by the export command + file: PathBuf, + }, +} + +/// Arguments shared by start and restart. +#[derive(Args, Debug, Clone, Default)] +pub struct StartArgs { + /// Agent name, alias, or group: + pub agent: String, + /// Run detached in the background; output goes to the agent log file + #[arg(short = 'b', long, conflicts_with = "foreground", action = ArgAction::SetTrue)] + pub background: bool, + /// Run in the foreground, attached to this terminal (the default) + #[arg(short = 'f', long, action = ArgAction::SetTrue)] + pub foreground: bool, + /// Extra arguments passed to the agent (repeatable; quoted strings are split on spaces) + #[arg(long, value_name = "ARGS", num_args = 0.., action = ArgAction::Append)] + pub args: Vec, + /// Set an environment variable for the agent (repeatable): --env KEY=VALUE + #[arg(long, value_name = "KEY=VALUE", action = ArgAction::Append)] + pub env: Vec, + /// Send a desktop notification once the agent has started + #[arg(long, action = ArgAction::SetTrue)] + pub notify: bool, +} + +/// Configuration management subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigCmd { + /// Print the effective (merged) configuration as YAML + Show, + /// Print the location of the configuration file(s) + Path, + /// Open the user configuration file in your editor + Edit, + /// Validate the configuration file(s) + Validate, + /// Add another agent definition file to the user configuration + Add { + /// YAML (or JSON) file defining agents to include + file: PathBuf, + }, +} diff --git a/src/commands/completion_cmd.rs b/src/commands/completion_cmd.rs new file mode 100644 index 0000000..d634de9 --- /dev/null +++ b/src/commands/completion_cmd.rs @@ -0,0 +1,13 @@ +//! completion: generate shell completion scripts (bash, zsh, fish, +//! powershell, elvish) via clap_complete. + +use crate::cli::Cli; +use anyhow::Result; +use clap::CommandFactory; +use clap_complete::{generate, Shell}; + +pub fn run(shell: Shell) -> Result { + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "am", &mut std::io::stdout()); + Ok(0) +} diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs new file mode 100644 index 0000000..ed51481 --- /dev/null +++ b/src/commands/config_cmd.rs @@ -0,0 +1,182 @@ +//! config subcommands: show, path, edit, validate, add. + +use super::*; +use crate::cli::ConfigCmd; +use crate::config; +use crate::output::print_json; +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const USER_CONFIG_TEMPLATE: &str = "# User configuration for agent-manager. +# This file overrides/completes the embedded default catalog. +# Run 'am config show' to see the effective configuration. +version: \"1.0\" + +agents: [] +"; + +pub fn run(app: &App, cmd: &ConfigCmd) -> Result { + match cmd { + ConfigCmd::Show => { + if app.json() { + print_json(&app.config); + } else { + println!("{}", serde_yaml::to_string(&app.config)?); + } + Ok(0) + } + ConfigCmd::Path => { + if app.json() { + print_json(&serde_json::json!({ + "origin": app.config_origin.as_ref().map(|p| p.display().to_string()), + "user_dir": app.paths.config_dir.as_ref().map(|p| p.display().to_string()), + "user_file": user_config_path_if_any(app).map(|p| p.display().to_string()), + "local_file": PathBuf::from(config::LOCAL_CONFIG_NAME).display().to_string(), + })); + } else { + match &app.config_origin { + Some(p) => println!("active config: {}", p.display()), + None => println!("no user config found — using the embedded default catalog"), + } + if let Some(d) = &app.paths.config_dir { + println!("user config directory: {}", d.display()); + println!( + "user config file: {}", + d.join(config::CONFIG_FILE_NAME).display() + ); + } + println!( + "local config file: {} (current directory)", + PathBuf::from(config::LOCAL_CONFIG_NAME).display() + ); + } + Ok(0) + } + ConfigCmd::Validate => { + let problems = config::validate(&app.config); + if app.json() { + print_json(&serde_json::json!({ + "valid": problems.is_empty(), + "problems": problems, + })); + return Ok(if problems.is_empty() { 0 } else { 1 }); + } + if problems.is_empty() { + app.log + .success(&format!("configuration is valid ({} agents)", app.catalog.len())); + Ok(0) + } else { + app.log.error(&format!( + "configuration problems: + - {}", + problems.join(" + - ") + )); + Ok(1) + } + } + ConfigCmd::Edit => { + let path = user_config_path(app)?; + let editor = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| { + if cfg!(windows) { + "notepad".to_string() + } else { + "vi".to_string() + } + }); + let status = Command::new(&editor) + .arg(&path) + .status() + .with_context(|| format!("cannot launch editor '{editor}'"))?; + Ok(status.code().unwrap_or(1)) + } + ConfigCmd::Add { file } => add_file(app, file), + } +} + +fn user_config_path_if_any(app: &App) -> Option { + app.paths + .config_dir + .as_ref() + .map(|d| d.join(config::CONFIG_FILE_NAME)) +} + +/// Ensure the user config file exists (creating a template when needed). +fn user_config_path(app: &App) -> Result { + let dir = app + .paths + .config_dir + .clone() + .ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("cannot create {}", dir.display()))?; + let path = dir.join(config::CONFIG_FILE_NAME); + if !path.exists() { + std::fs::write(&path, USER_CONFIG_TEMPLATE) + .with_context(|| format!("cannot create {}", path.display()))?; + } + Ok(path) +} + +/// config add: copy an agent definition file into the config dir and +/// register it via an include entry in the user config file. +fn add_file(app: &App, file: &Path) -> Result { + config::parse_file(file)?; + let dir = app + .paths + .config_dir + .clone() + .ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?; + std::fs::create_dir_all(&dir)?; + let base = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("agents.yaml") + .to_string(); + let dest = dir.join(&base); + std::fs::copy(file, &dest) + .with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?; + + let cfg_path = user_config_path(app)?; + let text = std::fs::read_to_string(&cfg_path)?; + let mut value: serde_yaml::Value = serde_yaml::from_str(&text) + .unwrap_or_else(|_| serde_yaml::Value::Null); + match &mut value { + serde_yaml::Value::Mapping(map) => { + let key = serde_yaml::Value::String("include".to_string()); + match map.get_mut(&key) { + Some(serde_yaml::Value::Sequence(seq)) => { + let entry = serde_yaml::Value::String(base.clone()); + if !seq.contains(&entry) { + seq.push(entry); + } + } + _ => { + map.insert( + key, + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String( + base.clone(), + )]), + ); + } + } + std::fs::write(&cfg_path, serde_yaml::to_string(&value)?) + .with_context(|| format!("cannot write {}", cfg_path.display()))?; + } + _ => { + std::fs::write( + &cfg_path, + format!("version: \"1.0\"\ninclude:\n - {base}\n"), + )?; + } + } + app.log.success(&format!( + "added {} and registered it in {}", + dest.display(), + cfg_path.display() + )); + Ok(0) +} diff --git a/src/commands/doctor_cmd.rs b/src/commands/doctor_cmd.rs new file mode 100644 index 0000000..35d6afd --- /dev/null +++ b/src/commands/doctor_cmd.rs @@ -0,0 +1,43 @@ +//! doctor command: render the environment report, optionally with --fix. + +use super::*; +use crate::doctor::{self, Status}; +use crate::output::{print_json, status_style, Table}; +use crate::runner::SystemRunner; +use anyhow::Result; + +pub fn run(app: &App, fix: bool) -> Result { + if fix { + let repaired = doctor::fix(app)?; + for r in &repaired { + app.log.success(r); + } + app.log.info("run 'am doctor' again to verify"); + return Ok(0); + } + // The doctor report probes real tools, even under --dry-run. + let runner = SystemRunner::new(false, false, &app.log); + let report = doctor::run(app, &runner); + if app.json() { + print_json(&report.checks); + return Ok(if report.ok() { 0 } else { 1 }); + } + let color = app.color(); + let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]); + for c in &report.checks { + let status = match c.status { + Status::Ok => status_style("ok", color), + Status::Warn => status_style("warn", color), + Status::Error => status_style("error", color), + }; + table.row(vec![c.name.clone(), status, c.detail.clone()]); + } + print!("{}", table.render()); + if report.problems() > 0 { + app.log + .warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems())); + } else { + app.log.success("environment looks good"); + } + Ok(if report.ok() { 0 } else { 1 }) +} diff --git a/src/commands/export_import.rs b/src/commands/export_import.rs new file mode 100644 index 0000000..82ea130 --- /dev/null +++ b/src/commands/export_import.rs @@ -0,0 +1,135 @@ +//! export/import: back up the configuration and installation state. + +use super::*; +use crate::config::{Config, Settings}; +use crate::state::StateFile; +use crate::output::print_json; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Serialize, Deserialize)] +struct ExportFile { + tool: String, + version: String, + generated_at: String, + config: Config, + state: StateFile, +} + +pub fn export_cmd(app: &App, output: &Path) -> Result { + let export = ExportFile { + tool: "agent-manager".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + generated_at: crate::installers::now_rfc3339(), + config: app.config.clone(), + state: app.state.load()?, + }; + if app.dry_run() { + app.log + .dry(format!("would write {}", output.display())); + return Ok(0); + } + let text = serde_json::to_string_pretty(&export)?; + std::fs::write(output, text) + .with_context(|| format!("cannot write {}", output.display()))?; + app.log + .success(&format!("exported config + state to {}", output.display())); + Ok(0) +} + +pub fn import_cmd(app: &App, file: &Path) -> Result { + let text = std::fs::read_to_string(file) + .with_context(|| format!("cannot read {}", file.display()))?; + let export: ExportFile = serde_json::from_str(&text) + .with_context(|| format!("{} is not a valid agent-manager export", file.display()))?; + if app.dry_run() { + app.log + .dry(format!("would import from {}", file.display())); + return Ok(0); + } + + // State: merge entries (existing ones win). + let mut sf = app.state.load()?; + let mut imported_state = 0; + for (name, entry) in export.state.installed { + if !sf.installed.contains_key(&name) { + sf.installed.insert(name, entry); + imported_state += 1; + } + } + app.state.save(&sf)?; + + // Config: write the exported agents to a file and include it. + let dir = app + .paths + .config_dir + .clone() + .ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?; + std::fs::create_dir_all(&dir)?; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let fname = format!("imported-{ts}.yaml"); + let dest = dir.join(&fname); + let cfg = Config { + version: "1.0".to_string(), + settings: Settings::default(), + aliases: Default::default(), + groups: Default::default(), + include: vec![], + agents: export.config.agents.clone(), + }; + std::fs::write(&dest, serde_yaml::to_string(&cfg)?) + .with_context(|| format!("cannot write {}", dest.display()))?; + + let cfg_path = dir.join(crate::config::CONFIG_FILE_NAME); + if !cfg_path.exists() { + std::fs::write( + &cfg_path, + format!("version: \"1.0\"\ninclude:\n - {fname}\n"), + )?; + } else { + let text = std::fs::read_to_string(&cfg_path)?; + let mut value: serde_yaml::Value = serde_yaml::from_str(&text) + .unwrap_or_else(|_| serde_yaml::Value::Null); + if let serde_yaml::Value::Mapping(map) = &mut value { + let key = serde_yaml::Value::String("include".to_string()); + match map.get_mut(&key) { + Some(serde_yaml::Value::Sequence(seq)) => { + let entry = serde_yaml::Value::String(fname.clone()); + if !seq.contains(&entry) { + seq.push(entry); + } + } + _ => { + map.insert( + key, + serde_yaml::Value::Sequence(vec![serde_yaml::Value::String( + fname.clone(), + )]), + ); + } + } + std::fs::write(&cfg_path, serde_yaml::to_string(&value)?)?; + } + } + + if app.json() { + print_json(&serde_json::json!({ + "agents_imported": export.config.agents.len(), + "state_entries_imported": imported_state, + "config_file": dest.display().to_string(), + })); + } else { + app.log.success(&format!( + "imported {} agent definition(s) and {} state entrie(s) from {}", + export.config.agents.len(), + imported_state, + file.display() + )); + app.log.info(&format!( + "definitions written to {}", + dest.display() + )); + } + Ok(0) +} diff --git a/src/commands/info_cmd.rs b/src/commands/info_cmd.rs new file mode 100644 index 0000000..f2ab9f1 --- /dev/null +++ b/src/commands/info_cmd.rs @@ -0,0 +1,197 @@ +//! info: everything known about one agent. + +use super::*; +use crate::output::print_json; +use serde::Serialize; + +#[derive(Serialize)] +struct InfoJson { + name: String, + display_name: String, + description: Option, + category: Option, + website: Option, + tags: Vec, + status: String, + version: Option, + managed: bool, + running: bool, + pid: Option, + installable: bool, + note: Option, + platforms: Vec, + run: Option, + args: Vec, + env: std::collections::BTreeMap, + install_methods: Vec, + post_install: Vec, + dependencies: Vec, + path: Option, +} + +#[derive(Serialize)] +struct DepJson { + name: String, + min_version: Option, + install_hint: Option, +} + +pub fn run(app: &App, name: &str) -> Result { + let agent = require_agent(app, name)?; + let entry = app.state.get(&agent.name).ok().flatten(); + let running = entry + .as_ref() + .and_then(|e| e.pid) + .map(crate::process::is_running) + .unwrap_or(false); + let methods: Vec = agent + .install + .as_ref() + .map(|spec| spec.all_methods().iter().map(|m| describe_method(m)).collect()) + .unwrap_or_default(); + let deps: Vec = agent + .dependencies + .iter() + .map(|d| DepJson { + name: d.name.clone(), + min_version: d.min_version.clone(), + install_hint: d.install_hint.clone(), + }) + .collect(); + + if app.json() { + print_json(&InfoJson { + name: agent.name.clone(), + display_name: agent.title().to_string(), + description: agent.description.clone(), + category: agent.category.clone(), + website: agent.website.clone(), + tags: agent.tags.clone(), + status: if running { + "running".to_string() + } else if entry.is_some() { + "installed".to_string() + } else if !agent.installable { + "not-installable".to_string() + } else { + "not-installed".to_string() + }, + version: entry.as_ref().and_then(|e| e.version.clone()), + managed: entry.is_some(), + running, + pid: if running { entry.as_ref().and_then(|e| e.pid) } else { None }, + installable: agent.installable, + note: agent.note.clone(), + platforms: agent.platforms.clone(), + run: agent.run.clone(), + args: agent.args.clone(), + env: agent.env.clone(), + install_methods: methods, + post_install: agent + .install + .as_ref() + .map(|s| s.post_install.clone()) + .unwrap_or_default(), + dependencies: deps, + path: entry + .as_ref() + .map(|e| e.install_dir.clone()) + .or_else(|| external_path(agent).map(|p| p.display().to_string())), + }); + return Ok(0); + } + + println!("{}", agent.title()); + if agent.name != agent.title() { + println!(" slug: {}", agent.name); + } + if let Some(d) = &agent.description { + println!(" description: {d}"); + } + if let Some(c) = &agent.category { + println!(" category: {c}"); + } + if let Some(w) = &agent.website { + println!(" website: {w}"); + } + if !agent.tags.is_empty() { + println!(" tags: {}", agent.tags.join(", ")); + } + if !agent.platforms.is_empty() { + println!(" platforms: {}", agent.platforms.join(", ")); + } + + // Status block + let status = if running { + format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0)) + } else if entry.is_some() { + format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string())) + } else if !agent.installable { + "not installable locally".to_string() + } else if external_path(agent).is_some() { + "found on PATH (external — not managed)".to_string() + } else { + "not installed".to_string() + }; + println!(" status: {status}"); + + if let Some(run) = &agent.run { + let args = if agent.args.is_empty() { + String::new() + } else { + format!(" {}", agent.args.join(" ")) + }; + println!(" run: {run}{args}"); + } + if !agent.env.is_empty() { + println!( + " env: {}", + agent + .env + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ); + } + + if let Some(spec) = &agent.install { + println!(" install methods:"); + for (i, m) in spec.all_methods().iter().enumerate() { + let note = m + .note + .as_deref() + .map(|n| format!(" ({n})")) + .unwrap_or_default(); + println!(" {}) {}{}", i + 1, describe_method(m), note); + } + if !spec.post_install.is_empty() { + println!(" post-install: {}", spec.post_install.join(" && ")); + } + } + if !agent.dependencies.is_empty() { + println!(" dependencies:"); + for d in &agent.dependencies { + let min = d + .min_version + .as_deref() + .map(|m| format!(" >= {m}")) + .unwrap_or_default(); + let hint = d + .install_hint + .as_deref() + .map(|h| format!(" — {h}")) + .unwrap_or_default(); + println!(" - {}{}{}", d.name, min, hint); + } + } + if let Some(n) = &agent.note { + println!(" note: {n}"); + } + Ok(0) +} + +fn external_path(agent: &AgentDef) -> Option { + let first = agent.first_token()?; + which::which(first).ok() +} diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs new file mode 100644 index 0000000..8ac9d23 --- /dev/null +++ b/src/commands/install_cmd.rs @@ -0,0 +1,152 @@ +//! install: resolve the agent, check dependencies, confirm, run the right +//! installer, and record the installation in the state database. + +use super::*; +use crate::installers; +use crate::runner::SystemRunner; +use anyhow::{anyhow, bail, Result}; +use std::collections::BTreeMap; + +pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result { + let agent = require_agent(app, agent_name)?; + let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); + + if !agent.supports_current_platform() { + bail!( + "agent '{}' is not available on {} (supported: {})", + agent.name, + crate::config::current_platform(), + agent.platforms.join(", ") + ); + } + if !agent.installable { + bail!( + "agent '{}' is not installable locally{}", + agent.name, + agent + .note + .as_deref() + .map(|n| format!(": {n}")) + .unwrap_or_default() + ); + } + let spec = agent + .install + .as_ref() + .ok_or_else(|| anyhow!("agent '{}' has no install definition", agent.name))?; + let methods = spec.all_methods(); + let method = select_method(&methods, method_sel)?; + + // Already installed? + let existing = app.state.get(&agent.name).ok().flatten(); + if existing.is_some() && !force && !app.dry_run() { + let version = existing + .as_ref() + .and_then(|e| e.version.clone()) + .unwrap_or_else(|| "version unknown".to_string()); + bail!( + "agent '{}' is already installed ({version}); use --force to reinstall, or 'am update {}'", + agent.name, + agent.name + ); + } + if let Some(e) = &existing { + if let Some(pid) = e.pid { + if crate::process::is_running(pid) { + bail!( + "agent '{}' is currently running (pid {pid}); stop it first with 'am stop {}'", + agent.name, + agent.name + ); + } + } + } + + // Dependencies (checked against real tools unless dry-run). + crate::deps::ensure_deps(app, agent, &runner)?; + + // Confirmation summary for scripts and destructive actions. + if !app.dry_run() + && !app.yes() + && app.config.settings.confirm_before_run() + && method.kind == crate::config::InstallType::Curl + { + app.log.info(&format!( + "About to download and run the install script for {}", + agent.title() + )); + app.log + .info(&format!(" script: {}", method.script.as_deref().unwrap_or("?"))); + if !app.confirm("Run this install script?")? { + app.log.info("aborted"); + return Ok(0); + } + } else if !app.dry_run() && !app.yes() && existing.is_some() { + app.log.info(&format!( + "Reinstalling {} via {}", + agent.title(), + describe_method(method) + )); + if !app.confirm("Proceed with reinstall?")? { + app.log.info("aborted"); + return Ok(0); + } + } + + app.log.info(&format!( + "installing {} ({})…", + agent.title(), + describe_method(method) + )); + let mut outcome = installers::run_install(app, agent, method, &runner)?; + if app.dry_run() { + app.log.info("dry run complete — nothing was changed"); + return Ok(0); + } + + // Post-install commands (bin dir first on PATH). + if !spec.post_install.is_empty() { + let bin_str = app.paths.bin_dir.display().to_string(); + let mut env = BTreeMap::new(); + if let Ok(path) = std::env::var("PATH") { + env.insert( + "PATH".to_string(), + format!("{bin_str}{}{path}", std::path::MAIN_SEPARATOR), + ); + } + for cmd in &spec.post_install { + let tokens = shell_words::split(cmd) + .map_err(|e| anyhow!("cannot parse post-install command '{cmd}': {e}"))?; + if tokens.is_empty() { + continue; + } + let prog = tokens[0].clone(); + let args: Vec = tokens[1..].to_vec(); + app.log.info(&format!("post-install: {cmd}")); + installers::sh(&runner, &prog, &args, &env, None)?; + } + } + + // Fallback version detection. + if outcome.version.is_none() { + if let Some(bin) = outcome.bins.first() { + outcome.version = installers::detect_version(&runner, bin); + } + } + + let entry = installers::make_entry(agent, method, &outcome, app); + let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string()); + app.state.set(&entry)?; + app.log.success(&format!( + "installed {} (v{version}) in {}", + agent.title(), + entry.install_dir + )); + if existing.is_none() { + app.log.info(&format!( + "start it with: am start {}", + agent.name + )); + } + Ok(0) +} diff --git a/src/commands/list_cmd.rs b/src/commands/list_cmd.rs new file mode 100644 index 0000000..152bf35 --- /dev/null +++ b/src/commands/list_cmd.rs @@ -0,0 +1,151 @@ +//! list: installed agents by default (managed, running and external), or +//! the whole catalog with --all. + +use super::*; +use crate::output::{print_json, status_style, Table}; +use crate::probe::ExternalInfo; +use crate::state::InstalledEntry; +use serde::Serialize; + +#[derive(Serialize)] +struct AgentRow { + name: String, + display_name: String, + category: Option, + description: Option, + status: String, + version: Option, + source: String, + method: Option, + path: Option, + running: bool, + pid: Option, + installable: bool, + note: Option, +} + +/// run: installed agents only, unless all is set (whole catalog). +pub fn run(app: &App, all: bool, category: Option<&str>) -> Result { + let entries = app.state.all().unwrap_or_default(); + let probe = crate::probe::detect_externals(app); + let mut rows: Vec = Vec::new(); + for agent in app.catalog.agents() { + if agent.hidden { + continue; + } + if let Some(cat) = category { + if agent.category.as_deref() != Some(cat) { + continue; + } + } + rows.push(row_for(agent, entries.get(&agent.name), &probe)); + } + if !all { + rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external"); + } + rows.sort_by(|a, b| a.name.cmp(&b.name)); + + if app.json() { + print_json(&rows); + return Ok(0); + } + let color = app.color(); + let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]); + for r in &rows { + table.row(vec![ + r.display_name.clone(), + r.version.clone().unwrap_or_else(|| "—".to_string()), + status_style(&r.status, color), + r.source.clone(), + r.path.clone().unwrap_or_else(|| "—".to_string()), + ]); + } + print!("{}", table.render()); + if rows.is_empty() { + app.log.info(if all { + "no agents found" + } else { + "no installed agents found — run 'am list --all' for the full catalog" + }); + } + Ok(0) +} + +fn row_for(agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInfo) -> AgentRow { + let (status, source, path, version, running, pid, method) = if let Some(e) = entry { + let running = e.pid.map(crate::process::is_running).unwrap_or(false); + let status = if running { "running" } else { "installed" }; + let path = if e + .bins + .first() + .map(|b| std::path::Path::new(b).exists()) + .unwrap_or(false) + { + Some(e.bins[0].clone()) + } else { + Some(e.install_dir.clone()) + }; + ( + status.to_string(), + "managed".to_string(), + path, + e.version.clone(), + running, + e.pid, + Some(e.method.clone()), + ) + } else if !agent.installable { + ( + "not-installable".to_string(), + "-".to_string(), + None, + None, + false, + None, + None, + ) + } else { + match probe.path_of(&agent.name) { + Some(p) => { + let version = probe + .version_of(&agent.name) + .cloned() + .flatten() + .or_else(|| agent.version.clone()); + ( + "external".to_string(), + "external".to_string(), + Some(p.display().to_string()), + version, + false, + None, + None, + ) + } + None => ( + "not-installed".to_string(), + "-".to_string(), + None, + None, + false, + None, + None, + ), + } + }; + AgentRow { + name: agent.name.clone(), + display_name: agent.title().to_string(), + category: agent.category.clone(), + description: agent.description.clone(), + status, + version, + source, + method, + path, + running, + pid, + installable: agent.installable, + note: agent.note.clone(), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..16ccea1 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,213 @@ +//! Command implementations and dispatch. + +pub mod completion_cmd; +pub mod config_cmd; +pub mod doctor_cmd; +pub mod export_import; +pub mod info_cmd; +pub mod install_cmd; +pub mod list_cmd; +pub mod run_cmd; +pub mod search_cmd; +pub mod self_update; +pub mod status_cmd; +pub mod uninstall_cmd; +pub mod update_cmd; + +use crate::app::App; +use crate::cli::Command; +use crate::config::{AgentDef, MethodSpec}; +use crate::state::InstalledEntry; +use anyhow::{anyhow, bail, Result}; +use std::collections::BTreeMap; +use std::path::Path; + +pub fn execute(app: &App) -> Result { + match &app.cli.command { + None => crate::repl::run(app), + Some(cmd) => execute_command(app, cmd), + } +} + +/// Execute one parsed command (also used by the interactive shell). +pub fn execute_command(app: &App, cmd: &Command) -> Result { + match cmd { + Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()), + Command::Start(a) => run_cmd::start(app, a), + Command::Stop { + agent, + force, + timeout, + } => run_cmd::stop(app, agent, *force, *timeout), + Command::Restart { + start, + force, + timeout, + } => run_cmd::restart(app, start, *force, *timeout), + Command::Status { agent } => status_cmd::run(app, agent.as_deref()), + Command::Install { + agent, + method, + force, + } => install_cmd::run(app, agent, method.as_deref(), *force), + Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), + Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all), + Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()), + Command::Info { agent } => info_cmd::run(app, agent), + Command::Config(sub) => config_cmd::run(app, sub), + Command::Doctor { fix } => doctor_cmd::run(app, *fix), + Command::Run { agent, args } => run_cmd::run(app, agent, args), + Command::Completion { shell } => completion_cmd::run(*shell), + Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()), + Command::Export { output } => export_import::export_cmd(app, output), + Command::Import { file } => export_import::import_cmd(app, file), + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +pub struct ResolvedExec { + pub program: String, + pub args: Vec, + pub env: BTreeMap, +} + +/// Resolve the program, arguments and environment for running an agent. +/// Preference order: recorded binary > run command first token (resolved on +/// PATH when possible). +pub fn resolve_exec( + app: &App, + agent: &AgentDef, + extra_args: &[String], + extra_env: &BTreeMap, +) -> Result { + let entry: Option = app.state.get(&agent.name).ok().flatten(); + let mut tokens = agent.run_tokens(); + if tokens.is_empty() { + bail!("agent '{}' has no run command", agent.name); + } + let program = match &entry { + Some(e) if !e.bins.is_empty() => { + let p = std::path::PathBuf::from(&e.bins[0]); + if p.exists() || app.dry_run() { + p.display().to_string() + } else { + tokens.remove(0) + } + } + _ => { + let first = tokens.remove(0); + if first.contains(std::path::MAIN_SEPARATOR) || Path::new(&first).exists() { + first + } else if let Ok(p) = which::which(&first) { + p.display().to_string() + } else { + first + } + } + }; + let mut args: Vec = tokens; + args.extend(agent.args.iter().cloned()); + args.extend(extra_args.iter().cloned()); + let mut env = agent.env.clone(); + env.extend(extra_env.clone()); + Ok(ResolvedExec { program, args, env }) +} + +/// Parse --env KEY=VALUE entries. +pub fn parse_env_list(items: &[String]) -> Result> { + let mut map = BTreeMap::new(); + for item in items { + let Some((k, v)) = item.split_once('=') else { + bail!("invalid --env '{item}': expected KEY=VALUE"); + }; + if k.trim().is_empty() { + bail!("invalid --env '{item}': empty key"); + } + map.insert(k.to_string(), v.to_string()); + } + Ok(map) +} + +/// Parse --args entries; quoted strings are split on whitespace. +pub fn parse_extra_args(items: &[String]) -> Vec { + let mut out = Vec::new(); + for item in items { + if item.contains(' ') || item.contains('\t') { + if let Ok(parts) = shell_words::split(item) { + out.extend(parts); + continue; + } + } + out.push(item.clone()); + } + out +} + +/// Resolve an agent by name or alias, with a helpful error message. +pub fn require_agent<'a>(app: &'a App, name_or_alias: &str) -> Result<&'a AgentDef> { + app.catalog.resolve(name_or_alias).ok_or_else(|| { + anyhow!( + "unknown agent '{name_or_alias}' — run 'am search ' or 'am list' to see available agents" + ) + }) +} + +/// One-line description of an install method. +pub fn describe_method(m: &MethodSpec) -> String { + let mut parts = vec![format!("type={}", m.kind)]; + if let Some(p) = &m.package { + parts.push(format!("package={p}")); + } + if let Some(u) = &m.url { + parts.push(format!("url={u}")); + } + if let Some(s) = &m.script { + parts.push(format!("script={s}")); + } + if let Some(r) = &m.repo { + parts.push(format!("repo={r}")); + } + if let Some(b) = &m.binary_path { + parts.push(format!("binary_path={b}")); + } + parts.join(" ") +} + +/// Select an install method by index (1-based) or type name. +pub fn select_method<'a>( + methods: &[&'a MethodSpec], + sel: Option<&str>, +) -> Result<&'a MethodSpec> { + if methods.is_empty() { + bail!("no install methods defined"); + } + match sel { + None => Ok(methods[0]), + Some(s) => { + if let Ok(idx) = s.parse::() { + if (1..=methods.len()).contains(&idx) { + return Ok(methods[idx - 1]); + } + bail!("method index {idx} out of range (1..={})", methods.len()); + } + let lower = s.trim_start_matches("type:").to_lowercase(); + methods + .iter() + .find(|m| m.kind.as_str() == lower) + .copied() + .ok_or_else(|| { + anyhow!( + "unknown method '{s}'; available: {}", + methods + .iter() + .map(|m| m.kind.as_str()) + .collect::>() + .join(", ") + ) + }) + } + } +} diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs new file mode 100644 index 0000000..82382dc --- /dev/null +++ b/src/commands/run_cmd.rs @@ -0,0 +1,187 @@ +//! start, stop, restart and run: process management commands. + +use super::*; +use crate::cli::StartArgs; +use crate::process; +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Command; + +pub fn start(app: &App, opts: &StartArgs) -> Result { + let extra_env = parse_env_list(&opts.env)?; + let extra_args = parse_extra_args(&opts.args); + if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) { + let members = app.catalog.group_members(group); + if members.is_empty() { + bail!("unknown group '{group}'"); + } + for m in members { + start_one(app, m, &extra_args, &extra_env, opts.notify, true)?; + } + return Ok(0); + } + let agent = require_agent(app, &opts.agent)?; + start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background) +} + +fn start_one( + app: &App, + agent: &AgentDef, + extra_args: &[String], + extra_env: &BTreeMap, + notify: bool, + background: bool, +) -> Result { + let exec = resolve_exec(app, agent, extra_args, extra_env)?; + let bin = PathBuf::from(&exec.program); + let is_managed = app.state.get(&agent.name).ok().flatten().is_some(); + if background { + let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?; + if app.dry_run() { + return Ok(0); + } + if is_managed { + app.state + .update_pid(&agent.name, Some(pid), Some(crate::installers::now_rfc3339()))?; + } else { + app.log.warn(&format!( + "agent '{}' is external (not installed by agent-manager); PID {pid} is not recorded", + agent.name + )); + } + let log = process::agent_log_path(app, &agent.name); + app.log.success(&format!( + "{} started (pid {pid}); log: {}", + agent.title(), + log.display() + )); + if notify { + process::desktop_notify("agent-manager", &format!("{} started", agent.title())); + } + Ok(0) + } else { + if app.dry_run() { + app.log.dry(format!( + "would run {} {} (foreground)", + exec.program, + exec.args.join(" ") + )); + return Ok(0); + } + let (prog, prefix) = crate::runner::resolve_program(&exec.program); + let mut full_args = prefix; + full_args.extend(exec.args.iter().cloned()); + let status = Command::new(&prog) + .args(&full_args) + .envs(&exec.env) + .status() + .with_context(|| format!("failed to run {}", exec.program))?; + Ok(status.code().unwrap_or(1)) + } +} + +pub fn stop(app: &App, target: &str, force: bool, timeout: Option) -> Result { + let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout()); + if let Some(group) = crate::catalog::Catalog::parse_group_selector(target) { + let members = app.catalog.group_members(group); + if members.is_empty() { + bail!("unknown group '{group}'"); + } + for m in members.iter().rev() { + stop_one(app, m, force, timeout)?; + } + return Ok(0); + } + let agent = require_agent(app, target)?; + stop_one(app, agent, force, timeout) +} + +fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result { + let Some(entry) = app.state.get(&agent.name).ok().flatten() else { + app.log.info(&format!( + "agent '{}' is not managed by agent-manager; nothing to stop", + agent.name + )); + return Ok(0); + }; + let Some(pid) = entry.pid else { + app.log.info(&format!("agent '{}' is not running", agent.name)); + return Ok(0); + }; + if app.dry_run() { + app.log.dry(format!( + "would stop pid {pid} ({}{})", + agent.name, + if force { "force" } else { "graceful" } + )); + return Ok(0); + } + if !process::is_running(pid) { + app.log.warn(&format!( + "stale PID {pid} for '{}' (process no longer running); cleaning up", + agent.name + )); + app.state.update_pid(&agent.name, None, None)?; + return Ok(0); + } + app.log.info(&format!("stopping '{}' (pid {pid})", agent.name)); + let stopped = process::stop_pid(pid, force, timeout); + app.state.update_pid(&agent.name, None, None)?; + if stopped { + app.log.success(&format!("{} stopped", agent.title())); + Ok(0) + } else { + app.log.error(&format!("failed to stop pid {pid}")); + Ok(1) + } +} + +pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option) -> Result { + let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout()); + if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) { + let members = app.catalog.group_members(group); + if members.is_empty() { + bail!("unknown group '{group}'"); + } + for m in members.iter().rev() { + stop_one(app, m, force, timeout)?; + } + let extra_env = parse_env_list(&opts.env)?; + let extra_args = parse_extra_args(&opts.args); + for m in members { + start_one(app, m, &extra_args, &extra_env, opts.notify, true)?; + } + return Ok(0); + } + let agent = require_agent(app, &opts.agent)?; + stop_one(app, agent, force, timeout)?; + let extra_env = parse_env_list(&opts.env)?; + let extra_args = parse_extra_args(&opts.args); + start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background) +} + +/// run: execute the agent command directly, no process management. +pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result { + let agent = require_agent(app, target)?; + let extra_args: Vec = extra.iter().map(|o| o.to_string_lossy().to_string()).collect(); + let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?; + if app.dry_run() { + app.log.dry(format!( + "would run {} {}", + exec.program, + exec.args.join(" ") + )); + return Ok(0); + } + let (prog, prefix) = crate::runner::resolve_program(&exec.program); + let mut full_args = prefix; + full_args.extend(exec.args.iter().cloned()); + let status = Command::new(&prog) + .args(&full_args) + .envs(&exec.env) + .status() + .with_context(|| format!("failed to run {}", exec.program))?; + Ok(status.code().unwrap_or(1)) +} diff --git a/src/commands/search_cmd.rs b/src/commands/search_cmd.rs new file mode 100644 index 0000000..af18b9b --- /dev/null +++ b/src/commands/search_cmd.rs @@ -0,0 +1,62 @@ +//! search: keyword search over the catalog. + +use super::*; +use crate::output::{print_json, Table}; +use serde::Serialize; + +#[derive(Serialize)] +struct SearchRow { + name: String, + display_name: String, + category: Option, + installable: bool, + description: Option, + website: Option, + tags: Vec, +} + +pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result { + let hits = app.catalog.search(keyword, category); + if app.json() { + let rows: Vec = hits + .iter() + .map(|a| SearchRow { + name: a.name.clone(), + display_name: a.title().to_string(), + category: a.category.clone(), + installable: a.installable, + description: a.description.clone(), + website: a.website.clone(), + tags: a.tags.clone(), + }) + .collect(); + print_json(&rows); + return Ok(0); + } + let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]); + for a in hits { + let desc = truncate(a.description.as_deref().unwrap_or(""), 56); + table.row(vec![ + a.title().to_string(), + a.category.clone().unwrap_or_else(|| "—".to_string()), + if a.installable { "yes" } else { "no" }.to_string(), + desc, + ]); + } + print!("{}", table.render()); + if app.catalog.search(keyword, category).is_empty() { + app.log + .info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", "))); + } + Ok(0) +} + +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut cut: String = s.chars().take(max - 1).collect(); + cut.push('…'); + cut + } +} diff --git a/src/commands/self_update.rs b/src/commands/self_update.rs new file mode 100644 index 0000000..a2cd43b --- /dev/null +++ b/src/commands/self_update.rs @@ -0,0 +1,78 @@ +//! self-update: download a newer agent-manager binary from GitHub Releases. + +use super::*; +use crate::download; +use anyhow::{anyhow, bail, Context, Result}; +use std::cmp::Ordering; +use std::path::Path; + +pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result { + let Some(repo) = app.config.settings.self_update_repo.clone() else { + bail!( + "self-update is not configured: set 'settings.self_update_repo: \"owner/repo\"' in your config" + ); + }; + let current = env!("CARGO_PKG_VERSION"); + let release = download::latest_release(&repo)?; + let tag = release.tag_name.trim_start_matches('v').to_string(); + + if check { + match crate::version::compare(&tag, current) { + Some(Ordering::Greater) => { + println!("update available: {current} -> {tag}"); + Ok(0) + } + _ => { + println!("agent-manager {current} is up to date"); + Ok(0) + } + } + } else { + if crate::version::compare(&tag, current) != Some(Ordering::Greater) { + app.log.success(&format!( + "agent-manager {current} is already the latest version" + )); + return Ok(0); + } + if app.dry_run() { + app.log + .dry(format!("would download agent-manager {tag}")); + return Ok(0); + } + let picked = download::pick_asset(&release, &[], &[], None) + .ok_or_else(|| anyhow!("no release asset of '{repo}' matches this platform"))?; + app.log.info(&format!( + "downloading agent-manager {tag} ({})", + picked.name + )); + let current_exe = std::env::current_exe()?; + let dest = to.map(|p| p.to_path_buf()).unwrap_or_else(|| current_exe.clone()); + let tmpdir = tempfile::tempdir()?; + let tmp = tmpdir.path().join("am.new"); + download::download(&picked.url, &tmp, None, true)?; + if dest == current_exe && cfg!(windows) { + // The running executable is locked on Windows. + let new_path = current_exe.with_extension("exe.new"); + std::fs::copy(&tmp, &new_path) + .with_context(|| format!("cannot write {}", new_path.display()))?; + app.log.success(&format!( + "downloaded {tag} to {} — replace the running binary manually", + new_path.display() + )); + } else { + std::fs::copy(&tmp, &dest) + .with_context(|| format!("cannot write {}", dest.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &dest, + std::fs::Permissions::from_mode(0o755), + ); + } + app.log + .success(&format!("updated agent-manager to {tag} ({})", dest.display())); + } + Ok(0) + } +} diff --git a/src/commands/status_cmd.rs b/src/commands/status_cmd.rs new file mode 100644 index 0000000..c8da559 --- /dev/null +++ b/src/commands/status_cmd.rs @@ -0,0 +1,179 @@ +//! status: detailed state of one agent, or of every installed agent. + +use super::*; +use crate::output::{print_json, status_style, Table}; +use crate::probe::ExternalInfo; +use crate::state::InstalledEntry; +use serde::Serialize; + +#[derive(Serialize)] +struct StatusDetail { + name: String, + display_name: String, + status: String, + version: Option, + method: Option, + path: Option, + run: Option, + pid: Option, + started_at: Option, + installed_at: Option, + log: Option, + managed: bool, +} + +pub fn run(app: &App, agent_name: Option<&str>) -> Result { + let probe = crate::probe::detect_externals(app); + match agent_name { + Some(name) => status_one(app, name, &probe), + None => status_all(app, &probe), + } +} + +fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result { + let agent = require_agent(app, name)?; + let entry: Option = app.state.get(&agent.name).ok().flatten(); + let detail = detail_for(app, agent, entry.as_ref(), probe); + if app.json() { + print_json(&detail); + return Ok(0); + } + let color = app.color(); + println!("{} ({})", agent.title(), agent.name); + let status_line = status_style(&detail.status, color); + println!(" status: {status_line}"); + println!( + " version: {}", + detail.version.as_deref().unwrap_or("unknown") + ); + println!(" method: {}", detail.method.as_deref().unwrap_or("—")); + println!(" path: {}", detail.path.as_deref().unwrap_or("—")); + println!(" run: {}", detail.run.as_deref().unwrap_or("—")); + if let Some(pid) = detail.pid { + println!(" pid: {pid}"); + } + if let Some(t) = &detail.started_at { + println!(" started: {t}"); + } + if let Some(t) = &detail.installed_at { + println!(" installed: {t}"); + } + if let Some(l) = &detail.log { + println!(" log: {l}"); + } + Ok(0) +} + +fn status_all(app: &App, probe: &ExternalInfo) -> Result { + let entries = app.state.all().unwrap_or_default(); + let mut rows: Vec = Vec::new(); + for agent in app.catalog.agents() { + if agent.hidden { + continue; + } + let entry = entries.get(&agent.name); + let d = detail_for(app, agent, entry, probe); + if d.status == "running" || d.status == "installed" || d.status == "external" { + rows.push(AgentRow { + name: agent.name.clone(), + display_name: agent.title().to_string(), + status: d.status, + version: d.version, + pid: d.pid, + path: d.path, + }); + } + } + rows.sort_by(|a, b| a.name.cmp(&b.name)); + if app.json() { + print_json(&rows); + return Ok(0); + } + let color = app.color(); + let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]); + for r in &rows { + table.row(vec![ + r.display_name.clone(), + r.version.clone().unwrap_or_else(|| "—".to_string()), + status_style(&r.status, color), + r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()), + r.path.clone().unwrap_or_else(|| "—".to_string()), + ]); + } + print!("{}", table.render()); + if rows.is_empty() { + app.log.info("no installed or running agents"); + } + Ok(0) +} + +#[derive(Serialize)] +struct AgentRow { + name: String, + display_name: String, + status: String, + version: Option, + pid: Option, + path: Option, +} + +fn detail_for( + app: &App, + agent: &AgentDef, + entry: Option<&InstalledEntry>, + probe: &ExternalInfo, +) -> StatusDetail { + match entry { + Some(e) => { + let running = e.pid.map(crate::process::is_running).unwrap_or(false); + let status = if running { "running" } else { "installed" }; + let path = e + .bins + .first() + .filter(|b| std::path::Path::new(b).exists()) + .cloned() + .or_else(|| Some(e.install_dir.clone())); + StatusDetail { + name: agent.name.clone(), + display_name: agent.title().to_string(), + status: status.to_string(), + version: e.version.clone(), + method: Some(e.method.clone()), + path, + run: Some(e.run.clone()), + pid: if running { e.pid } else { None }, + started_at: if running { e.started_at.clone() } else { None }, + installed_at: Some(e.installed_at.clone()), + log: Some(crate::process::agent_log_path(app, &agent.name).display().to_string()), + managed: true, + } + } + None => { + let external = probe.path_of(&agent.name).cloned(); + let version = external + .as_ref() + .and_then(|_p| probe.version_of(&agent.name).cloned().flatten()) + .or_else(|| agent.version.clone()); + StatusDetail { + name: agent.name.clone(), + display_name: agent.title().to_string(), + status: if external.is_some() { + "external".to_string() + } else if agent.installable { + "not-installed".to_string() + } else { + "not-installable".to_string() + }, + version, + method: None, + path: external.map(|p| p.display().to_string()), + run: agent.run.clone(), + pid: None, + started_at: None, + installed_at: None, + log: None, + managed: false, + } + } + } +} diff --git a/src/commands/uninstall_cmd.rs b/src/commands/uninstall_cmd.rs new file mode 100644 index 0000000..9983cc9 --- /dev/null +++ b/src/commands/uninstall_cmd.rs @@ -0,0 +1,113 @@ +//! uninstall: stop the agent if needed, remove its files, bins and state +//! entry; --purge also removes logs and the user-config definition. + +use super::*; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +pub fn run(app: &App, agent_name: &str, purge: bool) -> Result { + let agent = require_agent(app, agent_name)?; + let entry = app.state.get(&agent.name).ok().flatten(); + let Some(entry) = entry else { + app.log.info(&format!( + "agent '{}' is not managed by agent-manager — nothing to uninstall", + agent.name + )); + return Ok(0); + }; + + // Stop it first when running. + if let Some(pid) = entry.pid { + if crate::process::is_running(pid) { + app.log.info(&format!("stopping running agent (pid {pid})")); + if app.dry_run() { + app.log.dry(format!("would stop pid {pid}")); + } else { + crate::process::stop_pid(pid, false, app.config.settings.stop_timeout()); + app.state.update_pid(&agent.name, None, None)?; + } + } + } + + let dir = PathBuf::from(&entry.install_dir); + if app.dry_run() { + app.log.dry(format!("would remove {}", dir.display())); + } else { + if dir.exists() { + std::fs::remove_dir_all(&dir) + .with_context(|| format!("cannot remove {}", dir.display()))?; + } + for b in &entry.bins { + let p = PathBuf::from(b); + if p.is_file() { + std::fs::remove_file(&p) + .with_context(|| format!("cannot remove {}", p.display()))?; + } + } + app.state.remove(&agent.name)?; + } + + if purge { + let log = crate::process::agent_log_path(app, &agent.name); + if app.dry_run() { + app.log.dry(format!("would remove log {}", log.display())); + } else if log.exists() { + std::fs::remove_file(&log).ok(); + } + remove_from_user_config(app, &agent.name)?; + } + + app.log.success(&format!( + "uninstalled {} (files removed{})", + agent.title(), + if purge { ", config purged" } else { "" } + )); + Ok(0) +} + +/// Remove the agent entry from the user config file (--purge). +fn remove_from_user_config(app: &App, name: &str) -> Result<()> { + let Some(path) = &app.config_origin else { + app.log.verbose("no user config file — nothing to purge"); + return Ok(()); + }; + let is_json = path.extension().map(|e| e == "json").unwrap_or(false); + if is_json { + app.log + .warn("config purge is not supported for JSON config files"); + return Ok(()); + } + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + let mut value: serde_yaml::Value = serde_yaml::from_str(&text) + .with_context(|| format!("cannot parse {}", path.display()))?; + let mut removed = false; + if let serde_yaml::Value::Mapping(map) = &mut value { + if let Some(serde_yaml::Value::Sequence(seq)) = map.get_mut("agents") { + let before = seq.len(); + seq.retain(|v| { + v.get("name") + .and_then(|n| n.as_str()) + .map(|n| n != name) + .unwrap_or(true) + }); + removed = seq.len() != before; + } + } + if !removed { + app.log.verbose(&format!("'{name}' not defined in {}", path.display())); + return Ok(()); + } + let backup = path.with_extension("yaml.bak"); + std::fs::copy(path, &backup) + .with_context(|| format!("cannot create backup {}", backup.display()))?; + std::fs::write(path, serde_yaml::to_string(&value)?) + .with_context(|| format!("cannot write {}", path.display()))?; + app.log.info(&format!( + "removed '{name}' from {} (backup: {})", + path.display(), + backup.display() + )); + Ok(()) +} + diff --git a/src/commands/update_cmd.rs b/src/commands/update_cmd.rs new file mode 100644 index 0000000..f5fd114 --- /dev/null +++ b/src/commands/update_cmd.rs @@ -0,0 +1,104 @@ +//! update: compare the installed version with the latest available one and +//! reinstall when an update exists. + +use super::*; +use crate::installers; +use crate::runner::{Runner, SystemRunner}; +use anyhow::{bail, Result}; +use std::cmp::Ordering; +use std::path::PathBuf; + +pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result { + let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log); + if let Some(name) = agent_name { + return update_one(app, &runner, name); + } + if all { + let entries = app.state.all()?; + let names: Vec = entries.keys().cloned().collect(); + let mut failed = 0; + for name in names { + if let Err(e) = update_one(app, &runner, &name) { + failed += 1; + app.log.error(&format!("{name}: {e:#}")); + } + } + return Ok(if failed > 0 { 1 } else { 0 }); + } + bail!("update requires an agent name, or --all"); +} + +fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { + let agent = require_agent(app, name)?; + let Some(entry) = app.state.get(&agent.name).ok().flatten() else { + bail!( + "agent '{}' is not installed by agent-manager — use 'am install {}'", + agent.name, + agent.name + ); + }; + let spec = agent + .install + .as_ref() + .ok_or_else(|| anyhow::anyhow!("agent '{}' has no install definition", agent.name))?; + let methods = spec.all_methods(); + // Prefer the method that was originally used. + let method = methods + .iter() + .find(|m| m.kind.as_str() == entry.method) + .copied() + .unwrap_or(methods[0]); + + let latest = if app.dry_run() { + None + } else { + installers::latest_version(app, agent, method, runner)? + }; + let current = entry.version.clone().or_else(|| { + entry + .bins + .first() + .and_then(|b| installers::detect_version(runner, &PathBuf::from(b))) + }); + + match (&latest, ¤t) { + (Some(l), Some(c)) => match crate::version::compare(l, c) { + Some(Ordering::Greater) => { + app.log.info(&format!("update available: {c} -> {l}")); + } + Some(Ordering::Equal) | Some(Ordering::Less) => { + app.log + .success(&format!("{} is up to date (v{c})", agent.title())); + return Ok(0); + } + None => { + app.log.warn(&format!("cannot compare versions ({c} vs {l}); reinstalling")); + } + }, + (Some(l), None) => app.log.info(&format!( + "latest available: {l} (installed version unknown); reinstalling" + )), + (None, Some(c)) => app.log.warn(&format!( + "cannot determine the latest version (installed: {c}); reinstalling" + )), + (None, None) => app.log.warn("cannot determine versions; reinstalling"), + } + + if app.dry_run() { + app.log + .dry(format!("would reinstall {} via {}", agent.name, method.kind)); + return Ok(0); + } + crate::deps::ensure_deps(app, agent, runner)?; + let outcome = installers::run_install(app, agent, method, runner)?; + let mut new_entry = installers::make_entry(agent, method, &outcome, app); + new_entry.installed_at = entry.installed_at.clone(); + new_entry.updated_at = Some(installers::now_rfc3339()); + let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string()); + app.state.set(&new_entry)?; + app.log.success(&format!( + "updated {} to v{version}", + agent.title() + )); + Ok(0) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..73992b2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,739 @@ +//! Configuration: YAML schema, loading with includes, merging with the +//! embedded default catalog, validation, and path expansion. + +use crate::cli::Cli; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Default catalog embedded in the binary at compile time. +pub const DEFAULT_CONFIG: &str = include_str!("../config.yaml"); + +/// Name of the user configuration file inside the config directory. +pub const CONFIG_FILE_NAME: &str = "config.yaml"; + +/// Name of the local (current directory) configuration file. +pub const LOCAL_CONFIG_NAME: &str = "agent-manager.yaml"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default = "default_version")] + pub version: String, + #[serde(default)] + pub settings: Settings, + #[serde(default)] + pub aliases: BTreeMap, + #[serde(default)] + pub groups: BTreeMap>, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub agents: Vec, +} + +fn default_version() -> String { + "1.0".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Settings { + /// Installation root for all agents. + #[serde(default)] + pub install_dir: Option, + /// Directory for logs. + #[serde(default)] + pub log_dir: Option, + /// Shell used to execute install scripts (defaults to sh on Unix). + #[serde(default)] + pub default_shell: Option, + /// Try to install missing dependencies automatically. + #[serde(default)] + pub auto_install_deps: Option, + /// Ask for confirmation before running install scripts. + #[serde(default)] + pub confirm_before_run: Option, + /// Grace period (seconds) between SIGTERM and SIGKILL; 0 = default (5). + #[serde(default)] + pub stop_timeout_secs: u64, + /// GitHub repository (owner/repo) used by the self-update command. + #[serde(default)] + pub self_update_repo: Option, +} + +impl Settings { + pub fn auto_install_deps(&self) -> bool { + self.auto_install_deps.unwrap_or(true) + } + + pub fn confirm_before_run(&self) -> bool { + self.confirm_before_run.unwrap_or(true) + } + + pub fn stop_timeout(&self) -> u64 { + if self.stop_timeout_secs == 0 { + 5 + } else { + self.stop_timeout_secs + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentDef { + /// Unique identifier (slug). + pub name: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub website: Option, + #[serde(default)] + pub install: Option, + #[serde(default)] + pub dependencies: Vec, + /// Command used to start the agent (e.g. "claude", "python -m nanobot"). + #[serde(default)] + pub run: Option, + /// Default arguments appended to the run command. + #[serde(default)] + pub args: Vec, + /// Default environment variables for the agent. + #[serde(default)] + pub env: BTreeMap, + /// Known version (otherwise detected via --version). + #[serde(default)] + pub version: Option, + #[serde(default)] + pub tags: Vec, + /// When false, the agent is listed but cannot be installed locally. + #[serde(default = "default_true")] + pub installable: bool, + /// Explanation shown when installable is false. + #[serde(default)] + pub note: Option, + /// Hidden from list/search output (still addressable by name). + #[serde(default)] + pub hidden: bool, + /// Restrict the agent to platforms: linux, macos, windows (empty = all). + #[serde(default)] + pub platforms: Vec, +} + +fn default_true() -> bool { + true +} + +impl AgentDef { + pub fn title(&self) -> &str { + self.display_name.as_deref().unwrap_or(&self.name) + } + + /// First word of the run command (the executable name). + pub fn first_token(&self) -> Option { + self.run + .as_deref() + .and_then(|r| shell_words::split(r).ok()) + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + } + + /// The run command split into tokens (empty when unset). + pub fn run_tokens(&self) -> Vec { + self.run + .as_deref() + .and_then(|r| shell_words::split(r).ok()) + .unwrap_or_default() + } + + pub fn supports_current_platform(&self) -> bool { + if self.platforms.is_empty() { + return true; + } + let platform = current_platform(); + self.platforms.iter().any(|p| p == platform) + } +} + +pub fn current_platform() -> &'static str { + if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstallType { + Npm, + Pip, + Uv, + Cargo, + Go, + Bun, + Curl, + Binary, + Git, +} + +impl InstallType { + pub fn as_str(&self) -> &'static str { + match self { + InstallType::Npm => "npm", + InstallType::Pip => "pip", + InstallType::Uv => "uv", + InstallType::Cargo => "cargo", + InstallType::Go => "go", + InstallType::Bun => "bun", + InstallType::Curl => "curl", + InstallType::Binary => "binary", + InstallType::Git => "git", + } + } +} + +impl std::fmt::Display for InstallType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A single install method: npm, pip, uv, cargo, go, bun, curl, binary or git. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MethodSpec { + #[serde(rename = "type")] + pub kind: InstallType, + /// Package name (npm, pip, cargo, go, bun). + #[serde(default)] + pub package: Option, + /// Direct download URL (binary method). + #[serde(default)] + pub url: Option, + /// Install script URL (curl method). + #[serde(default)] + pub script: Option, + /// GitHub repository "owner/repo" (binary method) or full clone URL (git). + #[serde(default)] + pub repo: Option, + /// Substrings that must all appear in the release asset name. + #[serde(default)] + pub asset_match: Vec, + /// Substrings that must not appear in the release asset name. + #[serde(default)] + pub asset_exclude: Vec, + /// Expected checksum of the downloaded file, "sha256:". + #[serde(default)] + pub checksum: Option, + /// Expected binary name (used to locate the executable). + #[serde(default)] + pub binary: Option, + /// Build command(s) for the git method (string or list). + #[serde(default)] + pub build: Option, + /// Path of the built binary inside the repository (git method). + #[serde(default)] + pub binary_path: Option, + /// Extra arguments for the installer command. + #[serde(default)] + pub args: Vec, + /// Extra environment for the installer command. + #[serde(default)] + pub env: BTreeMap, + /// Free-form note shown in info and method listings. + #[serde(default)] + pub note: Option, +} + +/// A string or a list of strings (for build commands and similar fields). +#[derive(Debug, Clone, Serialize)] +pub enum OneOrMany { + One(String), + Many(Vec), +} + +impl OneOrMany { + pub fn items(&self) -> Vec { + match self { + OneOrMany::One(s) => vec![s.clone()], + OneOrMany::Many(v) => v.clone(), + } + } +} + +impl<'de> Deserialize<'de> for OneOrMany { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = OneOrMany; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string or a list of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(OneOrMany::One(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(OneOrMany::One(v)) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut out = Vec::new(); + while let Some(v) = seq.next_element::()? { + out.push(v); + } + Ok(OneOrMany::Many(out)) + } + } + deserializer.deserialize_any(Visitor) + } +} + +/// Install specification: one primary method, optionally several alternatives. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallSpec { + #[serde(flatten)] + pub primary: MethodSpec, + /// Alternative methods; the first one is the default. + #[serde(default)] + pub methods: Vec, + /// Commands executed after a successful install (PATH includes the bin dir). + #[serde(default)] + pub post_install: Vec, +} + +impl InstallSpec { + /// All available methods: the alternatives when present, else the primary. + pub fn all_methods(&self) -> Vec<&MethodSpec> { + if self.methods.is_empty() { + vec![&self.primary] + } else { + self.methods.iter().collect() + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Dependency { + pub name: String, + /// Command used to probe the tool (defaults from the name). + #[serde(default)] + pub check: Option, + /// Minimum required version (lenient comparison). + #[serde(default)] + pub min_version: Option, + /// Human-readable hint on how to install the tool. + #[serde(default)] + pub install_hint: Option, + /// Command that installs the tool (used with auto_install_deps). + #[serde(default)] + pub install_cmd: Vec, +} + +// --------------------------------------------------------------------------- +// Loading and merging +// --------------------------------------------------------------------------- + +pub struct LoadedConfig { + pub config: Config, + /// The main user configuration file that was found (if any). + pub origin: Option, +} + +/// Load the effective configuration: embedded defaults, then the user file +/// (found by priority order), then its includes. +pub fn load(cli: &Cli) -> Result { + let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG) + .context("embedded default catalog is invalid — this is a bug in agent-manager")?; + let mut origin: Option = None; + + let mut candidates: Vec = Vec::new(); + if let Some(p) = std::env::var_os("AGENT_MANAGER_CONFIG") { + candidates.push(PathBuf::from(p)); + } + if let Some(p) = &cli.config { + candidates.push(p.clone()); + } + candidates.push(PathBuf::from(LOCAL_CONFIG_NAME)); + if let Some(dir) = user_config_dir() { + candidates.push(dir.join(CONFIG_FILE_NAME)); + } + + let chosen = candidates.into_iter().find(|p| p.is_file()); + if let Some(path) = chosen { + let overlay = load_file_with_includes(&path, &mut HashSet::new())?; + merge(&mut base, overlay); + origin = Some(path); + } + + let problems = validate(&base); + if !problems.is_empty() { + return Err(anyhow!( + "configuration problems: + - {}", + problems.join(" + - ") + )); + } + Ok(LoadedConfig { config: base, origin }) +} + +/// Parse a YAML or JSON config file. +pub fn parse_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read config file {}", path.display()))?; + let is_json = path.extension().map(|e| e == "json").unwrap_or(false); + if is_json { + serde_json::from_str(&text) + .map_err(|e| anyhow!("invalid JSON in {}: {e}", path.display())) + } else { + serde_yaml::from_str(&text) + .map_err(|e| anyhow!("invalid YAML in {}: {e}", path.display())) + } +} + +fn load_file_with_includes(path: &Path, visited: &mut HashSet) -> Result { + let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !visited.insert(canon.clone()) { + return Err(anyhow!("config include cycle detected at {}", path.display())); + } + let mut cfg = parse_file(path)?; + let includes = cfg.include.clone(); + cfg.include.clear(); + + let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let mut merged = Config::default(); + merged.version = cfg.version.clone(); + for inc in &includes { + let inc_path = expand_path(inc); + let abs = if inc_path.is_absolute() { + inc_path + } else { + base_dir.join(inc_path) + }; + let sub = load_file_with_includes(&abs, visited)?; + merge(&mut merged, sub); + } + merge(&mut merged, cfg); + Ok(merged) +} + +/// Merge an overlay config into a base config: user definitions replace +/// default agents with the same name; maps are merged key by key. +pub fn merge(base: &mut Config, overlay: Config) { + let s = &mut base.settings; + let o = overlay.settings; + if o.install_dir.is_some() { + s.install_dir = o.install_dir; + } + if o.log_dir.is_some() { + s.log_dir = o.log_dir; + } + if o.default_shell.is_some() { + s.default_shell = o.default_shell; + } + if o.auto_install_deps.is_some() { + s.auto_install_deps = o.auto_install_deps; + } + if o.confirm_before_run.is_some() { + s.confirm_before_run = o.confirm_before_run; + } + if o.stop_timeout_secs != 0 { + s.stop_timeout_secs = o.stop_timeout_secs; + } + if o.self_update_repo.is_some() { + s.self_update_repo = o.self_update_repo; + } + for (k, v) in overlay.aliases { + base.aliases.insert(k, v); + } + for (k, v) in overlay.groups { + base.groups.insert(k, v); + } + for agent in overlay.agents { + if let Some(existing) = base.agents.iter_mut().find(|a| a.name == agent.name) { + *existing = agent; + } else { + base.agents.push(agent); + } + } +} + +/// Validate a merged config. Returns a list of human-readable problems. +pub fn validate(config: &Config) -> Vec { + let mut problems = Vec::new(); + if config.version != "1" && config.version != "1.0" { + problems.push(format!( + "unsupported config version '{}' (expected '1.0')", + config.version + )); + } + let mut seen = HashSet::new(); + for agent in &config.agents { + if !seen.insert(agent.name.clone()) { + problems.push(format!("duplicate agent name '{}'", agent.name)); + } + if agent.name.is_empty() + || !agent + .name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + problems.push(format!( + "agent name '{}' must be a lowercase slug ([a-z0-9_-])", + agent.name + )); + } + if agent.installable { + if agent.run.as_deref().unwrap_or("").trim().is_empty() { + problems.push(format!("agent '{}': 'run' is required when installable", agent.name)); + } + if agent.install.is_none() { + problems.push(format!( + "agent '{}': 'install' block is required when installable", + agent.name + )); + } + } + for dep in &agent.dependencies { + if dep.name.trim().is_empty() { + problems.push(format!("agent '{}': dependency without a name", agent.name)); + } + } + } + problems +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +/// Expand "~" and environment variables ("$HOME", "${VAR}") in a path. +pub fn expand_path(input: &str) -> PathBuf { + let mut s = input.to_string(); + if s == "~" { + s = home_dir() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|| "~".to_string()); + } else if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) { + if let Some(h) = home_dir() { + s = format!("{}{}{}", h.to_string_lossy(), std::path::MAIN_SEPARATOR, rest); + } + } + PathBuf::from(expand_vars(&s)) +} + +/// Expand "$VAR" and "${VAR}" occurrences. +pub fn expand_vars(input: &str) -> String { + let chars: Vec = input.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '$' && i + 1 < chars.len() { + if chars[i + 1] == '{' { + if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') { + let name: String = chars[i + 2..i + 2 + end].iter().collect(); + if let Ok(v) = std::env::var(&name) { + out.push_str(&v); + i += end + 3; + continue; + } + } + } else { + let mut j = i + 1; + while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { + j += 1; + } + if j > i + 1 { + let name: String = chars[i + 1..j].iter().collect(); + if let Ok(v) = std::env::var(&name) { + out.push_str(&v); + i = j; + continue; + } + } + } + } + out.push(chars[i]); + i += 1; + } + out +} + +/// The user's home directory. +pub fn home_dir() -> Option { + if let Some(h) = std::env::var_os("HOME") { + return Some(PathBuf::from(h)); + } + if let Some(h) = std::env::var_os("USERPROFILE") { + return Some(PathBuf::from(h)); + } + None +} + +/// Directory holding the user configuration file. +pub fn user_config_dir() -> Option { + if let Some(d) = std::env::var_os("AGENT_MANAGER_CONFIG_DIR") { + return Some(PathBuf::from(d)); + } + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| home_dir().map(|h| h.join(".config"))) + }; + base.map(|b| b.join("agent-manager")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_embedded_default() { + let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); + assert!(cfg.agents.len() >= 60, "catalog too small: {}", cfg.agents.len()); + assert!(cfg.agents.iter().any(|a| a.name == "claude-code")); + assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg)); + } + + #[test] + fn expands_paths() { + std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value"); + let home = home_dir().unwrap_or_else(|| PathBuf::from("/home/test")); + let p = expand_path("~/a/$AM_TEST_EXPAND_VAR/b"); + let s = p.to_string_lossy().to_string(); + assert!(!s.contains('~')); + assert!(!s.contains("$AM_TEST_EXPAND_VAR")); + assert!(s.contains("expanded-value")); + assert!(s.contains("a")); + assert!(s.contains(&home.to_string_lossy().to_string())); + } + + #[test] + fn merges_user_over_default() { + let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); + let overlay: Config = serde_yaml::from_str( + r#" +version: "1.0" +agents: + - name: claude-code + display_name: "Mon Claude" + install: + type: npm + package: "@anthropic-ai/claude-code" + run: claude + - name: my-agent + display_name: "My Agent" + install: + type: binary + url: "https://example.com/bin" + run: my-agent +"#, + ) + .unwrap(); + merge(&mut base, overlay); + let cc = base.agents.iter().find(|a| a.name == "claude-code").unwrap(); + assert_eq!(cc.display_name.as_deref(), Some("Mon Claude")); + assert!(base.agents.iter().any(|a| a.name == "my-agent")); + assert!(validate(&base).is_empty()); + } + + #[test] + fn validate_rejects_bad_agents() { + let cfg: Config = serde_yaml::from_str( + r#" +version: "1.0" +agents: + - name: broken + installable: true + run: "" + install: + type: npm + package: x + - name: broken + install: + type: npm + package: y + run: broken +"#, + ) + .unwrap(); + let problems = validate(&cfg); + assert!(problems.iter().any(|p| p.contains("duplicate"))); + assert!(problems.iter().any(|p| p.contains("'run' is required"))); + } + + #[test] + fn one_or_many_parses_both() { + let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap(); + assert_eq!(a.items(), vec!["make build".to_string()]); + let b: OneOrMany = serde_yaml::from_str("- cargo +- build").unwrap(); + assert_eq!(b.items(), vec!["cargo".to_string(), "build".to_string()]); + } + + #[test] + fn includes_resolve_relative() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("sub.yaml"); + std::fs::write( + &sub, + r#" +version: "1.0" +agents: + - name: from-include + display_name: "Included" + install: { type: npm, package: pkg } + run: from-include +"#, + ) + .unwrap(); + let main = dir.path().join("main.yaml"); + std::fs::write( + &main, + r#" +version: "1.0" +include: [sub.yaml] +agents: + - name: from-main + display_name: "Main" + install: { type: npm, package: pkg2 } + run: from-main +"#, + ) + .unwrap(); + let cfg = load_file_with_includes(&main, &mut HashSet::new()).unwrap(); + assert!(cfg.agents.iter().any(|a| a.name == "from-include")); + assert!(cfg.agents.iter().any(|a| a.name == "from-main")); + } +} diff --git a/src/deps.rs b/src/deps.rs new file mode 100644 index 0000000..0398c19 --- /dev/null +++ b/src/deps.rs @@ -0,0 +1,208 @@ +//! Dependency checking: probe required tools (node, python, go, ...), +//! compare minimum versions, and produce actionable error messages. + +use crate::app::App; +use crate::config::{AgentDef, Dependency}; +use crate::runner::Runner; +use anyhow::{bail, Result}; +use std::collections::BTreeMap; +use std::cmp::Ordering; + +#[derive(Debug, Clone)] +pub struct DepCheck { + pub name: String, + pub found: bool, + pub version: Option, + pub min: Option, + pub ok: bool, + pub detail: String, +} + +/// Default probe command for a dependency name: (program, args). +fn default_probe(name: &str) -> (String, Vec) { + match name { + "go" => ("go".to_string(), vec!["version".to_string()]), + _ => (name.to_string(), vec!["--version".to_string()]), + } +} + +/// Probe one dependency. Python is special: python3 is preferred. +pub fn check_dep(runner: &dyn Runner, dep: &Dependency) -> DepCheck { + let probes: Vec<(String, Vec)> = if dep.name == "python" { + vec![ + ("python3".to_string(), vec!["--version".to_string()]), + ("python".to_string(), vec!["--version".to_string()]), + ] + } else if let Some(check) = &dep.check { + match shell_words::split(check) { + Ok(tokens) if !tokens.is_empty() => { + let prog = tokens[0].clone(); + vec![(prog, tokens[1..].to_vec())] + } + _ => vec![default_probe(&dep.name)], + } + } else { + vec![default_probe(&dep.name)] + }; + let empty = BTreeMap::new(); + for (prog, args) in probes { + if runner.which(&prog).is_none() { + continue; + } + match runner.run(&prog, &args, &empty, None, true) { + Ok(out) if out.code == 0 => { + let combined = format!("{} +{}", out.stdout, out.stderr); + let version = crate::version::find_version(&combined); + let ok = match (&dep.min_version, &version) { + (Some(min), Some(v)) => crate::version::compare(v, min) + .map(|o| o != Ordering::Less) + .unwrap_or(true), + _ => true, + }; + let detail = if ok { + version.clone().unwrap_or_else(|| "found".to_string()) + } else { + format!( + "version {} is too old (need >= {})", + version.as_deref().unwrap_or("unknown"), + dep.min_version.as_deref().unwrap_or("?") + ) + }; + return DepCheck { + name: dep.name.clone(), + found: true, + version, + min: dep.min_version.clone(), + ok, + detail, + }; + } + Ok(out) => { + return DepCheck { + name: dep.name.clone(), + found: true, + version: None, + min: dep.min_version.clone(), + ok: false, + detail: format!("probe failed (exit code {})", out.code), + }; + } + Err(_) => {} + } + } + DepCheck { + name: dep.name.clone(), + found: false, + version: None, + min: dep.min_version.clone(), + ok: false, + detail: "not found on PATH".to_string(), + } +} + +/// Check every dependency of an agent. +pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec { + agent + .dependencies + .iter() + .map(|d| check_dep(runner, d)) + .collect() +} + +/// Fail with an actionable message when dependencies are missing. +/// With auto_install_deps enabled, dependencies that define an install_cmd +/// are offered for automatic installation first. +pub fn ensure_deps(app: &App, agent: &AgentDef, runner: &dyn Runner) -> Result<()> { + let mut checks = check_agent(agent, runner); + if checks.iter().all(|c| c.ok) { + return Ok(()); + } + if app.config.settings.auto_install_deps() && !app.dry_run() { + for dep in &agent.dependencies { + if dep.install_cmd.is_empty() { + continue; + } + if check_dep(runner, dep).ok { + continue; + } + let cmd_line = dep.install_cmd.join(" "); + app.log.warn(&format!("dependency '{}' is missing", dep.name)); + let proceed = app + .confirm(&format!("Run '{cmd_line}' to install it?")) + .unwrap_or(false); + if proceed { + let prog = dep.install_cmd[0].clone(); + let args: Vec = dep.install_cmd[1..].to_vec(); + let empty = BTreeMap::new(); + let out = runner.run(&prog, &args, &empty, None, false)?; + if out.code != 0 { + app.log + .warn(&format!("automatic installation of '{}' failed", dep.name)); + } + } + } + checks = check_agent(agent, runner); + } + if checks.iter().all(|c| c.ok) { + return Ok(()); + } + let mut lines = Vec::new(); + for c in checks.iter().filter(|c| !c.ok) { + let mut line = format!(" - {}: {}", c.name, c.detail); + if let Some(min) = &c.min { + line.push_str(&format!(" (minimum: {min})")); + } + if let Some(dep) = agent.dependencies.iter().find(|d| d.name == c.name) { + if let Some(hint) = &dep.install_hint { + line.push_str(&format!(" — install via {hint}")); + } + } + lines.push(line); + } + bail!( + "agent '{}' has unmet dependencies: +{}", + agent.name, + lines.join(" +") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Dependency; + use crate::runner::MockRunner; + + #[test] + fn missing_tool_reported() { + let runner = MockRunner::new(); + let dep = Dependency { + name: "node".to_string(), + check: None, + min_version: Some("18.0.0".to_string()), + install_hint: Some("https://nodejs.org".to_string()), + install_cmd: vec![], + }; + let c = check_dep(&runner, &dep); + assert!(!c.ok); + assert!(!c.found); + assert_eq!(c.detail, "not found on PATH"); + } + + #[test] + fn python3_preferred() { + let runner = MockRunner::new().with_found("python3").with_found("python"); + let dep = Dependency { + name: "python".to_string(), + check: None, + min_version: Some("3.9".to_string()), + install_hint: None, + install_cmd: vec![], + }; + let c = check_dep(&runner, &dep); + // MockRunner returns empty output => version unknown => ok when no min. + assert!(c.found); + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..9ab66bc --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,229 @@ +//! doctor: environment checks — tools, config validity, paths, +//! permissions — with an optional --fix mode. + +use crate::app::App; +use crate::runner::Runner; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Ok, + Warn, + Error, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Check { + pub name: String, + pub status: Status, + pub detail: String, +} + +pub struct DoctorReport { + pub checks: Vec, +} + +impl DoctorReport { + pub fn ok(&self) -> bool { + !self.checks.iter().any(|c| c.status == Status::Error) + } + + pub fn problems(&self) -> usize { + self.checks.iter().filter(|c| c.status != Status::Ok).count() + } +} + +/// Args for probing a tool's version. +fn probe_args(tool: &str) -> Vec { + match tool { + "go" => vec!["version".to_string()], + _ => vec!["--version".to_string()], + } +} + +pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport { + let mut checks = Vec::new(); + + // --- tools ---------------------------------------------------------- + // (name, required) + let tools: &[(&str, bool)] = &[ + ("git", true), + ("sh", cfg!(unix)), + ("curl", true), + ("node", false), + ("npm", false), + ("python3", false), + ("python", false), + ("cargo", false), + ("go", false), + ("bun", false), + ("uv", false), + ("docker", false), + ]; + for (tool, required) in tools { + match runner.which(tool) { + Some(path) => { + let mut detail = path.display().to_string(); + let empty = BTreeMap::new(); + if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) { + if out.code == 0 { + let combined = format!("{} +{}", out.stdout, out.stderr); + if let Some(v) = crate::version::find_version(&combined) { + detail = format!("{} (v{v})", path.display()); + } + } + } + checks.push(Check { + name: tool.to_string(), + status: Status::Ok, + detail, + }); + } + None => { + let (status, detail) = if *required { + ( + Status::Error, + "required but not found on PATH".to_string(), + ) + } else { + (Status::Ok, "not installed".to_string()) + }; + checks.push(Check { + name: tool.to_string(), + status, + detail, + }); + } + } + } + + // --- configuration --------------------------------------------------- + match &app.config_origin { + Some(path) => checks.push(Check { + name: "config".to_string(), + status: Status::Ok, + detail: path.display().to_string(), + }), + None => checks.push(Check { + name: "config".to_string(), + status: Status::Warn, + detail: "no user config file found; using the embedded default catalog".to_string(), + }), + } + let problems = crate::config::validate(&app.config); + if problems.is_empty() { + checks.push(Check { + name: "config-schema".to_string(), + status: Status::Ok, + detail: format!("{} agents defined", app.catalog.len()), + }); + } else { + checks.push(Check { + name: "config-schema".to_string(), + status: Status::Error, + detail: problems.join("; "), + }); + } + + // --- state file ------------------------------------------------------ + match app.state.load() { + Ok(sf) => checks.push(Check { + name: "state".to_string(), + status: Status::Ok, + detail: format!("{} installed agent(s)", sf.installed.len()), + }), + Err(e) => checks.push(Check { + name: "state".to_string(), + status: Status::Error, + detail: format!("{e:#}"), + }), + } + + // --- directories ----------------------------------------------------- + for (name, dir) in [ + ("install-dir", app.paths.install_dir.clone()), + ("bin-dir", app.paths.bin_dir.clone()), + ("log-dir", app.paths.log_dir.clone()), + ] { + let status = if dir.is_dir() { + Status::Ok + } else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) { + Status::Warn + } else { + Status::Warn + }; + let detail = if dir.is_dir() { + dir.display().to_string() + } else { + format!("{} (missing; 'am doctor --fix' creates it)", dir.display()) + }; + checks.push(Check { + name: name.to_string(), + status, + detail, + }); + } + + // --- bin dir on PATH -------------------------------------------------- + let on_path = is_on_path(&app.paths.bin_dir); + checks.push(Check { + name: "bin-on-path".to_string(), + status: if on_path { Status::Ok } else { Status::Warn }, + detail: if on_path { + format!("{} is on PATH", app.paths.bin_dir.display()) + } else { + format!( + "{} is not on PATH — add it so agents are found from your shell", + app.paths.bin_dir.display() + ) + }, + }); + + DoctorReport { checks } +} + +fn is_on_path(dir: &std::path::Path) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|p| { + if cfg!(windows) { + p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase() + } else { + p == dir + } + }) +} + +/// Repair what can be repaired: create directories, fix a corrupt state file. +pub fn fix(app: &App) -> Result> { + let mut repaired = Vec::new(); + app.paths.ensure().context("creating directories")?; + repaired.push(format!("created {}", app.paths.install_dir.display())); + repaired.push(format!("created {}", app.paths.log_dir.display())); + + // Corrupt state file: back it up and start fresh. + if app.state.path().exists() && app.state.load().is_err() { + let backup = app + .state + .path() + .with_extension("json.corrupt.bak"); + std::fs::rename(app.state.path(), &backup).with_context(|| { + format!( + "cannot back up corrupt state file {} to {}", + app.state.path().display(), + backup.display() + ) + })?; + app.state.save(&crate::state::StateFile::default())?; + repaired.push(format!( + "corrupt state file moved to {} and recreated empty", + backup.display() + )); + } + Ok(repaired) +} diff --git a/src/download.rs b/src/download.rs new file mode 100644 index 0000000..f41b40f --- /dev/null +++ b/src/download.rs @@ -0,0 +1,470 @@ +//! Downloading (with checksum verification), GitHub Releases resolution, +//! archive extraction and executable discovery. + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::io::{IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +pub const USER_AGENT: &str = concat!("agent-manager/", env!("CARGO_PKG_VERSION")); + +// --------------------------------------------------------------------------- +// URL validation + download +// --------------------------------------------------------------------------- + +/// Only http, https and file schemes are allowed. +pub fn validate_url(url: &str) -> Result<()> { + let lower = url.to_lowercase(); + let ok = lower.starts_with("https://") + || lower.starts_with("http://") + || lower.starts_with("file://"); + if !ok { + bail!( + "refusing URL with unsupported scheme (only http, https and file are allowed): {url}" + ); + } + Ok(()) +} + +/// Download a URL to dest, verifying the optional sha256 checksum. +pub fn download(url: &str, dest: &Path, expected_sha256: Option<&str>, progress: bool) -> Result<()> { + validate_url(url)?; + if let Some(local) = url.strip_prefix("file://") { + let src = PathBuf::from(local); + std::fs::copy(&src, dest) + .with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?; + verify_sha256(dest, expected_sha256)?; + return Ok(()); + } + + let resp = ureq::get(url) + .set("User-Agent", USER_AGENT) + .call() + .map_err(|e| { + match e { + ureq::Error::Status(code, r) => anyhow!( + "download failed for {url}: HTTP {code} {}", + r.status_text() + ), + other => anyhow!("download failed for {url}: {other}"), + } + })?; + let total: Option = resp.header("content-length").and_then(|v| v.parse().ok()); + let mut reader = resp.into_reader(); + let mut file = std::fs::File::create(dest) + .with_context(|| format!("cannot create {}", dest.display()))?; + let mut buf = [0u8; 65536]; + let mut done: u64 = 0; + let mut last_shown: u64 = 0; + let show = progress && std::io::stderr().is_terminal(); + loop { + let n = reader + .read(&mut buf) + .with_context(|| format!("read error while downloading {url}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n])?; + done += n as u64; + if show && (done - last_shown >= 8 * 1024 * 1024 || done == total.unwrap_or(u64::MAX)) { + last_shown = done; + match total { + Some(t) => eprint!( + "\r downloading {:.1} / {:.1} MB", + done as f64 / 1e6, + t as f64 / 1e6 + ), + None => eprint!("\r downloaded {:.1} MB", done as f64 / 1e6), + } + std::io::stderr().flush().ok(); + } + } + if show { + eprintln!(); + } + verify_sha256(dest, expected_sha256)?; + Ok(()) +} + +fn verify_sha256(path: &Path, expected: Option<&str>) -> Result<()> { + let Some(exp) = expected else { + return Ok(()); + }; + let hex = exp + .strip_prefix("sha256:") + .or_else(|| exp.strip_prefix("SHA256:")) + .unwrap_or(exp) + .trim(); + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("invalid sha256 checksum in config: {exp}"); + } + let mut f = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut f, &mut hasher)?; + let actual = to_hex(&hasher.finalize()); + if !actual.eq_ignore_ascii_case(hex) { + bail!( + "checksum mismatch for {}: expected {hex}, got {actual}", + path.display() + ); + } + Ok(()) +} + +pub fn to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +// --------------------------------------------------------------------------- +// GitHub Releases +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct GhAsset { + pub name: String, + pub browser_download_url: String, + #[serde(default)] + pub size: u64, +} + +#[derive(Debug, Deserialize)] +pub struct GhRelease { + pub tag_name: String, + #[serde(default)] + pub assets: Vec, +} + +pub fn latest_release(repo: &str) -> Result { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + let resp = ureq::get(&url) + .set("User-Agent", USER_AGENT) + .call() + .map_err(|e| match e { + ureq::Error::Status(404, _) => { + anyhow!("no GitHub releases found for repository '{repo}'") + } + other => anyhow!("GitHub API request failed for '{repo}': {other}"), + })?; + let text = resp + .into_string() + .context("reading GitHub API response")?; + serde_json::from_str::(&text).context("parsing GitHub API response") +} + +pub struct PickedAsset { + pub name: String, + pub url: String, + pub tag: String, +} + +/// Choose the release asset that best matches the current platform and arch. +pub fn pick_asset( + release: &GhRelease, + want: &[String], + exclude: &[String], + binary_hint: Option<&str>, +) -> Option { + let platform = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + let mut best: Option<(u32, usize, &GhAsset)> = None; + for asset in &release.assets { + let lower = asset.name.to_lowercase(); + // Skip checksums, signatures and desktop packages. + if lower.ends_with(".sha256") + || lower.ends_with(".sha512") + || lower.ends_with(".sig") + || lower.ends_with(".asc") + || lower.ends_with(".pem") + || lower.contains("checksums") + || lower.ends_with(".dmg") + || lower.ends_with(".deb") + || lower.ends_with(".rpm") + || lower.ends_with(".msi") + || lower.ends_with(".apk") + { + continue; + } + if !want.iter().all(|w| lower.contains(&w.to_lowercase())) { + continue; + } + if exclude.iter().any(|w| lower.contains(&w.to_lowercase())) { + continue; + } + let score = match platform_score(&lower, platform, arch) { + Some(s) => s, + None => continue, + }; + let replace = match best { + None => true, + Some((s, _, _)) => score > s, + }; + if replace { + best = Some((score, asset.name.len(), asset)); + } + } + best.map(|(_, _, a)| { + let _ = binary_hint; + PickedAsset { + name: a.name.clone(), + url: a.browser_download_url.clone(), + tag: release.tag_name.clone(), + } + }) +} + +fn platform_score(lower: &str, platform: &str, arch: &str) -> Option { + let plat_ok = match platform { + "windows" => { + lower.contains("windows") + || lower.ends_with(".exe") + || lower.contains("win64") + || lower.contains("win32") + || lower.contains("msvc") + || lower.contains("mingw") + || lower.contains("pc-windows") + } + "macos" => { + lower.contains("darwin") + || lower.contains("macos") + || lower.contains("apple") + || lower.contains("osx") + } + _ => { + lower.contains("linux") + || lower.contains("unknown-linux") + || lower.contains("manylinux") + } + }; + if !plat_ok { + return None; + } + let arch_ok = match arch { + "aarch64" => lower.contains("aarch64") || lower.contains("arm64") || lower.contains("armv8"), + _ => { + lower.contains("x86_64") + || lower.contains("amd64") + || lower.contains("x64") + || lower.contains("i686") + } + }; + if !arch_ok && !lower.contains("universal") { + return None; + } + let mut s = 1u32; + if lower.contains("universal") { + s += 4; + } + if arch == "aarch64" && lower.contains("arm64") { + s += 3; + } + if arch == "x86_64" && (lower.contains("x86_64") || lower.contains("amd64")) { + s += 3; + } + if lower.contains("musl") { + s += 1; + } + if lower.contains("static") || lower.contains("portable") { + s += 1; + } + if lower.contains("armv7") + || lower.contains("armhf") + || lower.contains("armv6") + || lower.contains("i686") + || lower.contains("i386") + { + s = s.saturating_sub(2); + } + Some(s) +} + +// --------------------------------------------------------------------------- +// Archives and executables +// --------------------------------------------------------------------------- + +/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the +/// file is not a recognized archive (caller treats it as a plain binary). +pub fn extract_archive(path: &Path, dest: &Path) -> Result { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + std::fs::create_dir_all(dest)?; + if name.ends_with(".zip") { + extract_zip(path, dest)?; + Ok(true) + } else if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + let f = std::fs::File::open(path)?; + let dec = flate2::read::GzDecoder::new(f); + let mut ar = tar::Archive::new(dec); + ar.unpack(dest) + .with_context(|| format!("extracting {}", path.display()))?; + Ok(true) + } else if name.ends_with(".tar") { + let f = std::fs::File::open(path)?; + let mut ar = tar::Archive::new(f); + ar.unpack(dest) + .with_context(|| format!("extracting {}", path.display()))?; + Ok(true) + } else if name.ends_with(".gz") { + let inner = name.trim_end_matches(".gz").to_string(); + let out = dest.join(inner); + let f = std::fs::File::open(path)?; + let mut dec = flate2::read::GzDecoder::new(f); + let mut o = std::fs::File::create(&out)?; + std::io::copy(&mut dec, &mut o)?; + Ok(true) + } else { + Ok(false) + } +} + +fn extract_zip(path: &Path, dest: &Path) -> Result<()> { + let f = std::fs::File::open(path)?; + let mut zip = zip::ZipArchive::new(f) + .with_context(|| format!("opening zip archive {}", path.display()))?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + let Some(enclosed) = entry.enclosed_name() else { + bail!("zip archive {} contains an unsafe path", path.display()); + }; + let out_path = dest.join(enclosed); + if entry.is_dir() { + std::fs::create_dir_all(&out_path)?; + } else { + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut out = std::fs::File::create(&out_path)?; + std::io::copy(&mut entry, &mut out)?; + } + } + Ok(()) +} + +/// Find the executable in a directory tree: exact name match first, then a +/// single candidate file, then a single executable file (Unix). +pub fn find_executable(dir: &Path, expected: Option<&str>) -> Result { + let mut files = Vec::new(); + collect_files(dir, &mut files); + if files.is_empty() { + bail!("no files found in {}", dir.display()); + } + if let Some(exp) = expected { + for f in &files { + let name = f + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + let stripped = name.strip_suffix(".exe").unwrap_or(&name); + if stripped.eq_ignore_ascii_case(exp) { + return Ok(f.clone()); + } + } + } + if files.len() == 1 { + return Ok(files.remove(0)); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let exec: Vec<&PathBuf> = files + .iter() + .filter(|f| { + std::fs::metadata(f) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }) + .collect(); + if exec.len() == 1 { + return Ok(exec[0].clone()); + } + } + let names: Vec = files + .iter() + .map(|f| format!(" {}", f.display())) + .collect(); + bail!( + "cannot determine which file is the executable in {}; candidates: +{}", + dir.display(), + names.join(" +") + ) +} + +fn collect_files(dir: &Path, out: &mut Vec) { + if let Ok(rd) = std::fs::read_dir(dir) { + let mut entries: Vec = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + collect_files(&p, out); + } else { + out.push(p); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_validation() { + assert!(validate_url("https://example.com/x").is_ok()); + assert!(validate_url("http://example.com/x").is_ok()); + assert!(validate_url("file:///tmp/x").is_ok()); + assert!(validate_url("ftp://example.com/x").is_err()); + assert!(validate_url("javascript:alert(1)").is_err()); + } + + #[test] + fn sha256_verification() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("f.bin"); + std::fs::write(&p, b"hello world").unwrap(); + let good = to_hex(&Sha256::digest(b"hello world")); + verify_sha256(&p, Some(&good)).unwrap(); + verify_sha256(&p, Some("0000000000000000000000000000000000000000000000000000000000000000")) + .unwrap_err(); + } + + #[test] + fn extracts_and_finds() { + let dir = tempfile::tempdir().unwrap(); + let payload = dir.path().join("app.tar.gz"); + { + let f = std::fs::File::create(&payload).unwrap(); + let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default()); + let mut ar = tar::Builder::new(enc); + let mut header = tar::Header::new_gnu(); + header.set_size(6); + header.set_mode(0o755); + header.set_cksum(); + ar.append_data(&mut header, "bin/app", &b"binary"[..]).unwrap(); + ar.finish().unwrap(); + } + let out = dir.path().join("out"); + assert!(extract_archive(&payload, &out).unwrap()); + let found = find_executable(&out, Some("app")).unwrap(); + assert!(found.ends_with("bin/app")); + } +} diff --git a/src/installers/binary.rs b/src/installers/binary.rs new file mode 100644 index 0000000..512f615 --- /dev/null +++ b/src/installers/binary.rs @@ -0,0 +1,103 @@ +//! Binary installer: download a precompiled binary (direct URL or GitHub +//! Releases asset), verify its checksum, extract it if needed, and install +//! it into the global bin directory. + +use super::*; + +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let root = install_root(app, &agent.name); + let dist = root.join("dist"); + let bindir = bin_dir(app); + ensure_dir(app, &root)?; + ensure_dir(app, &dist)?; + ensure_dir(app, &bindir)?; + + // Resolve the download URL (direct or GitHub release asset). + let (url, tag): (String, Option) = if let Some(u) = &method.url { + (u.clone(), None) + } else if let Some(repo) = &method.repo { + let release = crate::download::latest_release(repo)?; + let picked = crate::download::pick_asset( + &release, + &method.asset_match, + &method.asset_exclude, + method.binary.as_deref(), + ) + .ok_or_else(|| { + anyhow!( + "no release asset of '{repo}' matches this platform ({}/{})", + crate::config::current_platform(), + std::env::consts::ARCH + ) + })?; + app.log + .info(&format!("selected asset: {} ({})", picked.name, picked.tag)); + ( + picked.url, + Some(picked.tag.trim_start_matches('v').to_string()), + ) + } else { + bail!("binary install requires 'url' or 'repo' (GitHub owner/repo)"); + }; + + let names = expected_bin_names(agent, method); + let bin_name = names + .first() + .cloned() + .unwrap_or_else(|| agent.name.clone()); + let dest_bin = bindir.join(exe_name(&bin_name)); + + if app.dry_run() { + app.log + .dry(format!("would download {url} and install {}", dest_bin.display())); + return Ok(InstallOutcome { + bins: vec![dest_bin], + version: tag, + }); + } + + let tmpdir = tempfile::tempdir()?; + let tmpfile = tmpdir.path().join("download"); + crate::download::download(&url, &tmpfile, method.checksum.as_deref(), true)?; + if crate::download::extract_archive(&tmpfile, &dist)? { + let found = crate::download::find_executable(&dist, Some(&bin_name))?; + std::fs::copy(&found, &dest_bin).with_context(|| { + format!("cannot copy {} to {}", found.display(), dest_bin.display()) + })?; + app.log + .info(&format!("extracted {} -> {}", found.display(), dest_bin.display())); + } else { + std::fs::copy(&tmpfile, &dest_bin).with_context(|| { + format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display()) + })?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dest_bin, std::fs::Permissions::from_mode(0o755)); + } + let version = detect_version(runner, &dest_bin).or(tag); + Ok(InstallOutcome { + bins: vec![dest_bin], + version, + }) +} + +/// Latest release tag (without the leading "v"). +pub fn latest( + _app: &App, + _agent: &AgentDef, + method: &MethodSpec, + _runner: &dyn Runner, +) -> Result> { + let Some(repo) = method.repo.as_deref() else { + return Ok(None); + }; + let release = crate::download::latest_release(repo)?; + Ok(Some(release.tag_name.trim_start_matches('v').to_string())) +} diff --git a/src/installers/cargo.rs b/src/installers/cargo.rs new file mode 100644 index 0000000..ab996c8 --- /dev/null +++ b/src/installers/cargo.rs @@ -0,0 +1,76 @@ +//! cargo installer: cargo install --root . + +use super::*; + +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let pkg = method + .package + .as_deref() + .ok_or_else(|| anyhow!("cargo install requires 'package' (crate name)"))?; + require_tool(runner, "cargo", "https://rustup.rs")?; + let root = install_root(app, &agent.name); + ensure_dir(app, &root)?; + let mut args: Vec = vec![ + "install".into(), + "--root".into(), + root.display().to_string(), + pkg.to_string(), + ]; + args.extend(method.args.iter().cloned()); + sh(runner, "cargo", &args, &method.env, None)?; + // cargo puts the binaries in /bin + let expected = expected_bin_names(agent, method); + let bins = find_bins(&root.join("bin"), &expected); + let version = bins.first().and_then(|b| detect_version(runner, b)); + Ok(InstallOutcome { bins, version }) +} + +/// Latest version from cargo search --limit 1. +pub fn latest( + _app: &App, + _agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result> { + let Some(pkg) = method.package.as_deref() else { + return Ok(None); + }; + if runner.which("cargo").is_none() { + return Ok(None); + } + let empty = BTreeMap::new(); + let out = runner.run( + "cargo", + &[ + "search".to_string(), + pkg.to_string(), + "--limit".to_string(), + "1".to_string(), + ], + &empty, + None, + true, + )?; + if out.code != 0 { + return Ok(None); + } + // Output looks like: smelt = "0.5.0" # description + let text = out.stdout.trim().to_string(); + let Some(eq) = text.find('=') else { + return Ok(None); + }; + let rest = text[eq + 1..].trim(); + let Some(start) = rest.find('"') else { + return Ok(None); + }; + let inner = &rest[start + 1..]; + let Some(end) = inner.find('"') else { + return Ok(None); + }; + Ok(Some(inner[..end].to_string())) +} diff --git a/src/installers/git.rs b/src/installers/git.rs new file mode 100644 index 0000000..709c6d6 --- /dev/null +++ b/src/installers/git.rs @@ -0,0 +1,159 @@ +//! Git installer: clone the repository, run the build command(s), then +//! either copy the built binary or create a shim that runs the agent from +//! the repository directory. + +use super::*; + +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let url = method + .repo + .as_deref() + .ok_or_else(|| anyhow!("git install requires 'repo' (clone URL)"))?; + if !(url.starts_with("https://") + || url.starts_with("http://") + || url.starts_with("git@") + || url.starts_with("file://")) + { + bail!("unsupported git URL: {url}"); + } + require_tool(runner, "git", "https://git-scm.com")?; + let root = install_root(app, &agent.name); + let repo_dir = root.join("repo"); + let bindir = bin_dir(app); + ensure_dir(app, &root)?; + ensure_dir(app, &bindir)?; + + if app.dry_run() { + app.log + .dry(format!("would clone {url} into {} and build", repo_dir.display())); + return Ok(InstallOutcome::default()); + } + if repo_dir.exists() { + std::fs::remove_dir_all(&repo_dir) + .with_context(|| format!("cannot remove {}", repo_dir.display()))?; + } + let mut env = method.env.clone(); + env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()); + sh( + runner, + "git", + &[ + "clone".to_string(), + "--depth".to_string(), + "1".to_string(), + url.to_string(), + repo_dir.display().to_string(), + ], + &env, + None, + )?; + + // Build commands (string or list). + if let Some(build) = &method.build { + for cmd in build.items() { + let tokens = shell_words::split(&cmd) + .map_err(|e| anyhow!("cannot parse build command '{cmd}': {e}"))?; + if tokens.is_empty() { + continue; + } + let prog = tokens[0].clone(); + let args: Vec = tokens[1..].to_vec(); + let mut benv = BTreeMap::new(); + let nm = repo_dir.join("node_modules").join(".bin"); + if nm.is_dir() { + if let Ok(path) = std::env::var("PATH") { + benv.insert( + "PATH".to_string(), + format!("{}{}{}", nm.display(), std::path::MAIN_SEPARATOR, path), + ); + } + } + sh(runner, &prog, &args, &benv, Some(&repo_dir))?; + } + } + + // Install the binary (copy) or a shim that runs from the repo. + let names = expected_bin_names(agent, method); + let bin_name = names + .first() + .cloned() + .unwrap_or_else(|| agent.name.clone()); + let mut bins = Vec::new(); + if let Some(bp) = &method.binary_path { + let src = repo_dir.join(bp); + if !src.exists() { + bail!( + "built binary not found at {} (build succeeded but 'binary_path' may be wrong)", + src.display() + ); + } + let dest = bindir.join(exe_name(&bin_name)); + std::fs::copy(&src, &dest) + .with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?; + bins.push(dest); + } else { + let shim = bindir.join(exe_name(&bin_name)); + write_shim(agent, &shim, &repo_dir)?; + bins.push(shim); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for b in &bins { + let _ = std::fs::set_permissions(b, std::fs::Permissions::from_mode(0o755)); + } + } + + // Version: git describe --tags --always inside the repo. + let empty = BTreeMap::new(); + let version = runner + .run( + "git", + &[ + "-C".to_string(), + repo_dir.display().to_string(), + "describe".to_string(), + "--tags".to_string(), + "--always".to_string(), + ], + &empty, + None, + true, + ) + .ok() + .filter(|o| o.code == 0) + .map(|o| o.stdout.trim().to_string()) + .filter(|s| !s.is_empty()); + Ok(InstallOutcome { bins, version }) +} + +/// Create a small script that runs the agent from inside the repository +/// directory (with node_modules/.bin on PATH for Node-based projects). +fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> { + let run = agent.run.clone().unwrap_or_default(); + if run.is_empty() { + bail!("git install without 'binary_path' needs a 'run' command for the shim"); + } + let script = if cfg!(windows) { + let nm = repo_dir.join("node_modules").join(".bin"); + format!( + "@echo off\r\ncd /d \"{repo}\"\r\nset \"PATH={nm};%PATH%\"\r\n{run} %*\r\n", + repo = repo_dir.display(), + nm = nm.display() + ) + } else { + let nm = repo_dir.join("node_modules").join(".bin"); + format!( + "#!/bin/sh\ncd \"{repo}\"\n[ -d \"{nm}\" ] && PATH=\"{nm}:$PATH\"\nexec {run} \"$@\"\n", + repo = repo_dir.display(), + nm = nm.display() + ) + }; + std::fs::write(shim, script).with_context(|| format!("cannot write shim {}", shim.display()))?; + Ok(()) +} diff --git a/src/installers/golang.rs b/src/installers/golang.rs new file mode 100644 index 0000000..de08644 --- /dev/null +++ b/src/installers/golang.rs @@ -0,0 +1,32 @@ +//! Go installer: go install @latest into our own GOBIN. + +use super::*; + +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let pkg = method + .package + .as_deref() + .ok_or_else(|| anyhow!("go install requires 'package' (module path)"))?; + require_tool(runner, "go", "https://go.dev/dl")?; + let bindir = bin_dir(app); + ensure_dir(app, &bindir)?; + // go install needs "@version"; default to @latest. + let target = if pkg.contains('@') { + pkg.to_string() + } else { + format!("{pkg}@latest") + }; + let mut env = method.env.clone(); + env.insert("GOBIN".to_string(), bindir.display().to_string()); + let args = vec!["install".to_string(), target]; + sh(runner, "go", &args, &env, None)?; + let expected = expected_bin_names(agent, method); + let bins = find_bins(&bindir, &expected); + let version = bins.first().and_then(|b| detect_version(runner, b)); + Ok(InstallOutcome { bins, version }) +} diff --git a/src/installers/mod.rs b/src/installers/mod.rs new file mode 100644 index 0000000..39b609b --- /dev/null +++ b/src/installers/mod.rs @@ -0,0 +1,296 @@ +//! Installer implementations: one module per install method. +//! +//! All installers share the same helpers (command execution through a +//! Runner, bin-dir snapshots, version detection) so they behave identically +//! under --dry-run and in tests. + +pub mod binary; +pub mod cargo; +pub mod git; +pub mod golang; +pub mod npm; +pub mod pipuv; +pub mod script; + +use crate::app::App; +use crate::config::{AgentDef, InstallType, MethodSpec}; +use crate::runner::{RunOutput, Runner}; +use crate::state::InstalledEntry; +use anyhow::{anyhow, bail, Context, Result}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Platform executable suffix ("" on Unix, ".exe" on Windows). +pub const EXE_SUFFIX: &str = if cfg!(windows) { ".exe" } else { "" }; + +#[derive(Debug, Clone, Default)] +pub struct InstallOutcome { + /// Executables (or shims) that belong to this installation. + pub bins: Vec, + /// Detected version (when possible). + pub version: Option, +} + +/// Execute the right installer for the method type. +pub fn run_install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + match method.kind { + InstallType::Npm => npm::install(app, agent, method, runner), + InstallType::Bun => npm::bun_install(app, agent, method, runner), + InstallType::Pip => pipuv::install(app, agent, method, runner, false), + InstallType::Uv => pipuv::install(app, agent, method, runner, true), + InstallType::Cargo => cargo::install(app, agent, method, runner), + InstallType::Go => golang::install(app, agent, method, runner), + InstallType::Curl => script::install(app, agent, method, runner), + InstallType::Binary => binary::install(app, agent, method, runner), + InstallType::Git => git::install(app, agent, method, runner), + } +} + +/// Best-effort "latest available version" for the method type. +pub fn latest_version( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result> { + match method.kind { + InstallType::Npm | InstallType::Bun => npm::latest(app, agent, method, runner), + InstallType::Pip | InstallType::Uv => pipuv::latest(app, agent, method, runner), + InstallType::Cargo => cargo::latest(app, agent, method, runner), + InstallType::Binary => binary::latest(app, agent, method, runner), + _ => Ok(None), + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Root directory for one agent's installation. +pub fn install_root(app: &App, agent_name: &str) -> PathBuf { + app.paths.install_dir.join(agent_name) +} + +/// Global bin directory (shims/executables, added to PATH). +pub fn bin_dir(app: &App) -> PathBuf { + app.paths.bin_dir.clone() +} + +/// Create a directory unless running in dry-run mode. +pub fn ensure_dir(app: &App, path: &Path) -> Result<()> { + if !app.dry_run() { + std::fs::create_dir_all(path) + .with_context(|| format!("cannot create {}", path.display()))?; + } + Ok(()) +} + +/// Run a captured command, failing on a non-zero exit code. +pub fn sh( + runner: &dyn Runner, + cmd: &str, + args: &[String], + env: &BTreeMap, + cwd: Option<&Path>, +) -> Result { + let out = runner.run(cmd, args, env, cwd, true)?; + if out.code != 0 { + let mut detail = out.stderr.trim().to_string(); + if detail.is_empty() { + detail = out.stdout.trim().to_string(); + } + bail!( + "command failed (exit code {}): {} {} +{}", + out.code, + cmd, + args.join(" "), + detail + ); + } + Ok(out) +} + +/// Run a command with inherited stdio, failing on a non-zero exit code. +pub fn sh_inherit( + runner: &dyn Runner, + cmd: &str, + args: &[String], + env: &BTreeMap, + cwd: Option<&Path>, +) -> Result<()> { + let out = runner.run(cmd, args, env, cwd, false)?; + if out.code != 0 { + bail!( + "command failed (exit code {}): {} {}", + out.code, + cmd, + args.join(" ") + ); + } + Ok(()) +} + +/// Require a tool on the PATH, with an actionable error message. +pub fn require_tool(runner: &dyn Runner, tool: &str, hint: &str) -> Result { + runner + .which(tool) + .ok_or_else(|| anyhow!("'{tool}' not found on PATH — install it first ({hint})")) +} + +/// Detect a binary's version via --version, then -V. +pub fn detect_version(runner: &dyn Runner, bin: &Path) -> Option { + let empty = BTreeMap::new(); + if let Ok(out) = runner.run(&bin.display().to_string(), &["--version".to_string()], &empty, None, true) + { + if out.code == 0 { + let combined = format!("{} +{}", out.stdout, out.stderr); + if let Some(v) = crate::version::find_version(&combined) { + return Some(v); + } + } + } + if let Ok(out) = runner.run(&bin.display().to_string(), &["-V".to_string()], &empty, None, true) { + if out.code == 0 { + let combined = format!("{} +{}", out.stdout, out.stderr); + return crate::version::find_version(&combined); + } + } + None +} + +/// Executable name with the platform suffix. +pub fn exe_name(name: &str) -> String { + format!("{name}{EXE_SUFFIX}") +} + +/// Names the installed binaries should match: the binary hint, the run +/// command's first token, and the agent name. +pub fn expected_bin_names(agent: &AgentDef, method: &MethodSpec) -> Vec { + let mut names = Vec::new(); + if let Some(b) = &method.binary { + names.push(b.clone()); + } + if let Some(t) = agent.first_token() { + if !names.iter().any(|n| n.eq_ignore_ascii_case(&t)) { + names.push(t); + } + } + if !names.iter().any(|n| n == &agent.name) { + names.push(agent.name.clone()); + } + names +} + +/// Snapshot a directory: file name -> full path. +pub fn snapshot_dir(dir: &Path) -> BTreeMap { + let mut map = BTreeMap::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + let p = entry.path(); + if p.is_file() { + if let Some(name) = p.file_name().and_then(|n| n.to_str()) { + map.insert(name.to_string(), p); + } + } + } + } + map +} + +/// Entries present in "after" but not in "before". +pub fn diff_dir( + before: &BTreeMap, + after: &BTreeMap, +) -> Vec { + after + .iter() + .filter(|(k, _)| !before.contains_key(*k)) + .map(|(_, v)| v.clone()) + .collect() +} + +/// Files in dir whose base name (sans extension) matches an expected name. +/// On Windows, .cmd/.bat shims are preferred over bare scripts. +pub fn find_bins(dir: &Path, expected: &[String]) -> Vec { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { + return out; + }; + let mut entries: Vec = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if !p.is_file() { + continue; + } + let name = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + let stripped = name + .strip_suffix(".exe") + .or_else(|| name.strip_suffix(".cmd")) + .or_else(|| name.strip_suffix(".bat")) + .or_else(|| name.strip_suffix(".ps1")) + .unwrap_or(&name) + .to_string(); + if expected.iter().any(|e| e.eq_ignore_ascii_case(&stripped)) { + out.push(p); + } + } + // Prefer a launchable shim for this platform. + out.sort_by_key(|p| { + let name = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + if cfg!(windows) { + if name.ends_with(".cmd") || name.ends_with(".bat") { + 0u8 + } else if name.ends_with(".exe") { + 1 + } else { + 2 + } + } else if name.ends_with(".cmd") || name.ends_with(".exe") || name.ends_with(".ps1") { + 2 + } else { + 0 + } + }); + out +} + +pub fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339() +} + +/// Build the state entry recorded after a successful install. +pub fn make_entry( + agent: &AgentDef, + method: &MethodSpec, + outcome: &InstallOutcome, + app: &App, +) -> InstalledEntry { + InstalledEntry { + name: agent.name.clone(), + version: outcome.version.clone().or_else(|| agent.version.clone()), + method: method.kind.as_str().to_string(), + install_dir: install_root(app, &agent.name).display().to_string(), + bins: outcome.bins.iter().map(|b| b.display().to_string()).collect(), + run: agent.run.clone().unwrap_or_default(), + installed_at: now_rfc3339(), + updated_at: None, + pid: None, + started_at: None, + } +} diff --git a/src/installers/npm.rs b/src/installers/npm.rs new file mode 100644 index 0000000..7a1b44f --- /dev/null +++ b/src/installers/npm.rs @@ -0,0 +1,115 @@ +//! npm and bun installers: install a package globally inside our own +//! prefix so nothing pollutes the system. + +use super::*; + +/// npm: npm install -g --prefix . +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let pkg = method + .package + .as_deref() + .ok_or_else(|| anyhow!("npm install requires 'package'"))?; + require_tool(runner, "npm", "https://nodejs.org")?; + let root = install_root(app, &agent.name); + let bindir = bin_dir(app); + let before = snapshot_dir(&bindir); + ensure_dir(app, &root)?; + ensure_dir(app, &bindir)?; + let mut args: Vec = vec![ + "install".into(), + "-g".into(), + "--prefix".into(), + root.display().to_string(), + pkg.to_string(), + ]; + args.extend(method.args.iter().cloned()); + sh(runner, "npm", &args, &method.env, None)?; + // npm places the shims directly in on Windows and in + // /bin on Unix — check both, then the global bin dir. + let expected = expected_bin_names(agent, method); + let mut bins = find_bins(&root, &expected); + if bins.is_empty() { + bins = find_bins(&root.join("bin"), &expected); + } + if bins.is_empty() { + let after = snapshot_dir(&bindir); + bins = diff_dir(&before, &after); + } + if bins.is_empty() { + bins = find_bins(&bindir, &expected); + } + let version = bins.first().and_then(|b| detect_version(runner, b)); + Ok(InstallOutcome { bins, version }) +} + +/// bun: bun install -g with BUN_INSTALL pointed at our root. +pub fn bun_install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let pkg = method + .package + .as_deref() + .ok_or_else(|| anyhow!("bun install requires 'package'"))?; + require_tool(runner, "bun", "https://bun.sh")?; + let root = install_root(app, &agent.name); + let bindir = bin_dir(app); + let before = snapshot_dir(&bindir); + ensure_dir(app, &root)?; + ensure_dir(app, &bindir)?; + let mut env = method.env.clone(); + env.insert("BUN_INSTALL".to_string(), root.display().to_string()); + let mut args: Vec = vec!["install".into(), "-g".into(), pkg.to_string()]; + args.extend(method.args.iter().cloned()); + sh(runner, "bun", &args, &env, None)?; + let expected = expected_bin_names(agent, method); + let mut bins = find_bins(&root.join("bin"), &expected); + if bins.is_empty() { + bins = find_bins(&bindir, &expected); + } + if bins.is_empty() { + let after = snapshot_dir(&bindir); + bins = diff_dir(&before, &after); + } + let version = bins.first().and_then(|b| detect_version(runner, b)); + Ok(InstallOutcome { bins, version }) +} + +/// Latest published version: npm view version. +pub fn latest( + _app: &App, + _agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result> { + let Some(pkg) = method.package.as_deref() else { + return Ok(None); + }; + if runner.which("npm").is_none() { + return Ok(None); + } + let empty = BTreeMap::new(); + let out = runner.run( + "npm", + &["view".to_string(), pkg.to_string(), "version".to_string()], + &empty, + None, + true, + )?; + if out.code != 0 { + return Ok(None); + } + let text = out.stdout.trim(); + if text.is_empty() { + return Ok(None); + } + // npm view prints one version per line; take the last (latest). + Ok(text.lines().last().map(|l| l.trim().to_string())) +} diff --git a/src/installers/pipuv.rs b/src/installers/pipuv.rs new file mode 100644 index 0000000..93feccb --- /dev/null +++ b/src/installers/pipuv.rs @@ -0,0 +1,130 @@ +//! pip and uv installers: a private virtualenv per agent keeps everything +//! self-contained (no --user, no system pollution). + +use super::*; + +fn find_python(runner: &dyn Runner) -> Result { + runner + .which("python3") + .or_else(|| runner.which("python")) + .ok_or_else(|| { + anyhow!( + "neither 'python3' nor 'python' found on PATH — install Python first (https://python.org)" + ) + }) +} + +fn venv_bin_dir(venv: &Path) -> PathBuf { + if cfg!(windows) { + venv.join("Scripts") + } else { + venv.join("bin") + } +} + +fn venv_python(venv: &Path) -> PathBuf { + venv_bin_dir(venv).join(if cfg!(windows) { + "python.exe" + } else { + "python" + }) +} + +/// Shared implementation for pip (use_uv=false) and uv (use_uv=true). +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, + use_uv: bool, +) -> Result { + let pkg = method + .package + .as_deref() + .ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?; + let python = find_python(runner)?; + let root = install_root(app, &agent.name); + let venv = root.join("venv"); + ensure_dir(app, &root)?; + if !app.dry_run() && venv.exists() { + std::fs::remove_dir_all(&venv) + .with_context(|| format!("cannot remove {}", venv.display()))?; + } + let empty = BTreeMap::new(); + sh( + runner, + &python.display().to_string(), + &[ + "-m".to_string(), + "venv".to_string(), + venv.display().to_string(), + ], + &empty, + None, + )?; + let vp = venv_python(&venv); + let vbin = venv_bin_dir(&venv); + if use_uv { + let uv = require_tool( + runner, + "uv", + "https://docs.astral.sh/uv — e.g. 'pip install uv'", + )?; + let mut args: Vec = vec![ + "pip".into(), + "install".into(), + "--python".into(), + vp.display().to_string(), + pkg.to_string(), + ]; + args.extend(method.args.iter().cloned()); + sh(runner, &uv.display().to_string(), &args, &method.env, None)?; + } else { + let mut args: Vec = vec![ + "-m".into(), + "pip".into(), + "install".into(), + "--upgrade".into(), + pkg.to_string(), + ]; + args.extend(method.args.iter().cloned()); + sh(runner, &vp.display().to_string(), &args, &method.env, None)?; + } + let expected = expected_bin_names(agent, method); + let mut bins = find_bins(&vbin, &expected); + if bins.is_empty() { + bins = find_bins(&bin_dir(app), &expected); + } + let version = bins.first().and_then(|b| detect_version(runner, b)); + Ok(InstallOutcome { bins, version }) +} + +/// Latest published version from the PyPI JSON API. +pub fn latest( + _app: &App, + _agent: &AgentDef, + method: &MethodSpec, + _runner: &dyn Runner, +) -> Result> { + let Some(pkg) = method.package.as_deref() else { + return Ok(None); + }; + let url = format!("https://pypi.org/pypi/{pkg}/json"); + let resp = match ureq::get(&url) + .set("User-Agent", crate::download::USER_AGENT) + .call() + { + Ok(r) => r, + Err(_) => return Ok(None), + }; + let Ok(text) = resp.into_string() else { + return Ok(None); + }; + let Ok(v) = serde_json::from_str::(&text) else { + return Ok(None); + }; + Ok(v.get("info") + .and_then(|i| i.get("version")) + .and_then(|x| x.as_str()) + .map(|s| s.to_string())) +} diff --git a/src/installers/script.rs b/src/installers/script.rs new file mode 100644 index 0000000..f86d43d --- /dev/null +++ b/src/installers/script.rs @@ -0,0 +1,68 @@ +//! curl|sh installer: download the script, show it (verbose), confirm, then +//! execute it in its own directory. Never pipes curl directly into a shell. + +use super::*; + +pub fn install( + app: &App, + agent: &AgentDef, + method: &MethodSpec, + runner: &dyn Runner, +) -> Result { + let url = method + .script + .as_deref() + .ok_or_else(|| anyhow!("curl install requires 'script' (URL)"))?; + crate::download::validate_url(url)?; + if cfg!(windows) { + bail!( + "the curl install method needs a Unix shell; on Windows install '{}' manually or use WSL", + agent.title() + ); + } + require_tool(runner, "sh", "a POSIX shell (sh)")?; + let root = install_root(app, &agent.name); + ensure_dir(app, &root)?; + if app.dry_run() { + app.log + .dry(format!("would download and run install script {url}")); + return Ok(InstallOutcome::default()); + } + let tmp = tempfile::Builder::new().suffix(".sh").tempfile()?; + crate::download::download(url, tmp.path(), None, true)?; + if app.log.verbose { + if let Ok(text) = std::fs::read_to_string(tmp.path()) { + eprintln!("--- install script ({url}) ---"); + eprintln!("{text}"); + eprintln!("--- end of script ---"); + } + } + let shell = app + .config + .settings + .default_shell + .clone() + .unwrap_or_else(|| "sh".to_string()); + sh_inherit( + runner, + &shell, + &[tmp.path().display().to_string()], + &method.env, + Some(&root), + )?; + // Scripts usually install into the user profile; locate the binaries. + let expected = expected_bin_names(agent, method); + let mut bins = Vec::new(); + for name in &expected { + if let Some(p) = runner.which(name) { + if !bins.contains(&p) { + bins.push(p); + } + } + } + let version = bins + .first() + .and_then(|b| detect_version(runner, b)) + .or_else(|| agent.version.clone()); + Ok(InstallOutcome { bins, version }) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8d2c4a5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,62 @@ +//! agent-manager — a CLI to manage local AI coding agents. +//! +//! The crate is organised around a few core concepts: +//! +//! * cli — clap definitions for the am command line interface. +//! * config — YAML schema, loading, validation, includes, path expansion. +//! * catalog — the merged agent catalog (embedded defaults + user config). +//! * state — the local installation database (JSON file). +//! * process — background/foreground process management (PID bookkeeping). +//! * installers — one installer per method (npm, pip, cargo, go, bun, curl, +//! binary, git). +//! * commands — one module per CLI command. + +pub mod app; +pub mod catalog; +pub mod cli; +pub mod commands; +pub mod config; +pub mod deps; +pub mod doctor; +pub mod download; +pub mod installers; +pub mod output; +pub mod probe; +pub mod process; +pub mod repl; +pub mod runner; +pub mod state; +pub mod version; + +use clap::Parser; + +/// CLI entry point used by the am binary. Returns the process exit code. +pub fn main_entry() -> i32 { + let cli = cli::Cli::parse(); + match app::App::from_cli(cli) { + Ok(app) => match commands::execute(&app) { + Ok(code) => code, + Err(err) => { + if app.json() { + let payload = serde_json::json!({ + "error": format!("{:#}", err), + }); + let _ = serde_json::to_writer_pretty(std::io::stdout(), &payload); + let _ = writeln!(std::io::stdout()); + } else { + eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err)); + } + 1 + } + }, + Err(err) => { + // Config could not even be loaded: fall back to plain stderr output. + eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err)); + 1 + } + } +} + +#[allow(unused_imports)] +use colored::Colorize; +use std::io::Write; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fc6c229 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +//! Binary entry point for the am (agent-manager) CLI. +//! +//! All the logic lives in the agent_manager library crate so that +//! integration tests can exercise it directly. + +fn main() { + std::process::exit(agent_manager::main_entry()); +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..9763801 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,273 @@ +//! Console output helpers: colored logging, plain text tables, JSON output. + +use colored::Colorize; +use serde::Serialize; +use std::io::{IsTerminal, Write}; +use std::path::PathBuf; + +/// Name of the global log file inside the log directory. +pub const LOG_FILE: &str = "agent-manager.log"; + +// --------------------------------------------------------------------------- +// Logger +// --------------------------------------------------------------------------- + +/// Structured logger: writes every message to the timestamped log file and +/// echoes the important ones to stderr (stdout stays reserved for data). +#[derive(Debug)] +pub struct Logger { + log_dir: PathBuf, + pub verbose: bool, + pub quiet: bool, + pub no_color: bool, +} + +impl Logger { + pub fn new(log_dir: PathBuf, verbose: bool, quiet: bool, no_color: bool) -> Self { + Self { + log_dir, + verbose, + quiet, + no_color, + } + } + + pub fn log_dir(&self) -> &PathBuf { + &self.log_dir + } + + pub fn color_enabled(&self) -> bool { + !self.no_color && std::io::stderr().is_terminal() + } + + fn write_log(&self, line: &str) { + if std::fs::create_dir_all(&self.log_dir).is_err() { + return; + } + let path = self.log_dir.join(LOG_FILE); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let _ = writeln!(f, "[{ts}] {line}"); + } + } + + /// Detailed message, shown only in verbose mode (always written to the log). + pub fn verbose>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if self.verbose && !self.quiet { + if self.color_enabled() { + eprintln!("{}", msg.dimmed()); + } else { + eprintln!("{msg}"); + } + } + } + + /// Normal informational message. + pub fn info>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if !self.quiet { + eprintln!("{msg}"); + } + } + + /// Success message (green). + pub fn success>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if !self.quiet { + if self.color_enabled() { + eprintln!("{}", msg.green()); + } else { + eprintln!("{msg}"); + } + } + } + + /// Warning message (yellow). + pub fn warn>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if !self.quiet { + if self.color_enabled() { + eprintln!("{}", msg.yellow()); + } else { + eprintln!("{msg}"); + } + } + } + + /// Error message (red); always shown, even in quiet mode. + pub fn error>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if self.color_enabled() { + eprintln!("{}", msg.red()); + } else { + eprintln!("{msg}"); + } + } + + /// Dry-run note (yellow prefix). + pub fn dry>(&self, msg: S) { + let msg = msg.as_ref(); + self.write_log(msg); + if !self.quiet { + if self.color_enabled() { + eprintln!("{} {msg}", "[dry-run]".yellow().bold()); + } else { + eprintln!("[dry-run] {msg}"); + } + } + } + + /// Record an executed command (log file; echoed when verbose). + pub fn cmd(&self, display: &str) { + self.write_log(&format!("exec: {display}")); + if self.verbose && !self.quiet { + eprintln!(" {}", display.dimmed()); + } + } + + /// Record a command result. + pub fn result(&self, code: i32, stderr: &str) { + if code != 0 { + self.write_log(&format!("exit code {code}; stderr: {}", stderr.trim())); + } + } +} + +// --------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------- + +/// A minimal plain-text table. Cells must be pre-colored (widths are computed +/// on ANSI-stripped text). +pub struct Table { + headers: Vec, + rows: Vec>, +} + +const MAX_CELL: usize = 48; + +impl Table { + pub fn new(headers: Vec<&str>) -> Self { + Self { + headers: headers.into_iter().map(|h| h.to_string()).collect(), + rows: Vec::new(), + } + } + + pub fn row(&mut self, cells: Vec) -> &mut Self { + self.rows.push(cells); + self + } + + pub fn render(&self) -> String { + if self.headers.is_empty() { + return String::new(); + } + let cols = self.headers.len(); + let mut widths: Vec = self + .headers + .iter() + .map(|h| visible_len(h).min(MAX_CELL)) + .collect(); + for row in &self.rows { + for (i, cell) in row.iter().enumerate().take(cols) { + widths[i] = widths[i].max(visible_len(cell).min(MAX_CELL)); + } + } + let mut out = String::new(); + let mut header_line = String::new(); + for (i, h) in self.headers.iter().enumerate() { + if i > 0 { + header_line.push_str(" "); + } + header_line.push_str(&pad(h, widths[i])); + } + let header_line = header_line.trim_end().to_string(); + out.push_str(&format!("{} +", header_line.bold())); + out.push_str(&"-".repeat(visible_len(&header_line))); + out.push('\n'); + for row in &self.rows { + let mut l = String::new(); + for i in 0..cols { + if i > 0 { + l.push_str(" "); + } + let cell = row.get(i).cloned().unwrap_or_default(); + l.push_str(&pad(&cell, widths[i])); + } + out.push_str(l.trim_end()); + out.push('\n'); + } + out + } +} + +/// Color a status cell for display in tables. +pub fn status_style(status: &str, color: bool) -> String { + let s = status.to_string(); + if !color { + return s; + } + match status { + "running" => s.green().bold().to_string(), + "installed" => s.cyan().to_string(), + "external" => s.yellow().to_string(), + "not-installable" => s.red().to_string(), + "not-installed" => s.dimmed().to_string(), + "ok" => s.green().to_string(), + "warn" => s.yellow().to_string(), + "error" => s.red().bold().to_string(), + _ => s, + } +} + +/// Print a value as pretty JSON on stdout (for scripts). +pub fn print_json(value: &T) { + if serde_json::to_writer_pretty(std::io::stdout(), value).is_ok() { + println!(); + } +} + +fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '' { + for n in chars.by_ref() { + if n == 'm' { + break; + } + } + } else { + out.push(c); + } + } + out +} + +fn visible_len(s: &str) -> usize { + strip_ansi(s).chars().count() +} + +fn pad(s: &str, width: usize) -> String { + let n = visible_len(s); + if n > width { + let plain = strip_ansi(s); + let mut cut: String = plain.chars().take(width.saturating_sub(1)).collect(); + cut.push('…'); + cut + } else { + format!("{s}{}", " ".repeat(width - n)) + } +} diff --git a/src/probe.rs b/src/probe.rs new file mode 100644 index 0000000..a2a3645 --- /dev/null +++ b/src/probe.rs @@ -0,0 +1,325 @@ +//! External agent detection: PATH lookup for unmanaged agents, version +//! probing in parallel (one thread per binary), and an on-disk cache keyed +//! by the PATH string and by binary identity (path + mtime + size), so +//! repeated runs never re-scan the PATH or re-probe versions. + +use crate::app::App; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use wait_timeout::ChildExt; + +#[derive(Debug, Default)] +pub struct ExternalInfo { + /// agent name -> binary path (catalog agents found on PATH, unmanaged). + pub paths: BTreeMap, + /// agent name -> detected version (None = unknown). + pub versions: BTreeMap>, +} + +impl ExternalInfo { + pub fn path_of(&self, name: &str) -> Option<&PathBuf> { + self.paths.get(name) + } + + pub fn version_of(&self, name: &str) -> Option<&Option> { + self.versions.get(name) + } +} + +/// Detect the version of an external binary (--version), bounded by a +/// timeout so a misbehaving agent can never hang the whole listing. +pub fn detect_external_version(bin_path: &Path) -> Option { + use std::io::Read; + let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string()); + let mut cmd = std::process::Command::new(&prog); + cmd.args(&prefix) + .arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let Ok(mut child) = cmd.spawn() else { + return None; + }; + const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); + let status: Option = match child.wait_timeout(PROBE_TIMEOUT) { + Ok(Some(s)) => Some(s), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + None + } + Err(_) => None, + }; + let Some(status) = status else { + return None; + }; + if !status.success() { + return None; + } + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut o) = child.stdout.take() { + let _ = o.read_to_string(&mut stdout); + } + if let Some(mut e) = child.stderr.take() { + let _ = e.read_to_string(&mut stderr); + } + let combined = format!("{stdout} +{stderr}"); + crate::version::find_version(&combined) +} + +// --------------------------------------------------------------------------- +// On-disk cache +// --------------------------------------------------------------------------- + +#[derive(Debug, Default, Serialize, Deserialize)] +struct ProbeCache { + /// FNV-1a hash of the PATH string the token index was built from. + #[serde(default)] + path_hash: u64, + /// Catalog run-command first token -> resolved path (null = not found). + #[serde(default)] + tokens: BTreeMap>, + /// Binary identity key -> detected version. + #[serde(default)] + versions: BTreeMap>, +} + +fn path_hash() -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + if let Some(p) = std::env::var_os("PATH") { + for b in p.to_string_lossy().bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + } + h +} + +/// Build an index of every executable on the PATH in a single pass. +/// Much cheaper than one which() call per agent when the catalog is large. +fn build_path_index() -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let Some(path) = std::env::var_os("PATH") else { + return map; + }; + for dir in std::env::split_paths(&path) { + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in rd.flatten() { + let p = entry.path(); + if !p.is_file() { + continue; + } + let Some(name) = p.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let lower = name.to_lowercase(); + #[cfg(windows)] + { + let stem = if lower.ends_with(".exe") { + lower[..lower.len() - 4].to_string() + } else if lower.ends_with(".cmd") { + lower[..lower.len() - 4].to_string() + } else if lower.ends_with(".bat") { + lower[..lower.len() - 4].to_string() + } else if lower.ends_with(".com") { + lower[..lower.len() - 4].to_string() + } else { + continue; + }; + map.entry(stem).or_insert(p); + } + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let executable = std::fs::metadata(&p) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false); + if executable { + map.entry(lower).or_insert(p); + } + } + } + } + map +} + +/// Detect every unmanaged catalog agent present on the PATH. +/// +/// The PATH is scanned once and its result cached (invalidated by the PATH +/// string hash); version probes run in parallel and are cached by binary +/// identity. A steady-state run does no PATH scanning and no subprocess. +pub fn detect_externals(app: &App) -> ExternalInfo { + let managed: BTreeMap = + app.state.all().unwrap_or_default(); + let cache_path = app.paths.probe_cache_file.clone(); + let mut cache: ProbeCache = std::fs::read_to_string(&cache_path) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default(); + let mut dirty = false; + + // Resolve the run-command tokens of the catalog (once per PATH change). + let current_hash = path_hash(); + if cache.path_hash != current_hash { + let index = build_path_index(); + cache.tokens.clear(); + for agent in app.catalog.agents() { + let Some(token) = agent.first_token() else { + continue; + }; + if !cache.tokens.contains_key(&token) { + let found = index + .get(&token.to_lowercase()) + .map(|p| p.display().to_string()); + cache.tokens.insert(token, found); + } + } + cache.path_hash = current_hash; + dirty = true; + } + + let mut info = ExternalInfo::default(); + // Stale cached paths (binary removed since the cache was built) force a + // rebuild of the token index. + let stale = cache + .tokens + .values() + .flatten() + .any(|p| !Path::new(p).exists()); + if stale { + let index = build_path_index(); + cache.tokens.clear(); + for agent in app.catalog.agents() { + let Some(token) = agent.first_token() else { + continue; + }; + if !cache.tokens.contains_key(&token) { + let found = index + .get(&token.to_lowercase()) + .map(|p| p.display().to_string()); + cache.tokens.insert(token, found); + } + } + dirty = true; + } + for agent in app.catalog.agents() { + if agent.hidden || !agent.installable || managed.contains_key(&agent.name) { + continue; + } + let Some(token) = agent.first_token() else { + continue; + }; + if let Some(Some(path)) = cache.tokens.get(&token) { + info.paths.insert(agent.name.clone(), PathBuf::from(path)); + } + } + + // Versions: on-disk cache first, then parallel probes for the misses. + let mut missing: Vec<(String, PathBuf)> = Vec::new(); + for (name, bin) in &info.paths { + let key = bin_key(bin); + match cache.versions.get(&key) { + Some(v) => { + info.versions.insert(name.clone(), v.clone()); + } + None => missing.push((name.clone(), bin.clone())), + } + } + if !missing.is_empty() { + let results = std::sync::Mutex::new(Vec::new()); + std::thread::scope(|s| { + for (name, bin) in &missing { + let results = &results; + let name = name.clone(); + let bin = bin.clone(); + s.spawn(move || { + let version = detect_external_version(&bin); + results.lock().unwrap().push((name, bin, version)); + }); + } + }); + for (name, bin, version) in results.into_inner().unwrap_or_default() { + let key = bin_key(&bin); + cache.versions.insert(key.clone(), version.clone()); + info.versions.insert(name, version); + dirty = true; + } + } + // Keep the caches bounded. + if cache.versions.len() > 500 { + cache.versions.clear(); + dirty = true; + } + if dirty { + if let Ok(text) = serde_json::to_string_pretty(&cache) { + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&cache_path, text); + } + } + info +} + +/// Identity key for a binary: path + modification time + size, so a stale +/// cache entry is never reused after the binary changed. +fn bin_key(bin: &Path) -> String { + let meta = std::fs::metadata(bin).ok(); + let mtime = meta + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + let size = meta.map(|m| m.len()).unwrap_or(0); + format!("{}|{mtime}|{size}", bin.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bin_key_is_stable_and_distinct() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.exe"); + std::fs::write(&a, b"abc").unwrap(); + let k1 = bin_key(&a); + let k2 = bin_key(&a); + assert_eq!(k1, k2); + std::fs::write(&a, b"abcd").unwrap(); + let k3 = bin_key(&a); + assert_ne!(k1, k3); + } + + #[test] + fn probe_cache_roundtrip() { + let mut cache = ProbeCache::default(); + cache + .versions + .insert("C:/x|1|2".to_string(), Some("1.2.3".to_string())); + cache.tokens.insert("node".to_string(), None); + let text = serde_json::to_string(&cache).unwrap(); + let back: ProbeCache = serde_json::from_str(&text).unwrap(); + assert_eq!( + back.versions.get("C:/x|1|2").unwrap().as_deref(), + Some("1.2.3") + ); + assert!(back.tokens.contains_key("node")); + } + + #[test] + fn old_cache_format_is_ignored() { + // The previous cache format had only an "entries" field; loading it + // must not fail. + let text = r#"{ "entries": { "a": "1.0" } }"#; + let back: ProbeCache = serde_json::from_str(text).unwrap(); + assert!(back.versions.is_empty()); + assert!(back.tokens.is_empty()); + } +} diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..2be57e9 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,221 @@ +//! Process management: detached background start, PID bookkeeping, stop +//! with a graceful signal followed by a forceful kill after a timeout. + +use crate::app::App; +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Log file for a background agent. +pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf { + app.log.log_dir().join(format!("{agent_name}.log")) +} + +/// Is a process with this PID alive? +pub fn is_running(pid: u32) -> bool { + #[cfg(windows)] + { + let out = Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .any(|w| w == pid.to_string()), + Err(_) => false, + } + } + #[cfg(not(windows))] + { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} + +/// Send SIGTERM/SIGKILL (or taskkill / taskkill /F on Windows). +fn signal(pid: u32, force: bool) -> bool { + #[cfg(windows)] + { + let mut cmd = Command::new("taskkill"); + cmd.arg("/PID").arg(pid.to_string()); + if force { + cmd.arg("/T").arg("/F"); + } + cmd.status().map(|s| s.success()).unwrap_or(false) + } + #[cfg(not(windows))] + { + let sig = if force { "-KILL" } else { "-TERM" }; + Command::new("kill") + .arg(sig) + .arg(pid.to_string()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} + +/// Stop a process: TERM first, then KILL after the timeout. Returns whether +/// the process was running when the call started. +pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool { + if !is_running(pid) { + return false; + } + if force { + signal(pid, true); + } else { + signal(pid, false); + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + while Instant::now() < deadline { + if !is_running(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + if is_running(pid) { + signal(pid, true); + } + } + for _ in 0..50 { + if !is_running(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + !is_running(pid) +} + +/// Start a program detached: stdout/stderr are redirected to the agent log +/// file. Returns the PID. Fails when the process exits immediately. +pub fn spawn_background( + app: &App, + agent_name: &str, + program: &Path, + args: &[String], + env: &BTreeMap, +) -> Result { + let log = agent_log_path(app, agent_name); + if app.dry_run() { + app.log.dry(format!( + "would start {} (detached, log: {})", + program.display(), + log.display() + )); + return Ok(0); + } + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::File::create(&log) + .with_context(|| format!("cannot create log file {}", log.display()))?; + let (prog, prefix) = crate::runner::resolve_program(&program.display().to_string()); + let mut full_args = prefix; + full_args.extend(args.iter().cloned()); + let mut cmd = Command::new(&prog); + cmd.args(&full_args) + .envs(env) + .stdout(Stdio::from(file.try_clone()?)) + .stderr(Stdio::from(file)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + app.log + .cmd(&format!("{} {}", program.display(), args.join(" "))); + let mut child = cmd + .spawn() + .with_context(|| format!("failed to start {}", program.display()))?; + let pid = child.id(); + // Give the process a moment: some agents crash at startup. + std::thread::sleep(Duration::from_millis(400)); + match child.try_wait() { + Ok(Some(status)) => { + let tail = last_lines(&log, 8); + bail!( + "agent '{agent_name}' exited immediately after start (status {status}); log: {} +{}", + log.display(), + tail + ); + } + Ok(None) => {} + Err(_) => {} + } + Ok(pid) +} + +/// Tail of a text file (last n lines). +pub fn last_lines(path: &Path, n: usize) -> String { + let Ok(text) = std::fs::read_to_string(path) else { + return String::new(); + }; + let lines: Vec<&str> = text.lines().collect(); + let start = lines.len().saturating_sub(n); + lines[start..].join(" +") +} + +/// Best-effort desktop notification once an agent has started. +pub fn desktop_notify(title: &str, body: &str) { + #[cfg(target_os = "linux")] + { + let _ = Command::new("notify-send") + .args(["-a", "agent-manager", title, body]) + .spawn(); + } + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .args([ + "-e", + &format!("display notification \"{body}\" with title \"{title}\""), + ]) + .spawn(); + } + #[cfg(windows)] + { + // No built-in non-blocking toast without extra tooling; log instead. + let _ = (title, body); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sleep_cmd() -> (&'static str, Vec) { + if cfg!(windows) { + ("ping", vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()]) + } else { + ("sleep", vec!["30".to_string()]) + } + } + + #[test] + fn pid_lifecycle() { + let (prog, args) = sleep_cmd(); + let mut child = Command::new(prog).args(&args).spawn().unwrap(); + let pid = child.id(); + assert!(is_running(pid)); + assert!(stop_pid(pid, true, 5)); + assert!(!is_running(pid)); + let _ = child.wait(); + } + + #[test] + fn stop_non_running_is_false() { + assert!(!stop_pid(u32::MAX - 1, true, 1)); + } +} diff --git a/src/repl.rs b/src/repl.rs new file mode 100644 index 0000000..ded1f26 --- /dev/null +++ b/src/repl.rs @@ -0,0 +1,436 @@ +//! Interactive shell launched when am is run without a subcommand. +//! +//! Uses rustyline for line editing: Tab completion (commands, agents, aliases, +//! groups, flags), command history persisted in the state directory, and +//! graceful fallback to plain line input when no terminal is available. + +use crate::app::App; +use crate::cli::{Cli, Command, StartArgs}; +use crate::commands; +use anyhow::{anyhow, Result}; +use clap::CommandFactory; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Context, Helper}; +use std::collections::BTreeMap; +use std::io::{IsTerminal, Write}; + +// --------------------------------------------------------------------------- +// Tab completion +// --------------------------------------------------------------------------- + +/// Completion candidates for the interactive shell. +pub struct AmCompleter { + commands: Vec<&'static str>, + config_sub: Vec<&'static str>, + shells: Vec<&'static str>, + options: Vec<&'static str>, + agents: Vec, + aliases: Vec, + groups: Vec, +} + +impl AmCompleter { + pub fn new(app: &App) -> Self { + Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups) + } + + pub fn from_catalog( + catalog: &crate::catalog::Catalog, + aliases: &BTreeMap, + groups: &BTreeMap>, + ) -> Self { + let mut agents: Vec = catalog + .agents() + .iter() + .filter(|a| !a.hidden) + .map(|a| a.name.clone()) + .collect(); + agents.sort(); + let mut alias_list: Vec = aliases.keys().cloned().collect(); + alias_list.sort(); + let mut group_list: Vec = groups.keys().map(|g| format!("group:{g}")).collect(); + group_list.sort(); + Self { + commands: vec![ + "list", "status", "search", "info", "install", "uninstall", "update", + "start", "stop", "restart", "run", "doctor", "config", "completion", + "help", "version", "exit", + ], + config_sub: vec!["show", "path", "edit", "validate", "add"], + shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], + options: vec![ + "--background", "--force", "--purge", "--method", "--all", + "--category", "--args", "--env", "--notify", "--timeout", "--yes", + "--verbose", "--quiet", "--json", "--dry-run", "--no-color", + ], + agents, + aliases: alias_list, + groups: group_list, + } + } + + /// Pure completion logic: given the line up to the cursor, returns the + /// replacement start position and the matching candidate strings. + pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec) { + let before = &line[..pos.min(line.len())]; + let start = before + .rfind(char::is_whitespace) + .map(|i| i + 1) + .unwrap_or(0); + let word = &before[start..]; + let previous: Vec<&str> = before[..start].split_whitespace().collect(); + + let mut pool: Vec = Vec::new(); + if previous.is_empty() { + // Completing the command name itself. + pool = self.commands.iter().map(|s| s.to_string()).collect(); + } else { + let first = previous[0]; + match first { + "config" => { + pool = self.config_sub.iter().map(|s| s.to_string()).collect(); + } + "completion" => { + pool = self.shells.iter().map(|s| s.to_string()).collect(); + } + "install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run" + | "info" | "status" => { + if word.starts_with("group:") { + pool = self.groups.clone(); + } else if previous.len() == 1 { + pool.extend(self.agents.iter().cloned()); + pool.extend(self.aliases.iter().cloned()); + pool.extend(self.groups.iter().cloned()); + } else if word.starts_with('-') { + pool = self.options.iter().map(|s| s.to_string()).collect(); + } else { + // Extra arguments: only offer flags. + pool = self.options.iter().map(|s| s.to_string()).collect(); + } + } + _ => { + if word.starts_with('-') { + pool = self.options.iter().map(|s| s.to_string()).collect(); + } + } + } + } + let mut cands: Vec = pool + .iter() + .filter(|c| c.starts_with(word)) + .cloned() + .collect(); + if cands.is_empty() && !word.is_empty() { + let lower = word.to_lowercase(); + cands = pool + .into_iter() + .filter(|c| c.to_lowercase().starts_with(&lower)) + .collect(); + } else if word.is_empty() { + cands = pool; + } + cands.sort(); + cands.dedup(); + (start, cands) + } +} + +impl Completer for AmCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let (start, cands) = self.candidates_for(line, pos); + let pairs = cands + .into_iter() + .map(|c| Pair { + display: c.clone(), + replacement: c, + }) + .collect(); + Ok((start, pairs)) + } +} + +impl Hinter for AmCompleter { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +impl Highlighter for AmCompleter {} +impl Validator for AmCompleter {} +impl Helper for AmCompleter {} + +// --------------------------------------------------------------------------- +// Interactive loop +// --------------------------------------------------------------------------- + +pub fn run(app: &App) -> Result { + if !std::io::stdin().is_terminal() { + // Piped stdin: print the help instead of hanging. + let mut cmd = Cli::command(); + cmd.print_help()?; + println!(); + return Ok(2); + } + match run_with_editor(app) { + Ok(code) => Ok(code), + Err(e) => { + app.log.verbose(&format!( + "line editor unavailable ({e:#}); falling back to plain input" + )); + run_plain(app) + } + } +} + +fn run_with_editor(app: &App) -> Result { + let mut rl: rustyline::Editor = + rustyline::Editor::new()?; + rl.set_helper(Some(AmCompleter::new(app))); + let history_path = app.paths.state_file.with_file_name("history.txt"); + if rl.load_history(&history_path).is_err() { + // First run: no history yet. + } + eprintln!( + "agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'" + ); + loop { + match rl.readline("am> ") { + Ok(line) => { + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + continue; + } + let _ = rl.add_history_entry(trimmed.as_str()); + match trimmed.as_str() { + "exit" | "quit" | "q" => break, + "help" | "?" => print_help(), + "version" => println!("am {}", env!("CARGO_PKG_VERSION")), + _ => { + if let Err(e) = handle_line(app, &trimmed) { + app.log.error(&format!("{e:#}")); + } + } + } + } + Err(ReadlineError::Interrupted) => continue, + Err(ReadlineError::Eof) => break, + Err(e) => { + app.log.warn(&format!("input error: {e}")); + break; + } + } + } + let _ = rl.save_history(&history_path); + Ok(0) +} + +/// Plain fallback when no terminal/line editor is available. +fn run_plain(app: &App) -> Result { + eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit"); + loop { + eprint!("am> "); + std::io::stderr().flush().ok(); + let mut line = String::new(); + if std::io::stdin().read_line(&mut line).is_err() { + break; + } + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + match line.as_str() { + "exit" | "quit" | "q" => break, + "help" | "?" => print_help(), + "version" => println!("am {}", env!("CARGO_PKG_VERSION")), + _ => { + if let Err(e) = handle_line(app, &line) { + app.log.error(&format!("{e:#}")); + } + } + } + } + Ok(0) +} + +fn print_help() { + println!("commands:"); + println!(" list [--all]"); + println!(" status [agent]"); + println!(" search "); + println!(" info "); + println!(" install [--method ] [--force]"); + println!(" uninstall [--purge]"); + println!(" update | update --all"); + println!(" start [--background]"); + println!(" stop [--force]"); + println!(" restart [--background] [--force]"); + println!(" run [args...]"); + println!(" doctor [--fix]"); + println!(" config show | path | validate | edit"); + println!(" help | version | exit"); +} + +fn handle_line(app: &App, line: &str) -> Result<()> { + let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?; + if tokens.is_empty() { + return Ok(()); + } + let cmd = tokens[0].as_str(); + let rest = &tokens[1..]; + let need = |what: &str| -> Result { + rest.first() + .cloned() + .ok_or_else(|| anyhow!("missing {what}")) + }; + let flag = |f: &str| rest.iter().any(|t| t == f); + let method_flag = || { + rest.iter() + .position(|t| t == "--method") + .and_then(|i| rest.get(i + 1)) + .cloned() + }; + let command = match cmd { + "list" => Command::List { + all: flag("--all"), + category: None, + }, + "status" => Command::Status { + agent: rest.first().cloned(), + }, + "search" => Command::Search { + keyword: rest.join(" "), + category: None, + }, + "info" => Command::Info { + agent: need("agent name")?, + }, + "start" => Command::Start(StartArgs { + agent: need("agent name")?, + background: flag("--background") || flag("-b"), + ..Default::default() + }), + "stop" => Command::Stop { + agent: need("agent name")?, + force: flag("--force"), + timeout: None, + }, + "restart" => Command::Restart { + start: StartArgs { + agent: need("agent name")?, + background: flag("--background") || flag("-b"), + ..Default::default() + }, + force: flag("--force"), + timeout: None, + }, + "install" => Command::Install { + agent: need("agent name")?, + method: method_flag(), + force: flag("--force"), + }, + "uninstall" => Command::Uninstall { + agent: need("agent name")?, + purge: flag("--purge"), + }, + "update" => { + if flag("--all") { + Command::Update { + agent: None, + all: true, + } + } else { + Command::Update { + agent: rest.first().cloned(), + all: false, + } + } + } + "doctor" => Command::Doctor { fix: flag("--fix") }, + "run" => { + let agent = need("agent name")?; + Command::Run { + agent, + args: rest[1..].iter().map(|s| s.as_str().into()).collect(), + } + } + "config" => Command::Config(crate::cli::ConfigCmd::Show), + other => { + app.log + .error(&format!("unknown command '{other}' — type 'help'")); + return Ok(()); + } + }; + commands::execute_command(app, &command)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn completer() -> AmCompleter { + let cfg: crate::config::Config = + serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap(); + let catalog = crate::catalog::Catalog::new( + cfg.agents, + cfg.aliases.clone(), + cfg.groups.clone(), + ); + AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups) + } + + #[test] + fn completes_commands() { + let c = completer(); + let (start, cands) = c.candidates_for("sta", 3); + assert_eq!(start, 0); + assert!(cands.contains(&"start".to_string())); + assert!(cands.contains(&"status".to_string())); + } + + #[test] + fn completes_agent_names_and_aliases() { + let c = completer(); + let (start, cands) = c.candidates_for("install claude", 14); + assert_eq!(start, 8); + assert!(cands.contains(&"claude-code".to_string())); + let (_, cands) = c.candidates_for("info cc", 7); + assert!(cands.contains(&"cc".to_string())); + } + + #[test] + fn completes_group_selectors() { + let c = completer(); + let (_, cands) = c.candidates_for("start group:d", 13); + assert!(!cands.is_empty()); + assert!(cands.iter().all(|x| x.starts_with("group:"))); + assert!(cands.contains(&"group:dev".to_string())); + } + + #[test] + fn completes_flags_after_arguments() { + let c = completer(); + let (_, cands) = c.candidates_for("install smelt --dr", 18); + assert!(cands.contains(&"--dry-run".to_string())); + } + + #[test] + fn completes_config_subcommands() { + let c = completer(); + let (_, cands) = c.candidates_for("config sh", 9); + assert!(cands.contains(&"show".to_string())); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..a9b84de --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,186 @@ +//! Command execution abstraction: real process execution for production, +//! an in-memory recorder for tests and dry runs. + +use crate::output::Logger; +use anyhow::{Context, Result}; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub struct RunOutput { + pub code: i32, + pub stdout: String, + pub stderr: String, +} + +/// Resolve a command name to an executable path, handling Windows +/// .cmd/.bat scripts (which std::process::Command cannot launch directly). +/// Returns the program to spawn and a prefix of arguments to insert before +/// the caller's arguments (e.g. cmd.exe /C script.cmd). +pub fn resolve_program(cmd: &str) -> (String, Vec) { + #[cfg(windows)] + { + let path = if cmd.contains('\\') || cmd.contains('/') { + Some(PathBuf::from(cmd)) + } else { + which::which(cmd).ok() + }; + if let Some(p) = path { + let ext = p + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if ext == "cmd" || ext == "bat" { + let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()); + return (comspec, vec!["/C".to_string(), p.display().to_string()]); + } + return (p.display().to_string(), Vec::new()); + } + } + (cmd.to_string(), Vec::new()) +} + +/// Execute external commands with logging and dry-run support. +pub trait Runner { + fn run( + &self, + cmd: &str, + args: &[String], + env: &BTreeMap, + cwd: Option<&Path>, + capture: bool, + ) -> Result; + + /// Locate a binary on the PATH (like the which command). + fn which(&self, bin: &str) -> Option; +} + +/// Production runner backed by std::process::Command. +pub struct SystemRunner<'a> { + pub dry_run: bool, + pub verbose: bool, + pub log: &'a Logger, +} + +impl<'a> SystemRunner<'a> { + pub fn new(dry_run: bool, verbose: bool, log: &'a Logger) -> Self { + Self { + dry_run, + verbose, + log, + } + } +} + +impl<'a> Runner for SystemRunner<'a> { + fn run( + &self, + cmd: &str, + args: &[String], + env: &BTreeMap, + cwd: Option<&Path>, + capture: bool, + ) -> Result { + let display = if args.is_empty() { + cmd.to_string() + } else { + format!("{cmd} {}", args.join(" ")) + }; + if self.dry_run { + self.log.dry(format!("would run: {display}")); + return Ok(RunOutput::default()); + } + self.log.cmd(&display); + let (prog, prefix) = resolve_program(cmd); + let mut full_args = prefix; + full_args.extend(args.iter().cloned()); + let mut c = std::process::Command::new(&prog); + c.args(&full_args); + c.envs(env); + if let Some(dir) = cwd { + c.current_dir(dir); + } + if capture { + let out = c + .output() + .with_context(|| format!("failed to execute: {display}"))?; + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + self.log.result(code, &stderr); + Ok(RunOutput { + code, + stdout, + stderr, + }) + } else { + let status = c + .status() + .with_context(|| format!("failed to execute: {display}"))?; + Ok(RunOutput { + code: status.code().unwrap_or(-1), + stdout: String::new(), + stderr: String::new(), + }) + } + } + + fn which(&self, bin: &str) -> Option { + which::which(bin).ok() + } +} + +/// In-memory runner for tests: records every invocation, returns success. +#[derive(Default)] +pub struct MockRunner { + pub calls: std::sync::Mutex>, + pub found: HashSet, + pub outputs: std::sync::Mutex>, +} + +impl MockRunner { + pub fn new() -> Self { + Self::default() + } + + pub fn with_found(mut self, bin: &str) -> Self { + self.found.insert(bin.to_string()); + self + } + + pub fn with_output(mut self, out: RunOutput) -> Self { + self.outputs.get_mut().unwrap().push(out); + self + } + + pub fn snapshot(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl Runner for MockRunner { + fn run( + &self, + cmd: &str, + args: &[String], + _env: &BTreeMap, + _cwd: Option<&Path>, + _capture: bool, + ) -> Result { + self.calls + .lock() + .unwrap() + .push(format!("{cmd} {}", args.join(" "))); + let out = self.outputs.lock().unwrap().pop(); + Ok(out.unwrap_or_default()) + } + + fn which(&self, bin: &str) -> Option { + if self.found.contains(bin) { + Some(PathBuf::from(bin)) + } else { + None + } + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..a1cce61 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,178 @@ +//! The local installation database: a JSON file recording every agent +//! installed by agent-manager (name, version, paths, PID, dates). + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub const STATE_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateFile { + #[serde(default = "state_version")] + pub version: u32, + #[serde(default)] + pub installed: BTreeMap, +} + +impl Default for StateFile { + fn default() -> Self { + Self { + version: STATE_VERSION, + installed: BTreeMap::new(), + } + } +} + +fn state_version() -> u32 { + STATE_VERSION +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledEntry { + pub name: String, + #[serde(default)] + pub version: Option, + /// Installer method that was used (npm, pip, binary, git, ...). + pub method: String, + /// Root directory of this agent's installation. + pub install_dir: String, + /// Executable files/shims that belong to this installation. + #[serde(default)] + pub bins: Vec, + /// Run command (as configured at install time). + pub run: String, + /// RFC 3339 timestamp of the installation. + pub installed_at: String, + /// RFC 3339 timestamp of the last update (if any). + #[serde(default)] + pub updated_at: Option, + /// PID of the background process (when running). + #[serde(default)] + pub pid: Option, + /// RFC 3339 timestamp of the last background start. + #[serde(default)] + pub started_at: Option, +} + +#[derive(Debug, Clone)] +pub struct StateStore { + path: PathBuf, +} + +impl StateStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result { + if !self.path.exists() { + return Ok(StateFile::default()); + } + let text = std::fs::read_to_string(&self.path) + .with_context(|| format!("cannot read state file {}", self.path.display()))?; + let sf: StateFile = serde_json::from_str(&text).map_err(|e| { + anyhow!( + "state file {} is corrupt ({e}); run 'am doctor --fix' to repair it", + self.path.display() + ) + })?; + Ok(sf) + } + + pub fn save(&self, sf: &StateFile) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = self.path.with_extension("json.tmp"); + let text = serde_json::to_string_pretty(sf)?; + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, &self.path) + .with_context(|| format!("cannot write state file {}", self.path.display()))?; + Ok(()) + } + + pub fn get(&self, name: &str) -> Result> { + Ok(self.load()?.installed.get(name).cloned()) + } + + pub fn all(&self) -> Result> { + Ok(self.load()?.installed) + } + + pub fn set(&self, entry: &InstalledEntry) -> Result<()> { + let mut sf = self.load()?; + sf.installed.insert(entry.name.clone(), entry.clone()); + self.save(&sf) + } + + pub fn remove(&self, name: &str) -> Result { + let mut sf = self.load()?; + let removed = sf.installed.remove(name).is_some(); + if removed { + self.save(&sf)?; + } + Ok(removed) + } + + /// Update the PID / start timestamp of a background agent. + pub fn update_pid(&self, name: &str, pid: Option, started_at: Option) -> Result<()> { + let mut sf = self.load()?; + if let Some(e) = sf.installed.get_mut(name) { + e.pid = pid; + e.started_at = started_at; + self.save(&sf)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(dir: &Path) -> StateStore { + StateStore::new(dir.join("state.json")) + } + + #[test] + fn roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let st = store(dir.path()); + assert!(st.all().unwrap().is_empty()); + let entry = InstalledEntry { + name: "claude-code".to_string(), + version: Some("1.2.3".to_string()), + method: "npm".to_string(), + install_dir: "/tmp/cc".to_string(), + bins: vec!["/tmp/cc/bin/claude".to_string()], + run: "claude".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: Some(42), + started_at: None, + }; + st.set(&entry).unwrap(); + let got = st.get("claude-code").unwrap().unwrap(); + assert_eq!(got.version.as_deref(), Some("1.2.3")); + assert_eq!(got.pid, Some(42)); + st.update_pid("claude-code", None, None).unwrap(); + assert_eq!(st.get("claude-code").unwrap().unwrap().pid, None); + assert!(st.remove("claude-code").unwrap()); + assert!(st.all().unwrap().is_empty()); + } + + #[test] + fn corrupt_file_is_reported() { + let dir = tempfile::tempdir().unwrap(); + let st = store(dir.path()); + std::fs::write(st.path(), "{ not json").unwrap(); + let err = st.load().unwrap_err().to_string(); + assert!(err.contains("corrupt"), "unexpected error: {err}"); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..590ba83 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,158 @@ +//! Version string utilities: extraction from arbitrary command output and +//! lenient version comparison. + +use std::cmp::Ordering; + +/// Extract the first plausible version string from arbitrary text. +/// +/// Handles forms such as "v1.2.3", "1.2", "go1.21.0", "2026.1", as well as +/// suffixes like "-beta" or "+meta". Prefers candidates with the most +/// dot-separated components. +pub fn find_version(text: &str) -> Option { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut best: Option<(usize, usize, usize)> = None; // (dots, len, start) + let mut i = 0; + while i < n { + if chars[i].is_ascii_digit() { + let mut j = i; + while j < n && chars[j].is_ascii_digit() { + j += 1; + } + let mut dots = 0usize; + loop { + if j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() { + dots += 1; + j += 1; + while j < n && chars[j].is_ascii_digit() { + j += 1; + } + } else { + break; + } + } + let mut k = j; + if k < n && (chars[k] == '-' || chars[k] == '+') { + k += 1; + while k < n + && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-') + { + k += 1; + } + } + let len = k - i; + let score = dots.saturating_mul(1000) + len.min(999); + if score > best.map(|(d, l, _)| d * 1000 + l.min(999)).unwrap_or(0) { + best = Some((dots, len, i)); + } + i = k.max(j); + } else { + i += 1; + } + } + let (_, _, start) = best?; + // Reconstruct the chosen candidate. + let mut j = start; + while j < n && chars[j].is_ascii_digit() { + j += 1; + } + while j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() { + j += 1; + while j < n && chars[j].is_ascii_digit() { + j += 1; + } + } + let mut k = j; + if k < n && (chars[k] == '-' || chars[k] == '+') { + k += 1; + while k < n && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-') + { + k += 1; + } + } + while k > start && (chars[k - 1] == '.' || chars[k - 1] == '-') { + k -= 1; + } + if k <= start { + return None; + } + Some(chars[start..k].iter().collect()) +} + +/// Strip a leading "v"/"V" and surrounding whitespace. +pub fn normalize(s: &str) -> String { + let mut t = s.trim().to_string(); + if t.len() > 1 + && (t.starts_with('v') || t.starts_with('V')) + && t[1..].chars().next().map_or(false, |c| c.is_ascii_digit()) + { + t = t[1..].to_string(); + } + t +} + +/// Compare two version strings leniently: semver when possible, otherwise a +/// numeric-tuple comparison. Returns None when the two cannot be compared at +/// all (never happens for two non-empty strings). +pub fn compare(a: &str, b: &str) -> Option { + let a = normalize(a); + let b = normalize(b); + if a == b { + return Some(Ordering::Equal); + } + if let (Ok(av), Ok(bv)) = (semver::Version::parse(&a), semver::Version::parse(&b)) { + return Some(av.cmp(&bv)); + } + let pa = numeric_parts(&a); + let pb = numeric_parts(&b); + let mut idx = 0; + loop { + match (pa.get(idx), pb.get(idx)) { + (Some(x), Some(y)) => { + if x != y { + return Some(x.cmp(y)); + } + idx += 1; + } + (Some(_), None) => return Some(Ordering::Greater), + (None, Some(_)) => return Some(Ordering::Less), + (None, None) => return None, + } + } +} + +fn numeric_parts(s: &str) -> Vec { + s.split(|c: char| !c.is_ascii_digit()) + .filter(|p| !p.is_empty()) + .filter_map(|p| p.parse::().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_common_forms() { + assert_eq!(find_version("Claude Code v1.2.3 (abc)").as_deref(), Some("1.2.3")); + assert_eq!(find_version("go version go1.21.0 windows/amd64").as_deref(), Some("1.21.0")); + assert_eq!(find_version("uv 0.7.12").as_deref(), Some("0.7.12")); + assert_eq!(find_version("node 18.20.4").as_deref(), Some("18.20.4")); + assert_eq!(find_version("1.2.3-beta.1").as_deref(), Some("1.2.3-beta.1")); + assert_eq!(find_version("no version here"), None); + } + + #[test] + fn prefers_dotted_candidate() { + assert_eq!(find_version("cargo 1.85.0 (8a56 2026-01-01)").as_deref(), Some("1.85.0")); + } + + #[test] + fn compares_semver_and_numeric() { + assert_eq!(compare("1.10.0", "1.9.0"), Some(Ordering::Greater)); + assert_eq!(compare("v2.0.0", "2.0.0"), Some(Ordering::Equal)); + assert_eq!(compare("2.0.0-rc1", "2.0.0"), Some(Ordering::Less)); + assert_eq!(compare("2026", "2025"), Some(Ordering::Greater)); + assert_eq!(compare("0.7", "0.7.12"), Some(Ordering::Less)); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..51f440f --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,71 @@ +//! Shared helpers for integration tests: build an isolated App whose data, +//! state, logs and config all live inside one temporary directory. + +#![allow(dead_code)] + +use agent_manager::app::{App, Paths}; +use agent_manager::cli::Cli; +use agent_manager::output::Logger; +use agent_manager::state::StateStore; +use clap::Parser; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +fn fresh_dir(tag: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("am-test-{tag}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Build an App from CLI args, isolated in a fresh temporary directory. +/// A minimal config file is passed via --config so no user config leaks in. +pub fn test_app(tag: &str, args: &[&str]) -> App { + let dir = fresh_dir(tag); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n", + ) + .unwrap(); + let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()]; + full.extend(args.iter().map(|s| s.to_string())); + let cli = Cli::parse_from(full); + let mut app = App::from_cli(cli).expect("app should build"); + isolate(&mut app, &dir); + app +} + +/// Point every runtime path (data, state, logs) inside dir. +pub fn isolate(app: &mut App, dir: &PathBuf) { + app.paths = Paths { + install_dir: dir.join("agents"), + bin_dir: dir.join("agents").join("bin"), + log_dir: dir.join("logs"), + state_file: dir.join("state.json"), + probe_cache_file: dir.join("probe-cache.json"), + config_dir: Some(dir.join("config")), + }; + app.state = StateStore::new(app.paths.state_file.clone()); + app.log = Logger::new( + app.paths.log_dir.clone(), + app.cli.verbose, + app.cli.quiet, + app.cli.no_color, + ); +} + +/// A minimal valid config file with one agent, written into dir. +pub fn write_config(dir: &PathBuf, body: &str) -> PathBuf { + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + format!( + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{body}\n" + ), + ) + .unwrap(); + cfg +} diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..8f1a438 --- /dev/null +++ b/tests/config_test.rs @@ -0,0 +1,91 @@ +//! Integration tests for configuration loading, overriding and validation. + +mod common; + +use agent_manager::app::App; +use agent_manager::cli::Cli; +use clap::Parser; + +#[test] +fn user_config_overrides_embedded_catalog() { + let dir = std::env::temp_dir().join(format!("am-cfg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + r#" +version: "1.0" +agents: + - name: claude-code + display_name: "Mon Claude Custom" + install: { type: npm, package: "@anthropic-ai/claude-code" } + run: claude +"#, + ) + .unwrap(); + let cli = Cli::parse_from([ + "am".to_string(), + "--config".to_string(), + cfg.display().to_string(), + "list".to_string(), + ]); + let app = App::from_cli(cli).unwrap(); + let cc = app.catalog.get("claude-code").unwrap(); + assert_eq!(cc.display_name.as_deref(), Some("Mon Claude Custom")); + // The rest of the embedded catalog is still there. + assert!(app.catalog.len() >= 60); + assert_eq!(app.config_origin.as_deref(), Some(cfg.as_path())); +} + +#[test] +fn invalid_user_config_fails_to_load() { + let dir = std::env::temp_dir().join(format!("am-badcfg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + r#" +version: "1.0" +agents: + - name: broken + installable: true + run: "" + install: { type: npm, package: x } +"#, + ) + .unwrap(); + let cli = Cli::parse_from([ + "am".to_string(), + "--config".to_string(), + cfg.display().to_string(), + "list".to_string(), + ]); + let err = App::from_cli(cli).unwrap_err().to_string(); + assert!(err.contains("'run' is required"), "unexpected error: {err}"); +} + +#[test] +fn unknown_field_is_rejected() { + let dir = std::env::temp_dir().join(format!("am-badfield-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + r#" +version: "1.0" +agents: + - name: my-agent + install: { type: npm, package: pkg, typo_field: true } + run: my-agent +"#, + ) + .unwrap(); + let cli = Cli::parse_from([ + "am".to_string(), + "--config".to_string(), + cfg.display().to_string(), + "list".to_string(), + ]); + let err = App::from_cli(cli).unwrap_err().to_string(); + assert!(err.contains("typo_field"), "unexpected error: {err}"); +} diff --git a/tests/doctor_test.rs b/tests/doctor_test.rs new file mode 100644 index 0000000..a4c7d57 --- /dev/null +++ b/tests/doctor_test.rs @@ -0,0 +1,37 @@ +//! doctor report: structure and config/state checks. + +mod common; + +use agent_manager::doctor; +use agent_manager::runner::SystemRunner; + +#[test] +fn report_contains_core_checks() { + let app = common::test_app("doctor", &["doctor"]); + let runner = SystemRunner::new(false, false, &app.log); + let report = doctor::run(&app, &runner); + let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect(); + for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path"] { + assert!( + names.contains(&expected), + "missing check '{expected}' in {names:?}" + ); + } + // With our isolated test config the schema must be valid. + let schema = report + .checks + .iter() + .find(|c| c.name == "config-schema") + .unwrap(); + assert_eq!(schema.status, doctor::Status::Ok); +} + +#[test] +fn fix_creates_directories() { + let app = common::test_app("doctorfix", &["doctor"]); + assert!(!app.paths.install_dir.exists()); + let repaired = doctor::fix(&app).unwrap(); + assert!(repaired.len() >= 2); + assert!(app.paths.install_dir.is_dir()); + assert!(app.paths.log_dir.is_dir()); +} diff --git a/tests/dry_run_test.rs b/tests/dry_run_test.rs new file mode 100644 index 0000000..c35a443 --- /dev/null +++ b/tests/dry_run_test.rs @@ -0,0 +1,88 @@ +//! Dry-run behaviour: no directories created, no state written, but the +//! commands that would run are announced. + +mod common; + +use agent_manager::commands::install_cmd; +use agent_manager::commands::uninstall_cmd; +use agent_manager::state::InstalledEntry; + +#[test] +fn install_dry_run_creates_nothing() { + let app = common::test_app( + "dryinstall", + &["install", "smelt", "--dry-run", "--yes"], + ); + assert!(app.dry_run()); + let code = install_cmd::run(&app, "smelt", None, false).unwrap(); + assert_eq!(code, 0); + // Nothing was written. + assert!(!app.paths.install_dir.join("smelt").exists()); + assert!(app.state.all().unwrap().is_empty()); +} + +#[test] +fn install_without_dry_run_but_mock_is_not_supported() { + // Guard: install command requires a real runner; ensure the API surface + // used by the previous test is stable. + let app = common::test_app("dryguard", &["install", "smelt", "--dry-run", "--yes"]); + assert!(app.dry_run()); +} + +#[test] +fn uninstall_dry_run_keeps_everything() { + let app = common::test_app( + "dryuninstall", + &["uninstall", "smelt", "--dry-run", "--yes"], + ); + // Simulate an installed agent. + let dir = app.paths.install_dir.join("smelt"); + std::fs::create_dir_all(&dir).unwrap(); + let marker = dir.join("bin"); + std::fs::write(&marker, "x").unwrap(); + let entry = InstalledEntry { + name: "smelt".to_string(), + version: Some("0.5.0".to_string()), + method: "cargo".to_string(), + install_dir: dir.display().to_string(), + bins: vec![], + run: "smelt".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }; + app.state.set(&entry).unwrap(); + + let code = uninstall_cmd::run(&app, "smelt", true).unwrap(); + assert_eq!(code, 0); + // Directory and state entry survive a dry run. + assert!(dir.exists()); + assert!(app.state.get("smelt").unwrap().is_some()); +} + +#[test] +fn real_uninstall_removes_files_and_state() { + let app = common::test_app("uninstall", &["uninstall", "smelt", "--yes"]); + let dir = app.paths.install_dir.join("smelt"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("bin"), "x").unwrap(); + let entry = InstalledEntry { + name: "smelt".to_string(), + version: Some("0.5.0".to_string()), + method: "cargo".to_string(), + install_dir: dir.display().to_string(), + bins: vec![], + run: "smelt".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }; + app.state.set(&entry).unwrap(); + + let code = uninstall_cmd::run(&app, "smelt", false).unwrap(); + assert_eq!(code, 0); + assert!(!dir.exists()); + assert!(app.state.get("smelt").unwrap().is_none()); +} diff --git a/tests/process_test.rs b/tests/process_test.rs new file mode 100644 index 0000000..31f1573 --- /dev/null +++ b/tests/process_test.rs @@ -0,0 +1,66 @@ +//! Process lifecycle: background spawn, liveness, stop. + +mod common; + +use agent_manager::process; +use std::collections::BTreeMap; + +fn sleep_cmd() -> (&'static str, Vec) { + if cfg!(windows) { + ( + "ping", + vec![ + "-n".to_string(), + "40".to_string(), + "127.0.0.1".to_string(), + ], + ) + } else { + ("sleep", vec!["40".to_string()]) + } +} + +#[test] +fn spawn_detect_stop_cycle() { + let app = common::test_app("proc", &["status"]); + let (prog, args) = sleep_cmd(); + let env = BTreeMap::new(); + let pid = process::spawn_background( + &app, + "test-agent", + std::path::Path::new(prog), + &args, + &env, + ) + .unwrap(); + assert!(pid > 0); + assert!(process::is_running(pid)); + assert!(process::stop_pid(pid, true, 5)); + assert!(!process::is_running(pid)); + // The log file was created. + assert!(process::agent_log_path(&app, "test-agent").exists()); +} + +#[test] +fn spawning_a_failing_program_reports_an_error() { + let app = common::test_app("procfail", &["status"]); + let env = BTreeMap::new(); + // A command that exits immediately with an error. + let (prog, args) = if cfg!(windows) { + ("cmd", vec!["/C".to_string(), "exit 7".to_string()]) + } else { + ("sh", vec!["-c".to_string(), "exit 7".to_string()]) + }; + let err = process::spawn_background( + &app, + "failing", + std::path::Path::new(prog), + &args, + &env, + ) + .unwrap_err(); + assert!( + err.to_string().contains("exited immediately"), + "unexpected error: {err:#}" + ); +}