commit 8ee3ddf3dbd032034ab996a16cbee5fe291f498f Author: Bruno Charest Date: Sat Aug 15 11:26:34 2026 -0400 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 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:#}" + ); +}