diff --git a/Cargo.lock b/Cargo.lock index 1db1cca..96fd169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "assert-json-diff" @@ -100,9 +100,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -182,9 +182,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -251,9 +251,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" @@ -278,9 +278,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -300,9 +300,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -324,9 +324,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -348,9 +348,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -385,9 +385,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "flate2", @@ -453,7 +453,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -469,11 +469,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "document-features", "parking_lot", - "rustix 1.1.3", + "rustix 1.1.4", "winapi", ] @@ -567,9 +567,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -740,9 +740,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -755,9 +755,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -765,15 +765,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -782,15 +782,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -799,21 +799,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -823,7 +823,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -873,6 +872,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "h2" version = "0.3.27" @@ -1198,6 +1210,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1342,6 +1360,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1350,19 +1377,25 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" @@ -1370,7 +1403,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", ] @@ -1412,9 +1445,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1630,7 +1663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6eea3058763d6e656105d1403cb04e0a41b7bbac6362d413e7c33be0c32279c9" dependencies = [ "heck", - "itertools", + "itertools 0.13.0", "prost", "prost-types", ] @@ -1744,7 +1777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -1764,7 +1797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -1914,13 +1947,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm 0.28.1", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -1935,7 +1968,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2107,7 +2140,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2116,14 +2149,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2245,6 +2278,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -2538,9 +2577,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2596,14 +2635,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2803,7 +2842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -2904,9 +2943,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -2920,7 +2959,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -2937,6 +2976,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2982,11 +3027,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", @@ -3031,11 +3076,20 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" dependencies = [ "cfg-if", "once_cell", @@ -3046,9 +3100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" dependencies = [ "cfg-if", "futures-util", @@ -3060,9 +3114,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3070,9 +3124,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" dependencies = [ "bumpalo", "proc-macro2", @@ -3083,13 +3137,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -3103,11 +3179,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" dependencies = [ "js-sys", "wasm-bindgen", @@ -3474,6 +3562,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json 1.0.149", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json 1.0.149", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -3586,6 +3756,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/args.rs b/src/args.rs index fb2fe38..0840d7b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,6 +1,7 @@ -use clap::Args; use std::path::PathBuf; +use clap::Args; + pub use braintrust_sdk_rust::{DEFAULT_API_URL, DEFAULT_APP_URL}; #[derive(Debug, Clone, Args)] @@ -18,7 +19,13 @@ pub struct BaseArgs { pub org_name: Option, /// Override active project - #[arg(short = 'p', long, env = "BRAINTRUST_DEFAULT_PROJECT", global = true)] + #[arg( + short = 'p', + long, + env = "BRAINTRUST_DEFAULT_PROJECT", + hide_env_values = true, + global = true + )] pub project: Option, /// Override stored API key (or via BRAINTRUST_API_KEY) @@ -34,15 +41,25 @@ pub struct BaseArgs { pub no_input: bool, /// Override API URL (or via BRAINTRUST_API_URL) - #[arg(long, env = "BRAINTRUST_API_URL", global = true)] + #[arg( + long, + env = "BRAINTRUST_API_URL", + hide_env_values = true, + global = true + )] pub api_url: Option, /// Override app URL (or via BRAINTRUST_APP_URL) - #[arg(long, env = "BRAINTRUST_APP_URL", global = true)] + #[arg( + long, + env = "BRAINTRUST_APP_URL", + hide_env_values = true, + global = true + )] pub app_url: Option, /// Path to a .env file to load before running commands. - #[arg(long, env = "BRAINTRUST_ENV_FILE")] + #[arg(long, env = "BRAINTRUST_ENV_FILE", hide_env_values = true)] pub env_file: Option, } diff --git a/src/experiments/api.rs b/src/experiments/api.rs new file mode 100644 index 0000000..ae18a1d --- /dev/null +++ b/src/experiments/api.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Experiment { + pub id: String, + pub name: String, + pub project_id: String, + #[serde(default)] + pub public: bool, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub dataset_id: Option, + #[serde(default)] + pub dataset_version: Option, + #[serde(default)] + pub base_exp_id: Option, + #[serde(default)] + pub commit: Option, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Deserialize)] +struct ListResponse { + objects: Vec, +} + +pub async fn list_experiments(client: &ApiClient, project: &str) -> Result> { + let path = format!( + "/v1/experiment?org_name={}&project_name={}", + encode(client.org_name()), + encode(project) + ); + let list: ListResponse = client.get(&path).await?; + Ok(list.objects) +} + +pub async fn get_experiment_by_name( + client: &ApiClient, + project: &str, + name: &str, +) -> Result> { + let path = format!( + "/v1/experiment?org_name={}&project_name={}&experiment_name={}", + encode(client.org_name()), + encode(project), + encode(name) + ); + let list: ListResponse = client.get(&path).await?; + Ok(list.objects.into_iter().next()) +} + +pub async fn delete_experiment(client: &ApiClient, experiment_id: &str) -> Result<()> { + let path = format!("/v1/experiment/{}", encode(experiment_id)); + client.delete(&path).await +} diff --git a/src/experiments/delete.rs b/src/experiments/delete.rs new file mode 100644 index 0000000..2c55266 --- /dev/null +++ b/src/experiments/delete.rs @@ -0,0 +1,61 @@ +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::ui::{is_interactive, print_command_status, with_spinner, CommandStatus}; + +use super::{api, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, name: Option<&str>, force: bool) -> Result<()> { + let project_name = &ctx.project.name; + if force && name.is_none() { + bail!("name required when using --force. Use: bt experiments delete --force"); + } + + let experiment = match name { + Some(n) => api::get_experiment_by_name(&ctx.client, project_name, n) + .await? + .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, + None => { + if !is_interactive() { + bail!("experiment name required. Use: bt experiments delete "); + } + super::select_experiment_interactive(&ctx.client, project_name).await? + } + }; + + if !force && is_interactive() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete experiment '{}' from {}?", + &experiment.name, &project_name + )) + .default(false) + .interact()?; + if !confirm { + return Ok(()); + } + } + + match with_spinner( + "Deleting experiment...", + api::delete_experiment(&ctx.client, &experiment.id), + ) + .await + { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", experiment.name), + ); + eprintln!("Run `bt experiments list` to see remaining experiments."); + Ok(()) + } + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Failed to delete '{}'", experiment.name), + ); + Err(e) + } + } +} diff --git a/src/experiments/list.rs b/src/experiments/list.rs new file mode 100644 index 0000000..2319041 --- /dev/null +++ b/src/experiments/list.rs @@ -0,0 +1,73 @@ +use std::fmt::Write as _; + +use anyhow::Result; +use dialoguer::console; + +use crate::{ + ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, + utils::pluralize, +}; + +use super::{api, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { + let project_name = &ctx.project.name; + let experiments = with_spinner( + "Loading experiments...", + api::list_experiments(&ctx.client, project_name), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&experiments)?); + return Ok(()); + } + + let mut output = String::new(); + let count = format!( + "{} {}", + experiments.len(), + pluralize(experiments.len(), "experiment", None) + ); + writeln!( + output, + "{} found in {} {} {}\n", + console::style(count), + console::style(ctx.client.org_name()).bold(), + console::style("/").dim().bold(), + console::style(project_name).bold() + )?; + + let mut table = styled_table(); + table.set_header(vec![ + header("Name"), + header("Description"), + header("Created"), + header("Commit"), + ]); + apply_column_padding(&mut table, (0, 6)); + + for exp in &experiments { + let desc = exp + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + let created = exp + .created + .as_deref() + .map(|c| truncate(c, 10)) + .unwrap_or_else(|| "-".to_string()); + let commit = exp + .commit + .as_deref() + .map(|c| truncate(c, 7)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&exp.name, &desc, &created, &commit]); + } + + write!(output, "{table}")?; + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs new file mode 100644 index 0000000..ab2c2d0 --- /dev/null +++ b/src/experiments/mod.rs @@ -0,0 +1,133 @@ +use anyhow::{anyhow, bail, Result}; +use clap::{Args, Subcommand}; + +use crate::{ + args::BaseArgs, + auth::login, + config, + http::ApiClient, + projects::api::{get_project_by_name, Project}, + ui::{self, is_interactive, select_project_interactive, with_spinner}, +}; + +mod api; +mod delete; +mod list; +mod view; + +use api::{self as experiments_api, Experiment}; + +pub(crate) struct ResolvedContext { + pub client: ApiClient, + pub app_url: String, + pub project: Project, +} + +#[derive(Debug, Clone, Args)] +pub struct ExperimentsArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum ExperimentsCommands { + /// List all experiments + List, + /// View an experiment + View(ViewArgs), + /// Delete an experiment + Delete(DeleteArgs), +} + +#[derive(Debug, Clone, Args)] +struct ViewArgs { + /// Experiment name (positional) + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Experiment name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, + + /// Open in browser + #[arg(long)] + web: bool, +} + +impl ViewArgs { + fn name(&self) -> Option<&str> { + self.name_positional + .as_deref() + .or(self.name_flag.as_deref()) + } +} + +#[derive(Debug, Clone, Args)] +struct DeleteArgs { + /// Experiment name (positional) + #[arg(value_name = "NAME")] + name_positional: Option, + + /// Experiment name (flag) + #[arg(long = "name", short = 'n')] + name_flag: Option, + + /// Skip confirmation + #[arg(long, short = 'f')] + force: bool, +} + +impl DeleteArgs { + fn name(&self) -> Option<&str> { + self.name_positional + .as_deref() + .or(self.name_flag.as_deref()) + } +} + +pub(crate) async fn select_experiment_interactive( + client: &ApiClient, + project: &str, +) -> Result { + let mut experiments = with_spinner( + "Loading experiments...", + experiments_api::list_experiments(client, project), + ) + .await?; + + if experiments.is_empty() { + bail!("no experiments found"); + } + + experiments.sort_by(|a, b| a.name.cmp(&b.name)); + let names: Vec<&str> = experiments.iter().map(|e| e.name.as_str()).collect(); + let selection = ui::fuzzy_select("Select experiment", &names, 0)?; + Ok(experiments[selection].clone()) +} + +pub async fn run(base: BaseArgs, args: ExperimentsArgs) -> Result<()> { + let auth = login(&base).await?; + let client = ApiClient::new(&auth)?; + let config_project = config::load().ok().and_then(|c| c.project); + let project_name = match base.project.as_deref().or(config_project.as_deref()) { + Some(p) => p.to_string(), + None if is_interactive() => select_project_interactive(&client, None, None).await?, + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), + }; + + let project = get_project_by_name(&client, &project_name) + .await? + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; + + let ctx = ResolvedContext { + client, + app_url: auth.app_url, + project, + }; + + match args.command { + None | Some(ExperimentsCommands::List) => list::run(&ctx, base.json).await, + Some(ExperimentsCommands::View(v)) => view::run(&ctx, v.name(), base.json, v.web).await, + Some(ExperimentsCommands::Delete(d)) => delete::run(&ctx, d.name(), d.force).await, + } +} diff --git a/src/experiments/view.rs b/src/experiments/view.rs new file mode 100644 index 0000000..bd3e7f8 --- /dev/null +++ b/src/experiments/view.rs @@ -0,0 +1,111 @@ +use std::fmt::Write as _; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; +use urlencoding::encode; + +use crate::ui::{ + is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, +}; + +use super::{api, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, name: Option<&str>, json: bool, web: bool) -> Result<()> { + let project_name = &ctx.project.name; + let experiment = match name { + Some(n) => with_spinner( + "Loading experiment...", + api::get_experiment_by_name(&ctx.client, project_name, n), + ) + .await? + .ok_or_else(|| anyhow!("experiment '{n}' not found"))?, + None => { + if !is_interactive() { + bail!("experiment name required. Use: bt experiments view "); + } + super::select_experiment_interactive(&ctx.client, project_name).await? + } + }; + + let url = format!( + "{}/app/{}/p/{}/experiments/{}", + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(project_name), + encode(&experiment.name) + ); + + if web { + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string(&experiment)?); + return Ok(()); + } + + let mut output = String::new(); + writeln!( + output, + "Viewing {}", + console::style(&experiment.name).bold() + )?; + + let description = experiment + .description + .as_deref() + .filter(|d| !d.is_empty()) + .or_else(|| { + experiment + .metadata + .as_ref() + .and_then(|m| m.get("description")) + .and_then(|d| d.as_str()) + .filter(|d| !d.is_empty()) + }); + if let Some(desc) = description { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; + } + if let Some(created) = &experiment.created { + writeln!(output, "{} {}", console::style("Created:").dim(), created)?; + } + if let Some(commit) = &experiment.commit { + writeln!(output, "{} {}", console::style("Commit:").dim(), commit)?; + } + if let Some(dataset_id) = &experiment.dataset_id { + writeln!( + output, + "{} {}", + console::style("Dataset:").dim(), + dataset_id + )?; + } + writeln!( + output, + "{} {}", + console::style("Public:").dim(), + if experiment.public { "yes" } else { "no" } + )?; + if let Some(tags) = &experiment.tags { + if !tags.is_empty() { + writeln!( + output, + "{} {}", + console::style("Tags:").dim(), + tags.join(", ") + )?; + } + } + + writeln!( + output, + "\n{} {}", + console::style("View experiment results:").dim(), + console::style(&url).underlined() + )?; + + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/functions/api.rs b/src/functions/api.rs new file mode 100644 index 0000000..a230046 --- /dev/null +++ b/src/functions/api.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use urlencoding::encode; + +use crate::http::ApiClient; + +fn escape_sql(s: &str) -> String { + s.replace('\'', "''") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub id: String, + pub name: String, + pub slug: String, + pub project_id: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub function_type: Option, + #[serde(default)] + pub prompt_data: Option, + #[serde(default)] + pub function_data: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub _xact_id: Option, +} + +pub async fn list_functions( + client: &ApiClient, + project_id: &str, + function_type: Option<&str>, +) -> Result> { + let pid = escape_sql(project_id); + let query = match function_type { + Some(ft) => { + let ft = escape_sql(ft); + format!("SELECT * FROM project_functions('{pid}') WHERE function_type = '{ft}'") + } + None => format!("SELECT * FROM project_functions('{pid}')"), + }; + let response = client.btql::(&query).await?; + + Ok(response.data) +} + +pub async fn get_function_by_slug( + client: &ApiClient, + project_id: &str, + slug: &str, +) -> Result> { + let pid = escape_sql(project_id); + let slug = escape_sql(slug); + let query = format!("SELECT * FROM project_functions('{pid}') WHERE slug = '{slug}'"); + let response = client.btql(&query).await?; + + Ok(response.data.into_iter().next()) +} + +pub async fn invoke_function( + client: &ApiClient, + body: &serde_json::Value, +) -> Result { + let org_name = client.org_name(); + let headers = if !org_name.is_empty() { + vec![("x-bt-org-name", org_name)] + } else { + Vec::new() + }; + let timeout = std::time::Duration::from_secs(300); + client + .post_with_headers_timeout("/function/invoke", body, &headers, Some(timeout)) + .await +} + +pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()> { + let path = format!("/v1/function/{}", encode(function_id)); + client.delete(&path).await +} diff --git a/src/functions/delete.rs b/src/functions/delete.rs new file mode 100644 index 0000000..abf51f9 --- /dev/null +++ b/src/functions/delete.rs @@ -0,0 +1,81 @@ +use anyhow::{anyhow, bail, Result}; +use dialoguer::Confirm; + +use crate::ui::{is_interactive, print_command_status, with_spinner, CommandStatus}; + +use super::{api, label, label_plural, select_function_interactive}; +use super::{FunctionTypeFilter, ResolvedContext}; + +pub async fn run( + ctx: &ResolvedContext, + slug: Option<&str>, + force: bool, + ft: Option, +) -> Result<()> { + if force && slug.is_none() { + bail!( + "slug required when using --force. Use: bt {} delete --force", + label_plural(ft), + ); + } + + let project_id = &ctx.project.id; + + let function = match slug { + Some(s) => api::get_function_by_slug(&ctx.client, project_id, s) + .await? + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, + None => { + if !is_interactive() { + bail!( + "{} slug required. Use: bt {} delete ", + label(ft), + label_plural(ft), + ); + } + select_function_interactive(&ctx.client, project_id, ft).await? + } + }; + + if !force && is_interactive() { + let confirm = Confirm::new() + .with_prompt(format!( + "Delete {} '{}' from {}?", + label(ft), + &function.name, + &ctx.project.name + )) + .default(false) + .interact()?; + if !confirm { + return Ok(()); + } + } + + match with_spinner( + &format!("Deleting {}...", label(ft)), + api::delete_function(&ctx.client, &function.id), + ) + .await + { + Ok(_) => { + print_command_status( + CommandStatus::Success, + &format!("Deleted '{}'", function.name), + ); + eprintln!( + "Run `bt {} list` to see remaining {}.", + label_plural(ft), + label_plural(ft) + ); + Ok(()) + } + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Failed to delete '{}'", function.name), + ); + Err(e) + } + } +} diff --git a/src/functions/invoke.rs b/src/functions/invoke.rs new file mode 100644 index 0000000..7b16502 --- /dev/null +++ b/src/functions/invoke.rs @@ -0,0 +1,120 @@ +use std::io::{self, IsTerminal, Read}; + +use anyhow::{bail, Context, Result}; +use clap::Args; +use serde_json::{json, Value}; + +use super::{select_function_interactive, FunctionTypeFilter, ResolvedContext, SlugArgs}; +use crate::ui::is_interactive; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt functions invoke my-fn --input '{\"key\": \"value\"}' + bt functions invoke my-fn --message \"What is 2+2?\" + bt functions invoke my-fn -i '{\"my-var\": \"A very long text...\"}' -m \"Summarize this\" + bt functions invoke my-fn --mode json --version abc123 + ")] +pub(crate) struct InvokeArgs { + #[command(flatten)] + slug: SlugArgs, + + /// JSON input to the function + #[arg(long, short = 'i')] + input: Option, + + /// User message (repeatable, for LLM functions) + #[arg(long, short = 'm')] + message: Vec, + + /// Response format: auto, json, text, parallel + #[arg(long)] + mode: Option, + + /// Pin to a specific function version + #[arg(long)] + version: Option, +} + +impl InvokeArgs { + pub fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +fn resolve_input(input_arg: &Option) -> Result> { + if let Some(raw) = input_arg { + let parsed: Value = serde_json::from_str(raw).context("invalid JSON in --input")?; + return Ok(Some(parsed)); + } + + if !io::stdin().is_terminal() { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("failed to read from stdin")?; + let trimmed = buf.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed: Value = serde_json::from_str(trimmed).context("invalid JSON from stdin")?; + return Ok(Some(parsed)); + } + + Ok(None) +} + +pub async fn run( + ctx: &ResolvedContext, + args: &InvokeArgs, + json_output: bool, + ft: Option, +) -> Result<()> { + let slug = match args.slug() { + Some(s) => s.to_string(), + None if is_interactive() => { + let f = select_function_interactive(&ctx.client, &ctx.project.id, ft).await?; + f.slug + } + None => bail!(" required"), + }; + + let resolved_input = resolve_input(&args.input)?; + + let mut body = json!({ + "project_name": ctx.project.name, + "slug": slug, + }); + + if let Some(input) = resolved_input { + body["input"] = input; + } + if !args.message.is_empty() { + let messages: Vec = args + .message + .iter() + .map(|m| json!({"role": "user", "content": m})) + .collect(); + body["messages"] = json!(messages); + } + if let Some(mode) = &args.mode { + body["mode"] = json!(mode); + } + if let Some(version) = &args.version { + body["version"] = json!(version); + } + + let result = super::api::invoke_function(&ctx.client, &body).await?; + + if json_output { + println!("{}", serde_json::to_string(&result)?); + } else { + match &result { + Value::String(s) => println!("{s}"), + Value::Null => {} + _ => println!("{}", serde_json::to_string_pretty(&result)?), + } + } + + Ok(()) +} diff --git a/src/functions/list.rs b/src/functions/list.rs new file mode 100644 index 0000000..1a4a45e --- /dev/null +++ b/src/functions/list.rs @@ -0,0 +1,72 @@ +use std::fmt::Write as _; + +use anyhow::Result; +use dialoguer::console; + +use crate::ui::{ + apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner, +}; +use crate::utils::pluralize; + +use super::{api, label, label_plural, FunctionTypeFilter, ResolvedContext}; + +pub async fn run(ctx: &ResolvedContext, json: bool, ft: Option) -> Result<()> { + let function_type = ft.map(|f| f.as_str()); + let functions = with_spinner( + &format!("Loading {}...", label_plural(ft)), + api::list_functions(&ctx.client, &ctx.project.id, function_type), + ) + .await?; + + if json { + println!("{}", serde_json::to_string(&functions)?); + return Ok(()); + } + + let mut output = String::new(); + let count = format!( + "{} {}", + functions.len(), + pluralize(functions.len(), label(ft), Some(label_plural(ft))) + ); + writeln!( + output, + "{} found in {} {} {}\n", + console::style(count), + console::style(ctx.client.org_name()).bold(), + console::style("/").dim().bold(), + console::style(&ctx.project.name).bold() + )?; + + let mut table = styled_table(); + if ft.is_none() { + table.set_header(vec![ + header("Name"), + header("Type"), + header("Description"), + header("Slug"), + ]); + } else { + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + } + apply_column_padding(&mut table, (0, 6)); + + for func in &functions { + let desc = func + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + if ft.is_none() { + let t = func.function_type.as_deref().unwrap_or("-"); + table.add_row(vec![&func.name, t, &desc, &func.slug]); + } else { + table.add_row(vec![&func.name, &desc, &func.slug]); + } + } + + write!(output, "{table}")?; + print_with_pager(&output)?; + Ok(()) +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs new file mode 100644 index 0000000..f084b85 --- /dev/null +++ b/src/functions/mod.rs @@ -0,0 +1,334 @@ +use anyhow::{anyhow, bail, Result}; +use clap::{Args, Subcommand, ValueEnum}; + +use crate::{ + args::BaseArgs, + auth::login, + config, + http::ApiClient, + projects::api::{get_project_by_name, Project}, + ui::{self, is_interactive, select_project_interactive, with_spinner}, +}; + +pub(crate) mod api; +mod delete; +mod invoke; +mod list; +mod view; + +use api::Function; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum FunctionTypeFilter { + Llm, + Scorer, + Task, + Tool, + CustomView, + Preprocessor, + Facet, + Classifier, + Tag, + Parameters, +} + +impl FunctionTypeFilter { + pub fn as_str(&self) -> &'static str { + match self { + Self::Llm => "llm", + Self::Scorer => "scorer", + Self::Task => "task", + Self::Tool => "tool", + Self::CustomView => "custom_view", + Self::Preprocessor => "preprocessor", + Self::Facet => "facet", + Self::Classifier => "classifier", + Self::Tag => "tag", + Self::Parameters => "parameters", + } + } + + fn label(&self) -> &'static str { + match self { + Self::Llm => "LLM", + Self::CustomView => "custom view", + _ => self.as_str(), + } + } + + fn plural(&self) -> &'static str { + match self { + Self::Llm => "LLMs", + Self::Scorer => "scorers", + Self::Task => "tasks", + Self::Tool => "tools", + Self::CustomView => "custom views", + Self::Preprocessor => "preprocessors", + Self::Facet => "facets", + Self::Classifier => "classifiers", + Self::Tag => "tags", + Self::Parameters => "parameters", + } + } +} + +fn build_web_path(function: &Function) -> String { + let id = &function.id; + match function.function_type.as_deref() { + Some("tool") => format!("tools?pr={}", urlencoding::encode(id)), + Some("scorer") => format!("scorers/{}", urlencoding::encode(id)), + Some("classifier") => { + let xact_id = function._xact_id.as_deref().unwrap_or(""); + format!( + "topics?topicMapId={}&topicMapVersion={}", + urlencoding::encode(id), + urlencoding::encode(xact_id) + ) + } + Some("parameters") => format!("parameters/{}", urlencoding::encode(id)), + _ => format!("functions/{}", urlencoding::encode(id)), + } +} + +fn label(ft: Option) -> &'static str { + ft.map_or("function", |f| f.label()) +} + +fn label_plural(ft: Option) -> &'static str { + ft.map_or("functions", |f| f.plural()) +} + +// --- Slug args (shared) --- + +#[derive(Debug, Clone, Args)] +struct SlugArgs { + /// Function slug + #[arg(value_name = "SLUG")] + slug_positional: Option, + /// Function slug + #[arg(long = "slug", short = 's')] + slug_flag: Option, +} + +impl SlugArgs { + fn slug(&self) -> Option<&str> { + self.slug_positional + .as_deref() + .or(self.slug_flag.as_deref()) + } +} + +// --- Wrapper args (bt tools / bt scorers) --- + +#[derive(Debug, Clone, Args)] +pub struct FunctionArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum FunctionCommands { + /// List all in the current project + List, + /// View a function's details + View(ViewArgs), + /// Delete a function + Delete(DeleteArgs), + /// Invoke a function + Invoke(invoke::InvokeArgs), +} + +// --- bt functions args --- + +#[derive(Debug, Clone, Args)] +pub struct FunctionsArgs { + /// Filter by function type + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Clone, Subcommand)] +enum FunctionsCommands { + /// List functions in the current project + List(FunctionsListArgs), + /// View function details + View(ViewArgs), + /// Delete a function + Delete(FunctionsDeleteArgs), + /// Invoke a function + Invoke(FunctionsInvokeArgs), +} + +#[derive(Debug, Clone, Args)] +struct FunctionsListArgs { + /// Filter by function type + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, +} + +#[derive(Debug, Clone, Args)] +struct FunctionsDeleteArgs { + #[command(flatten)] + slug: SlugArgs, + /// Skip confirmation + #[arg(long, short = 'f')] + force: bool, + /// Filter by function type (for interactive selection) + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, +} + +impl FunctionsDeleteArgs { + fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +#[derive(Debug, Clone, Args)] +struct FunctionsInvokeArgs { + #[command(flatten)] + inner: invoke::InvokeArgs, + /// Filter by function type (for interactive selection) + #[arg(long = "type", short = 't', value_enum)] + function_type: Option, +} + +// --- Shared view/delete args --- + +#[derive(Debug, Clone, Args)] +pub struct ViewArgs { + #[command(flatten)] + slug: SlugArgs, + /// Open in browser + #[arg(long)] + web: bool, + /// Show all configuration details + #[arg(long)] + verbose: bool, +} + +impl ViewArgs { + fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +#[derive(Debug, Clone, Args)] +pub struct DeleteArgs { + #[command(flatten)] + slug: SlugArgs, + /// Skip confirmation + #[arg(long, short = 'f')] + force: bool, +} + +impl DeleteArgs { + fn slug(&self) -> Option<&str> { + self.slug.slug() + } +} + +// --- Resolved context --- + +pub(crate) struct ResolvedContext { + pub client: ApiClient, + pub app_url: String, + pub project: Project, +} + +async fn resolve_context(base: &BaseArgs) -> Result { + let ctx = login(base).await?; + let client = ApiClient::new(&ctx)?; + let config_project = config::load().ok().and_then(|c| c.project); + let project_name = match base.project.as_deref().or(config_project.as_deref()) { + Some(p) => p.to_string(), + None if is_interactive() => select_project_interactive(&client, None, None).await?, + None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), + }; + let project = get_project_by_name(&client, &project_name) + .await? + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; + Ok(ResolvedContext { + client, + app_url: ctx.app_url, + project, + }) +} + +// --- Interactive selection --- + +pub(crate) async fn select_function_interactive( + client: &ApiClient, + project_id: &str, + ft: Option, +) -> Result { + let function_type = ft.map(|f| f.as_str()); + let mut functions = with_spinner( + &format!("Loading {}...", label_plural(ft)), + api::list_functions(client, project_id, function_type), + ) + .await?; + + if functions.is_empty() { + bail!("no {} found", label_plural(ft)); + } + + functions.sort_by(|a, b| a.name.cmp(&b.name)); + + let names: Vec = if ft.is_none() { + functions + .iter() + .map(|f| { + let t = f.function_type.as_deref().unwrap_or("?"); + format!("{} ({})", f.name, t) + }) + .collect() + } else { + functions.iter().map(|f| f.name.clone()).collect() + }; + + let selection = ui::fuzzy_select(&format!("Select {}", label(ft)), &names, 0)?; + Ok(functions[selection].clone()) +} + +// --- Entry points --- + +pub async fn run(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFilter) -> Result<()> { + let ctx = resolve_context(&base).await?; + let ft = Some(kind); + match args.command { + None | Some(FunctionCommands::List) => list::run(&ctx, base.json, ft).await, + Some(FunctionCommands::View(v)) => { + view::run(&ctx, v.slug(), base.json, v.web, v.verbose, ft).await + } + Some(FunctionCommands::Delete(d)) => delete::run(&ctx, d.slug(), d.force, ft).await, + Some(FunctionCommands::Invoke(i)) => invoke::run(&ctx, &i, base.json, ft).await, + } +} + +pub async fn run_functions(base: BaseArgs, args: FunctionsArgs) -> Result<()> { + let ctx = resolve_context(&base).await?; + match args.command { + None => list::run(&ctx, base.json, args.function_type).await, + Some(FunctionsCommands::List(ref la)) => list::run(&ctx, base.json, la.function_type).await, + Some(FunctionsCommands::View(v)) => { + view::run( + &ctx, + v.slug(), + base.json, + v.web, + v.verbose, + args.function_type, + ) + .await + } + Some(FunctionsCommands::Delete(d)) => { + delete::run(&ctx, d.slug(), d.force, d.function_type).await + } + Some(FunctionsCommands::Invoke(i)) => { + invoke::run(&ctx, &i.inner, base.json, i.function_type).await + } + } +} diff --git a/src/functions/view.rs b/src/functions/view.rs new file mode 100644 index 0000000..431bc5d --- /dev/null +++ b/src/functions/view.rs @@ -0,0 +1,395 @@ +use std::fmt::Write as _; + +use anyhow::{anyhow, bail, Result}; +use dialoguer::console; +use urlencoding::encode; + +use crate::ui::prompt_render::{ + render_code_lines, render_content_lines, render_options, render_prompt_block, +}; +use crate::ui::{ + is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, +}; + +use super::{api, build_web_path, label, label_plural, select_function_interactive}; +use super::{FunctionTypeFilter, ResolvedContext}; + +pub async fn run( + ctx: &ResolvedContext, + slug: Option<&str>, + json: bool, + web: bool, + verbose: bool, + ft: Option, +) -> Result<()> { + let project_id = &ctx.project.id; + let function = match slug { + Some(s) => with_spinner( + &format!("Loading {}...", label(ft)), + api::get_function_by_slug(&ctx.client, project_id, s), + ) + .await? + .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, + None => { + if !is_interactive() { + bail!( + "{} slug required. Use: bt {} view ", + label(ft), + label_plural(ft), + ); + } + select_function_interactive(&ctx.client, project_id, ft).await? + } + }; + + if web { + let path = build_web_path(&function); + let url = format!( + "{}/app/{}/p/{}/{}", + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(&ctx.project.name), + path + ); + open::that(&url)?; + print_command_status(CommandStatus::Success, &format!("Opened {url} in browser")); + return Ok(()); + } + + if json { + println!("{}", serde_json::to_string(&function)?); + return Ok(()); + } + + let mut output = String::new(); + writeln!(output, "Viewing {}", console::style(&function.name).bold())?; + + if let Some(ft) = &function.function_type { + writeln!(output, "{} {}", console::style("Type:").dim(), ft)?; + } + if let Some(desc) = &function.description { + if !desc.is_empty() { + writeln!(output, "{} {}", console::style("Description:").dim(), desc)?; + } + } + + if let Some(pd) = &function.prompt_data { + let options = pd.get("options"); + if let Some(model) = options + .and_then(|o| o.get("model")) + .and_then(|m| m.as_str()) + { + writeln!(output, "{} {}", console::style("Model:").dim(), model)?; + } + if verbose { + if let Some(opts) = options { + render_options(&mut output, opts)?; + } + } + writeln!(output)?; + + if let Some(prompt_block) = pd.get("prompt") { + render_prompt_block(&mut output, prompt_block)?; + } + } + + if let Some(fd) = &function.function_data { + if let Some(fd_type) = fd.get("type").and_then(|t| t.as_str()) { + match fd_type { + "code" => { + if let Some(data) = fd.get("data") { + let data_type = data.get("type").and_then(|t| t.as_str()); + + if let Some(runtime) = data.get("runtime_context").and_then(|rc| { + let rt = rc.get("runtime").and_then(|r| r.as_str())?; + let ver = rc.get("version").and_then(|v| v.as_str()).unwrap_or(""); + Some(if ver.is_empty() { + rt.to_string() + } else { + format!("{rt} {ver}") + }) + }) { + writeln!(output, "{} {}", console::style("Runtime:").dim(), runtime)?; + } + + match data_type { + Some("inline") => { + if let Some(code) = data.get("code").and_then(|c| c.as_str()) { + if !code.is_empty() { + writeln!(output)?; + writeln!(output, "{}", console::style("Code:").dim())?; + render_code_lines(&mut output, code)?; + } + } + } + Some("bundle") => { + match data.get("preview").and_then(|p| p.as_str()) { + Some(p) if !p.is_empty() => { + writeln!(output)?; + writeln!( + output, + "{}", + console::style("Code (preview):").dim() + )?; + render_code_lines(&mut output, p)?; + } + _ => { + writeln!( + output, + " {}", + console::style("Code bundle — preview not available") + .dim() + )?; + } + } + + if verbose { + if let Some(bid) = + data.get("bundle_id").and_then(|b| b.as_str()) + { + writeln!( + output, + " {} {}", + console::style("Bundle ID:").dim(), + bid + )?; + } + if let Some(loc) = data.get("location") { + let loc_str = match loc.get("type").and_then(|t| t.as_str()) + { + Some("experiment") => { + let eval_name = loc + .get("eval_name") + .and_then(|e| e.as_str()) + .unwrap_or("?"); + let pos_type = loc + .get("position") + .and_then(|p| p.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or("?"); + format!("experiment/{eval_name}/{pos_type}") + } + Some("function") => { + let index = loc + .get("index") + .and_then(|i| i.as_u64()) + .map(|i| i.to_string()) + .unwrap_or_else(|| "?".to_string()); + format!("function/{index}") + } + Some(other) => other.to_string(), + None => "?".to_string(), + }; + writeln!( + output, + " {} {}", + console::style("Location:").dim(), + loc_str + )?; + } + } + } + _ => { + writeln!(output, "{} code", console::style("Function:").dim())?; + } + } + } + } + "global" => { + writeln!( + output, + "{} global (built-in)", + console::style("Function:").dim() + )?; + if let Some(name) = fd.get("name").and_then(|n| n.as_str()) { + writeln!(output, " {} {}", console::style("Name:").dim(), name)?; + } + } + "facet" => { + if let Some(model) = fd.get("model").and_then(|m| m.as_str()) { + writeln!(output, "{} {}", console::style("Model:").dim(), model)?; + } + if let Some(prompt) = fd.get("prompt").and_then(|p| p.as_str()) { + writeln!(output)?; + writeln!(output, "{}", console::style("Prompt:").dim())?; + render_content_lines(&mut output, prompt)?; + } + if let Some(pp) = fd.get("preprocessor") { + let name = pp.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let pp_type = pp.get("type").and_then(|t| t.as_str()).unwrap_or("?"); + writeln!( + output, + "{} {} ({})", + console::style("Preprocessor:").dim(), + name, + pp_type + )?; + } + } + "topic_map" => { + if let Some(facet) = fd.get("source_facet").and_then(|f| f.as_str()) { + writeln!( + output, + "{} {}", + console::style("Source facet:").dim(), + facet + )?; + } + if let Some(model) = fd.get("embedding_model").and_then(|m| m.as_str()) { + writeln!( + output, + "{} {}", + console::style("Embedding model:").dim(), + model + )?; + } + if let Some(topics) = fd.get("topic_names").and_then(|t| t.as_object()) { + let mut names: Vec<&str> = topics + .iter() + .filter(|(k, _)| k.as_str() != "noise") + .filter_map(|(_, v)| v.as_str()) + .collect(); + names.sort(); + writeln!( + output, + "{} {}", + console::style("Topics:").dim(), + names.len() + )?; + for name in &names { + writeln!(output, " {name}")?; + } + } + let path = build_web_path(&function); + let url = format!( + "{}/app/{}/p/{}/{}", + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(&ctx.project.name), + path + ); + writeln!( + output, + "\n{} {}", + console::style("View in browser:").dim(), + console::style(&url).underlined() + )?; + } + "parameters" => { + render_parameters(&mut output, fd)?; + } + "prompt" => {} + other => { + writeln!(output, "{} {}", console::style("Function:").dim(), other)?; + } + } + } + } + + if verbose { + if let Some(tags) = &function.tags { + if !tags.is_empty() { + writeln!( + output, + "\n{} {}", + console::style("Tags:").dim(), + tags.join(", ") + )?; + } + } + if let Some(meta) = &function.metadata { + if let Some(obj) = meta.as_object() { + if !obj.is_empty() { + writeln!( + output, + "{} {}", + console::style("Metadata:").dim(), + serde_json::to_string_pretty(meta).unwrap_or_default() + )?; + } + } + } + } + + print_with_pager(&output)?; + Ok(()) +} + +fn render_prompt_value(output: &mut String, val: &serde_json::Value) -> Result<()> { + if let Some(model) = val + .get("options") + .and_then(|o| o.get("model")) + .and_then(|m| m.as_str()) + { + writeln!(output, " {} {}", console::style("Model:").dim(), model)?; + } + + let mut prompt_buf = String::new(); + if let Some(prompt_block) = val.get("prompt") { + render_prompt_block(&mut prompt_buf, prompt_block)?; + } + + for line in prompt_buf.lines() { + writeln!(output, " {line}")?; + } + Ok(()) +} + +fn render_parameters(output: &mut String, fd: &serde_json::Value) -> Result<()> { + let schema = fd.get("__schema"); + let data = fd.get("data"); + let properties = schema + .and_then(|s| s.get("properties")) + .and_then(|p| p.as_object()); + let required: Vec<&str> = schema + .and_then(|s| s.get("required")) + .and_then(|r| r.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let Some(props) = properties else { + return Ok(()); + }; + + writeln!(output)?; + writeln!(output, "{}", console::style("Fields:").dim())?; + + for (name, prop) in props { + let type_label = prop + .get("x-bt-type") + .or_else(|| prop.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let is_required = required.contains(&name.as_str()); + let tag = if is_required { "required" } else { "optional" }; + + writeln!( + output, + " {} {} {}", + console::style(name).bold(), + console::style(format!("({type_label})")).dim(), + console::style(format!("[{tag}]")).dim(), + )?; + + if let Some(desc) = prop.get("description").and_then(|d| d.as_str()) { + writeln!(output, " {desc}")?; + } + + if let Some(val) = data.and_then(|d| d.get(name)) { + if type_label == "prompt" { + render_prompt_value(output, val)?; + } else { + let display = match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => "null".to_string(), + other => serde_json::to_string(other).unwrap_or_default(), + }; + writeln!(output, " {} {}", console::style("Value:").dim(), display)?; + } + } + } + + Ok(()) +} diff --git a/src/http.rs b/src/http.rs index 9f7f0ab..2e43844 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,7 +1,8 @@ use anyhow::{Context, Result}; use reqwest::Client; use serde::de::DeserializeOwned; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json::json; use crate::auth::LoginContext; @@ -13,6 +14,11 @@ pub struct ApiClient { org_name: String, } +#[derive(Debug, Deserialize)] +pub struct BtqlResponse { + pub data: Vec, +} + impl ApiClient { pub fn new(ctx: &LoginContext) -> Result { let http = Client::builder() @@ -36,6 +42,11 @@ impl ApiClient { &self.org_name } + pub fn with_org_name(mut self, org: String) -> Self { + self.org_name = org; + self + } + pub async fn get(&self, path: &str) -> Result { let url = self.url(path); let response = self @@ -81,6 +92,21 @@ impl ApiClient { body: &B, headers: &[(&str, &str)], ) -> Result + where + T: DeserializeOwned, + B: Serialize, + { + self.post_with_headers_timeout(path, body, headers, None) + .await + } + + pub async fn post_with_headers_timeout( + &self, + path: &str, + body: &B, + headers: &[(&str, &str)], + timeout: Option, + ) -> Result where T: DeserializeOwned, B: Serialize, @@ -91,6 +117,9 @@ impl ApiClient { for (key, value) in headers { request = request.header(*key, *value); } + if let Some(t) = timeout { + request = request.timeout(t); + } let response = request.send().await.context("request failed")?; @@ -140,4 +169,20 @@ impl ApiClient { Ok(()) } + + pub async fn btql(&self, query: &str) -> Result> { + let body = json!({ + "query": query, + "fmt": "json", + }); + + let org_name = self.org_name(); + let headers = if !org_name.is_empty() { + vec![("x-bt-org-name", org_name)] + } else { + Vec::new() + }; + + self.post_with_headers("/btql", &body, &headers).await + } } diff --git a/src/main.rs b/src/main.rs index dfe2a54..4cc56fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,16 +9,20 @@ mod config; mod env; #[cfg(unix)] mod eval; +mod experiments; +mod functions; mod http; mod init; mod projects; mod prompts; +mod scorers; mod self_update; mod setup; mod sql; mod status; mod switch; mod sync; +mod tools; mod traces; mod ui; mod utils; @@ -44,25 +48,29 @@ const HELP_TEMPLATE: &str = "\ {before-help}{about} - {usage} Core - init Initialize .bt config directory and files - auth Authenticate bt with Braintrust - switch Switch org and project context - view View logs, traces, and spans + init Initialize .bt config directory and files + auth Authenticate bt with Braintrust + switch Switch org and project context + view View logs, traces, and spans Projects & resources - projects Manage projects - prompts Manage prompts + projects Manage projects + prompts Manage prompts + functions Manage functions (tools, scorers, and more) + tools Manage tools + scorers Manage scorers + experiments Manage experiments Data & evaluation - eval Run eval files - sql Run SQL queries against Braintrust - sync Synchronize project logs between Braintrust and local NDJSON files + eval Run eval files + sql Run SQL queries against Braintrust + sync Synchronize project logs between Braintrust and local NDJSON files Additional - docs Manage workflow docs for coding agents - self Self-management commands - setup Configure Braintrust setup flows - status Show current org and project context + docs Manage workflow docs for coding agents + self Self-management commands + setup Configure Braintrust setup flows + status Show current org and project context Flags --profile Use a saved login profile [env: BRAINTRUST_PROFILE] @@ -88,7 +96,7 @@ Read the manual at https://braintrust.dev/docs/cli version = CLI_VERSION, before_help = BANNER, help_template = HELP_TEMPLATE, - disable_help_subcommand = true, + after_help = "Docs: https://braintrust.dev/docs", )] struct Cli { #[command(subcommand)] @@ -119,6 +127,14 @@ enum Commands { #[command(name = "self")] /// Self-management commands SelfCommand(self_update::SelfArgs), + /// Manage tools + Tools(CLIArgs), + /// Manage scorers + Scorers(CLIArgs), + /// Manage functions (tools, scorers, and more) + Functions(CLIArgs), + /// Manage experiments + Experiments(CLIArgs), /// Synchronize project logs between Braintrust and local NDJSON files Sync(CLIArgs), /// Switch org and project context @@ -155,6 +171,10 @@ async fn main() -> Result<()> { Commands::Eval(cmd) => eval::run(cmd.base, cmd.args).await?, Commands::Projects(cmd) => projects::run(cmd.base, cmd.args).await?, Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, + Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?, + Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?, + Commands::Functions(cmd) => functions::run_functions(cmd.base, cmd.args).await?, + Commands::Experiments(cmd) => experiments::run(cmd.base, cmd.args).await?, Commands::Sync(cmd) => sync::run(cmd.base, cmd.args).await?, Commands::SelfCommand(args) => self_update::run(args).await?, Commands::Switch(cmd) => switch::run(cmd.base, cmd.args).await?, diff --git a/src/prompts/delete.rs b/src/prompts/delete.rs index 668b0f2..dbd437d 100644 --- a/src/prompts/delete.rs +++ b/src/prompts/delete.rs @@ -7,20 +7,23 @@ use crate::{ ui::{self, is_interactive, print_command_status, with_spinner, CommandStatus}, }; -pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: bool) -> Result<()> { +use super::ResolvedContext; + +pub async fn run(ctx: &ResolvedContext, slug: Option<&str>, force: bool) -> Result<()> { + let project_name = &ctx.project.name; if force && slug.is_none() { bail!("slug required when using --force. Use: bt prompts delete --force"); } let prompt = match slug { - Some(s) => api::get_prompt_by_slug(client, project, s) + Some(s) => api::get_prompt_by_slug(&ctx.client, project_name, s) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, None => { if !is_interactive() { bail!("prompt slug required. Use: bt prompts delete "); } - select_prompt_interactive(client, project).await? + select_prompt_interactive(&ctx.client, project_name).await? } }; @@ -28,7 +31,7 @@ pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: b let confirm = Confirm::new() .with_prompt(format!( "Delete prompt '{}' from {}?", - &prompt.name, project + &prompt.name, project_name )) .default(false) .interact()?; @@ -38,7 +41,12 @@ pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: b } } - match with_spinner("Deleting prompt...", api::delete_prompt(client, &prompt.id)).await { + match with_spinner( + "Deleting prompt...", + api::delete_prompt(&ctx.client, &prompt.id), + ) + .await + { Ok(_) => { print_command_status( CommandStatus::Success, diff --git a/src/prompts/list.rs b/src/prompts/list.rs index bf58922..a7f9e7c 100644 --- a/src/prompts/list.rs +++ b/src/prompts/list.rs @@ -4,52 +4,56 @@ use anyhow::Result; use dialoguer::console; use crate::{ - http::ApiClient, ui::{apply_column_padding, header, print_with_pager, styled_table, truncate, with_spinner}, utils::pluralize, }; -use super::api; +use super::{api, ResolvedContext}; -pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> { - let prompts = with_spinner("Loading prompts...", api::list_prompts(client, project)).await?; +pub async fn run(ctx: &ResolvedContext, json: bool) -> Result<()> { + let project_name = &ctx.project.name; + let prompts = with_spinner( + "Loading prompts...", + api::list_prompts(&ctx.client, project_name), + ) + .await?; if json { println!("{}", serde_json::to_string(&prompts)?); - } else { - let mut output = String::new(); - - let count = format!( - "{} {}", - prompts.len(), - pluralize(prompts.len(), "prompt", None) - ); - writeln!( - output, - "{} found in {} {} {}\n", - console::style(count), - console::style(org).bold(), - console::style("/").dim().bold(), - console::style(project).bold() - )?; - - let mut table = styled_table(); - table.set_header(vec![header("Name"), header("Description"), header("Slug")]); - apply_column_padding(&mut table, (0, 6)); - - for prompt in &prompts { - let desc = prompt - .description - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| truncate(s, 60)) - .unwrap_or_else(|| "-".to_string()); - table.add_row(vec![&prompt.name, &desc, &prompt.slug]); - } - - write!(output, "{table}")?; - print_with_pager(&output)?; + return Ok(()); } + let mut output = String::new(); + + let count = format!( + "{} {}", + prompts.len(), + pluralize(prompts.len(), "prompt", None) + ); + writeln!( + output, + "{} found in {} {} {}\n", + console::style(count), + console::style(ctx.client.org_name()).bold(), + console::style("/").dim().bold(), + console::style(project_name).bold() + )?; + + let mut table = styled_table(); + table.set_header(vec![header("Name"), header("Description"), header("Slug")]); + apply_column_padding(&mut table, (0, 6)); + + for prompt in &prompts { + let desc = prompt + .description + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| truncate(s, 60)) + .unwrap_or_else(|| "-".to_string()); + table.add_row(vec![&prompt.name, &desc, &prompt.slug]); + } + + write!(output, "{table}")?; + print_with_pager(&output)?; Ok(()) } diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index faf4348..be3f554 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -5,10 +5,16 @@ use crate::{ args::BaseArgs, auth::login, http::ApiClient, - projects::api::get_project_by_name, + projects::api::{get_project_by_name, Project}, ui::{is_interactive, select_project_interactive}, }; +pub(crate) struct ResolvedContext { + pub client: ApiClient, + pub app_url: String, + pub project: Project, +} + mod api; mod delete; mod list; @@ -81,9 +87,9 @@ impl DeleteArgs { } pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { - let ctx = login(&base).await?; - let client = ApiClient::new(&ctx)?; - let project = match base + let auth = login(&base).await?; + let client = ApiClient::new(&auth)?; + let project_name = match base .project .or_else(|| crate::config::load().ok().and_then(|c| c.project)) { @@ -92,27 +98,21 @@ pub async fn run(base: BaseArgs, args: PromptsArgs) -> Result<()> { None => anyhow::bail!("--project required (or set BRAINTRUST_DEFAULT_PROJECT)"), }; - get_project_by_name(&client, &project) + let project = get_project_by_name(&client, &project_name) .await? - .ok_or_else(|| anyhow!("project '{project}' not found"))?; + .ok_or_else(|| anyhow!("project '{project_name}' not found"))?; + + let ctx = ResolvedContext { + client, + app_url: auth.app_url, + project, + }; match args.command { - None | Some(PromptsCommands::List) => { - list::run(&client, &project, &ctx.login.org_name, base.json).await - } + None | Some(PromptsCommands::List) => list::run(&ctx, base.json).await, Some(PromptsCommands::View(p)) => { - view::run( - &client, - &ctx.app_url, - &project, - &ctx.login.org_name, - p.slug(), - base.json, - p.web, - p.verbose, - ) - .await + view::run(&ctx, p.slug(), base.json, p.web, p.verbose).await } - Some(PromptsCommands::Delete(p)) => delete::run(&client, &project, p.slug(), p.force).await, + Some(PromptsCommands::Delete(p)) => delete::run(&ctx, p.slug(), p.force).await, } } diff --git a/src/prompts/view.rs b/src/prompts/view.rs index f058175..66494c4 100644 --- a/src/prompts/view.rs +++ b/src/prompts/view.rs @@ -1,34 +1,27 @@ use std::fmt::Write as _; -use std::sync::LazyLock; use anyhow::{anyhow, bail, Result}; use dialoguer::console; -use regex::Regex; use urlencoding::encode; -static TEMPLATE_VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); - -use crate::http::ApiClient; use crate::prompts::delete::select_prompt_interactive; +use crate::ui::prompt_render::{render_options, render_prompt_block}; use crate::ui::{print_command_status, print_with_pager, with_spinner, CommandStatus}; -use super::api; +use super::{api, ResolvedContext}; -#[allow(clippy::too_many_arguments)] pub async fn run( - client: &ApiClient, - app_url: &str, - project: &str, - org_name: &str, + ctx: &ResolvedContext, slug: Option<&str>, json: bool, web: bool, verbose: bool, ) -> Result<()> { + let project_name = &ctx.project.name; let prompt = match slug { Some(s) => with_spinner( "Loading prompt...", - api::get_prompt_by_slug(client, project, s), + api::get_prompt_by_slug(&ctx.client, project_name, s), ) .await? .ok_or_else(|| anyhow!("prompt with slug '{s}' not found"))?, @@ -36,16 +29,16 @@ pub async fn run( if !crate::ui::is_interactive() { bail!("prompt slug required. Use: bt prompts view "); } - select_prompt_interactive(client, project).await? + select_prompt_interactive(&ctx.client, project_name).await? } }; if web { let url = format!( "{}/app/{}/p/{}/prompts/{}", - app_url.trim_end_matches('/'), - encode(org_name), - encode(project), + ctx.app_url.trim_end_matches('/'), + encode(ctx.client.org_name()), + encode(project_name), encode(&prompt.id) ); open::that(&url)?; @@ -80,184 +73,9 @@ pub async fn run( writeln!(output)?; if let Some(prompt_block) = prompt.prompt_data.as_ref().and_then(|pd| pd.get("prompt")) { - match prompt_block.get("type").and_then(|t| t.as_str()) { - Some("chat") => { - if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { - for msg in messages { - render_message(&mut output, msg)?; - } - } - } - Some("completion") => { - if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { - render_content_lines(&mut output, content)?; - writeln!(output)?; - } - } - _ => {} - } - - if let Some(tools_val) = prompt_block.get("tools") { - let tools: Option> = match tools_val { - serde_json::Value::Array(arr) => Some(arr.clone()), - serde_json::Value::String(s) => serde_json::from_str(s).ok(), - _ => None, - }; - if let Some(ref tools) = tools { - render_tools(&mut output, tools)?; - } - } + render_prompt_block(&mut output, prompt_block)?; } print_with_pager(&output)?; Ok(()) } - -fn render_message(output: &mut String, msg: &serde_json::Value) -> Result<()> { - let role = msg - .get("role") - .and_then(|r| r.as_str()) - .unwrap_or("unknown"); - let styled_role = match role { - "system" => console::style(role).dim().bold(), - "user" => console::style(role).green().bold(), - "assistant" => console::style(role).blue().bold(), - _ => console::style(role).bold(), - }; - writeln!(output, "{} {styled_role}", console::style("┃").dim())?; - - if let Some(content) = msg.get("content") { - match content { - serde_json::Value::String(s) => render_content_lines(output, s)?, - serde_json::Value::Array(parts) => { - for part in parts { - match part.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(text) = part.get("text").and_then(|t| t.as_str()) { - render_content_lines(output, text)?; - } - } - Some("image_url") => { - let url = part - .get("image_url") - .and_then(|iu| iu.get("url")) - .and_then(|u| u.as_str()) - .unwrap_or("?"); - writeln!( - output, - "{} {}", - console::style("│").dim(), - console::style(format!("[image: {url}]")).dim() - )?; - } - _ => {} - } - } - } - _ => {} - } - } - - if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { - for tc in tool_calls { - if let Some(func) = tc.get("function") { - let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let args = func.get("arguments").and_then(|a| a.as_str()).unwrap_or(""); - writeln!( - output, - "{} {}({})", - console::style("│").dim(), - console::style(name).yellow(), - args - )?; - } - } - } - - writeln!(output)?; - Ok(()) -} - -fn render_content_lines(output: &mut String, content: &str) -> Result<()> { - for line in content.lines() { - let highlighted = highlight_template_vars(line); - writeln!(output, "{} {highlighted}", console::style("│").dim())?; - } - Ok(()) -} - -fn highlight_template_vars(line: &str) -> String { - let re = &*TEMPLATE_VAR_RE; - let mut result = String::new(); - let mut last_end = 0; - for cap in re.find_iter(line) { - result.push_str(&line[last_end..cap.start()]); - result.push_str(&format!("{}", console::style(cap.as_str()).cyan().bold())); - last_end = cap.end(); - } - result.push_str(&line[last_end..]); - result -} - -fn render_options(output: &mut String, options: &serde_json::Value) -> Result<()> { - let Some(params) = options.get("params").and_then(|p| p.as_object()) else { - return Ok(()); - }; - - for (key, val) in params { - if !val.is_null() { - writeln!( - output, - " {:<24}{}", - console::style(format!("{key}:")).dim(), - format_param_value(val) - )?; - } - } - - Ok(()) -} - -fn format_param_value(val: &serde_json::Value) -> String { - match val { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Array(arr) => { - let items: Vec = arr.iter().map(format_param_value).collect(); - format!("[{}]", items.join(", ")) - } - other => other.to_string(), - } -} - -fn render_tools(output: &mut String, tools: &[serde_json::Value]) -> Result<()> { - writeln!( - output, - "{} {}", - console::style("┃").dim(), - console::style("tools").magenta().bold() - )?; - for tool in tools { - let func = tool.get("function").unwrap_or(tool); - let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let desc = func.get("description").and_then(|d| d.as_str()); - match desc { - Some(d) => writeln!( - output, - "{} {} {}", - console::style("│").dim(), - console::style(name).yellow(), - console::style(format!("— {d}")).dim() - )?, - None => writeln!( - output, - "{} {}", - console::style("│").dim(), - console::style(name).yellow() - )?, - } - } - writeln!(output)?; - Ok(()) -} diff --git a/src/scorers.rs b/src/scorers.rs new file mode 100644 index 0000000..40fe11a --- /dev/null +++ b/src/scorers.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +use crate::args::BaseArgs; +use crate::functions::{self, FunctionArgs, FunctionTypeFilter}; + +pub type ScorersArgs = FunctionArgs; + +pub async fn run(base: BaseArgs, args: ScorersArgs) -> Result<()> { + functions::run(base, args, FunctionTypeFilter::Scorer).await +} diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..255137e --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +use crate::args::BaseArgs; +use crate::functions::{self, FunctionArgs, FunctionTypeFilter}; + +pub type ToolsArgs = FunctionArgs; + +pub async fn run(base: BaseArgs, args: ToolsArgs) -> Result<()> { + functions::run(base, args, FunctionTypeFilter::Tool).await +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4e06f05..5a2ba4a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use std::sync::atomic::{AtomicBool, Ordering}; mod pager; +pub mod prompt_render; mod select; mod spinner; mod status; diff --git a/src/ui/prompt_render.rs b/src/ui/prompt_render.rs new file mode 100644 index 0000000..ec404fc --- /dev/null +++ b/src/ui/prompt_render.rs @@ -0,0 +1,202 @@ +use std::fmt::Write as _; +use std::sync::LazyLock; + +use anyhow::Result; +use dialoguer::console; +use regex::Regex; + +static TEMPLATE_VAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{\{([^}]+)\}\}").unwrap()); + +pub fn render_message(output: &mut String, msg: &serde_json::Value) -> Result<()> { + let role = msg + .get("role") + .and_then(|r| r.as_str()) + .unwrap_or("unknown"); + let styled_role = match role { + "system" => console::style(role).dim().bold(), + "user" => console::style(role).green().bold(), + "assistant" => console::style(role).blue().bold(), + _ => console::style(role).bold(), + }; + writeln!(output, "{} {styled_role}", console::style("┃").dim())?; + + if let Some(content) = msg.get("content") { + match content { + serde_json::Value::String(s) => render_content_lines(output, s)?, + serde_json::Value::Array(parts) => { + for part in parts { + match part.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + render_content_lines(output, text)?; + } + } + Some("image_url") => { + let url = part + .get("image_url") + .and_then(|iu| iu.get("url")) + .and_then(|u| u.as_str()) + .unwrap_or("?"); + writeln!( + output, + "{} {}", + console::style("│").dim(), + console::style(format!("[image: {url}]")).dim() + )?; + } + _ => {} + } + } + } + _ => {} + } + } + + if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) { + for tc in tool_calls { + if let Some(func) = tc.get("function") { + let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let args = func.get("arguments").and_then(|a| a.as_str()).unwrap_or(""); + writeln!( + output, + "{} {}({})", + console::style("│").dim(), + console::style(name).yellow(), + args + )?; + } + } + } + + writeln!(output)?; + Ok(()) +} + +pub fn render_content_lines(output: &mut String, content: &str) -> Result<()> { + for line in content.lines() { + let highlighted = highlight_template_vars(line); + writeln!(output, "{} {highlighted}", console::style("│").dim())?; + } + Ok(()) +} + +pub fn render_code_lines(output: &mut String, code: &str) -> Result<()> { + let lines: Vec<&str> = code.lines().collect(); + let width = lines.len().to_string().len(); + for (i, line) in lines.iter().enumerate() { + writeln!( + output, + " {} {} {}", + console::style(format!("{:>width$}", i + 1)).dim(), + console::style("│").dim(), + line + )?; + } + Ok(()) +} + +fn highlight_template_vars(line: &str) -> String { + let re = &*TEMPLATE_VAR_RE; + let mut result = String::new(); + let mut last_end = 0; + for cap in re.find_iter(line) { + result.push_str(&line[last_end..cap.start()]); + result.push_str(&format!("{}", console::style(cap.as_str()).cyan().bold())); + last_end = cap.end(); + } + result.push_str(&line[last_end..]); + result +} + +pub fn render_prompt_block(output: &mut String, prompt_block: &serde_json::Value) -> Result<()> { + match prompt_block.get("type").and_then(|t| t.as_str()) { + Some("chat") => { + if let Some(messages) = prompt_block.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + render_message(output, msg)?; + } + } + } + Some("completion") => { + if let Some(content) = prompt_block.get("content").and_then(|c| c.as_str()) { + render_content_lines(output, content)?; + writeln!(output)?; + } + } + _ => {} + } + if let Some(tools_val) = prompt_block.get("tools") { + let tools: Option> = match tools_val { + serde_json::Value::Array(arr) => Some(arr.clone()), + serde_json::Value::String(s) => serde_json::from_str(s).ok(), + _ => None, + }; + if let Some(ref tools) = tools { + render_tools(output, tools)?; + } + } + Ok(()) +} + +pub fn render_options(output: &mut String, options: &serde_json::Value) -> Result<()> { + let Some(params) = options.get("params").and_then(|p| p.as_object()) else { + return Ok(()); + }; + + for (key, val) in params { + if !val.is_null() { + writeln!( + output, + " {:<24}{}", + console::style(format!("{key}:")).dim(), + format_param_value(val) + )?; + } + } + + Ok(()) +} + +fn format_param_value(val: &serde_json::Value) -> String { + match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter().map(format_param_value).collect(); + format!("[{}]", items.join(", ")) + } + other => other.to_string(), + } +} + +pub fn render_tools(output: &mut String, tools: &[serde_json::Value]) -> Result<()> { + writeln!( + output, + "{} {}", + console::style("┃").dim(), + console::style("tools").magenta().bold() + )?; + for tool in tools { + let func = tool.get("function").unwrap_or(tool); + let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let desc = func.get("description").and_then(|d| d.as_str()); + match desc { + Some(d) => writeln!( + output, + "{} {} {}", + console::style("│").dim(), + console::style(name).yellow(), + console::style(format!("— {d}")).dim() + )?, + None => writeln!( + output, + "{} {}", + console::style("│").dim(), + console::style(name).yellow() + )?, + } + } + writeln!(output)?; + Ok(()) +}