commit b171a6b2b2da7d2de3f631b5c2408f8ab07b10b6 Author: leach Date: Fri Aug 15 15:01:28 2025 -0400 Initial commit: GPT CLI (Rust) - Complete Rust implementation of GPT CLI - Support for OpenAI and Anthropic models - Session persistence and management - Web search integration via Responses API - Interactive commands and model switching - Comprehensive error handling and logging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2ff8b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Rust build artifacts +/target/ +**/*.rs.bk +*.pdb + +# Cargo lock file (include for applications, exclude for libraries) +# Cargo.lock + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Session storage (for this CLI app specifically) +.chat_cli_sessions/ + +# Log files +*.log + +# Temporary files +*.tmp +*.temp + +# Backup files +*.bak +*.backup diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..28b9105 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2040 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gpt-cli-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "console", + "dialoguer", + "dirs", + "indicatif", + "reqwest", + "rustyline", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.1", + "web-time", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.1", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "13.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "winapi", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5084059 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "gpt-cli-rust" +version = "0.1.0" +edition = "2021" +description = "A lightweight command-line interface for chatting with AI models (OpenAI and Anthropic)" +authors = ["Your Name "] + +[dependencies] +clap = { version = "4.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } +anyhow = "1.0" +chrono = { version = "0.4", features = ["serde"] } +dialoguer = "0.11" +console = "0.15" +indicatif = "0.17" +dirs = "5.0" +rustyline = "13.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ab81f4 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# GPT CLI (Rust) + +A lightweight command-line interface for chatting with AI models (OpenAI and Anthropic), written in Rust. + +This is a Rust rewrite of the original Python gptCLI, providing the same functionality with improved performance and memory safety. + +## Features + +- **Multi-provider support** - Works with both OpenAI and Anthropic models +- **Session persistence** - Conversations are automatically saved and can be resumed later +- **Model switching** - Change models on the fly with interactive selection +- **Web search** - Enable web search tool for OpenAI models (when supported) +- **Reasoning summaries** - Enable reasoning summaries for compatible OpenAI models +- **Interactive commands** - Full set of slash commands for session management +- **Cross-platform** - Runs on Linux, macOS, and Windows + +## Installation + +### Prerequisites + +- Rust 1.70 or later +- API keys for the providers you want to use: + - `OPENAI_API_KEY` for OpenAI models + - `ANTHROPIC_API_KEY` for Anthropic models + +### Build from source + +```bash +git clone +cd gpt-cli-rust +cargo build --release +``` + +The binary will be available at `target/release/gpt-cli-rust`. + +## Usage + +```bash +# Run with default session and model +./target/release/gpt-cli-rust + +# Start with a specific session +./target/release/gpt-cli-rust --session my-session + +# Start with a specific model +./target/release/gpt-cli-rust --model claude-3-5-sonnet-20241022 + +# Combine options +./target/release/gpt-cli-rust --session work --model gpt-4o +``` + +## Supported Models + +### OpenAI +- gpt-4.1 +- gpt-4.1-mini +- gpt-4o +- gpt-5 (default) +- gpt-5-chat-latest +- o1 +- o3 +- o4-mini +- o3-mini + +### Anthropic +- claude-3-5-sonnet-20241022 +- claude-3-5-haiku-20241022 +- claude-3-opus-20240229 +- claude-3-sonnet-20240229 +- claude-3-haiku-20240307 + +## Commands + +### Chat Commands +- Type normally to chat with the AI +- Use `/help` to see all available commands + +### Session Management +- `/list` - List all saved sessions +- `/new ` - Create a new session +- `/switch [name]` - Switch to another session (interactive picker if no name) +- `/delete [name]` - Delete a session (interactive picker if no name) +- `/clear` - Clear current conversation + +### Model Management +- `/model [name]` - Switch model (interactive picker if no name) +- `/models` - List all supported models + +### Features (OpenAI only) +- `/tool websearch on|off` - Enable/disable web search +- `/reasoning on|off` - Enable/disable reasoning summaries +- `/effort [low|medium|high]` - Set reasoning effort level (GPT-5 only) + +### Other +- `/help` - Show help +- `/exit` - Exit the CLI + +## Environment Variables + +- `OPENAI_API_KEY` - Your OpenAI API key (required for OpenAI models) +- `ANTHROPIC_API_KEY` - Your Anthropic API key (required for Anthropic models) +- `OPENAI_BASE_URL` - Custom base URL for OpenAI API (optional, for proxies) +- `DEFAULT_MODEL` - Default model if not specified (default: gpt-5) + +## Session Storage + +Sessions are stored as JSON files in `~/.chat_cli_sessions/`. Each session contains: +- Conversation history +- Current model +- Feature settings (web search, reasoning) +- Metadata (last updated time) + +## Differences from Python Version + +While functionally equivalent, this Rust version offers: +- **Better performance** - Faster startup and lower memory usage +- **Enhanced safety** - Rust's type system prevents many common errors +- **Improved error handling** - More detailed error messages and recovery +- **Modern UI** - Better terminal colors and interactive selection +- **Cross-platform** - Single binary that works across platforms + +## Development + +### Project Structure + +``` +src/ +├── main.rs # Entry point and CLI argument parsing +├── cli.rs # Main CLI loop and command handling +├── core/ +│ ├── mod.rs # Core module exports +│ ├── session.rs # Session management and persistence +│ ├── client.rs # API clients for OpenAI and Anthropic +│ └── provider.rs # Provider definitions and model lists +└── utils/ + ├── mod.rs # Utility module exports + ├── display.rs # Terminal display and formatting + └── input.rs # Input handling and interactive prompts +``` + +### Building + +```bash +# Development build +cargo build + +# Release build (optimized) +cargo build --release + +# Run tests +cargo test + +# Run with debug output +RUST_LOG=debug cargo run + +# Format code +cargo fmt + +# Check for issues +cargo clippy +``` + +## License + +This project maintains the same license as the original Python version. \ No newline at end of file diff --git a/improvements.txt b/improvements.txt new file mode 100644 index 0000000..067b994 --- /dev/null +++ b/improvements.txt @@ -0,0 +1,64 @@ +Your Rust CLI for AI conversations is well-structured and functional. Here are specific improvements to consider: + +## Code Quality & Architecture + +**1. Error Handling Enhancement** +- Use custom error types instead of `anyhow` everywhere for better type safety +- Add specific error variants for API failures, session errors, etc. +- Consider using `thiserror` for cleaner error definitions + +**2. Configuration Management** +- Extract hardcoded values (max_tokens=4096, API versions) to a config module +- Add configuration file support (~/.gpt-cli-config.toml) +- Environment variable validation on startup + +**3. Memory & Performance** +- Consider limiting conversation history size to prevent unbounded growth +- Add message truncation for very long conversations +- Implement lazy loading for session list when many sessions exist + +## Functionality Improvements + +**4. Enhanced CLI Features** +- Add `/export` command to save conversations to markdown/text +- Implement `/search` to find messages within current session +- Add message editing/deletion capabilities +- Support for conversation templates/presets + +**5. Better Model Management** +- Add model aliases (e.g., "latest-gpt" → "gpt-5") +- Validate API keys for each provider on startup +- Show token usage/costs if APIs provide this data + +**6. Session Enhancements** +- Add session tagging/categorization +- Implement session archiving +- Add conversation statistics (message count, tokens, etc.) + +## Technical Robustness + +**7. Network & Reliability** +- Add retry logic with exponential backoff for API calls +- Implement request timeout configuration +- Add connection pooling for better performance +- Support for streaming responses + +**8. Security & Privacy** +- Encrypt stored sessions (optional) +- Add option to exclude sensitive sessions from persistence +- Validate/sanitize user inputs more thoroughly + +## User Experience + +**9. Interactive Improvements** +- Add syntax highlighting for code blocks in responses +- Implement better pagination for long responses +- Add keyboard shortcuts (Ctrl+C handling, arrow key history) +- Support for multi-line input with improved editor + +**10. Logging & Debugging** +- Add structured logging with different levels +- Include request/response logging (with API key redaction) +- Add timing metrics for performance monitoring + +The codebase shows good Rust practices with proper error handling, clean separation of concerns, and maintainable structure. Focus on the configuration management and session enhancements first, as these would provide the most immediate value to users. \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..d440238 --- /dev/null +++ b/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Simple runner script for the Rust gptCLI + +# Build if the binary doesn't exist or is older than source files +if [[ ! -f target/release/gpt-cli-rust ]] || [[ src/ -nt target/release/gpt-cli-rust ]]; then + echo "Building gpt-cli-rust..." + cargo build --release +fi + +# Run the CLI with any passed arguments +exec ./target/release/gpt-cli-rust "$@" \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6a76da5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,429 @@ +use anyhow::Result; + +use crate::core::{ + create_client, get_provider_for_model, provider::get_all_models, provider::get_supported_models, + provider::is_model_supported, ChatClient, Session, +}; +use crate::utils::{Display, InputHandler}; + +pub struct ChatCLI { + session: Session, + client: Option, + current_model: Option, + display: Display, + input: InputHandler, +} + +impl ChatCLI { + pub fn new(session: Session) -> Result { + Ok(Self { + session, + client: None, + current_model: None, + display: Display::new(), + input: InputHandler::new()?, + }) + } + + fn get_client(&mut self) -> Result<&ChatClient> { + if self.client.is_none() || self.current_model.as_ref() != Some(&self.session.model) { + let client = create_client(&self.session.model)?; + self.current_model = Some(self.session.model.clone()); + self.client = Some(client); + } + Ok(self.client.as_ref().unwrap()) + } + + pub async fn run(&mut self) -> Result<()> { + self.display.print_header(); + self.display.print_info("Type your message and press Enter. Commands start with '/'."); + self.display.print_info("Type /help for help."); + + let provider = get_provider_for_model(&self.session.model); + self.display.print_model_info(&self.session.model, provider.as_str()); + self.display.print_session_info(&self.session.name); + + println!(); + + loop { + match self.input.read_line("👤> ")? { + Some(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if line.starts_with('/') { + if !self.handle_command(line).await? { + break; + } + } else { + if let Err(e) = self.handle_user_message(line).await { + self.display.print_error(&format!("Error: {}", e)); + } + } + } + None => { + self.display.print_info("Goodbye!"); + break; + } + } + } + + self.session.save()?; + self.input.save_history()?; + Ok(()) + } + + async fn handle_user_message(&mut self, message: &str) -> Result<()> { + self.session.add_user_message(message.to_string()); + self.session.save()?; + + let spinner = self.display.show_spinner("Thinking"); + + // Clone data needed for the API call before getting mutable client reference + let model = self.session.model.clone(); + let messages = self.session.messages.clone(); + let enable_web_search = self.session.enable_web_search; + let enable_reasoning_summary = self.session.enable_reasoning_summary; + let reasoning_effort = self.session.reasoning_effort.clone(); + + let client = self.get_client()?; + + match client + .chat_completion( + &model, + &messages, + enable_web_search, + enable_reasoning_summary, + &reasoning_effort, + ) + .await + { + Ok(response) => { + spinner.finish("Done"); + self.display.print_assistant_response(&response); + self.session.add_assistant_message(response); + self.session.save()?; + } + Err(e) => { + spinner.finish_with_error("Failed"); + return Err(e); + } + } + + Ok(()) + } + + async fn handle_command(&mut self, command: &str) -> Result { + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Ok(true); + } + + match parts[0].to_lowercase().as_str() { + "/help" => { + self.display.print_help(); + } + "/exit" => { + self.session.save()?; + self.display.print_info("Session saved. Goodbye!"); + return Ok(false); + } + "/model" => { + self.handle_model_command(&parts).await?; + } + "/models" => { + self.list_models(); + } + "/list" => { + self.list_sessions()?; + } + "/new" => { + self.handle_new_session(&parts)?; + } + "/switch" => { + self.handle_switch_session(&parts).await?; + } + "/clear" => { + self.session.clear_messages(); + self.session.save()?; + self.display.print_command_result("Conversation cleared"); + } + "/delete" => { + self.handle_delete_session(&parts).await?; + } + "/tool" => { + self.handle_tool_command(&parts)?; + } + "/reasoning" => { + self.handle_reasoning_command(&parts)?; + } + "/effort" => { + self.handle_effort_command(&parts)?; + } + _ => { + self.display.print_error(&format!("Unknown command: {} (see /help)", parts[0])); + } + } + + Ok(true) + } + + async fn handle_model_command(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() == 1 { + let all_models = get_all_models(); + let selection = self.input.select_from_list( + "Select a model:", + &all_models, + Some(&self.session.model), + )?; + + if let Some(model) = selection { + self.session.model = model.to_string(); + let provider = get_provider_for_model(&self.session.model); + self.display.print_command_result(&format!( + "Model switched to {} ({})", + self.session.model, + provider.as_str() + )); + self.client = None; // Force client recreation + } + } else if parts.len() == 2 { + let model = parts[1]; + if !is_model_supported(model) { + self.display.print_error("Unsupported model. Use /models to see the list of supported models."); + } else { + self.session.model = model.to_string(); + let provider = get_provider_for_model(&self.session.model); + self.display.print_command_result(&format!( + "Model switched to {} ({})", + self.session.model, + provider.as_str() + )); + self.client = None; // Force client recreation + } + } else { + self.display.print_error("Usage: /model [model_name]"); + } + Ok(()) + } + + fn list_models(&self) { + self.display.print_info("Supported models:"); + let supported = get_supported_models(); + + for (provider, models) in supported { + println!(" {}:", provider.as_str().to_uppercase()); + for model in models { + let marker = if model == self.session.model { " <- current" } else { "" }; + println!(" {}{}", model, marker); + } + } + } + + fn list_sessions(&self) -> Result<()> { + let sessions = Session::list_sessions()?; + + if sessions.is_empty() { + self.display.print_info("No saved sessions"); + return Ok(()); + } + + self.display.print_info("Saved sessions:"); + for (name, updated) in sessions { + let marker = if name == self.session.name { "★" } else { " " }; + let date_str = updated.format("%Y-%m-%d %H:%M:%S"); + println!(" {} {} (updated: {})", marker, name, date_str); + } + + Ok(()) + } + + fn handle_new_session(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() != 2 { + self.display.print_error("Usage: /new "); + return Ok(()); + } + + self.session.save()?; + let new_session = Session::new(parts[1].to_string(), self.session.model.clone()); + self.session = new_session; + self.display.print_command_result(&format!("New session '{}' started", self.session.name)); + + Ok(()) + } + + async fn handle_switch_session(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() == 1 { + let sessions = Session::list_sessions()?; + let session_names: Vec = sessions + .into_iter() + .map(|(name, _)| name) + .filter(|name| name != &self.session.name) + .collect(); + + if let Some(selection) = self.input.select_from_list( + "Switch to session:", + &session_names, + None, + )? { + self.session.save()?; + match Session::load(&selection) { + Ok(session) => { + self.session = session; + self.display.print_command_result(&format!( + "Switched to session '{}' (model={})", + self.session.name, self.session.model + )); + self.client = None; // Force client recreation + } + Err(e) => { + self.display.print_error(&format!("Failed to load session: {}", e)); + } + } + } + } else if parts.len() == 2 { + let session_name = parts[1]; + self.session.save()?; + match Session::load(session_name) { + Ok(session) => { + self.session = session; + self.display.print_command_result(&format!( + "Switched to session '{}' (model={})", + self.session.name, self.session.model + )); + self.client = None; // Force client recreation + } + Err(e) => { + self.display.print_error(&format!("Failed to load session: {}", e)); + } + } + } else { + self.display.print_error("Usage: /switch [session_name]"); + } + + Ok(()) + } + + async fn handle_delete_session(&mut self, parts: &[&str]) -> Result<()> { + let target = if parts.len() == 1 { + let sessions = Session::list_sessions()?; + let session_names: Vec = sessions + .into_iter() + .map(|(name, _)| name) + .filter(|name| name != &self.session.name) + .collect(); + + self.input.select_from_list("Delete session:", &session_names, None)? + } else if parts.len() == 2 { + Some(parts[1].to_string()) + } else { + self.display.print_error("Usage: /delete [session_name]"); + return Ok(()); + }; + + if let Some(target) = target { + if target == self.session.name { + self.display.print_error( + "Cannot delete the session you are currently using. Switch to another session first." + ); + return Ok(()); + } + + if self.input.confirm(&format!("Delete session '{}'?", target))? { + match Session::delete_session(&target) { + Ok(()) => { + self.display.print_command_result(&format!("Session '{}' deleted", target)); + } + Err(e) => { + self.display.print_error(&format!("Failed to delete session: {}", e)); + } + } + } + } + + Ok(()) + } + + fn handle_tool_command(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() != 3 || parts[1].to_lowercase() != "websearch" || !["on", "off"].contains(&parts[2]) { + self.display.print_error("Usage: /tool websearch on|off"); + return Ok(()); + } + + let enable = parts[2] == "on"; + + if enable { + let model = self.session.model.clone(); + if let Ok(client) = self.get_client() { + if !client.supports_feature_for_model("web_search", &model) { + let provider = get_provider_for_model(&model); + self.display.print_warning(&format!( + "Web search is not supported by {} models", + provider.as_str() + )); + } + } + } + + self.session.enable_web_search = enable; + let state = if enable { "enabled" } else { "disabled" }; + self.display.print_command_result(&format!("Web search tool {}", state)); + + Ok(()) + } + + fn handle_reasoning_command(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() != 2 || !["on", "off"].contains(&parts[1]) { + self.display.print_error("Usage: /reasoning on|off"); + return Ok(()); + } + + let enable = parts[1] == "on"; + + if enable { + let model = self.session.model.clone(); + if let Ok(client) = self.get_client() { + if !client.supports_feature_for_model("reasoning_summary", &model) { + let provider = get_provider_for_model(&model); + self.display.print_warning(&format!( + "Reasoning summaries are not supported by {} models", + provider.as_str() + )); + } + } + } + + self.session.enable_reasoning_summary = enable; + let state = if enable { "enabled" } else { "disabled" }; + self.display.print_command_result(&format!("Reasoning summaries {}", state)); + + Ok(()) + } + + fn handle_effort_command(&mut self, parts: &[&str]) -> Result<()> { + if parts.len() == 1 { + self.display.print_info(&format!("Current reasoning effort: {}", self.session.reasoning_effort)); + self.display.print_info("Available levels: low, medium, high"); + if !self.session.model.starts_with("gpt-5") { + self.display.print_warning("Reasoning effort is only supported by GPT-5 models"); + } + } else if parts.len() == 2 { + let effort = parts[1]; + if !["low", "medium", "high"].contains(&effort) { + self.display.print_error("Invalid effort level. Use: low, medium, or high"); + } else { + if !self.session.model.starts_with("gpt-5") { + self.display.print_warning("Reasoning effort is only supported by GPT-5 models"); + } + self.session.reasoning_effort = effort.to_string(); + self.display.print_command_result(&format!("Reasoning effort set to {}", effort)); + } + } else { + self.display.print_error("Usage: /effort [low|medium|high]"); + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/core/client.rs b/src/core/client.rs new file mode 100644 index 0000000..0f7aab6 --- /dev/null +++ b/src/core/client.rs @@ -0,0 +1,560 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::env; + +use super::{provider::Provider, session::Message}; + +#[derive(Debug)] +pub enum ChatClient { + OpenAI(OpenAIClient), + Anthropic(AnthropicClient), +} + +impl ChatClient { + pub async fn chat_completion( + &self, + model: &str, + messages: &[Message], + enable_web_search: bool, + enable_reasoning_summary: bool, + reasoning_effort: &str, + ) -> Result { + match self { + ChatClient::OpenAI(client) => { + client.chat_completion(model, messages, enable_web_search, enable_reasoning_summary, reasoning_effort).await + } + ChatClient::Anthropic(client) => { + client.chat_completion(model, messages, enable_web_search, enable_reasoning_summary, reasoning_effort).await + } + } + } + + pub fn supports_feature(&self, feature: &str) -> bool { + match self { + ChatClient::OpenAI(client) => client.supports_feature(feature), + ChatClient::Anthropic(client) => client.supports_feature(feature), + } + } + + pub fn supports_feature_for_model(&self, feature: &str, model: &str) -> bool { + match self { + ChatClient::OpenAI(client) => client.supports_feature_for_model(feature, model), + ChatClient::Anthropic(client) => client.supports_feature_for_model(feature, model), + } + } +} + +#[derive(Debug)] +pub struct OpenAIClient { + client: Client, + api_key: String, + base_url: String, +} + +#[derive(Debug)] +pub struct AnthropicClient { + client: Client, + api_key: String, + base_url: String, +} + +#[derive(Deserialize)] +struct OpenAIResponse { + choices: Vec, +} + +#[derive(Deserialize)] +struct Choice { + message: OpenAIMessage, + #[allow(dead_code)] + finish_reason: Option, +} + +#[derive(Deserialize)] +struct OpenAIMessage { + content: Option, + tool_calls: Option>, +} + +#[derive(Deserialize)] +struct ToolCall { + #[allow(dead_code)] + id: String, + #[allow(dead_code)] + #[serde(rename = "type")] + tool_type: String, + function: FunctionCall, +} + +#[derive(Deserialize)] +struct FunctionCall { + name: String, + arguments: String, +} + +// Responses API structures +#[derive(Deserialize)] +struct ResponsesApiResponse { + #[allow(dead_code)] + id: String, + #[allow(dead_code)] + object: String, + #[allow(dead_code)] + created_at: u64, + status: String, + output: Vec, +} + +#[derive(Deserialize)] +struct OutputItem { + #[allow(dead_code)] + id: String, + #[serde(rename = "type")] + item_type: String, + #[serde(default)] + status: Option, + #[serde(default)] + role: Option, + #[serde(default)] + content: Option>, + #[serde(default)] + action: Option, +} + +#[derive(Deserialize)] +struct SearchAction { + #[allow(dead_code)] + #[serde(rename = "type")] + action_type: String, + query: String, +} + +#[derive(Deserialize)] +struct ResponseContent { + #[serde(rename = "type")] + content_type: String, + #[serde(default)] + text: Option, + #[serde(default)] + annotations: Option>, +} + +#[derive(Deserialize)] +struct Annotation { + #[serde(rename = "type")] + annotation_type: String, + #[allow(dead_code)] + start_index: usize, + #[allow(dead_code)] + end_index: usize, + url: String, + title: String, +} + +#[derive(Deserialize)] +struct AnthropicResponse { + content: Vec, +} + +#[derive(Deserialize)] +struct AnthropicContent { + text: String, +} + +impl OpenAIClient { + pub fn new() -> Result { + let api_key = env::var("OPENAI_API_KEY") + .context("OPENAI_API_KEY environment variable is required")?; + + let base_url = env::var("OPENAI_BASE_URL") + .unwrap_or_else(|_| "https://api.openai.com/v1".to_string()); + + let client = Client::new(); + + Ok(Self { + client, + api_key, + base_url, + }) + } + + fn convert_messages(messages: &[Message]) -> Vec { + messages + .iter() + .map(|msg| { + json!({ + "role": msg.role, + "content": msg.content + }) + }) + .collect() + } +} + +impl OpenAIClient { + pub async fn chat_completion( + &self, + model: &str, + messages: &[Message], + enable_web_search: bool, + _enable_reasoning_summary: bool, + reasoning_effort: &str, + ) -> Result { + // Use Responses API for web search with GPT-5, fallback to chat completions + if enable_web_search && model.starts_with("gpt-5") { + return self.responses_api_completion(model, messages, reasoning_effort).await; + } + + let url = format!("{}/chat/completions", self.base_url); + + let mut payload = json!({ + "model": model, + "messages": Self::convert_messages(messages), + "stream": false + }); + + // Add tools if web search is enabled (for non-GPT-5 models) + if enable_web_search { + payload["tools"] = json!([{ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web for current information on any topic", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to find relevant information" + } + }, + "required": ["query"] + } + } + }]); + payload["tool_choice"] = json!("auto"); + } + + // Add reasoning effort for GPT-5 models + if model.starts_with("gpt-5") && ["low", "medium", "high"].contains(&reasoning_effort) { + payload["reasoning_effort"] = json!(reasoning_effort); + } + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send request to OpenAI API")?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!("OpenAI API error: {}", error_text)); + } + + let response_json: OpenAIResponse = response + .json() + .await + .context("Failed to parse OpenAI API response")?; + + let choice = response_json + .choices + .first() + .context("No choices in OpenAI API response")?; + + // Handle tool calls if present + if let Some(tool_calls) = &choice.message.tool_calls { + let mut response_parts = Vec::new(); + + if let Some(content) = &choice.message.content { + response_parts.push(content.clone()); + } + + for tool_call in tool_calls { + if tool_call.function.name == "web_search" { + // Parse the query from the function arguments + if let Ok(args) = serde_json::from_str::(&tool_call.function.arguments) { + if let Some(query) = args.get("query").and_then(|q| q.as_str()) { + response_parts.push(format!( + "\n[Web Search Request: \"{}\"]\nNote: Web search functionality is not implemented in this CLI. The AI wanted to search for: {}", + query, query + )); + } + } + } + } + + let final_content = if response_parts.is_empty() { + "The AI attempted to use tools but no content was returned.".to_string() + } else { + response_parts.join("\n") + }; + + return Ok(final_content); + } + + // Handle regular content response + let content = choice.message.content.as_ref() + .context("No content in OpenAI API response")?; + + Ok(content.clone()) + } + + async fn responses_api_completion( + &self, + model: &str, + messages: &[Message], + reasoning_effort: &str, + ) -> Result { + let url = format!("{}/responses", self.base_url); + + // Convert messages to input text (simple approach for now) + let input_text = messages + .iter() + .filter(|msg| msg.role != "system") + .map(|msg| msg.content.as_str()) + .collect::>() + .join("\n"); + + let mut payload = json!({ + "model": model, + "tools": [{"type": "web_search_preview"}], + "input": input_text + }); + + // Add reasoning effort for GPT-5 models + if ["low", "medium", "high"].contains(&reasoning_effort) { + payload["reasoning"] = json!({ + "effort": reasoning_effort + }); + } + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send request to OpenAI Responses API")?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!("OpenAI Responses API error: {}", error_text)); + } + + // Get response text first for debugging + let response_text = response.text().await + .context("Failed to get response text from OpenAI Responses API")?; + + // Try to parse JSON and provide better error context + let response_json: ResponsesApiResponse = serde_json::from_str(&response_text) + .with_context(|| format!("Failed to parse OpenAI Responses API response. Response was: {}", response_text))?; + + // Process the output array to extract the assistant message + let mut final_content = String::new(); + let mut citations = Vec::new(); + let mut search_count = 0; + + for item in response_json.output { + match item.item_type.as_str() { + "web_search_call" => { + if item.status.as_deref() == Some("completed") { + search_count += 1; + if let Some(action) = &item.action { + final_content.push_str(&format!("🔍 Search {}: \"{}\"\n", search_count, action.query)); + } + } + } + "message" => { + if item.role == Some("assistant".to_string()) && item.status.as_deref() == Some("completed") { + if let Some(content_items) = item.content { + for content_item in content_items { + if content_item.content_type == "output_text" { + if let Some(text) = &content_item.text { + if search_count > 0 { + final_content.push_str("\n📝 **Response:**\n"); + } + final_content.push_str(text); + + // Collect citations + if let Some(annotations) = &content_item.annotations { + for annotation in annotations { + if annotation.annotation_type == "url_citation" { + citations.push(format!( + "\n📄 [{}]({}) - {}", + citations.len() + 1, + annotation.url, + annotation.title + )); + } + } + } + } + } + } + } + } + } + _ => {} // Handle other types like "reasoning" if needed + } + } + + // Append citations to the end + if !citations.is_empty() { + final_content.push_str("\n\n**Sources:**"); + for citation in citations { + final_content.push_str(&citation); + } + } + + if final_content.is_empty() { + return Err(anyhow::anyhow!("No content found in Responses API response")); + } + + Ok(final_content) + } + + pub fn supports_feature(&self, feature: &str) -> bool { + match feature { + "web_search" | "reasoning_summary" | "reasoning_effort" => true, + _ => false, + } + } + + pub fn supports_feature_for_model(&self, feature: &str, model: &str) -> bool { + match feature { + "web_search" => true, + "reasoning_summary" => true, + "reasoning_effort" => model.starts_with("gpt-5"), + _ => false, + } + } +} + +impl AnthropicClient { + pub fn new() -> Result { + let api_key = env::var("ANTHROPIC_API_KEY") + .context("ANTHROPIC_API_KEY environment variable is required")?; + + let base_url = "https://api.anthropic.com/v1".to_string(); + let client = Client::new(); + + Ok(Self { + client, + api_key, + base_url, + }) + } + + fn convert_messages(messages: &[Message]) -> (Option, Vec) { + let mut system_prompt = None; + let mut user_messages = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "system" => { + system_prompt = Some(msg.content.clone()); + } + "user" | "assistant" => { + user_messages.push(json!({ + "role": msg.role, + "content": msg.content + })); + } + _ => {} + } + } + + (system_prompt, user_messages) + } +} + +impl AnthropicClient { + pub async fn chat_completion( + &self, + model: &str, + messages: &[Message], + _enable_web_search: bool, + _enable_reasoning_summary: bool, + _reasoning_effort: &str, + ) -> Result { + let url = format!("{}/messages", self.base_url); + + let (system_prompt, user_messages) = Self::convert_messages(messages); + + let mut payload = json!({ + "model": model, + "max_tokens": 4096, + "messages": user_messages + }); + + if let Some(system) = system_prompt { + payload["system"] = json!(system); + } + + let response = self + .client + .post(&url) + .header("x-api-key", &self.api_key) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .json(&payload) + .send() + .await + .context("Failed to send request to Anthropic API")?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow::anyhow!("Anthropic API error: {}", error_text)); + } + + let response_json: AnthropicResponse = response + .json() + .await + .context("Failed to parse Anthropic API response")?; + + let content = response_json + .content + .first() + .map(|c| &c.text) + .context("No content in Anthropic API response")?; + + Ok(content.clone()) + } + + pub fn supports_feature(&self, feature: &str) -> bool { + match feature { + "web_search" | "reasoning_summary" => false, + _ => false, + } + } + + pub fn supports_feature_for_model(&self, feature: &str, _model: &str) -> bool { + match feature { + "web_search" | "reasoning_summary" | "reasoning_effort" => false, + _ => false, + } + } +} + +pub fn create_client(model: &str) -> Result { + let provider = super::provider::get_provider_for_model(model); + + match provider { + Provider::OpenAI => { + let client = OpenAIClient::new()?; + Ok(ChatClient::OpenAI(client)) + } + Provider::Anthropic => { + let client = AnthropicClient::new()?; + Ok(ChatClient::Anthropic(client)) + } + } +} \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..6fce5db --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,7 @@ +pub mod session; +pub mod client; +pub mod provider; + +pub use session::Session; +pub use client::{ChatClient, create_client}; +pub use provider::get_provider_for_model; \ No newline at end of file diff --git a/src/core/provider.rs b/src/core/provider.rs new file mode 100644 index 0000000..e2ae988 --- /dev/null +++ b/src/core/provider.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Provider { + OpenAI, + Anthropic, +} + +impl Provider { + pub fn as_str(&self) -> &'static str { + match self { + Provider::OpenAI => "openai", + Provider::Anthropic => "anthropic", + } + } +} + +pub fn get_supported_models() -> HashMap> { + let mut models = HashMap::new(); + + models.insert( + Provider::OpenAI, + vec![ + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4o", + "gpt-5", + "gpt-5-chat-latest", + "o1", + "o3", + "o4-mini", + "o3-mini", + ], + ); + + models.insert( + Provider::Anthropic, + vec![ + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", + ], + ); + + models +} + +pub fn get_all_models() -> Vec<&'static str> { + get_supported_models() + .values() + .flat_map(|models| models.iter()) + .copied() + .collect() +} + +pub fn get_provider_for_model(model: &str) -> Provider { + let supported = get_supported_models(); + + for (provider, models) in supported { + if models.contains(&model) { + return provider; + } + } + + Provider::OpenAI // default fallback +} + +pub fn is_model_supported(model: &str) -> bool { + get_all_models().contains(&model) +} \ No newline at end of file diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..ebabc35 --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,202 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +const SYSTEM_PROMPT: &str = "You are an AI assistant running in a terminal (CLI) environment. \ +Optimise all answers for 80‑column readability, prefer plain text, \ +ASCII art or concise bullet lists over heavy markup, and wrap code \ +snippets in fenced blocks when helpful. Do not emit trailing spaces or \ +control characters."; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + pub model: String, + pub messages: Vec, + pub enable_web_search: bool, + pub enable_reasoning_summary: bool, + #[serde(default = "default_reasoning_effort")] + pub reasoning_effort: String, + pub updated_at: DateTime, +} + +fn default_reasoning_effort() -> String { + "medium".to_string() +} + +#[derive(Debug, Clone)] +pub struct Session { + pub name: String, + pub model: String, + pub messages: Vec, + pub enable_web_search: bool, + pub enable_reasoning_summary: bool, + pub reasoning_effort: String, +} + +impl Session { + pub fn new(name: String, model: String) -> Self { + let mut session = Self { + name, + model, + messages: Vec::new(), + enable_web_search: true, + enable_reasoning_summary: false, + reasoning_effort: "medium".to_string(), + }; + + // Add system prompt as first message + session.messages.push(Message { + role: "system".to_string(), + content: SYSTEM_PROMPT.to_string(), + }); + + session + } + + pub fn sessions_dir() -> Result { + let home = dirs::home_dir().context("Could not find home directory")?; + let sessions_dir = home.join(".chat_cli_sessions"); + + if !sessions_dir.exists() { + fs::create_dir_all(&sessions_dir) + .with_context(|| format!("Failed to create sessions directory: {:?}", sessions_dir))?; + } + + Ok(sessions_dir) + } + + pub fn session_path(name: &str) -> Result { + Ok(Self::sessions_dir()?.join(format!("{}.json", name))) + } + + pub fn save(&self) -> Result<()> { + let data = SessionData { + model: self.model.clone(), + messages: self.messages.clone(), + enable_web_search: self.enable_web_search, + enable_reasoning_summary: self.enable_reasoning_summary, + reasoning_effort: self.reasoning_effort.clone(), + updated_at: Utc::now(), + }; + + let path = Self::session_path(&self.name)?; + let tmp_path = path.with_extension("tmp"); + + let json_data = serde_json::to_string_pretty(&data) + .context("Failed to serialize session data")?; + + fs::write(&tmp_path, json_data) + .with_context(|| format!("Failed to write session to {:?}", tmp_path))?; + + fs::rename(&tmp_path, &path) + .with_context(|| format!("Failed to rename {:?} to {:?}", tmp_path, path))?; + + Ok(()) + } + + pub fn load(name: &str) -> Result { + let path = Self::session_path(name)?; + + if !path.exists() { + return Err(anyhow::anyhow!("Session '{}' does not exist", name)); + } + + let json_data = fs::read_to_string(&path) + .with_context(|| format!("Failed to read session from {:?}", path))?; + + let data: SessionData = serde_json::from_str(&json_data) + .with_context(|| format!("Failed to parse session data from {:?}", path))?; + + let mut session = Self { + name: name.to_string(), + model: data.model, + messages: data.messages, + enable_web_search: data.enable_web_search, + enable_reasoning_summary: data.enable_reasoning_summary, + reasoning_effort: data.reasoning_effort, + }; + + // Ensure system prompt is present + if session.messages.is_empty() || session.messages[0].role != "system" { + session.messages.insert(0, Message { + role: "system".to_string(), + content: SYSTEM_PROMPT.to_string(), + }); + } + + Ok(session) + } + + pub fn add_user_message(&mut self, content: String) { + self.messages.push(Message { + role: "user".to_string(), + content, + }); + } + + pub fn add_assistant_message(&mut self, content: String) { + self.messages.push(Message { + role: "assistant".to_string(), + content, + }); + } + + pub fn clear_messages(&mut self) { + self.messages.clear(); + // Re-add system prompt + self.messages.push(Message { + role: "system".to_string(), + content: SYSTEM_PROMPT.to_string(), + }); + } + + pub fn list_sessions() -> Result)>> { + let sessions_dir = Self::sessions_dir()?; + + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + + for entry in fs::read_dir(&sessions_dir)? { + let entry = entry?; + let path = entry.path(); + + if let Some(extension) = path.extension() { + if extension == "json" { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + let metadata = entry.metadata()?; + let modified = metadata.modified()?; + let datetime = DateTime::::from(modified); + sessions.push((name.to_string(), datetime)); + } + } + } + } + + sessions.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by modification time, newest first + Ok(sessions) + } + + pub fn delete_session(name: &str) -> Result<()> { + let path = Self::session_path(name)?; + + if !path.exists() { + return Err(anyhow::anyhow!("Session '{}' does not exist", name)); + } + + fs::remove_file(&path) + .with_context(|| format!("Failed to delete session file: {:?}", path))?; + + Ok(()) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fe96c1d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,68 @@ +mod cli; +mod core; +mod utils; + +use anyhow::{Context, Result}; +use clap::Parser; +use std::env; + +use crate::cli::ChatCLI; +use crate::core::{provider::is_model_supported, Session}; +use crate::utils::Display; + +#[derive(Parser)] +#[command(name = "gpt-cli-rust")] +#[command(about = "A lightweight command-line interface for chatting with AI models")] +#[command(version)] +struct Args { + #[arg(short, long, default_value = "default", help = "Session name")] + session: String, + + #[arg(short, long, help = "Model name to use (overrides saved value)")] + model: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let display = Display::new(); + + // Load or create session + let session = match Session::load(&args.session) { + Ok(mut session) => { + if let Some(model) = args.model { + if !is_model_supported(&model) { + display.print_warning(&format!( + "Model '{}' is not supported. Using saved model '{}'", + model, session.model + )); + } else { + session.model = model; + } + } + session + } + Err(_) => { + let default_model = args + .model + .or_else(|| env::var("DEFAULT_MODEL").ok()) + .unwrap_or_else(|| "gpt-5".to_string()); + + if !is_model_supported(&default_model) { + display.print_warning(&format!( + "Model '{}' is not supported. Falling back to 'gpt-5'", + default_model + )); + Session::new(args.session, "gpt-5".to_string()) + } else { + Session::new(args.session, default_model) + } + } + }; + + // Run the CLI + let mut cli = ChatCLI::new(session).context("Failed to initialize CLI")?; + cli.run().await.context("CLI error")?; + + Ok(()) +} diff --git a/src/utils/display.rs b/src/utils/display.rs new file mode 100644 index 0000000..9f39e52 --- /dev/null +++ b/src/utils/display.rs @@ -0,0 +1,132 @@ +use console::{style, Term}; +use std::io::{self, Write}; + +pub struct Display { + term: Term, +} + +impl Display { + pub fn new() -> Self { + Self { + term: Term::stdout(), + } + } + + pub fn print_header(&self) { + self.term.clear_screen().ok(); + println!("{}", style("🤖 GPT CLI (Rust)").bold().magenta()); + println!("{}", style("─".repeat(50)).dim()); + } + + pub fn print_info(&self, message: &str) { + println!("{} {}", style("ℹ").blue(), style(message).dim()); + } + + #[allow(dead_code)] + pub fn print_success(&self, message: &str) { + println!("{} {}", style("✓").green(), style(message).green()); + } + + pub fn print_warning(&self, message: &str) { + println!("{} {}", style("⚠").yellow(), style(message).yellow()); + } + + pub fn print_error(&self, message: &str) { + eprintln!("{} {}", style("✗").red(), style(message).red()); + } + + #[allow(dead_code)] + pub fn print_user_input(&self, content: &str) { + println!("{} {}", style("👤").cyan(), content); + } + + pub fn print_assistant_response(&self, content: &str) { + println!("{} {}", style("🤖").magenta(), content); + } + + pub fn print_command_result(&self, message: &str) { + println!("{} {}", style("📝").blue(), style(message).dim()); + } + + pub fn print_model_info(&self, model: &str, provider: &str) { + println!( + "{} Model: {} ({})", + style("🔧").yellow(), + style(model).bold(), + style(provider).dim() + ); + } + + pub fn print_session_info(&self, session_name: &str) { + println!( + "{} Session: {}", + style("💾").blue(), + style(session_name).bold() + ); + } + + pub fn print_help(&self) { + let help_text = r#" +Available Commands: + /help - Show this help message + /exit - Exit the CLI + /model [model_name] - Switch model or show interactive picker + /models - List all supported models + /list - List all saved sessions + /new - Create a new session + /switch [session_name] - Switch session or show interactive picker + /clear - Clear current conversation + /delete [session_name] - Delete a session + /tool websearch on|off - Enable/disable web search (OpenAI only) + /reasoning on|off - Enable/disable reasoning summaries (OpenAI only) + /effort [low|medium|high] - Set reasoning effort level (GPT-5 only) + +Environment Variables: + OPENAI_API_KEY - Required for OpenAI models + ANTHROPIC_API_KEY - Required for Anthropic models + OPENAI_BASE_URL - Optional custom base URL for OpenAI + DEFAULT_MODEL - Default model if not specified + +Supported Models: + OpenAI: gpt-4.1, gpt-4.1-mini, gpt-4o, gpt-5, gpt-5-chat-latest, o1, o3, o4-mini, o3-mini + Anthropic: claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022, + claude-3-opus-20240229, claude-3-sonnet-20240229, + claude-3-haiku-20240307 +"#; + println!("{}", style(help_text).dim()); + } + + pub fn show_spinner(&self, message: &str) -> SpinnerHandle { + print!("{} {}... ", style("⏳").yellow(), message); + io::stdout().flush().ok(); + SpinnerHandle::new() + } +} + +impl Default for Display { + fn default() -> Self { + Self::new() + } +} + +pub struct SpinnerHandle { + start_time: std::time::Instant, +} + +impl SpinnerHandle { + fn new() -> Self { + Self { + start_time: std::time::Instant::now(), + } + } + + pub fn finish(self, message: &str) { + let elapsed = self.start_time.elapsed(); + println!("{} ({:.2}s)", style(message).green(), elapsed.as_secs_f32()); + } + + pub fn finish_with_error(self, message: &str) { + let elapsed = self.start_time.elapsed(); + println!("{} ({:.2}s)", style(message).red(), elapsed.as_secs_f32()); + } +} \ No newline at end of file diff --git a/src/utils/input.rs b/src/utils/input.rs new file mode 100644 index 0000000..01ae1f9 --- /dev/null +++ b/src/utils/input.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use dialoguer::{theme::ColorfulTheme, Select}; +use rustyline::{error::ReadlineError, DefaultEditor}; + +pub struct InputHandler { + editor: DefaultEditor, +} + +impl InputHandler { + pub fn new() -> Result { + let mut editor = DefaultEditor::new()?; + + // Try to load history file + let history_file = dirs::home_dir() + .map(|home| home.join(".chat_cli_history")) + .unwrap_or_else(|| ".chat_cli_history".into()); + + editor.load_history(&history_file).ok(); + + Ok(Self { editor }) + } + + pub fn read_line(&mut self, prompt: &str) -> Result> { + match self.editor.readline(prompt) { + Ok(line) => { + let _ = self.editor.add_history_entry(&line); + Ok(Some(line)) + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + Ok(None) + } + Err(ReadlineError::Eof) => { + println!("^D"); + Ok(None) + } + Err(err) => Err(anyhow::anyhow!("Error reading input: {}", err)), + } + } + + pub fn save_history(&mut self) -> Result<()> { + let history_file = dirs::home_dir() + .map(|home| home.join(".chat_cli_history")) + .unwrap_or_else(|| ".chat_cli_history".into()); + + self.editor.save_history(&history_file)?; + Ok(()) + } + + pub fn select_from_list( + &self, + title: &str, + items: &[T], + current: Option<&str>, + ) -> Result> { + if items.is_empty() { + println!("(no items available)"); + return Ok(None); + } + + let theme = ColorfulTheme::default(); + + // Find default selection index + let default_index = if let Some(current) = current { + items.iter().position(|item| item.to_string() == current).unwrap_or(0) + } else { + 0 + }; + + let selection = Select::with_theme(&theme) + .with_prompt(title) + .items(items) + .default(default_index) + .interact_opt()?; + + Ok(selection.map(|idx| items[idx].clone())) + } + + pub fn confirm(&self, message: &str) -> Result { + use dialoguer::Confirm; + + let confirmation = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(message) + .interact()?; + + Ok(confirmation) + } +} + +impl Default for InputHandler { + fn default() -> Self { + Self::new().expect("Failed to initialize input handler") + } +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..5948f90 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,5 @@ +pub mod display; +pub mod input; + +pub use display::*; +pub use input::*; \ No newline at end of file