2 Commits

Author SHA1 Message Date
github-actions[bot] 0c24694ff5 chore: bump Cargo.toml to 0.7.0 2026-06-18 22:01:38 +00:00
github-actions[bot] 1e006256f1 bump: version 0.6.0 → 0.7.0 [skip ci] 2026-06-18 22:01:35 +00:00
31 changed files with 348 additions and 2380 deletions
-36
View File
@@ -1,43 +1,7 @@
## v0.7.4 (2026-07-02)
### Feat
- Pin specific usql version to sbx kit
- recursively take ownership over the copied in coyote config for the sbx
- explicitly specify the COYOTE_CONFIG_DIR in the sbx kit
- --tail-logs can track log rollovers and incoporates a sleep timer to minimize idle CPU cycles
- Added support for log rolling so log files don't just blow up over time
### Fix
- Added back in --kit specification for the running of the sbx
- sbx isn't copying base files in their respective directories
- Update deprecated sbx kit config
- Properly chown the coyote config recursively and password file in the sbx
## v0.7.3 (2026-06-24)
### Fix
- apply bootstrapping of functions at startup to fix edge case
## v0.7.2 (2026-06-19)
### Fix
- usql version upgrade
## v0.7.1 (2026-06-19)
### Fix
- sbx mixins must be passed in directories, not as files and the files must be named spec.yaml per new sbx version
## v0.7.0 (2026-06-18)
### Feat
- added configurable cache path via the COYOTE_CACHE_PATH environment variable
- added a memory option to .set tab completions
- Added a diagnostic .info tools subcommand to make it easier to see what tools are enabled in all contexts
- Added additional info outputs for enabled skills and sbx directories
Generated
+118 -151
View File
@@ -141,9 +141,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.103"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
@@ -174,9 +174,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.2"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
@@ -321,11 +321,11 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys 0.42.0",
"aws-lc-sys 0.41.0",
"zeroize",
]
@@ -344,15 +344,14 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -567,7 +566,7 @@ dependencies = [
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
@@ -894,7 +893,7 @@ version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -923,12 +922,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
@@ -1067,9 +1060,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
[[package]]
name = "cc"
version = "1.2.65"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1111,9 +1104,9 @@ dependencies = [
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1194,14 +1187,14 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.6.7"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
dependencies = [
"clap",
"clap_lex",
"is_executable",
"shlex 2.0.1",
"shlex 1.3.0",
]
[[package]]
@@ -1322,9 +1315,9 @@ dependencies = [
[[package]]
name = "console"
version = "0.16.4"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
@@ -1409,7 +1402,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "coyote-ai"
version = "0.7.4"
version = "0.7.0"
dependencies = [
"ansi_colours",
"anyhow",
@@ -1421,7 +1414,7 @@ dependencies = [
"aws-smithy-types",
"base64",
"bincode 2.0.1",
"bitflags 2.13.0",
"bitflags",
"bm25",
"bytes",
"chrono",
@@ -1563,7 +1556,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"crossterm_winapi",
"derive_more 2.1.1",
"document-features",
@@ -1734,38 +1727,6 @@ dependencies = [
"syn",
]
[[package]]
name = "defmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1911,7 +1872,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
]
@@ -2046,9 +2007,9 @@ dependencies = [
[[package]]
name = "env_filter"
version = "2.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
@@ -2056,9 +2017,9 @@ dependencies = [
[[package]]
name = "env_logger"
version = "0.11.11"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
@@ -2776,9 +2737,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hybrid-array"
version = "0.4.13"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
@@ -2853,7 +2814,7 @@ dependencies = [
"http 1.4.2",
"hyper 1.10.1",
"hyper-util",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-native-certs",
"tokio",
"tokio-rustls 0.26.4",
@@ -3070,9 +3031,9 @@ dependencies = [
[[package]]
name = "indicatif"
version = "0.18.6"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
dependencies = [
"console",
"portable-atomic",
@@ -3105,7 +3066,7 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
@@ -3190,11 +3151,10 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.31"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [
"defmt",
"jiff-static",
"log",
"portable-atomic",
@@ -3204,9 +3164,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.31"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [
"proc-macro2",
"quote",
@@ -3274,9 +3234,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
dependencies = [
"cfg-if",
"futures-util",
@@ -3346,9 +3306,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.18"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
@@ -3382,9 +3342,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
dependencies = [
"serde_core",
]
@@ -3558,7 +3518,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ecce9d566cb9234ae3db9e249c8b55665feaaf32b0859ff1e27e310d2beb3d8"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"combine",
"libc",
"mach2",
@@ -3610,7 +3570,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@@ -3622,7 +3582,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@@ -3724,7 +3684,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
@@ -3736,7 +3696,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
"objc2-foundation",
]
@@ -3757,7 +3717,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"dispatch2",
"objc2",
]
@@ -3768,7 +3728,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -3801,7 +3761,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -3819,7 +3779,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"block2",
"libc",
"objc2",
@@ -3842,7 +3802,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
"objc2-core-foundation",
]
@@ -3853,7 +3813,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -3865,7 +3825,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -3917,7 +3877,7 @@ version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"libc",
"once_cell",
"onig_sys",
@@ -3941,12 +3901,13 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.6"
version = "5.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
@@ -3955,7 +3916,7 @@ version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
@@ -4112,6 +4073,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
@@ -4425,9 +4392,9 @@ dependencies = [
[[package]]
name = "quinn"
version = "0.11.11"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
@@ -4435,7 +4402,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.41",
"rustls 0.23.40",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
@@ -4445,9 +4412,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.15"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -4456,7 +4423,7 @@ dependencies = [
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -4481,9 +4448,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.46"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -4525,7 +4492,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20 0.10.1",
"chacha20 0.10.0",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -4590,7 +4557,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.0",
"bitflags",
]
[[package]]
@@ -4702,7 +4669,7 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-native-certs",
"rustls-pki-types",
"serde",
@@ -4749,7 +4716,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@@ -4795,9 +4762,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "1.8.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
dependencies = [
"async-trait",
"base64",
@@ -4822,9 +4789,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "1.8.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5"
checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -4931,7 +4898,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
@@ -4952,9 +4919,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.41"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
@@ -4980,9 +4947,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
@@ -4999,7 +4966,7 @@ dependencies = [
"jni",
"log",
"once_cell",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.13",
@@ -5162,7 +5129,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -5185,7 +5152,7 @@ version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"cssparser",
"derive_more 0.99.20",
"fxhash",
@@ -5792,7 +5759,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"byteorder",
"enum-as-inner",
"libc",
@@ -6075,7 +6042,7 @@ version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.41",
"rustls 0.23.40",
"tokio",
]
@@ -6198,7 +6165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"bitflags 2.13.0",
"bitflags",
"bytes",
"futures-core",
"futures-util",
@@ -6259,9 +6226,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.26.10"
version = "0.26.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55"
checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3"
dependencies = [
"cc",
"regex",
@@ -6471,7 +6438,7 @@ dependencies = [
"flate2",
"log",
"percent-encoding",
"rustls 0.23.41",
"rustls 0.23.40",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6537,9 +6504,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -6639,9 +6606,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [
"cfg-if",
"once_cell",
@@ -6652,9 +6619,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6662,9 +6629,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6672,9 +6639,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -6685,9 +6652,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [
"unicode-ident",
]
@@ -6737,7 +6704,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"rustix",
"wayland-backend",
"wayland-scanner",
@@ -6745,11 +6712,11 @@ dependencies = [
[[package]]
name = "wayland-protocols"
version = "0.32.13"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -6761,7 +6728,7 @@ version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.0",
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -6790,9 +6757,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.103"
version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -7444,9 +7411,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.5"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "coyote-ai"
version = "0.7.4"
version = "0.7.0"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "An all-in-one, batteries included LLM CLI Tool"
@@ -49,7 +49,7 @@ textwrap = "0.16.0"
ansi_colours = "1.2.2"
eventsource-stream = "0.2.3"
log = "0.4.28"
log4rs = { version = "1.4.0", features = ["file_appender", "rolling_file_appender", "compound_policy", "fixed_window_roller", "size_trigger"] }
log4rs = { version = "1.4.0", features = ["file_appender"] }
shell-words = "1.1.0"
sha2 = "0.10.8"
unicode-width = "0.2.0"
+2 -5
View File
@@ -59,9 +59,6 @@ Coyote requires the following tools to be installed on your system:
* [docker](https://docs.docker.com/engine/install/)
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
* [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases)
* **Homebrew:** `brew tap iwe-org/iwe && brew install iwe`
* **Cargo:** `cargo install iwec`
These tools are used to provide various functionalities within Coyote, such as document processing, JSON manipulation,
etc., and they are used within agents and tools.
@@ -101,7 +98,7 @@ You can use the following command to run a bash script that downloads and instal
OS (Linux/MacOS) and architecture (x86_64/arm64):
```shell
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/scripts/install_coyote.sh | bash
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/install_coyote.sh | bash
```
#### Windows/Linux/MacOS (`PowerShell`)
@@ -109,7 +106,7 @@ You can use the following command to run a PowerShell script that downloads and
for your OS (Windows/Linux/MacOS) and architecture (x86_64/arm64):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/scripts/install_coyote.ps1 | iex"
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr -useb https://raw.githubusercontent.com/Dark-Alex-17/coyote/main/scripts/install_coyote.ps1 | iex"
```
### Manual
-11
View File
@@ -1,11 +0,0 @@
schemaVersion: '1'
kind: mixin
name: sisyphus-ddg
description: >
Allows Sisyphus to hit all domains since it utilizes the DuckDuckGo
MCP server. This allows the MCP server to actually perform web searches
on arbitrary domains and retrieve info for the agent.
network:
allowedDomains:
- '*'
-5
View File
@@ -18,11 +18,6 @@
"type": "stdio",
"command": "uvx",
"args": ["duckduckgo-mcp-server"]
},
"iwe": {
"type": "stdio",
"command": "iwec",
"args": ["--project", "."]
}
}
}
+60 -76
View File
@@ -5,19 +5,20 @@
# sbx cp $HOME/.config/coyote/ testing:/home/agent/.config/
# sbx cp $HOME/.coyote_password testing:/home/agent/
# sbx run testing --kit ./sbx-kit/
schemaVersion: '1'
kind: sandbox
schemaVersion: "1"
kind: agent
name: coyote
displayName: Coyote
description: >
An all-in-one, batteries-included LLM CLI tool featuring Shell Assistant,
CLI & REPL mode, RAG, AI tools & agents, MCP servers, skills, and macros.
sandbox:
image: 'docker/sandbox-templates:shell-docker'
agent:
image: "docker/sandbox-templates:shell-docker"
aiFilename: COYOTE.md
# persistence: persistent
entrypoint:
run: ['bash', '-lc', 'exec /home/agent/.cargo/bin/coyote']
run: ["bash", "-lc", "exec /home/agent/.cargo/bin/coyote"]
network:
# Proxy-managed LLM providers: the proxy substitutes `proxy-managed` for
@@ -50,96 +51,96 @@ network:
serviceAuth:
openai:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
anthropic:
headerName: x-api-key
valueFormat: '%s'
valueFormat: "%s"
gemini:
headerName: x-goog-api-key
valueFormat: '%s'
valueFormat: "%s"
cohere:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
groq:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
openrouter:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
ai21:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
cloudflare:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
deepinfra:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
deepseek:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
mistral:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
perplexity:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
voyageai:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
xai:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
jina:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
ernie:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
hunyuan:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
minimax:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
moonshot:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
qianwen:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
zhipuai:
headerName: Authorization
valueFormat: 'Bearer %s'
valueFormat: "Bearer %s"
allowedDomains:
# Coyote release + self-update + model-registry sync
- 'github.com:443'
- 'api.github.com:443'
- 'raw.githubusercontent.com:443'
- 'objects.githubusercontent.com:443'
- '*.githubusercontent.com:443'
- "github.com:443"
- "api.github.com:443"
- "raw.githubusercontent.com:443"
- "objects.githubusercontent.com:443"
- "*.githubusercontent.com:443"
# Coyote install paths (cargo install + uv + rustup + Python tool deps at runtime)
- 'crates.io:443'
- 'static.crates.io:443'
- 'pypi.org:443'
- 'files.pythonhosted.org:443'
- 'astral.sh:443'
- 'sh.rustup.rs:443'
- 'static.rust-lang.org:443'
- "crates.io:443"
- "static.crates.io:443"
- "pypi.org:443"
- "files.pythonhosted.org:443"
- "astral.sh:443"
- "sh.rustup.rs:443"
- "static.rust-lang.org:443"
# LLM model OAuth + API endpoints
- 'claude.ai:443'
- 'console.anthropic.com:443'
- 'accounts.google.com:443'
- "claude.ai:443"
- "console.anthropic.com:443"
- "accounts.google.com:443"
# *.googleapis.com covers oauth2 + userinfo + VertexAI regional endpoints
# (*-aiplatform.googleapis.com). Do not narrow without re-checking VertexAI.
- '*.googleapis.com:443'
- "*.googleapis.com:443"
# Bedrock and GitHub Models use signed / GitHub-PAT auth that the proxy
# cannot rewrite. Domains are allow-listed; credentials must be injected
# separately (see README "Extending").
- '*.amazonaws.com:443'
- 'models.inference.ai.azure.com:443'
- "*.amazonaws.com:443"
- "models.inference.ai.azure.com:443"
credentials:
sources:
@@ -210,9 +211,8 @@ credentials:
environment:
variables:
IS_SANDBOX: '1'
IS_SANDBOX: "1"
COYOTE_LOG_LEVEL: INFO
COYOTE_CONFIG_DIR: /home/agent/.config/coyote
proxyManaged:
- OPENAI_API_KEY
- ANTHROPIC_API_KEY
@@ -250,37 +250,26 @@ commands:
libssl-dev \
pandoc \
bzip2
user: '1000'
user: "1000"
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
- command: |
curl -LsSf https://astral.sh/uv/install.sh | sh
if [ -f "$HOME/.local/bin/uv" ]; then
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx"
chmod +x "$HOME/.local/bin/uvx"
fi
user: '1000'
description: Install uv and write a uvx shell wrapper (the installer may place a macOS binary at this path on Docker-for-Mac hosts, which the Linux container cannot execute)
- command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
user: "1000"
description: Install uv (required for Python-based custom tools)
- command: |
set -euo pipefail
USQL_VERSION=0.21.4
USQL_VERSION="0.19.20"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) USQL_ARCH=amd64 ;;
aarch64) USQL_ARCH=arm64 ;;
*) echo "Unsupported arch for usql install: $ARCH" >&2; exit 1 ;;
esac
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
curl -fsSL --retry 3 "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o "$TMPDIR/usql.tar.bz2"
tar -xjf "$TMPDIR/usql.tar.bz2" -C "$TMPDIR"
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
user: '1000'
curl -sSL "https://github.com/xo/usql/releases/download/v${USQL_VERSION}/usql_static-${USQL_VERSION}-linux-${USQL_ARCH}.tar.bz2" -o /tmp/usql.tar.bz2
sudo tar -xjf /tmp/usql.tar.bz2 -C /usr/local/bin
sudo chmod +x /usr/local/bin/usql
rm -f /tmp/usql.tar.bz2
user: "1000"
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
- command: |
. "$HOME/.cargo/env"
cargo install --locked iwec
user: '1000'
description: Install the IWE MCP server binary (iwec) used by the built-in iwe MCP server and iwe-knowledge-base skill
- command: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y \
@@ -289,21 +278,16 @@ commands:
--target x86_64-unknown-linux-musl
. "$HOME/.cargo/env"
cargo install --locked coyote-ai
user: '1000'
user: "1000"
description: Install Coyote AI CLI via Rust's Cargo
startup:
- command:
[
'sh',
'-c',
'test -f "$HOME/.config/coyote/config.yaml" || coyote --info >/dev/null 2>&1 || true',
]
user: '1000'
- command: ["sh", "-c", "test -f \"$HOME/.config/coyote/config.yaml\" || coyote --info >/dev/null 2>&1 || true"]
user: "1000"
background: false
description: Bootstrap Coyote config directory on first sandbox start
agentContext: |
memory: |
## Sandbox environment
You are running inside a Docker sandbox launched via `sbx run coyote`. The
-65
View File
@@ -1,65 +0,0 @@
---
description: Navigate and curate markdown knowledge bases (plan repos, spec repos, companion docs) with IWE graph tools. Load when the workspace is or contains a markdown knowledge base and the task involves finding, reading, or reorganizing plans, specs, designs, or notes. Activates the iwe MCP server rooted at the current directory.
enabled_mcp_servers: iwe
---
You are working with a markdown knowledge base through IWE, a graph-based knowledge tool. The `iwe` MCP server is rooted at the current working directory (`--project .`), so the knowledge base is the directory Coyote was launched in. IWE derives structure from links: a link on its own line is an *inclusion link* (parent-child hierarchy); a link inside text is an *inline reference* (cross-reference, produces backlinks). The server watches the filesystem, so external edits are picked up automatically — never ask for a restart.
## When to use this (and when not)
Use IWE tools when the task involves a corpus of markdown documents: plan repositories, spec/design collections, companion docs repos, meeting notes, PKM vaults.
Do NOT use IWE tools for:
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there.
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
- **Source code** — IWE only understands markdown.
If unsure whether the current directory is actually a knowledge base, probe with `iwe_stats` first. Few or zero documents means this skill does not apply; unload it rather than forcing the tools.
## Orientation protocol (always start here)
Never guess document keys. Orient first:
1. `iwe_stats` — corpus size and shape. Cheap sanity check.
2. `iwe_find(query="<topic>")` — fuzzy search for entry points. Use `roots` behavior via structural selectors when you want top-level topics only.
3. `iwe_tree(key="<entry>", max_depth=2)` — see the hierarchy before reading bodies.
4. `iwe_retrieve(key="<entry>", depth=1, context=1)` — read with structure.
## Reading efficiently
`iwe_retrieve` is the workhorse. Control cost explicitly:
- `depth` — how many levels of included children to expand. Start at 1-2; increase only if needed.
- `context` — parent levels to include, so you know where a document sits. `context=1` is usually enough.
- `max_tokens` — ALWAYS set a budget (e.g. 2000-4000) on large corpora; results report truncation so you can drill further deliberately.
- `exclude` — pass keys you have already read to avoid re-retrieving known content.
- `links` / `backlinks` — include outbound/inbound references when tracing how a topic connects.
Scope searches structurally with selectors on `iwe_find`/`iwe_retrieve`/`iwe_tree`:
- `in` — only sub-documents of EVERY listed key (AND)
- `in_any` — sub-documents of at least one key (OR)
- `not_in` — exclude subtrees (e.g. archives)
Filter by frontmatter with the YAML query language: `status: draft`, `created: {$gte: "2026-01-01"}`, `tags: {$in: [urgent]}`, `reviewed: {$exists: true}`.
Use `iwe_squash(key=...)` to flatten a subtree into one linear document — good for producing a full plan readout or summary input.
## Writing and refactoring
Write tools: `iwe_create` (new doc from title + content), `iwe_update` (replace a doc's content), `iwe_delete` (remove + clean up references). Refactor tools: `iwe_rename` (key rename with automatic link updates everywhere), `iwe_extract` (split a section into its own doc, leaving an inclusion link), `iwe_inline` (merge a referenced doc back into its parent), `iwe_normalize` (reformat all docs consistently).
Rules:
- **Preview destructive operations**: `iwe_rename`, `iwe_delete`, `iwe_extract`, `iwe_inline`, and `iwe_normalize` support `dry_run` — use it first, show the user what will change, then apply.
- Never rename or delete by editing files directly; the refactor tools update every referencing document, manual edits break links.
- When adding a document, link it from an existing parent (inclusion link on its own line) so it joins the hierarchy instead of becoming an orphan.
- Match the corpus conventions: check an existing document's frontmatter fields before inventing your own schema.
- Do not run `iwe_normalize` across someone's knowledge base unprompted — it rewrites every file's formatting.
## Anti-patterns
- Retrieving with `depth=5` and no `max_tokens` "to get everything" — you will flood the context. Iterate: shallow first, drill selectively.
- Calling `iwe_find` repeatedly with rephrased queries when structural navigation (`iwe_tree`, selectors) would locate the document deterministically.
- Using IWE write tools on `.coyote/memory/` files — wrong tier; that corrupts the memory index.
- Creating documents without linking them into the hierarchy — orphans are invisible to depth-based retrieval.
-32
View File
@@ -377,14 +377,6 @@
thinking:
type: enabled
budget_tokens: 16000
- name: claude-sonnet-5
max_input_tokens: 1000000
max_output_tokens: 128000
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -930,14 +922,6 @@
thinking:
type: enabled
budget_tokens: 16000
- name: claude-sonnet-5
max_input_tokens: 1000000
max_output_tokens: 128000
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1119,14 +1103,6 @@
thinking:
type: enabled
budget_tokens: 16000
- name: us.anthropic.claude-sonnet-5
max_input_tokens: 1000000
max_output_tokens: 128000
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: us.anthropic.claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
@@ -1809,14 +1785,6 @@
output_price: 25
supports_vision: true
supports_function_calling: true
- name: anthropic/claude-sonnet-5
max_input_tokens: 1000000
max_output_tokens: 128000
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: anthropic/claude-sonnet-4.6
max_input_tokens: 200000
max_output_tokens: 8192
+3 -3
View File
@@ -39,7 +39,7 @@ switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) {
if (-not $BinDir) {
if ($isWin) { $BinDir = Join-Path $env:LOCALAPPDATA 'coyote\bin' }
else { $userHome = $env:HOME; if (-not $userHome) { $userHome = (Get-Item -Path ~).FullName }; $BinDir = Join-Path $userHome '.local/bin' }
else { $home = $env:HOME; if (-not $home) { $home = (Get-Item -Path ~).FullName }; $BinDir = Join-Path $home '.local/bin' }
}
New-Item -ItemType Directory -Force -Path $BinDir | Out-Null
@@ -95,13 +95,13 @@ if ($asset.name -match '\.zip$') {
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir)
} elseif ($asset.name -match '\.tar\.gz$' -or $asset.name -match '\.tgz$') {
$tar = Get-Command tar -ErrorAction SilentlyContinue
if ($tar) { & $tar.Source -xzf $archive -C $extractDir }
if ($tar) { & $tar.FullName -xzf $archive -C $extractDir }
else { Fail "Asset is tar archive but 'tar' is not available." }
} else {
try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $extractDir) }
catch {
$tar = Get-Command tar -ErrorAction SilentlyContinue
if ($tar) { & $tar.Source -xf $archive -C $extractDir } else { Fail "Unknown archive format; neither zip nor tar workable." }
if ($tar) { & $tar.FullName -xf $archive -C $extractDir } else { Fail "Unknown archive format; neither zip nor tar workable." }
}
}
Executable → Regular
+22 -13
View File
@@ -133,21 +133,30 @@ else
echo "Error: unsupported OS for this installer: $OS" >&2; exit 1
fi
DL_URLS=$(grep -oE '"browser_download_url":[[:space:]]*"[^"]+"' "$JSON" \
| sed -E 's/.*"browser_download_url":[[:space:]]*"//; s/"$//' \
|| true)
ASSET_NAME=""; ASSET_URL=""
for candidate in "${ASSET_CANDIDATES[@]}"; do
while IFS= read -r url; do
[[ -z "$url" ]] && continue
if [[ "$url" == */"$candidate" ]]; then
ASSET_NAME="$candidate"
ASSET_URL="$url"
break
fi
done <<< "$DL_URLS"
[[ -n "$ASSET_URL" ]] && break
NAME=$(grep -oE '"name":\s*"[^"]+"' "$JSON" | sed 's/"name":\s*"//; s/"$//' | grep -Fx "$candidate" || true)
if [[ -n "$NAME" ]]; then
ASSET_NAME="$NAME"
ASSET_URL=$(awk -v pat="$NAME" '
BEGIN{ FS=":"; want=0 }
/"name"/ {
line=$0;
gsub(/^\s+|\s+$/,"",line);
gsub(/"name"\s*:\s*"|"/ ,"", line);
want = (line==pat) ? 1 : 0;
next
}
want==1 && /"browser_download_url"/ {
u=$0;
gsub(/^\s+|\s+$/,"",u);
gsub(/.*"browser_download_url"\s*:\s*"|".*/ ,"", u);
print u;
exit
}
' "$JSON")
if [[ -n "$ASSET_URL" ]]; then break; fi
fi
done
if [[ -z "$ASSET_URL" ]]; then
+1 -29
View File
@@ -5,9 +5,9 @@ use crate::utils::list_file_names;
use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell;
use std::env;
use std::ffi::OsStr;
use std::io;
use std::{env, fs};
const COYOTE_CLI_NAME: &str = "coyote";
@@ -134,34 +134,6 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
.collect()
}
pub(super) fn mcp_server_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
let content = match fs::read_to_string(paths::mcp_config_file()) {
Ok(c) => c,
Err(_) => return vec![],
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return vec![],
};
let servers = match json.get("mcpServers").and_then(|v| v.as_object()) {
Some(s) => s,
None => return vec![],
};
servers
.iter()
.filter(|(_, v)| {
v.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "http" || t == "sse")
.unwrap_or(false)
})
.filter(|(k, _)| k.starts_with(&*cur))
.map(|(k, _)| CompletionCandidate::new(k))
.collect()
}
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_app_config_for_completion() {
+2 -5
View File
@@ -1,8 +1,8 @@
mod completer;
use crate::cli::completer::{
ShellCompletion, agent_completer, macro_completer, mcp_server_completer, model_completer,
rag_completer, role_completer, secrets_completer, session_completer,
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
role_completer, secrets_completer, session_completer,
};
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
use anyhow::{Context, Result};
@@ -171,9 +171,6 @@ pub struct Cli {
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
pub authenticate: Option<Option<String>>,
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
#[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))]
pub auth_mcp: Option<String>,
/// Generate static shell completion scripts
#[arg(long, value_name = "SHELL", value_enum)]
pub completions: Option<ShellCompletion>,
-7
View File
@@ -133,13 +133,6 @@ impl MessageContent {
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
MessageContent::Text(text) => Some(text),
_ => None,
}
}
pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {
match self {
MessageContent::Text(text) => *text = replace_fn(text),
+4 -10
View File
@@ -53,10 +53,6 @@ pub trait OAuthProvider: Send + Sync {
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
vec![]
}
fn fixed_redirect_uri(&self) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -76,16 +72,14 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let state = Uuid::new_v4().to_string();
let (redirect_uri, use_callback_listener) = if let Some(fixed) = provider.fixed_redirect_uri() {
(fixed, true)
} else if provider.uses_localhost_redirect() {
let redirect_uri = if provider.uses_localhost_redirect() {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let uri = format!("http://127.0.0.1:{port}/callback");
drop(listener);
(uri, true)
uri
} else {
(provider.redirect_uri().to_string(), false)
provider.redirect_uri().to_string()
};
let encoded_scopes = urlencoding::encode(provider.scopes());
@@ -118,7 +112,7 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let _ = open::that(&authorize_url);
let (code, returned_state) = if use_callback_listener {
let (code, returned_state) = if provider.uses_localhost_redirect() {
listen_for_oauth_callback(&redirect_uri)?
} else {
let input = Text::new("Paste the authorization code:").prompt()?;
+2 -11
View File
@@ -1,6 +1,4 @@
use crate::mcp::{
ConnectedServer, JsonField, McpServer, McpTransportType, oauth, spawn_mcp_server,
};
use crate::mcp::{ConnectedServer, JsonField, McpServer, McpTransportType, spawn_mcp_server};
use anyhow::Result;
use parking_lot::Mutex;
@@ -101,12 +99,7 @@ impl McpFactory {
return Ok(existing);
}
let bearer_token = if spec.is_remote() {
oauth::load_valid_mcp_token(name)
} else {
None
};
let handle = spawn_mcp_server(spec, log_path, bearer_token).await?;
let handle = spawn_mcp_server(spec, log_path).await?;
self.insert_active(key, &handle);
Ok(handle)
}
@@ -132,7 +125,6 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
}
}
@@ -149,7 +141,6 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers,
oauth_client_id: None,
}
}
-9
View File
@@ -118,14 +118,6 @@ pub struct MemoryFrontmatter {
pub description: Option<String>,
#[serde(default, rename = "type")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
}
#[derive(Debug, Clone)]
@@ -553,7 +545,6 @@ mod tests {
name: "test".into(),
description: Some("a test".into()),
kind: Some("user".into()),
..Default::default()
},
body: "Hello world\nmore text".into(),
};
-2
View File
@@ -135,7 +135,6 @@ const RAGS_DIR_NAME: &str = "rags";
const FUNCTIONS_DIR_NAME: &str = "functions";
const FUNCTIONS_BIN_DIR_NAME: &str = "bin";
const AGENTS_DIR_NAME: &str = "agents";
const REPL_HISTORY_DIR_NAME: &str = "repl-history";
const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
@@ -148,7 +147,6 @@ const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
const SBX_VAULT_MIXINS_DIR_NAME: &str = "sbx-vault-mixins";
const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
const GIT_DIR_NAME: &str = ".git";
const GITIGNORE_FILE_NAME: &str = ".gitignore";
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
+4 -30
View File
@@ -4,12 +4,10 @@ use super::{
ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME,
GLOBAL_TOOLS_UTILS_DIR_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME,
MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, SBX_VAULT_MIXINS_DIR_NAME,
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_VAULT_MIXINS_DIR_NAME, SKILLS_DIR_NAME,
WORKSPACE_MEMORY_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
use crate::config::session::Session;
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
use anyhow::{Context, Result, anyhow, bail};
@@ -35,14 +33,8 @@ pub fn local_path(name: &str) -> PathBuf {
}
pub fn cache_path() -> PathBuf {
if let Ok(v) = env::var(get_env_name("cache_dir")) {
PathBuf::from(v)
} else if let Ok(v) = env::var("XDG_CACHE_HOME") {
PathBuf::from(v).join(env!("CARGO_CRATE_NAME"))
} else {
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
base_dir.join(env!("CARGO_CRATE_NAME"))
}
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
base_dir.join(env!("CARGO_CRATE_NAME"))
}
pub fn sandbox_kit_override() -> Option<PathBuf> {
@@ -156,10 +148,6 @@ pub fn sbx_vault_mixins_hash_file() -> PathBuf {
sbx_vault_mixins_dir().join(SBX_KIT_HASH_FILE)
}
pub fn sbx_mixin_kits_dir() -> PathBuf {
cache_path().join(SBX_MIXIN_KITS_DIR_NAME)
}
pub fn config_file() -> PathBuf {
match env::var(get_env_name("config_file")) {
Ok(value) => PathBuf::from(value),
@@ -322,20 +310,6 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
.join(MEMORY_DIR_NAME)
}
pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME)
}
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
let history_key = if let Some(session) = &session {
format!("session_{}", session.name().replace('/', "_"))
} else {
"default".to_string()
};
repl_history_dir().join(history_key)
}
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
let log_level = env::var(get_env_name("log_level"))
.ok()
+2 -12
View File
@@ -18,16 +18,10 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
- `memory__read(name)`: Read a specific drill file's full content.
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
The MEMORY.md index is appended automatically; do not also update the index by hand.
Optional `superseded_by` / `expires` (YYYY-MM-DD) mark a memory as stale for later cleanup.
- `memory__rename(name, new_name, scope)`: Rename a drill file. Its index entry and every
[[wikilink]] to it are rewritten automatically.
- `memory__delete(name, scope)`: Delete a drill file and its index entry. Reports any
[[wikilinks]] left dangling in other files.
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
- `memory__list()`: See all known drill files and their metadata.
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files,
stale (superseded/expired) files, and index descriptions that drifted from the files.
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files.
RULES:
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
@@ -35,11 +29,7 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
MEMORY.md outside the managed path is invisible to memory.
- All drill files MUST go through `memory__write`. The index updates itself. Renames and
deletions MUST go through `memory__rename` / `memory__delete` so links stay intact.
- When a fact becomes outdated, update it in place, delete it, or mark the old file with
`superseded_by`/`expires` so `memory__lint` flags it later. Never leave contradictory
memories side by side.
- All drill files MUST go through `memory__write`. The index updates itself.
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
Use coyote's Vault for secrets.
+28 -46
View File
@@ -709,10 +709,6 @@ impl RequestContext {
}
pub fn extract_role(&self, app: &AppConfig) -> Result<Role> {
self.extract_role_impl(app, true)
}
fn extract_role_impl(&self, app: &AppConfig, inject_memory: bool) -> Result<Role> {
let mut role = if let Some(session) = self.session.as_ref() {
session.to_role()
} else if let Some(agent) = self.agent.as_ref() {
@@ -761,36 +757,34 @@ impl RequestContext {
}
}
if inject_memory {
let memory_config = self.memory_config();
if memory_config.enabled {
let store = MemoryStore {
global_dir: paths::global_memory_dir(),
workspace: memory_config.workspace,
};
let with_tools = app.function_calling_support;
let cap = if with_tools {
app.memory_cap_with_tools
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
} else {
app.memory_cap_without_tools
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
};
match memory::build_memory_section(&store, with_tools, cap) {
Ok(Some(section)) => {
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
role.append_to_prompt(separator);
role.append_to_prompt(&section);
role.append_to_prompt("\n\n");
role.append_to_prompt(if with_tools {
prompts::DEFAULT_MEMORY_INSTRUCTIONS
} else {
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
});
}
Ok(None) => {}
Err(e) => warn!("memory injection failed: {}", e),
let memory_config = self.memory_config();
if memory_config.enabled {
let store = MemoryStore {
global_dir: paths::global_memory_dir(),
workspace: memory_config.workspace,
};
let with_tools = app.function_calling_support;
let cap = if with_tools {
app.memory_cap_with_tools
.unwrap_or(DEFAULT_MEMORY_CAP_WITH_TOOLS)
} else {
app.memory_cap_without_tools
.unwrap_or(DEFAULT_MEMORY_CAP_WITHOUT_TOOLS)
};
match memory::build_memory_section(&store, with_tools, cap) {
Ok(Some(section)) => {
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
role.append_to_prompt(separator);
role.append_to_prompt(&section);
role.append_to_prompt("\n\n");
role.append_to_prompt(if with_tools {
prompts::DEFAULT_MEMORY_INSTRUCTIONS
} else {
prompts::DEFAULT_MEMORY_INSTRUCTIONS_READONLY
});
}
Ok(None) => {}
Err(e) => warn!("memory injection failed: {}", e),
}
}
@@ -1282,7 +1276,7 @@ impl RequestContext {
pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> {
let mut output = HashMap::new();
let role = self.extract_role_impl(app, false).unwrap_or_else(|err| {
let role = self.extract_role(app).unwrap_or_else(|err| {
warn!("failed to compute effective role for prompt rendering: {err}");
Role::default()
});
@@ -2340,17 +2334,6 @@ impl RequestContext {
}
_ => vec![],
};
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
if let Some(mcp_config) = &self.app.mcp_config {
values = super::map_completion_values(
mcp_config
.mcp_servers
.iter()
.filter(|(_, spec)| spec.is_remote())
.map(|(name, _)| name.clone())
.collect(),
);
}
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
@@ -3698,7 +3681,6 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
},
);
}
-8
View File
@@ -163,14 +163,6 @@ impl Session {
self.messages.is_empty() && self.compressed_messages.is_empty()
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn compressed_messages(&self) -> &[Message] {
&self.compressed_messages
}
pub fn name(&self) -> &str {
&self.name
}
-11
View File
@@ -68,14 +68,6 @@ fn normalize_version(requested: Option<String>) -> Option<String> {
}
}
fn preferred_update_target() -> Option<&'static str> {
match (env::consts::OS, env::consts::ARCH) {
("linux", "x86_64") => Some("x86_64-unknown-linux-musl"),
("linux", "aarch64") => Some("aarch64-unknown-linux-musl"),
_ => None,
}
}
fn is_dir_writable(dir: &Path) -> bool {
let probe = dir.join(format!(".coyote-update-write-test-{}", process::id()));
match OpenOptions::new().write(true).create_new(true).open(&probe) {
@@ -155,9 +147,6 @@ pub fn run_self_update(requested: Option<String>, force: bool) -> Result<()> {
if let Some(tag) = &target_tag {
builder.target_version_tag(tag.as_str());
}
if let Some(target) = preferred_update_target() {
builder.target(target);
}
let status = builder
.build()
.context("Failed to configure the self-update")?
+46 -721
View File
@@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
use std::{env, fs};
use anyhow::{Context, Result, anyhow, bail};
use chrono::Local;
use indexmap::IndexMap;
use serde_json::{Value, json};
@@ -98,32 +97,6 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
..Default::default()
},
),
(
"superseded_by".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Optional `name:` slug of the memory that replaces this one. \
`memory__lint` flags superseded files for cleanup. Omitting this \
on overwrite clears any previous value."
.into(),
),
..Default::default()
},
),
(
"expires".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Optional ISO date (YYYY-MM-DD) after which this memory is stale. \
`memory__lint` flags expired files. Omitting this on overwrite \
clears any previous value."
.into(),
),
..Default::default()
},
),
])),
required: Some(vec![
"name".to_string(),
@@ -191,90 +164,6 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
},
agent: false,
},
FunctionDeclaration {
name: format!("{MEMORY_FUNCTION_PREFIX}rename"),
description:
"Rename a memory file. Its MEMORY.md index entry and every [[wikilink]] to it in \
other memory files are rewritten automatically."
.to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
(
"name".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Current `name:` slug of the memory file".into()),
..Default::default()
},
),
(
"new_name".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"New kebab-case slug for the file (no extension)".into(),
),
..Default::default()
},
),
(
"scope".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
.into(),
),
..Default::default()
},
),
])),
required: Some(vec![
"name".to_string(),
"new_name".to_string(),
"scope".to_string(),
]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{MEMORY_FUNCTION_PREFIX}delete"),
description:
"Delete a memory file and remove its MEMORY.md index entry. Reports any \
[[wikilinks]] in other memory files left dangling by the deletion."
.to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
(
"name".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"The `name:` slug of the memory file to delete".into(),
),
..Default::default()
},
),
(
"scope".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
.into(),
),
..Default::default()
},
),
])),
required: Some(vec!["name".to_string(), "scope".to_string()]),
..Default::default()
},
agent: false,
},
]
}
@@ -325,13 +214,47 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
"workspace": store.workspace.as_ref().map(workspace_label),
}))
}
"write" => write_memory(&store, &cwd, args),
"rename" => rename_memory(&store, &cwd, args),
"delete" => delete_memory(&store, &cwd, args),
"write" => {
let name = arg_str(args, "name")?;
let description = arg_str(args, "description")?;
let content = arg_str(args, "content")?;
let scope = arg_str(args, "scope")?;
let kind = args.get("type").and_then(Value::as_str).map(String::from);
let target_dir = match scope.as_str() {
"global" => paths::global_memory_dir(),
"workspace" => workspace_write_dir(&store, &cwd)?,
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
};
let file = MemoryFile {
path: target_dir.join(format!("{name}.md")),
frontmatter: MemoryFrontmatter {
name: name.clone(),
description: Some(description.clone()),
kind,
},
body: content,
};
file.save()?;
let index_path = target_dir.join("MEMORY.md");
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
Ok(json!({
"status": "ok",
"path": file.path.display().to_string(),
"index_path": index_path.display().to_string(),
"index_updated": index_updated,
}))
}
"edit_index" => {
let scope = arg_str(args, "scope")?;
let content = arg_str(args, "content")?;
let target_dir = scope_dir(&store, &cwd, &scope)?;
let target_dir = match scope.as_str() {
"global" => paths::global_memory_dir(),
"workspace" => workspace_write_dir(&store, &cwd)?,
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
};
let index_path = write_memory_index(&target_dir, &content)?;
Ok(json!({
@@ -344,229 +267,19 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
}
}
fn write_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
let name = arg_str(args, "name")?;
let description = arg_str(args, "description")?;
let content = arg_str(args, "content")?;
let scope = arg_str(args, "scope")?;
let kind = args.get("type").and_then(Value::as_str).map(String::from);
let superseded_by = args
.get("superseded_by")
.and_then(Value::as_str)
.map(String::from);
let expires = args
.get("expires")
.and_then(Value::as_str)
.map(String::from);
let target_dir = scope_dir(store, cwd, &scope)?;
let path = target_dir.join(format!("{name}.md"));
let previous = if path.exists() {
MemoryFile::load(&path).ok()
} else {
None
};
let today = today_string();
let created = previous
.as_ref()
.and_then(|p| p.frontmatter.created.clone())
.unwrap_or_else(|| today.clone());
let file = MemoryFile {
path,
frontmatter: MemoryFrontmatter {
name: name.clone(),
description: Some(description.clone()),
kind,
created: Some(created),
updated: Some(today),
superseded_by,
expires,
},
body: content,
};
file.save()?;
let index_path = target_dir.join("MEMORY.md");
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
Ok(json!({
"status": "ok",
"path": file.path.display().to_string(),
"index_path": index_path.display().to_string(),
"index_updated": index_updated,
"replaced": previous.is_some(),
"previous_description": previous.and_then(|p| p.frontmatter.description),
}))
}
fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
let name = arg_str(args, "name")?;
let new_name = arg_str(args, "new_name")?;
let scope = arg_str(args, "scope")?;
if new_name.is_empty()
|| !new_name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
bail!(
"invalid new_name '{}': use a kebab-case slug (alphanumeric, hyphens, underscores)",
new_name
);
}
if name == new_name {
bail!("new_name matches the current name");
}
let target_dir = scope_dir(store, cwd, &scope)?;
let files = store.list_files()?;
let file = files
.iter()
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?
.clone();
if target_dir.join(format!("{new_name}.md")).exists()
|| files
.iter()
.any(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == new_name)
{
bail!(
"memory file '{}' already exists in scope '{}'",
new_name,
scope
);
}
let needle = format!("[[{name}]]");
let replacement = format!("[[{new_name}]]");
let mut renamed = file.clone();
renamed.path = target_dir.join(format!("{new_name}.md"));
renamed.frontmatter.name = new_name.clone();
renamed.frontmatter.updated = Some(today_string());
renamed.body = renamed.body.replace(&needle, &replacement);
renamed.save()?;
fs::remove_file(&file.path).with_context(|| format!("remove {}", file.path.display()))?;
let mut rewritten = Vec::new();
for f in &files {
if f.path == file.path || !f.body.contains(&needle) {
continue;
}
let mut updated = f.clone();
updated.body = updated.body.replace(&needle, &replacement);
updated.save()?;
rewritten.push(f.frontmatter.name.clone());
}
// Own-scope index: rewrite the wikilink, drop any leftover references to the
// old name, and guarantee the new name is present.
let index_path = target_dir.join("MEMORY.md");
if let Ok(existing) = fs::read_to_string(&index_path)
&& existing.contains(&needle)
{
fs::write(&index_path, existing.replace(&needle, &replacement))?;
}
remove_index_entry(&index_path, &name)?;
let description = renamed.frontmatter.description.clone().unwrap_or_default();
ensure_index_entry(&index_path, &new_name, &description)?;
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only.
for other_index in other_index_paths(store, &target_dir) {
if let Ok(existing) = fs::read_to_string(&other_index)
&& existing.contains(&needle)
{
fs::write(&other_index, existing.replace(&needle, &replacement))?;
}
}
Ok(json!({
"status": "ok",
"old_path": file.path.display().to_string(),
"new_path": renamed.path.display().to_string(),
"rewritten_references": rewritten,
}))
}
fn delete_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
let name = arg_str(args, "name")?;
let scope = arg_str(args, "scope")?;
let target_dir = scope_dir(store, cwd, &scope)?;
let files = store.list_files()?;
let file = files
.iter()
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?;
let deleted_path = file.path.clone();
fs::remove_file(&deleted_path).with_context(|| format!("delete {}", deleted_path.display()))?;
let index_path = target_dir.join("MEMORY.md");
let index_updated = remove_index_entry(&index_path, &name)?;
let dangling: Vec<String> = files
.iter()
.filter(|f| f.path != deleted_path && extract_wikilinks(&f.body).iter().any(|l| l == &name))
.map(|f| f.frontmatter.name.clone())
.collect();
Ok(json!({
"status": "ok",
"deleted_path": deleted_path.display().to_string(),
"index_updated": index_updated,
"dangling_references": dangling,
}))
}
fn scope_dir(store: &MemoryStore, cwd: &Path, scope: &str) -> Result<PathBuf> {
match scope {
"global" => Ok(paths::global_memory_dir()),
"workspace" => workspace_write_dir(store, cwd),
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
}
}
fn today_string() -> String {
Local::now().format("%Y-%m-%d").to_string()
}
fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let global_index = store.global_dir.join("MEMORY.md");
if store.global_dir.as_path() != own_dir && global_index.exists() {
out.push(global_index);
}
match &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => {
let index = dir.join("MEMORY.md");
if dir.as_path() != own_dir && index.exists() {
out.push(index);
}
}
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
out.push(file.clone());
}
_ => {}
}
out
}
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
fs::create_dir_all(target_dir)?;
let index_path = target_dir.join("MEMORY.md");
fs::write(&index_path, content)?;
Ok(index_path)
}
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
let existing = fs::read_to_string(index_path).unwrap_or_default();
if index_references(&existing, name) {
let already_referenced =
existing.contains(&format!("[[{name}]]")) || existing.contains(&format!("{name}.md"));
if already_referenced {
return Ok(false);
}
@@ -584,40 +297,6 @@ fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Resul
}
fs::write(index_path, new_content)?;
Ok(true)
}
fn line_references(line: &str, name: &str) -> bool {
let file_name = format!("{name}.md");
line.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_' || c == '.'))
.any(|token| token == file_name || token.trim_matches('.') == name)
}
fn index_references(index: &str, name: &str) -> bool {
index.lines().any(|line| line_references(line, name))
}
fn remove_index_entry(index_path: &Path, name: &str) -> Result<bool> {
let Ok(existing) = fs::read_to_string(index_path) else {
return Ok(false);
};
let kept: Vec<&str> = existing
.lines()
.filter(|line| !line_references(line, name))
.collect();
let mut new_content = kept.join("\n");
if existing.ends_with('\n') && !new_content.is_empty() {
new_content.push('\n');
}
if new_content == existing {
return Ok(false);
}
fs::write(index_path, new_content)?;
Ok(true)
}
@@ -671,11 +350,9 @@ fn workspace_label(w: &WorkspaceMemory) -> Value {
fn lint_memory(store: &MemoryStore) -> Result<Value> {
let files = store.list_files()?;
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
let today = today_string();
let mut oversized = Vec::new();
let mut broken_links = Vec::new();
let mut stale = Vec::new();
for f in &files {
if f.char_len() > PER_FILE_SOFT_CAP {
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
@@ -685,54 +362,16 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
}
}
if let Some(target) = &f.frontmatter.superseded_by {
stale.push(json!({
"name": &f.frontmatter.name,
"reason": "superseded",
"superseded_by": target,
"target_exists": names.contains(target.as_str()),
}));
}
if let Some(expires) = &f.frontmatter.expires
&& expires.as_str() < today.as_str()
{
stale.push(json!({
"name": &f.frontmatter.name,
"reason": "expired",
"expires": expires,
}));
}
}
let global_index = store.load_global_index()?.unwrap_or_default();
let workspace_index = store
.load_workspace_index()
.ok()
.flatten()
let index_content = store
.load_global_index()?
.or_else(|| store.load_workspace_index().ok().flatten())
.unwrap_or_default();
let mut orphans = Vec::new();
let mut description_drift = Vec::new();
for f in &files {
let index = if f.path.starts_with(&store.global_dir) {
&global_index
} else {
&workspace_index
};
if !index_references(index, &f.frontmatter.name) {
if !index_content.contains(&f.frontmatter.name) {
orphans.push(f.frontmatter.name.clone());
} else if let (Some(index_desc), Some(file_desc)) = (
index_description(index, &f.frontmatter.name),
f.frontmatter.description.as_deref(),
) && index_desc != file_desc
{
description_drift.push(json!({
"name": &f.frontmatter.name,
"index_description": index_desc,
"file_description": file_desc,
}));
}
}
@@ -741,26 +380,13 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
"oversized": oversized,
"broken_wikilinks": broken_links,
"orphans": orphans,
"stale": stale,
"description_drift": description_drift,
}))
}
fn index_description(index: &str, name: &str) -> Option<String> {
let marker = format!("[[{name}]]");
index.lines().find_map(|line| {
let pos = line.find(&marker)?;
let rest = line[pos + marker.len()..].trim_start();
let desc = rest.strip_prefix(':')?.trim();
(!desc.is_empty()).then(|| desc.to_string())
})
}
fn extract_wikilinks(body: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = body.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'['
&& bytes[i + 1] == b'['
@@ -1050,305 +676,4 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn line_references_requires_exact_token_match() {
assert!(line_references("- [[auth]]: description", "auth"));
assert!(line_references("- auth.md is here", "auth"));
assert!(line_references("- referenced", "referenced"));
assert!(line_references("see auth.", "auth"));
assert!(!line_references("- [[auth-flow]]: description", "auth"));
assert!(!line_references("- oauth.md legacy", "auth"));
assert!(!line_references("- preauth notes", "auth"));
}
#[test]
fn remove_index_entry_drops_only_matching_lines() {
let root = temp_root("index_remove");
let index = root.join("MEMORY.md");
fs::write(
&index,
"# Memory Index\n\n- [[keep]]: stays\n- [[gone]]: removed\n",
)
.unwrap();
assert!(remove_index_entry(&index, "gone").unwrap());
let content = fs::read_to_string(&index).unwrap();
assert!(content.contains("[[keep]]"));
assert!(!content.contains("[[gone]]"));
assert!(!remove_index_entry(&index, "gone").unwrap());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn lint_checks_orphans_against_own_scope_index() {
let root = temp_root("lint_scopes");
let global = root.join("global");
fs::create_dir_all(&global).unwrap();
fs::write(global.join("MEMORY.md"), "- [[global-note]]: g\n").unwrap();
fs::write(
global.join("global-note.md"),
"---\nname: global-note\n---\ng\n",
)
.unwrap();
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join("MEMORY.md"), "- [[ws-note]]: w\n").unwrap();
fs::write(
structured.join("ws-note.md"),
"---\nname: ws-note\n---\nw\n",
)
.unwrap();
let store = MemoryStore {
global_dir: global,
workspace: discover_workspace_memory(&workspace),
};
let report = lint_memory(&store).unwrap();
assert!(
report["orphans"].as_array().unwrap().is_empty(),
"expected no orphans, got: {report}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn lint_flags_stale_and_description_drift() {
let root = temp_root("lint_stale");
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(
structured.join("MEMORY.md"),
"- [[old-plan]]: old\n- [[bygone]]: e\n- [[drifted]]: index says this\n",
)
.unwrap();
fs::write(
structured.join("old-plan.md"),
"---\nname: old-plan\nsuperseded_by: new-plan\n---\nx\n",
)
.unwrap();
fs::write(
structured.join("bygone.md"),
"---\nname: bygone\nexpires: 2000-01-01\n---\nx\n",
)
.unwrap();
fs::write(
structured.join("drifted.md"),
"---\nname: drifted\ndescription: file says that\n---\nx\n",
)
.unwrap();
let store = MemoryStore {
global_dir: root.join("nonexistent_global"),
workspace: discover_workspace_memory(&workspace),
};
let report = lint_memory(&store).unwrap();
let stale = report["stale"].as_array().unwrap();
let reasons: Vec<(&str, &str)> = stale
.iter()
.map(|v| (v["name"].as_str().unwrap(), v["reason"].as_str().unwrap()))
.collect();
assert!(reasons.contains(&("old-plan", "superseded")));
assert!(reasons.contains(&("bygone", "expired")));
let superseded = stale.iter().find(|v| v["name"] == "old-plan").unwrap();
assert_eq!(superseded["target_exists"], false);
let drift = report["description_drift"].as_array().unwrap();
assert_eq!(drift.len(), 1);
assert_eq!(drift[0]["name"], "drifted");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn delete_memory_removes_file_index_entry_and_reports_dangling() {
let root = temp_root("delete");
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(
structured.join("MEMORY.md"),
"# Memory Index\n\n- [[doomed]]: bye\n- [[linker]]: links\n",
)
.unwrap();
fs::write(
structured.join("doomed.md"),
"---\nname: doomed\n---\nbye\n",
)
.unwrap();
fs::write(
structured.join("linker.md"),
"---\nname: linker\n---\nsee [[doomed]]\n",
)
.unwrap();
let store = MemoryStore {
global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace),
};
let args = json!({"name": "doomed", "scope": "workspace"});
let result = delete_memory(&store, &workspace, &args).unwrap();
assert_eq!(result["status"], "ok");
assert_eq!(result["index_updated"], true);
assert!(!structured.join("doomed.md").exists());
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
assert!(!index.contains("doomed"));
assert!(index.contains("[[linker]]"));
assert_eq!(
result["dangling_references"].as_array().unwrap(),
&vec![json!("linker")]
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_memory_moves_file_and_rewrites_references() {
let root = temp_root("rename");
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(
structured.join("MEMORY.md"),
"# Memory Index\n\n- [[old-name]]: the plan\n- [[linker]]: links\n",
)
.unwrap();
fs::write(
structured.join("old-name.md"),
"---\nname: old-name\ndescription: the plan\n---\nself link [[old-name]]\n",
)
.unwrap();
fs::write(
structured.join("linker.md"),
"---\nname: linker\n---\nsee [[old-name]] and [[old-name-extended]]\n",
)
.unwrap();
let store = MemoryStore {
global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace),
};
let args = json!({"name": "old-name", "new_name": "new-name", "scope": "workspace"});
let result = rename_memory(&store, &workspace, &args).unwrap();
assert_eq!(result["status"], "ok");
assert!(!structured.join("old-name.md").exists());
let renamed = MemoryFile::load(&structured.join("new-name.md")).unwrap();
assert_eq!(renamed.frontmatter.name, "new-name");
assert!(renamed.body.contains("[[new-name]]"));
let linker = fs::read_to_string(structured.join("linker.md")).unwrap();
assert!(linker.contains("[[new-name]]"));
assert!(
linker.contains("[[old-name-extended]]"),
"unrelated links must be untouched: {linker}"
);
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
assert!(index.contains("- [[new-name]]: the plan"));
assert!(!index.contains("[[old-name]]"));
assert!(index.contains("[[linker]]"));
assert_eq!(
result["rewritten_references"].as_array().unwrap(),
&vec![json!("linker")]
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_memory_rejects_collisions_and_bad_slugs() {
let root = temp_root("rename_guard");
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join("MEMORY.md"), "- [[a]]: a\n- [[b]]: b\n").unwrap();
fs::write(structured.join("a.md"), "---\nname: a\n---\nx\n").unwrap();
fs::write(structured.join("b.md"), "---\nname: b\n---\nx\n").unwrap();
let store = MemoryStore {
global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace),
};
let collision = json!({"name": "a", "new_name": "b", "scope": "workspace"});
let err = rename_memory(&store, &workspace, &collision).unwrap_err();
assert!(err.to_string().contains("already exists"));
let bad_slug = json!({"name": "a", "new_name": "bad name!", "scope": "workspace"});
let err = rename_memory(&store, &workspace, &bad_slug).unwrap_err();
assert!(err.to_string().contains("invalid new_name"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn write_memory_stamps_timestamps_and_reports_replacement() {
let root = temp_root("write_stamps");
let workspace = root.join("ws");
let structured = workspace.join(".coyote").join("memory");
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join("MEMORY.md"), "# Memory Index\n").unwrap();
let store = MemoryStore {
global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace),
};
let first = json!({
"name": "fact",
"description": "first version",
"content": "body v1",
"scope": "workspace",
"expires": "2099-01-01",
});
let before = today_string();
let result = write_memory(&store, &workspace, &first).unwrap();
let after = today_string();
assert_eq!(result["replaced"], false);
assert_eq!(result["previous_description"], Value::Null);
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
let created = saved.frontmatter.created.clone().expect("created stamped");
assert!(
created == before || created == after,
"created '{created}' should be stamped with today's date"
);
assert_eq!(saved.frontmatter.updated, Some(created.clone()));
assert_eq!(saved.frontmatter.expires.as_deref(), Some("2099-01-01"));
assert_eq!(saved.frontmatter.superseded_by, None);
let second = json!({
"name": "fact",
"description": "second version",
"content": "body v2",
"scope": "workspace",
});
let result = write_memory(&store, &workspace, &second).unwrap();
assert_eq!(result["replaced"], true);
assert_eq!(result["previous_description"], "first version");
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
assert_eq!(
saved.frontmatter.created,
Some(created),
"creation date must be preserved across overwrites"
);
assert!(saved.frontmatter.updated.is_some());
assert_eq!(saved.frontmatter.expires, None);
let _ = fs::remove_dir_all(&root);
}
}
+6 -69
View File
@@ -28,22 +28,18 @@ use crate::config::{
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
};
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error};
use crate::repl::Repl;
use crate::utils::*;
use crate::vault::{Vault, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
use crate::vault::Vault;
use anyhow::{Result, anyhow, bail};
use clap::{CommandFactory, Parser};
use clap_complete::CompleteEnv;
use client::ClientConfig;
use inquire::{Select, Text, set_global_render_config};
use log::{LevelFilter, warn};
use log4rs::append::console::ConsoleAppender;
use log4rs::append::rolling_file::RollingFileAppender;
use log4rs::append::rolling_file::policy::compound::CompoundPolicy;
use log4rs::append::rolling_file::policy::compound::roll::fixed_window::FixedWindowRoller;
use log4rs::append::rolling_file::policy::compound::trigger::size::SizeTrigger;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use oauth::OAuthProvider;
@@ -121,49 +117,6 @@ async fn main() -> Result<()> {
return Ok(());
}
if let Some(server_name) = &cli.auth_mcp {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?;
let mcp_path = paths::mcp_config_file();
if !mcp_path.exists() {
bail!(
"No MCP configuration file found at '{}'",
mcp_path.display()
);
}
let raw = tokio::fs::read_to_string(&mcp_path)
.await
.with_context(|| format!("Failed to read MCP config at '{}'", mcp_path.display()))?;
let (content, missing) = interpolate_secrets(&raw, &vault)?;
if !missing.is_empty() {
bail!(
"MCP config references vault secrets that are missing: {:?}",
missing
);
}
let mcp_config: McpServersConfig =
serde_json::from_str(&content).context("Failed to parse MCP config file")?;
let spec = mcp_config
.mcp_servers
.get(server_name.as_str())
.ok_or_else(|| anyhow!("MCP server '{server_name}' not found in mcp.json"))?;
if !spec.is_remote() {
bail!(
"MCP server '{server_name}' is a stdio server; OAuth is only supported for http/sse servers"
);
}
let url = spec.url.as_deref().expect("validated: remote spec has url");
mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?;
println!("Authentication saved. '{server_name}' is now available for use.");
return Ok(());
}
if vault_flags {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
@@ -184,10 +137,7 @@ async fn main() -> Result<()> {
)
.await?,
);
let mut ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
let app_config = Arc::clone(&ctx.app.config);
ctx.bootstrap_tools(&app_config, start_mcp_servers, abort_signal.clone())
.await?;
let ctx = RequestContext::bootstrap(app_state, working_mode, info_flag)?;
{
let app = &*ctx.app.config;
@@ -632,20 +582,7 @@ fn setup_logger() -> Result<Option<PathBuf>> {
}
Some(path) => {
ensure_parent_exists(&path)?;
let archive_pattern = path
.with_extension("archived.{}.log")
.to_string_lossy()
.into_owned();
let trigger = SizeTrigger::new(10 * 1024 * 1024);
let roller = FixedWindowRoller::builder()
.build(&archive_pattern, 5)
.unwrap();
let policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
let file_appender = RollingFileAppender::builder()
.encoder(encoder.clone())
.build(path, Box::new(policy));
let file_appender = FileAppender::builder().encoder(encoder.clone()).build(path);
match file_appender {
Ok(appender) => {
@@ -668,7 +605,7 @@ fn setup_logger() -> Result<Option<PathBuf>> {
fn init_file_logger(
log_level: LevelFilter,
log_filter: Option<String>,
file_appender: RollingFileAppender,
file_appender: FileAppender,
) -> log4rs::Config {
let root_log_level = if log_filter.is_some() {
LevelFilter::Off
+11 -172
View File
@@ -1,4 +1,3 @@
pub(crate) mod oauth;
mod sse_transport;
use crate::config::AppConfig;
@@ -74,8 +73,6 @@ pub(crate) struct McpServer {
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_client_id: Option<String>,
}
impl McpServer {
@@ -110,10 +107,10 @@ impl McpServer {
"MCP server '{name}' is missing a \"command\" field (required for stdio transport)"
));
}
if self.url.is_some() || self.headers.is_some() || self.oauth_client_id.is_some() {
if self.url.is_some() || self.headers.is_some() {
return Err(anyhow!(
"MCP server '{name}' has type \"stdio\" but also specifies remote fields \
(url/headers/oauth_client_id). Remove the remote fields or change the type to \"http\" or \"sse\"."
(url/headers). Remove the remote fields or change the type to \"http\" or \"sse\"."
));
}
}
@@ -240,7 +237,7 @@ impl McpRegistry {
debug!("Starting selected MCP servers: {:?}", ids_to_start);
let results: Vec<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> = stream::iter(
let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter(
ids_to_start
.into_iter()
.map(|id| async { self.start_server(id).await }),
@@ -249,7 +246,7 @@ impl McpRegistry {
.try_collect()
.await?;
for (id, server, catalog) in results.into_iter().flatten() {
for (id, server, catalog) in results {
self.servers.insert(id.clone(), server);
self.catalogs.insert(id, catalog);
}
@@ -260,30 +257,14 @@ impl McpRegistry {
async fn start_server(
&self,
id: String,
) -> Result<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> {
) -> Result<(String, Arc<ConnectedServer>, ServerCatalog)> {
let spec = self
.config
.as_ref()
.and_then(|c| c.mcp_servers.get(&id))
.with_context(|| format!("MCP server not found in config: {id}"))?;
let bearer_token = if spec.is_remote() {
oauth::load_valid_mcp_token(&id)
} else {
None
};
let service = match spawn_mcp_server(spec, self.log_path.as_deref(), bearer_token).await {
Ok(s) => s,
Err(e) if is_auth_required_error(&e) => {
warn!(
"MCP server '{id}' requires OAuth authentication. \
Run `.mcp auth {id}` in the REPL to authenticate, then restart Coyote."
);
return Ok(None);
}
Err(e) => return Err(e),
};
let service = spawn_mcp_server(spec, self.log_path.as_deref()).await?;
let tools = service.list_tools(None).await?;
debug!("Available tools for MCP server {id}: {tools:?}");
@@ -308,7 +289,7 @@ impl McpRegistry {
info!("Started MCP server: {id}");
Ok(Some((id.to_string(), service, catalog)))
Ok((id.to_string(), service, catalog))
}
fn resolve_server_ids(&self, enabled_mcp_servers: Option<Vec<String>>) -> Vec<String> {
@@ -356,18 +337,15 @@ impl McpRegistry {
pub(crate) async fn spawn_mcp_server(
spec: &McpServer,
log_path: Option<&Path>,
bearer_token: Option<String>,
) -> Result<Arc<ConnectedServer>> {
match spec.transport_type {
McpTransportType::Http => {
let url = spec.url.as_deref().expect("validated: http spec has url");
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
spawn_http_mcp_server(url, headers.as_ref()).await
spawn_http_mcp_server(url, spec.headers.as_ref()).await
}
McpTransportType::Sse => {
let url = spec.url.as_deref().expect("validated: sse spec has url");
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
spawn_sse_mcp_server(url, headers.as_ref()).await
spawn_sse_mcp_server(url, spec.headers.as_ref()).await
}
McpTransportType::Stdio => {
let command = spec
@@ -379,30 +357,6 @@ pub(crate) async fn spawn_mcp_server(
}
}
fn merge_bearer_token(
headers: Option<&IndexMap<String, String>>,
bearer_token: Option<String>,
) -> Option<IndexMap<String, String>> {
match (headers, bearer_token) {
(None, None) => None,
(Some(h), None) => Some(h.clone()),
(None, Some(token)) => {
let mut m = IndexMap::new();
m.insert("Authorization".to_string(), format!("Bearer {token}"));
Some(m)
}
(Some(h), Some(token)) => {
let mut m = h.clone();
m.insert("Authorization".to_string(), format!("Bearer {token}"));
Some(m)
}
}
}
fn is_auth_required_error(e: &anyhow::Error) -> bool {
e.to_string().contains("Auth required")
}
async fn spawn_http_mcp_server(
url: &str,
headers: Option<&IndexMap<String, String>>,
@@ -479,12 +433,8 @@ async fn spawn_stdio_mcp_server(
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.with_context(|| format!("Failed to open MCP log file at '{}'", log_path.display()))?;
let (transport, _) = TokioChildProcess::builder(cmd)
.stderr(log_file)
.spawn()
.with_context(|| format!("Failed to spawn MCP server: {command}"))?;
.open(log_path)?;
let (transport, _) = TokioChildProcess::builder(cmd).stderr(log_file).spawn()?;
transport
} else {
TokioChildProcess::new(cmd)?
@@ -511,7 +461,6 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
}
}
@@ -524,7 +473,6 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers: None,
oauth_client_id: None,
}
}
@@ -537,7 +485,6 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers: None,
oauth_client_id: None,
}
}
@@ -555,7 +502,6 @@ mod tests {
#[test]
fn validate_stdio_with_command_succeeds() {
let spec = stdio_server("npx");
assert!(spec.validate("test").is_ok());
}
@@ -569,11 +515,8 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"command\" field"));
}
@@ -587,11 +530,8 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
@@ -607,18 +547,14 @@ mod tests {
cwd: None,
url: None,
headers: Some(headers),
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
#[test]
fn validate_http_with_url_succeeds() {
let spec = http_server("http://localhost:8080");
assert!(spec.validate("test").is_ok());
}
@@ -632,11 +568,8 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
@@ -650,11 +583,8 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
@@ -668,11 +598,8 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
@@ -686,18 +613,14 @@ mod tests {
cwd: Some("/tmp".into()),
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
#[test]
fn validate_sse_with_url_succeeds() {
let spec = sse_server("http://sse.example.com");
assert!(spec.validate("test").is_ok());
}
@@ -711,11 +634,8 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
@@ -741,13 +661,9 @@ mod tests {
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.contains_key("my-server"));
let spec = &config.mcp_servers["my-server"];
assert_eq!(spec.transport_type, McpTransportType::Stdio);
assert_eq!(spec.command.as_deref(), Some("npx"));
assert_eq!(
@@ -768,9 +684,7 @@ mod tests {
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let spec = &config.mcp_servers["remote"];
assert_eq!(spec.transport_type, McpTransportType::Http);
assert_eq!(spec.url.as_deref(), Some("http://localhost:8080/mcp"));
assert_eq!(
@@ -795,9 +709,7 @@ mod tests {
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let env = config.mcp_servers["s"].env.as_ref().unwrap();
assert!(matches!(env["STR_VAR"], JsonField::Str(ref s) if s == "hello"));
assert!(matches!(env["BOOL_VAR"], JsonField::Bool(true)));
assert!(matches!(env["INT_VAR"], JsonField::Int(42)));
@@ -811,9 +723,7 @@ mod tests {
"remote-api": { "type": "http", "url": "http://api.example.com" }
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(config.mcp_servers.contains_key("github"));
assert!(config.mcp_servers.contains_key("remote-api"));
@@ -822,9 +732,7 @@ mod tests {
#[test]
fn deserialize_empty_servers_map() {
let json = r#"{ "mcpServers": {} }"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.is_empty());
}
@@ -839,96 +747,77 @@ mod tests {
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers["s"].cwd.as_deref(), Some("/tmp/work"));
}
#[test]
fn resolve_all_returns_all_configured_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
ids.sort();
assert_eq!(ids, vec!["github", "jira", "slack"]);
}
#[test]
fn resolve_comma_separated_returns_matching_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids =
registry.resolve_server_ids(Some(vec!["github".to_string(), "jira".to_string()]));
ids.sort();
assert_eq!(ids, vec!["github", "jira"]);
}
#[test]
fn resolve_single_server_name() {
let registry = make_registry_with_config(&["github", "slack"]);
let ids = registry.resolve_server_ids(Some(vec!["slack".to_string()]));
assert_eq!(ids, vec!["slack"]);
}
#[test]
fn resolve_none_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(None);
assert!(ids.is_empty());
}
#[test]
fn resolve_no_config_returns_empty() {
let registry = McpRegistry::default();
let ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
assert!(ids.is_empty());
}
#[test]
fn resolve_nonexistent_server_filtered_out() {
let registry = make_registry_with_config(&["github"]);
let ids = registry
.resolve_server_ids(Some(vec!["github".to_string(), "nonexistent".to_string()]));
assert_eq!(ids, vec!["github"]);
}
#[test]
fn resolve_all_nonexistent_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(Some(vec!["foo".to_string(), "bar".to_string()]));
assert!(ids.is_empty());
}
#[test]
fn resolve_trims_whitespace() {
let registry = make_registry_with_config(&["github", "slack"]);
let mut ids = registry.resolve_server_ids(Some(vec![
" github ".to_string(),
" slack ".to_string(),
]));
ids.sort();
assert_eq!(ids, vec!["github", "slack"]);
}
#[test]
fn registry_default_is_empty() {
let registry = McpRegistry::default();
assert!(registry.is_empty());
assert!(registry.list_started_servers().is_empty());
assert!(registry.mcp_config().is_none());
@@ -938,7 +827,6 @@ mod tests {
#[test]
fn registry_with_config_reports_config() {
let registry = make_registry_with_config(&["github"]);
assert!(registry.mcp_config().is_some());
assert!(
registry
@@ -955,53 +843,4 @@ mod tests {
assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search");
assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe");
}
#[test]
fn merge_bearer_token_both_none_returns_none() {
assert!(merge_bearer_token(None, None).is_none());
}
#[test]
fn merge_bearer_token_headers_only_passes_through() {
let mut h = IndexMap::new();
h.insert("X-Key".to_string(), "val".to_string());
let result = merge_bearer_token(Some(&h), None).unwrap();
assert_eq!(result["X-Key"], "val");
assert!(!result.contains_key("Authorization"));
}
#[test]
fn merge_bearer_token_token_only_injects_bearer() {
let result = merge_bearer_token(None, Some("tok123".to_string())).unwrap();
assert_eq!(result["Authorization"], "Bearer tok123");
}
#[test]
fn merge_bearer_token_both_merges_and_overrides_authorization() {
let mut h = IndexMap::new();
h.insert("Authorization".to_string(), "old".to_string());
h.insert("X-Custom".to_string(), "keep".to_string());
let result = merge_bearer_token(Some(&h), Some("newtoken".to_string())).unwrap();
assert_eq!(result["Authorization"], "Bearer newtoken");
assert_eq!(result["X-Custom"], "keep");
}
#[test]
fn is_auth_required_error_matches_rmcp_message() {
let e = anyhow!("Auth required, when send initialize request");
assert!(is_auth_required_error(&e));
}
#[test]
fn is_auth_required_error_does_not_match_unrelated() {
let e = anyhow!("Connection refused");
assert!(!is_auth_required_error(&e));
}
}
-329
View File
@@ -1,329 +0,0 @@
use crate::client::oauth::{OAuthProvider, TokenRequestFormat, load_oauth_tokens, run_oauth_flow};
use crate::config::paths;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use inquire::Text;
use log::warn;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::net::TcpListener;
use url::Url;
#[derive(Debug, Deserialize)]
struct ProtectedResourceMetadata {
#[serde(default)]
authorization_servers: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OAuthServerMetadata {
authorization_endpoint: String,
token_endpoint: String,
#[serde(default)]
scopes_supported: Vec<String>,
registration_endpoint: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct McpRegistration {
client_id: String,
}
struct McpOAuthProvider {
client_id: String,
authorize_url: String,
token_url: String,
scopes: String,
fixed_redirect: String,
}
impl OAuthProvider for McpOAuthProvider {
fn provider_name(&self) -> &str {
"MCP"
}
fn client_id(&self) -> &str {
&self.client_id
}
fn authorize_url(&self) -> &str {
&self.authorize_url
}
fn token_url(&self) -> &str {
&self.token_url
}
fn redirect_uri(&self) -> &str {
""
}
fn scopes(&self) -> &str {
&self.scopes
}
fn token_request_format(&self) -> TokenRequestFormat {
TokenRequestFormat::FormUrlEncoded
}
fn uses_localhost_redirect(&self) -> bool {
false
}
fn fixed_redirect_uri(&self) -> Option<String> {
Some(self.fixed_redirect.clone())
}
}
pub async fn run_mcp_oauth_flow(
server_name: &str,
server_url: &str,
configured_client_id: Option<&str>,
) -> Result<()> {
let metadata = discover_oauth_metadata(server_url).await?;
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
let client_id = if let Some(id) = configured_client_id {
id.to_string()
} else if let Some(cached) = load_registered_client_id(server_name) {
cached
} else if let Some(reg_endpoint) = &metadata.registration_endpoint {
match register_client(reg_endpoint, &redirect_uri).await {
Ok(id) => {
let _ = save_registered_client_id(server_name, &id);
id
}
Err(e) => {
warn!("Dynamic client registration failed: {e}. Falling back to manual entry.");
Text::new("Enter the OAuth client ID for this MCP server:")
.prompt()
.context("Failed to read client ID")?
}
}
} else {
Text::new("Enter the OAuth client ID for this MCP server:")
.prompt()
.context("Failed to read client ID")?
};
let provider = McpOAuthProvider {
client_id,
authorize_url: metadata.authorization_endpoint,
token_url: metadata.token_endpoint,
scopes: metadata.scopes_supported.join(" "),
fixed_redirect: redirect_uri,
};
run_oauth_flow(&provider, &mcp_token_key(server_name)).await
}
pub fn load_valid_mcp_token(server_name: &str) -> Option<String> {
let tokens = load_oauth_tokens(&mcp_token_key(server_name))?;
if Utc::now().timestamp() < tokens.expires_at {
Some(tokens.access_token)
} else {
None
}
}
fn mcp_token_key(server_name: &str) -> String {
format!("mcp_{server_name}")
}
fn load_registered_client_id(server_name: &str) -> Option<String> {
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json"));
let content = fs::read_to_string(path).ok()?;
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
Some(reg.client_id)
}
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
let dir = paths::oauth_tokens_path();
fs::create_dir_all(&dir)?;
let path = dir.join(format!("mcp_{server_name}_registration.json"));
let reg = McpRegistration {
client_id: client_id.to_string(),
};
fs::write(path, serde_json::to_string_pretty(&reg)?)?;
Ok(())
}
async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
let body = serde_json::json!({
"client_name": "Coyote",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
});
let response: serde_json::Value = Client::new()
.post(endpoint)
.json(&body)
.send()
.await
.context("Failed to reach registration endpoint")?
.json()
.await
.context("Failed to parse registration response")?;
response["client_id"]
.as_str()
.ok_or_else(|| anyhow!("Missing client_id in registration response: {response}"))
.map(|s| s.to_string())
}
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
let base = extract_base_url(server_url)?;
let client = Client::new();
// RFC 9728: try protected resource metadata first; it points to the auth server
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
if let Ok(resp) = client.get(&pr_url).send().await
&& resp.status().is_success()
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
&& let Some(auth_server) = pr.authorization_servers.first()
{
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
{
return Ok(meta);
}
}
let as_url = format!("{base}/.well-known/oauth-authorization-server");
let resp = client
.get(&as_url)
.send()
.await
.with_context(|| format!("Failed to reach {as_url}"))?;
if resp.status().is_success() {
return resp
.json::<OAuthServerMetadata>()
.await
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
}
Err(anyhow!(
"Could not discover OAuth metadata for '{server_url}'.\n\
Tried:\n {pr_url}\n {as_url}\n\
Ensure the server supports MCP OAuth discovery, or consult its documentation."
))
}
fn extract_base_url(url: &str) -> Result<String> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let scheme = parsed.scheme();
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("No host in URL: {url}"))?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
Ok(format!("{scheme}://{host}{port}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::get_env_name;
use serial_test::serial;
use std::{
env, fs,
time::{self, SystemTime},
};
fn with_temp_cache<F: FnOnce()>(f: F) {
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-mcp-oauth-test-{unique}"));
fs::create_dir_all(&root).unwrap();
let env_key = get_env_name("cache_dir");
let prev = env::var_os(&env_key);
unsafe {
env::set_var(&env_key, &root);
}
f();
unsafe {
match prev {
Some(v) => env::set_var(&env_key, v),
None => env::remove_var(&env_key),
}
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn extract_base_url_strips_path_and_query() {
let result = extract_base_url("https://mcp.notion.com/mcp?foo=bar").unwrap();
assert_eq!(result, "https://mcp.notion.com");
}
#[test]
fn extract_base_url_preserves_explicit_port() {
let result = extract_base_url("http://localhost:8080/mcp").unwrap();
assert_eq!(result, "http://localhost:8080");
}
#[test]
fn extract_base_url_standard_port_omitted() {
let result = extract_base_url("https://example.com/mcp/v1").unwrap();
assert_eq!(result, "https://example.com");
}
#[test]
fn extract_base_url_rejects_invalid_url() {
assert!(extract_base_url("not-a-url").is_err());
}
#[test]
#[serial]
fn registered_client_id_roundtrip() {
with_temp_cache(|| {
save_registered_client_id("notion", "client-xyz-123").unwrap();
let loaded = load_registered_client_id("notion");
assert_eq!(loaded, Some("client-xyz-123".to_string()));
});
}
#[test]
#[serial]
fn load_registered_client_id_returns_none_for_missing() {
with_temp_cache(|| {
let loaded = load_registered_client_id("no-such-server");
assert!(loaded.is_none());
});
}
#[test]
#[serial]
fn registered_client_id_second_save_overwrites_first() {
with_temp_cache(|| {
save_registered_client_id("github", "first-id").unwrap();
save_registered_client_id("github", "second-id").unwrap();
let loaded = load_registered_client_id("github");
assert_eq!(loaded, Some("second-id".to_string()));
});
}
}
+8 -163
View File
@@ -6,10 +6,7 @@ use self::completer::ReplCompleter;
use self::highlighter::ReplHighlighter;
use self::prompt::ReplPrompt;
use crate::client::{
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
oauth,
};
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
use crate::config::{
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
macro_execute,
@@ -23,7 +20,7 @@ use crate::utils::{
};
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::{config, graph, mcp, resolve_oauth_client};
use crate::{config, graph, resolve_oauth_client};
use anyhow::{Context, Result, bail};
use crossterm::cursor::SetCursorStyle;
use fancy_regex::Regex;
@@ -32,9 +29,9 @@ use log::warn;
use parking_lot::RwLock;
use reedline::CursorConfig;
use reedline::{
ColumnarMenu, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
Keybindings, Reedline, ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi,
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
ColumnarMenu, EditCommand, EditMode, Emacs, KeyCode, KeyModifiers, Keybindings, Reedline,
ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi, default_emacs_keybindings,
default_vi_insert_keybindings, default_vi_normal_keybindings,
};
use reedline::{MenuBuilder, Signal};
use std::sync::LazyLock;
@@ -52,7 +49,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -66,11 +63,6 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
"Authenticate the current model client via OAuth (if configured)",
AssertState::pass(),
),
ReplCommand::new(
".mcp auth",
"Authenticate with an MCP server via OAuth",
AssertState::pass(),
),
ReplCommand::new(
".edit config",
"Modify configuration file",
@@ -321,58 +313,6 @@ Type ".help" for additional help.
}
}
{
let (messages_snapshot, compressed_count) = {
let ctx = self.ctx.read();
if let Some(session) = &ctx.session {
let msgs: Vec<Message> = session
.messages()
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed = session.compressed_messages().len();
(msgs, compressed)
} else {
(vec![], 0)
}
};
if !messages_snapshot.is_empty() || compressed_count > 0 {
let app = Arc::clone(&self.ctx.read().app.config);
if compressed_count > 0 {
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown; compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
}
}
loop {
if self.abort_signal.aborted_ctrld() {
break;
@@ -448,14 +388,6 @@ Type ".help" for additional help.
editor = editor.with_buffer_editor(command, temp_file);
}
if app.save_shell_history {
let ctx = ctx.read();
let history_path = paths::repl_history_file(&ctx.session);
if let Ok(history) = FileBackedHistory::with_file(1000, history_path) {
editor = editor.with_history(Box::new(history));
}
}
Ok(editor)
}
@@ -609,53 +541,6 @@ pub async fn run_repl_command(
let (client_name, provider) = resolve_oauth_client(Some(client.name()), &clients)?;
oauth::run_oauth_flow(&*provider, &client_name).await?;
}
".mcp" => match args {
Some(args) => {
let mut parts = args.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("").trim();
let rest = parts.next().map(str::trim).unwrap_or("");
match sub {
"auth" => {
if rest.is_empty() {
println!("Usage: .mcp auth <server_name>");
} else {
let server_name = rest;
let server_spec = ctx
.app
.mcp_config
.as_ref()
.and_then(|c| c.mcp_servers.get(server_name))
.cloned();
match server_spec {
None => {
bail!("MCP server '{}' not found in mcp.json.", server_name)
}
Some(spec) if !spec.is_remote() => bail!(
"MCP server '{}' uses stdio transport; \
OAuth is only supported for http/sse servers.",
server_name
),
Some(spec) => {
let url = spec
.url
.as_deref()
.expect("validated: remote spec has url");
let client_id = spec.oauth_client_id.as_deref();
mcp::oauth::run_mcp_oauth_flow(server_name, url, client_id)
.await?;
println!(
"Authentication saved. \
Restart Coyote to connect to '{server_name}'."
);
}
}
}
}
_ => unknown_command()?,
}
}
None => println!("Usage: .mcp auth <server_name>"),
},
".prompt" => match args {
Some(text) => {
let app = Arc::clone(&ctx.app.config);
@@ -747,46 +632,6 @@ pub async fn run_repl_command(
session.set_autonaming(false);
}
}
if let Some(session) = &ctx.session {
let messages_snapshot: Vec<Message> = session
.messages()
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed_count = session.compressed_messages().len();
if !messages_snapshot.is_empty() || compressed_count > 0 {
if compressed_count > 0 {
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown — compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
}
}
}
".install" => {
let trimmed = args.map(str::trim).unwrap_or("");
@@ -1570,8 +1415,8 @@ mod tests {
}
#[test]
fn repl_commands_has_50_entries() {
assert_eq!(REPL_COMMANDS.len(), 50);
fn repl_commands_has_49_entries() {
assert_eq!(REPL_COMMANDS.len(), 49);
}
#[test]
-205
View File
@@ -1,16 +1,13 @@
use std::env;
use std::fs;
use std::fs::{read_dir, read_to_string};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_yaml::Value;
use sha2::{Digest, Sha256};
use crate::config::paths;
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
#[derive(Debug, Clone)]
pub struct DiscoveredMixin {
@@ -20,46 +17,6 @@ pub struct DiscoveredMixin {
pub domain_count: usize,
}
impl DiscoveredMixin {
pub fn kit_path(&self) -> Result<PathBuf> {
if self.path.is_dir() {
return Ok(self.path.clone());
}
wrap_mixin_as_kit(&self.path)
}
}
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
let bytes = fs::read(mixin_path)
.with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = format!("{:x}", hasher.finalize());
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
let spec_path = kit_dir.join(KIT_SPEC_FILE_NAME);
if let Ok(existing) = fs::read(&spec_path)
&& existing == bytes
{
return Ok(kit_dir);
}
fs::create_dir_all(&kit_dir)
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
fs::write(&spec_path, &bytes)
.with_context(|| format!("Failed to write {}", spec_path.display()))?;
debug!(
"Wrapped mixin {} as kit at {}",
mixin_path.display(),
kit_dir.display()
);
Ok(kit_dir)
}
pub fn discover() -> Result<Vec<DiscoveredMixin>> {
let mut out = Vec::new();
@@ -277,166 +234,4 @@ network:
let found = collect_subdir_mixins(&absent);
assert!(found.is_empty());
}
mod wrap_as_kit {
use super::*;
use serial_test::serial;
use std::ffi::OsString;
struct TestCacheDirGuard {
key: String,
previous: Option<OsString>,
path: PathBuf,
}
impl TestCacheDirGuard {
fn new() -> Self {
let key = crate::utils::get_env_name("cache_dir");
let previous = env::var_os(&key);
let nanos = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-mixin-wrap-cache-{nanos}"));
fs::create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestCacheDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(v) => env::set_var(&self.key, v),
None => env::remove_var(&self.key),
}
}
let _ = fs::remove_dir_all(&self.path);
}
}
fn write_mixin(name: &str, content: &str) -> PathBuf {
let root = unique_root(&format!("wrap-src-{name}"));
let path = root.join("sbx-mixin.yaml");
fs::write(&path, content).unwrap();
path
}
#[test]
#[serial]
fn wrap_mixin_as_kit_creates_spec_yaml_with_original_content() {
let _guard = TestCacheDirGuard::new();
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
let mixin = write_mixin("content", content);
let kit_dir = wrap_mixin_as_kit(&mixin).unwrap();
let spec = kit_dir.join("spec.yaml");
assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir");
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
}
#[test]
#[serial]
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
let _guard = TestCacheDirGuard::new();
let content = "schemaVersion: \"1\"\nkind: mixin\nname: probe\n";
let mixin_one = write_mixin("dedup-1", content);
let mixin_two = write_mixin("dedup-2", content);
let kit_a = wrap_mixin_as_kit(&mixin_one).unwrap();
let kit_b = wrap_mixin_as_kit(&mixin_two).unwrap();
assert_eq!(
kit_a, kit_b,
"same content should share the same content-addressed kit dir"
);
}
#[test]
#[serial]
fn wrap_mixin_as_kit_different_content_yields_different_dirs() {
let _guard = TestCacheDirGuard::new();
let mixin_a = write_mixin("diff-a", "kind: mixin\nname: a\n");
let mixin_b = write_mixin("diff-b", "kind: mixin\nname: b\n");
let kit_a = wrap_mixin_as_kit(&mixin_a).unwrap();
let kit_b = wrap_mixin_as_kit(&mixin_b).unwrap();
assert_ne!(
kit_a, kit_b,
"different content must hash to different kit dirs"
);
}
#[test]
#[serial]
fn wrap_mixin_as_kit_is_idempotent_on_cache_hit() {
let _guard = TestCacheDirGuard::new();
let mixin = write_mixin("idempotent", "kind: mixin\nname: probe\n");
let kit_first = wrap_mixin_as_kit(&mixin).unwrap();
let spec = kit_first.join("spec.yaml");
let mtime_first = fs::metadata(&spec).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let kit_second = wrap_mixin_as_kit(&mixin).unwrap();
let mtime_second = fs::metadata(kit_second.join("spec.yaml"))
.unwrap()
.modified()
.unwrap();
assert_eq!(kit_first, kit_second);
assert_eq!(
mtime_first, mtime_second,
"cache hit must not rewrite spec.yaml"
);
}
#[test]
#[serial]
fn kit_path_passes_through_existing_directory() {
let _guard = TestCacheDirGuard::new();
let dir = unique_root("kit-path-dir-passthrough");
let m = DiscoveredMixin {
path: dir.clone(),
label: "vault".into(),
install_count: 1,
domain_count: 1,
};
assert_eq!(m.kit_path().unwrap(), dir);
}
#[test]
#[serial]
fn kit_path_wraps_file_into_kit_dir() {
let _guard = TestCacheDirGuard::new();
let mixin = write_mixin("kit-path-wrap", "kind: mixin\nname: probe\n");
let m = DiscoveredMixin {
path: mixin.clone(),
label: mixin.display().to_string(),
install_count: 0,
domain_count: 0,
};
let wrapped = m.kit_path().unwrap();
assert!(wrapped.is_dir(), "kit_path of a file should be a directory");
assert!(wrapped.join("spec.yaml").exists());
assert_ne!(
wrapped, mixin,
"kit_path should not return the original file path"
);
}
}
}
+19 -109
View File
@@ -316,7 +316,6 @@ fn sandbox_exists(name: &str) -> Result<bool> {
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
info!("Creating sandbox '{name}'");
let args = build_create_args(name, kit_path, mixins)?;
debug!("sbx {}", args.join(" "));
let status = Command::new(SBX_BINARY)
.args(&args)
.stdin(Stdio::inherit())
@@ -343,23 +342,22 @@ fn build_create_args(
let mut args = vec![
"create".to_string(),
"--name".to_string(),
name.to_string(),
"--kit".to_string(),
kit_str.to_string(),
];
for mixin in mixins {
let mixin_kit = mixin.kit_path()?;
let mixin_str = mixin_kit
let mixin_str = mixin
.path
.to_str()
.ok_or_else(|| anyhow!("Mixin kit path is not valid UTF-8: {}", mixin_kit.display()))?
.to_string();
.ok_or_else(|| anyhow!("Mixin path is not valid UTF-8: {}", mixin.path.display()))?;
args.push("--kit".to_string());
args.push(mixin_str);
args.push(mixin_str.to_string());
}
args.push(SANDBOX_AGENT.to_string());
args.push("--name".to_string());
args.push(name.to_string());
args.push(".".to_string());
Ok(args)
@@ -370,17 +368,10 @@ fn copy_host_files(name: &str) -> Result<()> {
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
if config_dir.exists() {
let sandbox_config_dir = "/home/agent/.config/coyote";
ensure_sandbox_dir(name, sandbox_config_dir)?;
let dest = format!("{name}:{sandbox_config_dir}/");
for entry in fs::read_dir(&config_dir)
.with_context(|| format!("Failed to read {}", config_dir.display()))?
{
let entry = entry?;
let path = entry.path();
sbx_cp(&path.display().to_string(), &dest)?;
}
chown_agent_recursive(name, sandbox_config_dir)?;
ensure_sandbox_dir(name, "/home/agent/.config")?;
let src = format!("{}/", config_dir.display());
let dest = format!("{name}:/home/agent/.config/");
sbx_cp(&src, &dest)?;
} else {
debug!(
"Skipping config copy: {} does not exist",
@@ -398,7 +389,6 @@ fn copy_host_files(name: &str) -> Result<()> {
}
let dest = format!("{name}:{dest_path}");
sbx_cp(&password_file.display().to_string(), &dest)?;
chown_agent_recursive(name, &dest_path)?;
}
Some(password_file) => {
debug!(
@@ -514,9 +504,8 @@ fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
let kit_str = kit_path
.to_str()
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
debug!("sbx run --name {name} --kit {kit_str}");
let status = Command::new(SBX_BINARY)
.args(["run", "--name", name, "--kit", kit_str])
.args(["run", name, "--kit", kit_str])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
@@ -530,27 +519,6 @@ fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
Ok(())
}
fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
let path_q = shell_words::quote(path);
let cmd = format!("sudo chown -R agent:agent {path_q}");
debug!("sbx exec {sandbox}: {cmd}");
let status = Command::new(SBX_BINARY)
.args(["exec", sandbox, "sh", "-c", &cmd])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to spawn `sbx exec` to chown copied files")?;
if !status.success() {
bail!("Chowning '{path}' in sandbox failed: sbx exec exited with {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -622,24 +590,15 @@ mod tests {
#[test]
fn build_create_args_emits_base_kit_before_mixins() {
let kit = PathBuf::from("/cache/sbx-kit");
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
let dir_b = env::temp_dir().join(format!("coyote-mixin-b-{unique}"));
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let mixins = vec![
DiscoveredMixin {
path: dir_a.clone(),
path: PathBuf::from("/cfg/sbx-mixin.yaml"),
label: "user".into(),
install_count: 0,
domain_count: 0,
},
DiscoveredMixin {
path: dir_b.clone(),
path: PathBuf::from("/cfg/agents/sql/sbx-mixin.yaml"),
label: "sql".into(),
install_count: 0,
domain_count: 0,
@@ -652,21 +611,18 @@ mod tests {
args,
vec![
"create".to_string(),
"--name".to_string(),
"my-box".to_string(),
"--kit".to_string(),
"/cache/sbx-kit".to_string(),
"--kit".to_string(),
dir_a.display().to_string(),
"/cfg/sbx-mixin.yaml".to_string(),
"--kit".to_string(),
dir_b.display().to_string(),
"/cfg/agents/sql/sbx-mixin.yaml".to_string(),
"coyote".to_string(),
"--name".to_string(),
"my-box".to_string(),
".".to_string(),
]
);
let _ = fs::remove_dir_all(&dir_a);
let _ = fs::remove_dir_all(&dir_b);
}
#[test]
@@ -677,11 +633,11 @@ mod tests {
args,
vec![
"create".to_string(),
"--name".to_string(),
"box".to_string(),
"--kit".to_string(),
"/cache/sbx-kit".to_string(),
"coyote".to_string(),
"--name".to_string(),
"box".to_string(),
".".to_string(),
]
);
@@ -689,7 +645,6 @@ mod tests {
mod vault_mixins {
use super::*;
use crate::utils::get_env_name;
use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider;
use gman::providers::azure_key_vault::AzureKeyVaultProvider;
use gman::providers::gcp_secret_manager::GcpSecretManagerProvider;
@@ -697,46 +652,6 @@ mod tests {
use gman::providers::local::LocalProvider;
use gman::providers::one_password::OnePasswordProvider;
use serial_test::serial;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestCacheDirGuard {
key: String,
previous: Option<std::ffi::OsString>,
path: PathBuf,
}
impl TestCacheDirGuard {
fn new() -> Self {
let key = get_env_name("cache_dir");
let previous = env::var_os(&key);
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("coyote-sandbox-vault-tests-{unique}"));
fs::create_dir_all(&path).unwrap();
unsafe {
env::set_var(&key, &path);
}
Self {
key,
previous,
path,
}
}
}
impl Drop for TestCacheDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(v) => env::set_var(&self.key, v),
None => env::remove_var(&self.key),
}
}
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn returns_none_for_local() {
@@ -749,7 +664,6 @@ mod tests {
#[test]
#[serial]
fn returns_some_for_aws() {
let _guard = TestCacheDirGuard::new();
let p = SupportedProvider::AwsSecretsManager {
provider_def: AwsSecretsManagerProvider {
aws_profile: None,
@@ -766,7 +680,6 @@ mod tests {
#[test]
#[serial]
fn returns_some_for_gcp() {
let _guard = TestCacheDirGuard::new();
let p = SupportedProvider::GcpSecretManager {
provider_def: GcpSecretManagerProvider {
gcp_project_id: None,
@@ -782,7 +695,6 @@ mod tests {
#[test]
#[serial]
fn returns_some_for_one_password() {
let _guard = TestCacheDirGuard::new();
let p = SupportedProvider::OnePassword {
provider_def: OnePasswordProvider {
vault: None,
@@ -799,7 +711,6 @@ mod tests {
#[test]
#[serial]
fn returns_some_for_azure() {
let _guard = TestCacheDirGuard::new();
let p = SupportedProvider::AzureKeyVault {
provider_def: AzureKeyVaultProvider { vault_name: None },
};
@@ -813,7 +724,6 @@ mod tests {
#[test]
#[serial]
fn returns_some_for_gopass() {
let _guard = TestCacheDirGuard::new();
let p = SupportedProvider::Gopass {
provider_def: GopassProvider { store: None },
};
+8 -33
View File
@@ -1,11 +1,9 @@
use crate::config::paths;
use colored::Colorize;
use fancy_regex::Regex;
use std::fs::{self, File};
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::process;
use std::time::Duration;
use tokio::time::sleep;
pub async fn tail_logs(no_color: bool) {
let re = Regex::new(r"^(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+<(?P<opid>[^\s>]+)>\s+\[(?P<level>[A-Z]+)\]\s+(?P<logger>[^:]+):(?P<line>\d+)\s+-\s+(?P<message>.*)$").unwrap();
@@ -18,43 +16,20 @@ pub async fn tail_logs(no_color: bool) {
process::exit(1);
};
let mut line_buf = String::new();
let mut lines = reader.lines();
loop {
match reader.read_line(&mut line_buf) {
Ok(0) => {
if file_was_rotated(&file_path, &mut reader) {
let file = File::open(&file_path).expect("Cannot open file");
reader = BufReader::new(file);
}
sleep(Duration::from_millis(100)).await;
}
Ok(_) => {
let line = line_buf.trim_end();
if no_color {
println!("{line}");
} else {
let colored_line = colorize_log_line(line, &re);
println!("{colored_line}");
}
line_buf.clear();
}
Err(_) => {
line_buf.clear();
sleep(Duration::from_millis(100)).await;
if let Some(Ok(line)) = lines.next() {
if no_color {
println!("{line}");
} else {
let colored_line = colorize_log_line(&line, &re);
println!("{colored_line}");
}
}
}
}
fn file_was_rotated(path: &std::path::Path, reader: &mut BufReader<File>) -> bool {
let current_pos = reader.stream_position().unwrap_or(0);
match fs::metadata(path) {
Ok(metadata) => metadata.len() < current_pos,
Err(_) => true,
}
}
fn colorize_log_line(line: &str, re: &Regex) -> String {
if let Some(caps) = re.captures(line).expect("Failed to capture log line") {
let level = &caps["level"];