From f82a5898b9354d2d9a5e813c5db773eb8b3809c4 Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Tue, 16 Dec 2025 15:02:03 +0100 Subject: [PATCH] feat: updated syncable-cli --- Cargo.lock | 353 +++++-------- Cargo.toml | 6 +- src/agent/config.rs | 248 --------- src/agent/mod.rs | 461 +++++++---------- src/agent/session.rs | 388 ++++++++++++++ src/agent/tools/analyze.rs | 30 +- src/agent/tools/discover.rs | 459 ----------------- src/agent/tools/file_ops.rs | 121 +++-- src/agent/tools/generate.rs | 164 ------ src/agent/tools/mod.rs | 22 - src/agent/tools/search.rs | 478 ------------------ src/agent/tools/security.rs | 28 +- src/agent/ui.rs | 384 -------------- src/agent/ui/colors.rs | 112 ++++ src/agent/ui/hooks.rs | 179 +++++++ src/agent/ui/mod.rs | 23 + src/agent/ui/response.rs | 425 ++++++++++++++++ src/agent/ui/spinner.rs | 315 ++++++++++++ src/agent/ui/streaming.rs | 277 ++++++++++ src/agent/ui/tool_display.rs | 227 +++++++++ .../Screenshot 2025-12-16 at 08.21.18.png | Bin 0 -> 93181 bytes src/analyzer/frameworks/go.rs | 38 +- src/analyzer/frameworks/java.rs | 44 +- src/analyzer/frameworks/javascript.rs | 54 +- src/analyzer/frameworks/python.rs | 72 +-- src/analyzer/frameworks/rust.rs | 78 +-- src/cli.rs | 10 +- src/config/mod.rs | 73 ++- src/config/types.rs | 28 +- src/error.rs | 3 + src/lib.rs | 57 +-- src/main.rs | 100 +--- 32 files changed, 2575 insertions(+), 2682 deletions(-) delete mode 100644 src/agent/config.rs create mode 100644 src/agent/session.rs delete mode 100644 src/agent/tools/discover.rs delete mode 100644 src/agent/tools/generate.rs delete mode 100644 src/agent/tools/search.rs delete mode 100644 src/agent/ui.rs create mode 100644 src/agent/ui/colors.rs create mode 100644 src/agent/ui/hooks.rs create mode 100644 src/agent/ui/mod.rs create mode 100644 src/agent/ui/response.rs create mode 100644 src/agent/ui/spinner.rs create mode 100644 src/agent/ui/streaming.rs create mode 100644 src/agent/ui/tool_display.rs create mode 100644 src/analyzer/Screenshot 2025-12-16 at 08.21.18.png diff --git a/Cargo.lock b/Cargo.lock index 84d54962..4c446e99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,7 +370,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -379,7 +379,7 @@ version = "4.5.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -412,19 +412,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[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.0", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.0" @@ -446,22 +433,13 @@ checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] name = "convert_case" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ "unicode-segmentation", ] -[[package]] -name = "coolor" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" -dependencies = [ - "crossterm", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -506,32 +484,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crokey" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51360853ebbeb3df20c76c82aecf43d387a62860f1a59ba65ab51f00eea85aad" -dependencies = [ - "crokey-proc_macros", - "crossterm", - "once_cell", - "serde", - "strict", -] - -[[package]] -name = "crokey-proc_macros" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf1a727caeb5ee5e0a0826a97f205a9cf84ee964b0b48239fef5214a00ae439" -dependencies = [ - "crossterm", - "proc-macro2", - "quote", - "strict", - "syn", -] - [[package]] name = "crossbeam" version = "0.8.4" @@ -588,33 +540,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -670,55 +595,61 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.4.0" +name = "deluxe" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" dependencies = [ - "powerfmt", - "serde", + "deluxe-core", + "deluxe-macros", + "once_cell", + "proc-macro2", + "syn", ] [[package]] -name = "derive_more" -version = "2.1.0" +name = "deluxe-core" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" dependencies = [ - "derive_more-impl", + "arrayvec", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn", ] [[package]] -name = "derive_more-impl" -version = "2.1.0" +name = "deluxe-macros" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" dependencies = [ - "convert_case", + "deluxe-core", + "heck 0.4.1", + "if_chain", + "proc-macro-crate", "proc-macro2", "quote", - "rustc_version", "syn", ] [[package]] -name = "deunicode" -version = "1.6.2" +name = "deranged" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] [[package]] -name = "dialoguer" -version = "0.11.0" +name = "deunicode" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console 0.15.11", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", -] +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "difflib" @@ -795,15 +726,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dunce" version = "1.0.5" @@ -1170,7 +1092,7 @@ dependencies = [ "gix-utils", "itoa", "thiserror 2.0.12", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -1252,7 +1174,7 @@ dependencies = [ "smallvec", "thiserror 2.0.12", "unicode-bom", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -1503,7 +1425,7 @@ dependencies = [ "itoa", "smallvec", "thiserror 2.0.12", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -1637,7 +1559,7 @@ dependencies = [ "gix-utils", "maybe-async", "thiserror 2.0.12", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -1669,7 +1591,7 @@ dependencies = [ "gix-validate", "memmap2", "thiserror 2.0.12", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -1970,6 +1892,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2250,6 +2178,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "ignore" version = "0.4.23" @@ -2282,13 +2216,22 @@ version = "0.17.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4adb2ee6ad319a912210a36e56e3623555817bcc877a7e6e8802d1d69c4d8056" dependencies = [ - "console 0.16.0", + "console", "portable-atomic", "unicode-width 0.2.0", "unit-prefix", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "io-close" version = "0.3.7" @@ -2398,29 +2341,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "lazy-regex" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2471,12 +2391,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.13" @@ -2541,15 +2455,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimad" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c5d708226d186590a7b6d4a9780e2bdda5f689e0d58cd17012a298efd745d2" -dependencies = [ - "once_cell", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2572,7 +2477,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -2957,6 +2861,16 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -3292,25 +3206,24 @@ dependencies = [ [[package]] name = "rig-core" -version = "0.27.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3799afd8ba38d90d9886be5bf596b0159043f88598b40e1f5aa08aad488f2223" +checksum = "01be6d1a1f71ac7fb77c1d84bff8f2b54bf51a1e8111935b9c87799c4e8c1970" dependencies = [ "as-any", "async-stream", "base64", "bytes", "eventsource-stream", - "fastrand", "futures", "futures-timer", "glob", "http", - "mime", "mime_guess", "ordered-float", "pin-project-lite", "reqwest", + "rig-derive", "schemars", "serde", "serde_json", @@ -3321,6 +3234,21 @@ dependencies = [ "url", ] +[[package]] +name = "rig-derive" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f4b48f1449fa214d5cb11d0d0d952fd4c13b7ca5d1eaac64c87ce03cfb9e24" +dependencies = [ + "convert_case", + "deluxe", + "indoc", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "ring" version = "0.17.14" @@ -3347,15 +3275,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "781442f29170c5c93b7185ad559492601acdc71d5bb0706f5868094f45cfcd08" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.0.7" @@ -3695,36 +3614,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -3811,10 +3700,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "strict" -version = "0.2.0" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "strsim" @@ -3830,9 +3719,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -3860,13 +3749,10 @@ dependencies = [ "chrono", "clap", "colored", - "console 0.15.11", "crossbeam", "dashmap", - "dialoguer", "dirs", "env_logger", - "futures", "futures-util", "glob", "indicatif", @@ -3893,7 +3779,6 @@ dependencies = [ "tera", "term_size", "termcolor", - "termimad", "textwrap", "thiserror 2.0.12", "tokio", @@ -4025,22 +3910,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "termimad" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22117210909e9dfff30a558f554c7fb3edb198ef614e7691386785fb7679677c" -dependencies = [ - "coolor", - "crokey", - "crossbeam", - "lazy-regex", - "minimad", - "serde", - "thiserror 1.0.69", - "unicode-width 0.1.14", -] - [[package]] name = "termtree" version = "0.5.1" @@ -4222,7 +4091,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -4237,7 +4106,7 @@ dependencies = [ "toml_datetime 0.7.1", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -4267,6 +4136,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -4278,7 +4158,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.10", ] [[package]] @@ -4287,7 +4167,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ - "winnow", + "winnow 0.7.10", ] [[package]] @@ -5013,6 +4893,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.10" diff --git a/Cargo.toml b/Cargo.toml index 56869905..847804e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,11 +69,7 @@ rand = "0.8" futures-util = "0.3" # Agent dependencies (using Rig - LLM application framework) -rig-core = "0.27" -dialoguer = "0.11" # Interactive terminal prompts -termimad = "0.30" # Markdown rendering in terminal -console = "0.15" # Terminal styling and control -futures = "0.3" # Async stream processing +rig-core = { version = "0.26", features = ["derive"] } [dev-dependencies] assert_cmd = "2" diff --git a/src/agent/config.rs b/src/agent/config.rs deleted file mode 100644 index f1247cee..00000000 --- a/src/agent/config.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Agent configuration and credentials management -//! -//! Handles storing and retrieving LLM provider credentials securely. -//! Credentials are stored in ~/.syncable/credentials.toml - -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -use super::{AgentError, AgentResult, ProviderType}; - -/// Credentials for LLM providers -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AgentCredentials { - /// Default provider to use - #[serde(default)] - pub default_provider: Option, - - /// Default model to use - #[serde(default)] - pub default_model: Option, - - /// OpenAI API key - #[serde(default)] - pub openai_api_key: Option, - - /// Anthropic API key - #[serde(default)] - pub anthropic_api_key: Option, -} - -impl AgentCredentials { - /// Get the syncable config directory (~/.syncable) - pub fn config_dir() -> Option { - dirs::home_dir().map(|h| h.join(".syncable")) - } - - /// Get the credentials file path - pub fn credentials_path() -> Option { - Self::config_dir().map(|d| d.join("credentials.toml")) - } - - /// Load credentials from file - pub fn load() -> AgentResult { - let path = Self::credentials_path() - .ok_or_else(|| AgentError::ClientError("Could not determine home directory".into()))?; - - if !path.exists() { - return Ok(Self::default()); - } - - let content = fs::read_to_string(&path) - .map_err(|e| AgentError::ClientError(format!("Failed to read credentials: {}", e)))?; - - toml::from_str(&content) - .map_err(|e| AgentError::ClientError(format!("Failed to parse credentials: {}", e))) - } - - /// Save credentials to file - pub fn save(&self) -> AgentResult<()> { - let dir = Self::config_dir() - .ok_or_else(|| AgentError::ClientError("Could not determine home directory".into()))?; - - // Create directory if it doesn't exist - if !dir.exists() { - fs::create_dir_all(&dir) - .map_err(|e| AgentError::ClientError(format!("Failed to create config dir: {}", e)))?; - } - - let path = dir.join("credentials.toml"); - let content = toml::to_string_pretty(self) - .map_err(|e| AgentError::ClientError(format!("Failed to serialize credentials: {}", e)))?; - - fs::write(&path, content) - .map_err(|e| AgentError::ClientError(format!("Failed to write credentials: {}", e)))?; - - // Set restrictive permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = fs::Permissions::from_mode(0o600); - fs::set_permissions(&path, perms).ok(); - } - - Ok(()) - } - - /// Check if credentials exist for a provider - pub fn has_credentials(&self, provider: ProviderType) -> bool { - match provider { - ProviderType::OpenAI => self.openai_api_key.is_some(), - ProviderType::Anthropic => self.anthropic_api_key.is_some(), - } - } - - /// Get the API key for a provider - pub fn get_api_key(&self, provider: ProviderType) -> Option<&str> { - match provider { - ProviderType::OpenAI => self.openai_api_key.as_deref(), - ProviderType::Anthropic => self.anthropic_api_key.as_deref(), - } - } - - /// Set the API key for a provider - pub fn set_api_key(&mut self, provider: ProviderType, key: String) { - match provider { - ProviderType::OpenAI => self.openai_api_key = Some(key), - ProviderType::Anthropic => self.anthropic_api_key = Some(key), - } - } - - /// Get the default provider - pub fn get_default_provider(&self) -> Option { - self.default_provider.as_ref().and_then(|p| p.parse().ok()) - } - - /// Set the default provider - pub fn set_default_provider(&mut self, provider: ProviderType) { - self.default_provider = Some(provider.to_string()); - } -} - -/// Run the first-time setup wizard for agent credentials -pub fn run_setup_wizard() -> AgentResult<(ProviderType, Option)> { - use dialoguer::{Select, Input, theme::ColorfulTheme}; - - println!("\n Welcome to Syncable Agent Setup\n"); - println!("This wizard will help you configure your LLM provider.\n"); - - // Provider selection - let providers = &["OpenAI (GPT-4)", "Anthropic (Claude)"]; - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select your LLM provider") - .items(providers) - .default(0) - .interact() - .map_err(|e| AgentError::ClientError(format!("Selection failed: {}", e)))?; - - let provider = match selection { - 0 => ProviderType::OpenAI, - 1 => ProviderType::Anthropic, - _ => ProviderType::OpenAI, - }; - - // API key input - let env_var = match provider { - ProviderType::OpenAI => "OPENAI_API_KEY", - ProviderType::Anthropic => "ANTHROPIC_API_KEY", - }; - - let key_hint = match provider { - ProviderType::OpenAI => "sk-... (from platform.openai.com)", - ProviderType::Anthropic => "sk-ant-... (from console.anthropic.com)", - }; - - println!("\nYou can get your API key from:"); - match provider { - ProviderType::OpenAI => println!(" https://platform.openai.com/api-keys"), - ProviderType::Anthropic => println!(" https://console.anthropic.com/settings/keys"), - } - println!(); - - let api_key: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Enter your API key {}", key_hint)) - .interact_text() - .map_err(|e| AgentError::ClientError(format!("Input failed: {}", e)))?; - - if api_key.is_empty() { - return Err(AgentError::MissingApiKey(env_var.into())); - } - - // Model selection (optional) - let default_models = match provider { - ProviderType::OpenAI => vec!["gpt-4o (recommended)", "gpt-4", "gpt-3.5-turbo"], - ProviderType::Anthropic => vec!["claude-3-5-sonnet-latest (recommended)", "claude-3-opus-latest", "claude-3-haiku-20240307"], - }; - - let model_selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Select default model") - .items(&default_models) - .default(0) - .interact() - .map_err(|e| AgentError::ClientError(format!("Selection failed: {}", e)))?; - - let model = match provider { - ProviderType::OpenAI => match model_selection { - 0 => "gpt-4o", - 1 => "gpt-4", - 2 => "gpt-3.5-turbo", - _ => "gpt-4o", - }, - ProviderType::Anthropic => match model_selection { - 0 => "claude-3-5-sonnet-latest", - 1 => "claude-3-opus-latest", - 2 => "claude-3-haiku-20240307", - _ => "claude-3-5-sonnet-latest", - }, - }; - - // Save credentials - let mut creds = AgentCredentials::load().unwrap_or_default(); - creds.set_api_key(provider, api_key.clone()); - creds.set_default_provider(provider); - creds.default_model = Some(model.to_string()); - creds.save()?; - - // Also set the environment variable for this session - // SAFETY: We're setting a well-known env var with a valid string value - unsafe { std::env::set_var(env_var, &api_key) }; - - println!("\n Credentials saved to ~/.syncable/credentials.toml"); - println!("You can update them anytime by running: sync-ctl chat --setup\n"); - - Ok((provider, Some(model.to_string()))) -} - -/// Ensure credentials are available, prompting for setup if needed -pub fn ensure_credentials(provider: Option) -> AgentResult<(ProviderType, Option)> { - let creds = AgentCredentials::load().unwrap_or_default(); - - // Determine which provider to use - let provider = provider - .or_else(|| creds.get_default_provider()) - .unwrap_or(ProviderType::OpenAI); - - // Check if we have credentials for this provider - let env_var = match provider { - ProviderType::OpenAI => "OPENAI_API_KEY", - ProviderType::Anthropic => "ANTHROPIC_API_KEY", - }; - - // First check environment variable - if std::env::var(env_var).is_ok() { - return Ok((provider, creds.default_model.clone())); - } - - // Then check stored credentials - if let Some(key) = creds.get_api_key(provider) { - // Set environment variable for this session - // SAFETY: We're setting a well-known env var with a valid string value - unsafe { std::env::set_var(env_var, key) }; - return Ok((provider, creds.default_model.clone())); - } - - // No credentials found, run setup - println!("No API key found for {}.", provider); - run_setup_wizard() -} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 862e1f6a..47942d74 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -10,26 +10,34 @@ //! sync-ctl chat //! //! # With specific provider -//! sync-ctl chat --provider openai --model gpt-4o +//! sync-ctl chat --provider openai --model gpt-5.2 //! //! # Single query -//! sync-ctl chat -q "What security issues does this project have?" +//! sync-ctl chat --query "What security issues does this project have?" //! ``` +//! +//! # Interactive Commands +//! +//! - `/model` - Switch to a different AI model +//! - `/provider` - Switch provider (prompts for API key if needed) +//! - `/help` - Show available commands +//! - `/clear` - Clear conversation history +//! - `/exit` - Exit the chat -pub mod config; +pub mod session; pub mod tools; pub mod ui; -use futures::StreamExt; +use colored::Colorize; use rig::{ - agent::MultiTurnStreamItem, client::{CompletionClient, ProviderClient}, - completion::{Message, Prompt}, + completion::Prompt, providers::{anthropic, openai}, - streaming::{StreamedAssistantContent, StreamingChat}, }; -use std::io::{self, BufRead, Write}; +use session::ChatSession; use std::path::Path; +use std::sync::Arc; +use ui::{ResponseFormatter, Spinner, ToolDisplayHook, spawn_tool_display_handler}; /// Provider type for the agent #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -71,9 +79,6 @@ pub enum AgentError { #[error("Tool error: {0}")] ToolError(String), - - #[error("Client initialization error: {0}")] - ClientError(String), } pub type AgentResult = Result; @@ -81,293 +86,157 @@ pub type AgentResult = Result; /// Get the system prompt for the agent fn get_system_prompt(project_path: &Path) -> String { format!( - r#"You are an expert AI coding assistant integrated into the Syncable CLI. You help developers understand, navigate, and improve their codebases through deep, thorough investigation. + r#"You are a helpful AI assistant integrated into the Syncable CLI tool. You help developers understand and improve their codebases. ## Project Context -Project location: {} - -## Your Tools - -### 🏗️ MONOREPO DISCOVERY (USE FIRST!) -- **discover_services** - **START HERE for monorepos!** Lists ALL services/packages with their: - - Names, paths, types (Next.js, Express, Rust binary, etc.) - - Frameworks detected (React, Prisma, tRPC, etc.) - - Workspace configuration - - Use `path: "apps"` or `path: "services"` to focus on specific areas - -### 🔍 DEEP ANALYSIS -- **analyze_project** - Comprehensive analysis of a specific project - - **ALWAYS specify `path`** to analyze individual services: `path: "apps/api"` - - `mode: "json"` - Structured data (default, best for parsing) - - `mode: "detailed"` - Full analysis with Docker info - - **For monorepos: Call this MULTIPLE TIMES with different paths!** - -### 🔎 CODE SEARCH -- **search_code** - Grep-like search across files - - `pattern: "function_name"` - Find where things are defined/used - - `path: "apps/api"` - Search within specific service - - `regex: true` - Enable regex patterns - - `extension: "ts"` - Filter by file type - - `max_results: 100` - Increase for thorough search - -- **find_files** - Find files by name/pattern - - `pattern: "*.config.*"` - Find all config files - - `pattern: "Dockerfile*"` - Find Dockerfiles - - `include_dirs: true` - Include directories - -- **read_file** - Read actual file contents - - Use after finding files to see implementation details - - `start_line`/`end_line` - Read specific sections - -- **list_directory** - Explore directory structure - - `recursive: true` - See nested structure - -### 🛡️ SECURITY -- **security_scan** - Find secrets, hardcoded credentials, security issues -- **check_vulnerabilities** - Check dependencies for known CVEs - -### 📦 GENERATION -- **generate_iac** - Generate Infrastructure as Code - - `path: "apps/api"` - Generate for specific service - - `generate_type: "dockerfile" | "compose" | "terraform" | "all"` - -## AGENTIC INVESTIGATION PROTOCOL - -You are a DEEPLY INVESTIGATIVE agent. You have up to 300 tool calls - USE THEM! - -### For Monorepos (multiple services/packages): -1. **ALWAYS start with `discover_services`** to map the entire structure -2. **Analyze EACH relevant service individually** with `analyze_project(path: "service/path")` -3. **Search across the monorepo** for patterns, shared code, cross-service dependencies -4. **Read key files** in each service (entry points, configs, main logic) -5. **Cross-reference** - how do services communicate? What's shared? - -### For Deep Investigation: -1. **Don't stop at surface level** - dig into implementation -2. **Follow the code** - if you find a function call, search for its definition -3. **Check configs** - look for .env files, config directories, environment setup -4. **Examine dependencies** - package.json, Cargo.toml, what's being used? -5. **Read actual source code** - use read_file to understand implementation - -### Investigation Mindset: -- "I found 5 services, let me analyze each one..." -- "The API uses Express, let me find the route definitions..." -- "This imports from ../shared, let me explore that directory..." -- "There's a database connection, let me find the schema..." -- "I see tRPC, let me find the router definitions..." - -## Response Guidelines -- NEVER answer without thorough investigation first -- Show your exploration: "Discovering services... Found 5 apps. Analyzing apps/api..." -- For each service: summarize its purpose, tech stack, key files -- When asked to investigate: USE MANY TOOLS, explore deeply -- Format code with ```language blocks -- Be specific: "In apps/api/src/routes/users.ts line 45..." -- Don't guess - if you're uncertain, explore more!"#, +You are currently working with a project located at: {} + +## Your Capabilities +You have access to tools to help analyze and understand the project: + +1. **analyze_project** - Analyze the project to detect languages, frameworks, dependencies, and architecture +2. **security_scan** - Perform security analysis to find potential vulnerabilities and secrets +3. **check_vulnerabilities** - Check dependencies for known security vulnerabilities +4. **read_file** - Read the contents of a file in the project +5. **list_directory** - List files and directories in a path + +## Guidelines +- Use the available tools to gather information before answering questions about the project +- Be concise but thorough in your explanations +- When you find issues, suggest specific fixes +- Format code examples using markdown code blocks"#, project_path.display() ) } -/// Run the agent in interactive mode with beautiful UI +/// Run the agent in interactive mode with custom REPL supporting /model and /provider commands pub async fn run_interactive( project_path: &Path, provider: ProviderType, model: Option, ) -> AgentResult<()> { use tools::*; - use ui::AgentUI; - let project_path_buf = project_path.to_path_buf(); - let preamble = get_system_prompt(project_path); - let mut ui = AgentUI::new(); - let mut chat_history: Vec = Vec::new(); - - let provider_name = match provider { - ProviderType::OpenAI => "OpenAI", - ProviderType::Anthropic => "Anthropic", - }; - - match provider { - ProviderType::OpenAI => { - let client = openai::Client::from_env(); - let model_name = model.as_deref().unwrap_or("gpt-4o"); - - let agent = client - .agent(model_name) - .preamble(&preamble) - .max_tokens(4096) - .tool(DiscoverServicesTool::new(project_path_buf.clone())) - .tool(AnalyzeTool::new(project_path_buf.clone())) - .tool(SecurityScanTool::new(project_path_buf.clone())) - .tool(VulnerabilitiesTool::new(project_path_buf.clone())) - .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())) - .tool(SearchCodeTool::new(project_path_buf.clone())) - .tool(FindFilesTool::new(project_path_buf.clone())) - .tool(GenerateIaCTool::new(project_path_buf.clone())) - .build(); - - ui.print_welcome(provider_name, model_name); - - // Custom chat loop with streaming - loop { - ui.print_prompt(); - io::stdout().flush().ok(); - - let mut input = String::new(); - if io::stdin().lock().read_line(&mut input).is_err() { - break; - } - - let input = input.trim(); - if input.is_empty() { + let mut session = ChatSession::new(project_path, provider, model); + + // Load API key from config file to env if not already set + ChatSession::load_api_key_to_env(session.provider); + + // Check if API key is configured, prompt if not + if !ChatSession::has_api_key(session.provider) { + ChatSession::prompt_api_key(session.provider)?; + } + + session.print_banner(); + + loop { + // Read user input + let input = match session.read_input() { + Ok(input) => input, + Err(_) => break, + }; + + if input.is_empty() { + continue; + } + + // Check for commands + if ChatSession::is_command(&input) { + match session.process_command(&input) { + Ok(true) => continue, + Ok(false) => break, // /exit + Err(e) => { + eprintln!("{}", format!("Error: {}", e).red()); continue; } - if input.eq_ignore_ascii_case("exit") || input.eq_ignore_ascii_case("quit") { - println!("\n {} Goodbye!\n", ui::SPARKLES); - break; - } - - ui.start_thinking(); - - // Use streaming chat with multi-turn enabled for tool calls - let mut stream = agent.stream_chat(input, chat_history.clone()).multi_turn(300).await; - ui.stop_thinking(); - ui.print_assistant_header(); - ui.start_streaming(); - - let mut full_response = String::new(); - let mut had_tool_calls = false; - let mut last_update = 0; - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(text))) => { - full_response.push_str(&text.text); - // Update progress every 50 chars - if full_response.len() - last_update > 50 { - ui.update_streaming(full_response.len()); - last_update = full_response.len(); - } - } - Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall(tool_call))) => { - had_tool_calls = true; - ui.pause_spinner(); - ui.print_tool_call_notification(&tool_call.function.name); - ui.print_tool_call_complete(&tool_call.function.name); - ui.start_streaming(); - } - Ok(MultiTurnStreamItem::StreamAssistantItem(_)) => {} - Ok(MultiTurnStreamItem::StreamUserItem(_)) => {} - Ok(MultiTurnStreamItem::FinalResponse(_)) => {} - Err(e) => { - ui.print_error(&format!("Stream error: {}", e)); - break; - } - _ => {} - } - } - - // Render the complete response with markdown - ui.finish_streaming_and_render(&full_response); - - // Update chat history - if !full_response.is_empty() || had_tool_calls { - chat_history.push(Message::user(input)); - chat_history.push(Message::assistant(&full_response)); - } } } - ProviderType::Anthropic => { - let client = anthropic::Client::from_env(); - let model_name = model.as_deref().unwrap_or("claude-3-5-sonnet-latest"); - - let agent = client - .agent(model_name) - .preamble(&preamble) - .max_tokens(4096) - .tool(DiscoverServicesTool::new(project_path_buf.clone())) - .tool(AnalyzeTool::new(project_path_buf.clone())) - .tool(SecurityScanTool::new(project_path_buf.clone())) - .tool(VulnerabilitiesTool::new(project_path_buf.clone())) - .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())) - .tool(SearchCodeTool::new(project_path_buf.clone())) - .tool(FindFilesTool::new(project_path_buf.clone())) - .tool(GenerateIaCTool::new(project_path_buf.clone())) - .build(); - - ui.print_welcome(provider_name, model_name); - - // Custom chat loop with streaming - loop { - ui.print_prompt(); - io::stdout().flush().ok(); - - let mut input = String::new(); - if io::stdin().lock().read_line(&mut input).is_err() { - break; - } - - let input = input.trim(); - if input.is_empty() { - continue; - } - if input.eq_ignore_ascii_case("exit") || input.eq_ignore_ascii_case("quit") { - println!("\n {} Goodbye!\n", ui::SPARKLES); - break; - } - - ui.start_thinking(); - - // Use streaming chat with multi-turn enabled for tool calls - let mut stream = agent.stream_chat(input, chat_history.clone()).multi_turn(300).await; - ui.stop_thinking(); - ui.print_assistant_header(); - ui.start_streaming(); - - let mut full_response = String::new(); - let mut had_tool_calls = false; - let mut last_update = 0; - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(text))) => { - full_response.push_str(&text.text); - // Update progress every 50 chars - if full_response.len() - last_update > 50 { - ui.update_streaming(full_response.len()); - last_update = full_response.len(); - } - } - Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall(tool_call))) => { - had_tool_calls = true; - ui.pause_spinner(); - ui.print_tool_call_notification(&tool_call.function.name); - ui.print_tool_call_complete(&tool_call.function.name); - ui.start_streaming(); - } - Ok(MultiTurnStreamItem::StreamAssistantItem(_)) => {} - Ok(MultiTurnStreamItem::StreamUserItem(_)) => {} - Ok(MultiTurnStreamItem::FinalResponse(_)) => {} - Err(e) => { - ui.print_error(&format!("Stream error: {}", e)); - break; + + // Check API key before making request (in case provider changed) + if !ChatSession::has_api_key(session.provider) { + eprintln!("{}", "No API key configured. Use /provider to set one.".yellow()); + continue; + } + + // Start spinner for visual feedback + println!(); + let spinner = Arc::new(Spinner::new("Thinking...")); + + // Create hook for tool display + let (hook, receiver) = ToolDisplayHook::new(); + let spinner_clone = spinner.clone(); + let _tool_display_handle = spawn_tool_display_handler(receiver, spinner_clone); + + let project_path_buf = session.project_path.clone(); + let preamble = get_system_prompt(&session.project_path); + + let response = match session.provider { + ProviderType::OpenAI => { + let client = openai::Client::from_env(); + // For GPT-5.x reasoning models, explicitly set reasoning_effort to avoid + // deserialization errors (Rig's ReasoningEffort enum lacks "none" variant) + let reasoning_params = if session.model.starts_with("gpt-5") || session.model.starts_with("o1") { + Some(serde_json::json!({ + "reasoning": { + "effort": "medium" } - _ => {} - } - } - - // Render the complete response with markdown - ui.finish_streaming_and_render(&full_response); - - // Update chat history - if !full_response.is_empty() || had_tool_calls { - chat_history.push(Message::user(input)); - chat_history.push(Message::assistant(&full_response)); + })) + } else { + None + }; + + let mut builder = client + .agent(&session.model) + .preamble(&preamble) + .max_tokens(4096) + .tool(AnalyzeTool::new(project_path_buf.clone())) + .tool(SecurityScanTool::new(project_path_buf.clone())) + .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(ReadFileTool::new(project_path_buf.clone())) + .tool(ListDirectoryTool::new(project_path_buf)); + + if let Some(params) = reasoning_params { + builder = builder.additional_params(params); } + + let agent = builder.build(); + // Allow up to 10 tool call turns for thorough analysis + // Use hook to display tool calls as they happen + agent.prompt(&input).with_hook(hook.clone()).multi_turn(10).await + } + ProviderType::Anthropic => { + let client = anthropic::Client::from_env(); + let agent = client + .agent(&session.model) + .preamble(&preamble) + .max_tokens(4096) + .tool(AnalyzeTool::new(project_path_buf.clone())) + .tool(SecurityScanTool::new(project_path_buf.clone())) + .tool(VulnerabilitiesTool::new(project_path_buf.clone())) + .tool(ReadFileTool::new(project_path_buf.clone())) + .tool(ListDirectoryTool::new(project_path_buf)) + .build(); + + // Allow up to 10 tool call turns for thorough analysis + // Use hook to display tool calls as they happen + agent.prompt(&input).with_hook(hook.clone()).multi_turn(10).await + } + }; + + match response { + Ok(text) => { + // Stop spinner and show beautifully formatted response + spinner.stop().await; + ResponseFormatter::print_response(&text); + session.history.push(("user".to_string(), input)); + session.history.push(("assistant".to_string(), text)); + } + Err(e) => { + spinner.stop().await; + eprintln!("{}", format!("Error: {}", e).red()); } } + println!(); } Ok(()) @@ -388,49 +257,59 @@ pub async fn run_query( match provider { ProviderType::OpenAI => { let client = openai::Client::from_env(); - let model_name = model.as_deref().unwrap_or("gpt-4o"); + let model_name = model.as_deref().unwrap_or("gpt-5.2"); + + // For GPT-5.x reasoning models, explicitly set reasoning_effort + let reasoning_params = if model_name.starts_with("gpt-5") || model_name.starts_with("o1") { + Some(serde_json::json!({ + "reasoning": { + "effort": "medium" + } + })) + } else { + None + }; - let agent = client + let mut builder = client .agent(model_name) .preamble(&preamble) .max_tokens(4096) - .tool(DiscoverServicesTool::new(project_path_buf.clone())) .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())) - .tool(SearchCodeTool::new(project_path_buf.clone())) - .tool(FindFilesTool::new(project_path_buf.clone())) - .tool(GenerateIaCTool::new(project_path_buf)) - .build(); + .tool(ListDirectoryTool::new(project_path_buf)); + + if let Some(params) = reasoning_params { + builder = builder.additional_params(params); + } + + let agent = builder.build(); agent .prompt(query) + .multi_turn(10) .await .map_err(|e| AgentError::ProviderError(e.to_string())) } ProviderType::Anthropic => { let client = anthropic::Client::from_env(); - let model_name = model.as_deref().unwrap_or("claude-3-5-sonnet-latest"); + let model_name = model.as_deref().unwrap_or("claude-sonnet-4-20250514"); let agent = client .agent(model_name) .preamble(&preamble) .max_tokens(4096) - .tool(DiscoverServicesTool::new(project_path_buf.clone())) .tool(AnalyzeTool::new(project_path_buf.clone())) .tool(SecurityScanTool::new(project_path_buf.clone())) .tool(VulnerabilitiesTool::new(project_path_buf.clone())) .tool(ReadFileTool::new(project_path_buf.clone())) - .tool(ListDirectoryTool::new(project_path_buf.clone())) - .tool(SearchCodeTool::new(project_path_buf.clone())) - .tool(FindFilesTool::new(project_path_buf.clone())) - .tool(GenerateIaCTool::new(project_path_buf)) + .tool(ListDirectoryTool::new(project_path_buf)) .build(); agent .prompt(query) + .multi_turn(10) .await .map_err(|e| AgentError::ProviderError(e.to_string())) } diff --git a/src/agent/session.rs b/src/agent/session.rs new file mode 100644 index 00000000..d2a7d0d0 --- /dev/null +++ b/src/agent/session.rs @@ -0,0 +1,388 @@ +//! Interactive chat session with /model and /provider commands +//! +//! Provides a rich REPL experience similar to Claude Code with: +//! - `/model` - Select from available models based on configured API keys +//! - `/provider` - Switch provider (prompts for API key if not set) +//! - `/help` - Show available commands +//! - `/clear` - Clear conversation history +//! - `/exit` or `/quit` - Exit the session + +use crate::agent::{AgentError, AgentResult, ProviderType}; +use crate::config::{load_agent_config, save_agent_config}; +use colored::Colorize; +use std::io::{self, Write}; +use std::path::Path; + +const ROBOT: &str = "🤖"; + +/// Available models per provider +pub fn get_available_models(provider: ProviderType) -> Vec<(&'static str, &'static str)> { + match provider { + ProviderType::OpenAI => vec![ + ("gpt-5.2", "GPT-5.2 - Latest reasoning model (Dec 2025)"), + ("gpt-5.2-mini", "GPT-5.2 Mini - Fast and affordable"), + ("gpt-4o", "GPT-4o - Multimodal workhorse"), + ("o1-preview", "o1-preview - Advanced reasoning"), + ], + ProviderType::Anthropic => vec![ + ("claude-sonnet-4-20250514", "Claude 4 Sonnet - Latest (May 2025)"), + ("claude-3-5-sonnet-latest", "Claude 3.5 Sonnet - Previous gen"), + ("claude-3-opus-latest", "Claude 3 Opus - Most capable"), + ("claude-3-haiku-latest", "Claude 3 Haiku - Fast and cheap"), + ], + } +} + +/// Chat session state +pub struct ChatSession { + pub provider: ProviderType, + pub model: String, + pub project_path: std::path::PathBuf, + pub history: Vec<(String, String)>, // (role, content) +} + +impl ChatSession { + pub fn new(project_path: &Path, provider: ProviderType, model: Option) -> Self { + let default_model = match provider { + ProviderType::OpenAI => "gpt-5.2".to_string(), + ProviderType::Anthropic => "claude-sonnet-4-20250514".to_string(), + }; + + Self { + provider, + model: model.unwrap_or(default_model), + project_path: project_path.to_path_buf(), + history: Vec::new(), + } + } + + /// Check if API key is configured for a provider (env var OR config file) + pub fn has_api_key(provider: ProviderType) -> bool { + // Check environment variable first + let env_key = match provider { + ProviderType::OpenAI => std::env::var("OPENAI_API_KEY").ok(), + ProviderType::Anthropic => std::env::var("ANTHROPIC_API_KEY").ok(), + }; + + if env_key.is_some() { + return true; + } + + // Check config file + let agent_config = load_agent_config(); + match provider { + ProviderType::OpenAI => agent_config.openai_api_key.is_some(), + ProviderType::Anthropic => agent_config.anthropic_api_key.is_some(), + } + } + + /// Load API key from config if not in env, and set it in env for use + pub fn load_api_key_to_env(provider: ProviderType) { + let env_var = match provider { + ProviderType::OpenAI => "OPENAI_API_KEY", + ProviderType::Anthropic => "ANTHROPIC_API_KEY", + }; + + // If already in env, do nothing + if std::env::var(env_var).is_ok() { + return; + } + + // Load from config and set in env + let agent_config = load_agent_config(); + let key = match provider { + ProviderType::OpenAI => agent_config.openai_api_key, + ProviderType::Anthropic => agent_config.anthropic_api_key, + }; + + if let Some(key) = key { + // SAFETY: Single-threaded CLI context during initialization + unsafe { + std::env::set_var(env_var, &key); + } + } + } + + /// Get configured providers (those with API keys) + pub fn get_configured_providers() -> Vec { + let mut providers = Vec::new(); + if Self::has_api_key(ProviderType::OpenAI) { + providers.push(ProviderType::OpenAI); + } + if Self::has_api_key(ProviderType::Anthropic) { + providers.push(ProviderType::Anthropic); + } + providers + } + + /// Prompt user to enter API key for a provider + pub fn prompt_api_key(provider: ProviderType) -> AgentResult { + let env_var = match provider { + ProviderType::OpenAI => "OPENAI_API_KEY", + ProviderType::Anthropic => "ANTHROPIC_API_KEY", + }; + + println!("\n{}", format!("🔑 No API key found for {}", provider).yellow()); + println!("Please enter your {} API key:", provider); + print!("> "); + io::stdout().flush().unwrap(); + + let mut key = String::new(); + io::stdin().read_line(&mut key).map_err(|e| AgentError::ToolError(e.to_string()))?; + let key = key.trim().to_string(); + + if key.is_empty() { + return Err(AgentError::MissingApiKey(env_var.to_string())); + } + + // Set for current session + // SAFETY: We're in a single-threaded CLI context during initialization + unsafe { + std::env::set_var(env_var, &key); + } + + // Save to config file for persistence + let mut agent_config = load_agent_config(); + match provider { + ProviderType::OpenAI => agent_config.openai_api_key = Some(key.clone()), + ProviderType::Anthropic => agent_config.anthropic_api_key = Some(key.clone()), + } + + if let Err(e) = save_agent_config(&agent_config) { + eprintln!("{}", format!("Warning: Could not save config: {}", e).yellow()); + } else { + println!("{}", "✓ API key saved to ~/.syncable.toml".green()); + } + + Ok(key) + } + + /// Handle /model command - interactive model selection + pub fn handle_model_command(&mut self) -> AgentResult<()> { + let models = get_available_models(self.provider); + + println!("\n{}", format!("📋 Available models for {}:", self.provider).cyan().bold()); + println!(); + + for (i, (id, desc)) in models.iter().enumerate() { + let marker = if *id == self.model { "→ " } else { " " }; + let num = format!("[{}]", i + 1); + println!(" {} {} {} - {}", marker, num.dimmed(), id.white().bold(), desc.dimmed()); + } + + println!(); + println!("Enter number to select, or press Enter to keep current:"); + print!("> "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + + if input.is_empty() { + println!("{}", format!("Keeping model: {}", self.model).dimmed()); + return Ok(()); + } + + if let Ok(num) = input.parse::() { + if num >= 1 && num <= models.len() { + let (id, desc) = models[num - 1]; + self.model = id.to_string(); + println!("{}", format!("✓ Switched to {} - {}", id, desc).green()); + } else { + println!("{}", "Invalid selection".red()); + } + } else { + // Allow direct model name input + self.model = input.to_string(); + println!("{}", format!("✓ Set model to: {}", input).green()); + } + + Ok(()) + } + + /// Handle /provider command - switch provider with API key prompt if needed + pub fn handle_provider_command(&mut self) -> AgentResult<()> { + let providers = [ProviderType::OpenAI, ProviderType::Anthropic]; + + println!("\n{}", "🔄 Available providers:".cyan().bold()); + println!(); + + for (i, provider) in providers.iter().enumerate() { + let marker = if *provider == self.provider { "→ " } else { " " }; + let has_key = if Self::has_api_key(*provider) { + "✓ API key configured".green() + } else { + "⚠ No API key".yellow() + }; + let num = format!("[{}]", i + 1); + println!(" {} {} {} - {}", marker, num.dimmed(), provider.to_string().white().bold(), has_key); + } + + println!(); + println!("Enter number to select:"); + print!("> "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + + if let Ok(num) = input.parse::() { + if num >= 1 && num <= providers.len() { + let new_provider = providers[num - 1]; + + // Check if API key exists, prompt if not + if !Self::has_api_key(new_provider) { + Self::prompt_api_key(new_provider)?; + } + + self.provider = new_provider; + + // Set default model for new provider + let default_model = match new_provider { + ProviderType::OpenAI => "gpt-5.2", + ProviderType::Anthropic => "claude-sonnet-4-20250514", + }; + self.model = default_model.to_string(); + + println!("{}", format!("✓ Switched to {} with model {}", new_provider, default_model).green()); + } else { + println!("{}", "Invalid selection".red()); + } + } + + Ok(()) + } + + /// Handle /help command + pub fn print_help() { + println!(); + println!("{}", "📖 Available Commands:".cyan().bold()); + println!(); + println!(" {} - Select a different AI model", "/model".white().bold()); + println!(" {} - Switch provider (OpenAI/Anthropic)", "/provider".white().bold()); + println!(" {} - Clear conversation history", "/clear".white().bold()); + println!(" {} - Show this help message", "/help".white().bold()); + println!(" {} - Exit the chat", "/exit".white().bold()); + println!(); + println!("{}", "Just type your message and press Enter to chat!".dimmed()); + println!(); + } + + + /// Print session banner with colorful SYNCABLE ASCII art + pub fn print_logo() { + // Colors matching the logo gradient: purple → orange → pink + // Using ANSI 256 colors for better gradient + + // Purple shades for S, y + let purple = "\x1b[38;5;141m"; // Light purple + // Orange shades for n, c + let orange = "\x1b[38;5;216m"; // Peach/orange + // Pink shades for a, b, l, e + let pink = "\x1b[38;5;212m"; // Hot pink + let magenta = "\x1b[38;5;207m"; // Magenta + let reset = "\x1b[0m"; + + println!(); + println!( + "{} ███████╗{}{} ██╗ ██╗{}{}███╗ ██╗{}{} ██████╗{}{} █████╗ {}{}██████╗ {}{}██╗ {}{}███████╗{}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!( + "{} ██╔════╝{}{} ╚██╗ ██╔╝{}{}████╗ ██║{}{} ██╔════╝{}{} ██╔══██╗{}{}██╔══██╗{}{}██║ {}{}██╔════╝{}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!( + "{} ███████╗{}{} ╚████╔╝ {}{}██╔██╗ ██║{}{} ██║ {}{} ███████║{}{}██████╔╝{}{}██║ {}{}█████╗ {}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!( + "{} ╚════██║{}{} ╚██╔╝ {}{}██║╚██╗██║{}{} ██║ {}{} ██╔══██║{}{}██╔══██╗{}{}██║ {}{}██╔══╝ {}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!( + "{} ███████║{}{} ██║ {}{}██║ ╚████║{}{} ╚██████╗{}{} ██║ ██║{}{}██████╔╝{}{}███████╗{}{}███████╗{}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!( + "{} ╚══════╝{}{} ╚═╝ {}{}╚═╝ ╚═══╝{}{} ╚═════╝{}{} ╚═╝ ╚═╝{}{}╚═════╝ {}{}╚══════╝{}{}╚══════╝{}", + purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset + ); + println!(); + } + + /// Print the welcome banner + pub fn print_banner(&self) { + // Print the gradient ASCII logo + Self::print_logo(); + + // Print agent info + println!( + " {} {} powered by {}: {}", + ROBOT, + "Syncable Agent".white().bold(), + self.provider.to_string().cyan(), + self.model.cyan() + ); + println!( + " {}", + "Your AI-powered code analysis assistant".dimmed() + ); + println!(); + println!( + " {} Type your questions. Use {} to exit.\n", + "→".cyan(), + "exit".yellow().bold() + ); + } + + + /// Process a command (returns true if should continue, false if should exit) + pub fn process_command(&mut self, input: &str) -> AgentResult { + let cmd = input.trim().to_lowercase(); + + match cmd.as_str() { + "/exit" | "/quit" | "/q" => { + println!("\n{}", "👋 Goodbye!".green()); + return Ok(false); + } + "/help" | "/h" | "/?" => { + Self::print_help(); + } + "/model" | "/m" => { + self.handle_model_command()?; + } + "/provider" | "/p" => { + self.handle_provider_command()?; + } + "/clear" | "/c" => { + self.history.clear(); + println!("{}", "✓ Conversation history cleared".green()); + } + _ => { + if cmd.starts_with('/') { + println!("{}", format!("Unknown command: {}. Type /help for available commands.", cmd).yellow()); + } + } + } + + Ok(true) + } + + /// Check if input is a command + pub fn is_command(input: &str) -> bool { + input.trim().starts_with('/') + } + + /// Read user input with prompt + pub fn read_input(&self) -> io::Result { + print!("{}", "You: ".green().bold()); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) + } +} diff --git a/src/agent/tools/analyze.rs b/src/agent/tools/analyze.rs index 4b69b5be..b060f1ab 100644 --- a/src/agent/tools/analyze.rs +++ b/src/agent/tools/analyze.rs @@ -6,16 +6,11 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::PathBuf; -use crate::analyzer::display::{DisplayMode, display_analysis_to_string}; -use crate::analyzer::analyze_monorepo; - /// Arguments for the analyze tool #[derive(Debug, Deserialize)] pub struct AnalyzeArgs { /// Optional subdirectory path to analyze pub path: Option, - /// Display mode: "matrix" (default), "detailed", "summary", or "json" - pub mode: Option, } /// Error type for analyze tool @@ -45,18 +40,13 @@ impl Tool for AnalyzeTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), - description: "Analyze the project to detect programming languages, frameworks, dependencies, build tools, and architecture patterns. Returns a comprehensive overview of the project's technology stack. Use 'detailed' mode for full analysis, 'summary' for quick overview, 'json' for structured data.".to_string(), + description: "Analyze the project to detect programming languages, frameworks, dependencies, build tools, and architecture patterns. Returns a comprehensive overview of the project's technology stack.".to_string(), parameters: json!({ "type": "object", "properties": { "path": { "type": "string", "description": "Optional subdirectory path to analyze (relative to project root). If not provided, analyzes the entire project." - }, - "mode": { - "type": "string", - "enum": ["matrix", "detailed", "summary", "json"], - "description": "Display mode: 'matrix' for compact dashboard, 'detailed' for full analysis with Docker info, 'summary' for brief overview, 'json' for structured data. Default is 'json' for best agent parsing." } } }), @@ -70,21 +60,9 @@ impl Tool for AnalyzeTool { self.project_path.clone() }; - // Parse display mode - default to JSON for agent consumption - let display_mode = match args.mode.as_deref() { - Some("matrix") => DisplayMode::Matrix, - Some("detailed") => DisplayMode::Detailed, - Some("summary") => DisplayMode::Summary, - Some("json") | None => DisplayMode::Json, - _ => DisplayMode::Json, - }; - - match analyze_monorepo(&path) { - Ok(analysis) => { - // Use the display system to format output - let output = display_analysis_to_string(&analysis, display_mode); - Ok(output) - } + match crate::analyzer::analyze_project(&path) { + Ok(analysis) => serde_json::to_string_pretty(&analysis) + .map_err(|e| AnalyzeError(format!("Failed to serialize: {}", e))), Err(e) => Err(AnalyzeError(format!("Analysis failed: {}", e))), } } diff --git a/src/agent/tools/discover.rs b/src/agent/tools/discover.rs deleted file mode 100644 index 8a70e5e9..00000000 --- a/src/agent/tools/discover.rs +++ /dev/null @@ -1,459 +0,0 @@ -//! Service/Package discovery tool for monorepo exploration -//! -//! Helps the agent discover and understand the structure of monorepos. - -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -// ============================================================================ -// Discover Services Tool -// ============================================================================ - -#[derive(Debug, Deserialize)] -pub struct DiscoverServicesArgs { - /// Optional subdirectory to search within - pub path: Option, - /// Include detailed package info (dependencies, scripts) - pub detailed: Option, -} - -#[derive(Debug, thiserror::Error)] -#[error("Discovery error: {0}")] -pub struct DiscoverServicesError(String); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiscoverServicesTool { - project_path: PathBuf, -} - -impl DiscoverServicesTool { - pub fn new(project_path: PathBuf) -> Self { - Self { project_path } - } - - fn should_skip_dir(name: &str) -> bool { - matches!( - name, - "node_modules" - | ".git" - | "target" - | "__pycache__" - | ".venv" - | "dist" - | "build" - | ".next" - | ".nuxt" - | "vendor" - | ".cache" - | "coverage" - | "tmp" - | "temp" - | ".turbo" - | ".pnpm" - ) - } - - fn detect_package_type(path: &Path) -> Option<(&'static str, PathBuf)> { - let indicators = [ - ("package.json", "node"), - ("Cargo.toml", "rust"), - ("go.mod", "go"), - ("pyproject.toml", "python"), - ("requirements.txt", "python"), - ("pom.xml", "java"), - ("build.gradle", "java"), - ("build.gradle.kts", "kotlin"), - ("composer.json", "php"), - ("Gemfile", "ruby"), - ("pubspec.yaml", "dart"), - ]; - - for (file, pkg_type) in indicators { - let manifest = path.join(file); - if manifest.exists() { - return Some((pkg_type, manifest)); - } - } - None - } - - fn parse_package_json(path: &Path, detailed: bool) -> Option { - let content = fs::read_to_string(path).ok()?; - let json: serde_json::Value = serde_json::from_str(&content).ok()?; - - let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); - let version = json.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"); - let description = json.get("description").and_then(|v| v.as_str()); - let private = json.get("private").and_then(|v| v.as_bool()).unwrap_or(false); - - // Detect project type from dependencies - let deps = json.get("dependencies").and_then(|v| v.as_object()); - let dev_deps = json.get("devDependencies").and_then(|v| v.as_object()); - - let mut project_type = "unknown"; - let mut frameworks: Vec<&str> = Vec::new(); - - if let Some(d) = deps { - if d.contains_key("next") { - project_type = "Next.js App"; - frameworks.push("Next.js"); - } else if d.contains_key("react") { - project_type = "React App"; - frameworks.push("React"); - } else if d.contains_key("vue") { - project_type = "Vue App"; - frameworks.push("Vue"); - } else if d.contains_key("svelte") || d.contains_key("@sveltejs/kit") { - project_type = "Svelte App"; - frameworks.push("Svelte"); - } else if d.contains_key("express") { - project_type = "Express API"; - frameworks.push("Express"); - } else if d.contains_key("fastify") { - project_type = "Fastify API"; - frameworks.push("Fastify"); - } else if d.contains_key("hono") { - project_type = "Hono API"; - frameworks.push("Hono"); - } else if d.contains_key("@nestjs/core") { - project_type = "NestJS API"; - frameworks.push("NestJS"); - } - - // Detect additional frameworks - if d.contains_key("prisma") || d.contains_key("@prisma/client") { - frameworks.push("Prisma"); - } - if d.contains_key("drizzle-orm") { - frameworks.push("Drizzle"); - } - if d.contains_key("tailwindcss") { - frameworks.push("Tailwind"); - } - if d.contains_key("trpc") || d.contains_key("@trpc/server") { - frameworks.push("tRPC"); - } - } - - let mut result = json!({ - "name": name, - "version": version, - "type": project_type, - "frameworks": frameworks, - "private": private, - }); - - if let Some(desc) = description { - result["description"] = json!(desc); - } - - if detailed { - // Add scripts - if let Some(scripts) = json.get("scripts").and_then(|v| v.as_object()) { - let script_names: Vec<&str> = scripts.keys().map(|s| s.as_str()).collect(); - result["scripts"] = json!(script_names); - } - - // Add key dependencies count - if let Some(d) = deps { - result["dependencies_count"] = json!(d.len()); - } - if let Some(d) = dev_deps { - result["dev_dependencies_count"] = json!(d.len()); - } - - // Check for workspaces - if let Some(workspaces) = json.get("workspaces") { - result["workspaces"] = workspaces.clone(); - } - } - - Some(result) - } - - fn parse_cargo_toml(path: &Path, detailed: bool) -> Option { - let content = fs::read_to_string(path).ok()?; - let toml: toml::Value = toml::from_str(&content).ok()?; - - let package = toml.get("package")?; - let name = package.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); - let version = package.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"); - let description = package.get("description").and_then(|v| v.as_str()); - - // Detect project type - let project_type = if path.parent().map(|p| p.join("src/main.rs").exists()).unwrap_or(false) { - "binary" - } else if path.parent().map(|p| p.join("src/lib.rs").exists()).unwrap_or(false) { - "library" - } else { - "unknown" - }; - - let mut frameworks: Vec<&str> = Vec::new(); - - // Check dependencies for frameworks - if let Some(deps) = toml.get("dependencies").and_then(|v| v.as_table()) { - if deps.contains_key("actix-web") { - frameworks.push("Actix-web"); - } - if deps.contains_key("axum") { - frameworks.push("Axum"); - } - if deps.contains_key("rocket") { - frameworks.push("Rocket"); - } - if deps.contains_key("tokio") { - frameworks.push("Tokio"); - } - if deps.contains_key("sqlx") { - frameworks.push("SQLx"); - } - if deps.contains_key("diesel") { - frameworks.push("Diesel"); - } - } - - let mut result = json!({ - "name": name, - "version": version, - "type": project_type, - "frameworks": frameworks, - }); - - if let Some(desc) = description { - result["description"] = json!(desc); - } - - if detailed { - // Check for workspace members - if let Some(workspace) = toml.get("workspace") { - if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) { - let member_strs: Vec<&str> = members - .iter() - .filter_map(|v| v.as_str()) - .collect(); - result["workspace_members"] = json!(member_strs); - } - } - - // Count dependencies - if let Some(deps) = toml.get("dependencies").and_then(|v| v.as_table()) { - result["dependencies_count"] = json!(deps.len()); - } - } - - Some(result) - } - - fn parse_go_mod(path: &Path, _detailed: bool) -> Option { - let content = fs::read_to_string(path).ok()?; - - // Extract module name from first line - let module_name = content - .lines() - .find(|l| l.starts_with("module ")) - .map(|l| l.trim_start_matches("module ").trim()) - .unwrap_or("unknown"); - - // Extract Go version - let go_version = content - .lines() - .find(|l| l.starts_with("go ")) - .map(|l| l.trim_start_matches("go ").trim()); - - let mut result = json!({ - "name": module_name, - "type": "go module", - }); - - if let Some(v) = go_version { - result["go_version"] = json!(v); - } - - Some(result) - } -} - -#[derive(Debug, Serialize)] -struct ServiceInfo { - name: String, - path: String, - package_type: String, - info: serde_json::Value, -} - -impl Tool for DiscoverServicesTool { - const NAME: &'static str = "discover_services"; - - type Error = DiscoverServicesError; - type Args = DiscoverServicesArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: r#"Discover all services, packages, and projects in a monorepo. -Returns a list of all packages with their names, types, frameworks, and locations. -Use this FIRST when exploring a monorepo to understand its structure. -Then use analyze_project with specific paths to deep-dive into individual services."#.to_string(), - parameters: json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Subdirectory to search within (e.g., 'apps', 'packages', 'services')" - }, - "detailed": { - "type": "boolean", - "description": "Include detailed info like scripts, workspace config. Default: true" - } - } - }), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let search_root = if let Some(ref subpath) = args.path { - self.project_path.join(subpath) - } else { - self.project_path.clone() - }; - - if !search_root.exists() { - return Err(DiscoverServicesError(format!( - "Path does not exist: {}", - args.path.unwrap_or_default() - ))); - } - - let detailed = args.detailed.unwrap_or(true); - let mut services: Vec = Vec::new(); - let mut workspace_roots: HashMap = HashMap::new(); - - // First check root for workspace config - if let Some((pkg_type, manifest_path)) = Self::detect_package_type(&search_root) { - let info = match pkg_type { - "node" => Self::parse_package_json(&manifest_path, true), - "rust" => Self::parse_cargo_toml(&manifest_path, true), - "go" => Self::parse_go_mod(&manifest_path, detailed), - _ => None, - }; - - if let Some(info) = info { - // Check if this is a workspace root - if info.get("workspaces").is_some() || info.get("workspace_members").is_some() { - workspace_roots.insert("root".to_string(), info); - } - } - } - - // Walk the directory tree - for entry in WalkDir::new(&search_root) - .max_depth(6) // Deep enough for nested monorepos - .into_iter() - .filter_entry(|e| { - if e.file_type().is_dir() { - if let Some(name) = e.file_name().to_str() { - return !Self::should_skip_dir(name); - } - } - true - }) - .filter_map(|e| e.ok()) - { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - // Skip the root - we already checked it - if path == search_root { - continue; - } - - if let Some((pkg_type, manifest_path)) = Self::detect_package_type(path) { - let info = match pkg_type { - "node" => Self::parse_package_json(&manifest_path, detailed), - "rust" => Self::parse_cargo_toml(&manifest_path, detailed), - "go" => Self::parse_go_mod(&manifest_path, detailed), - _ => Some(json!({"type": pkg_type})), - }; - - if let Some(info) = info { - // Skip template placeholders - if let Some(name) = info.get("name").and_then(|v| v.as_str()) { - if name.contains("${") || name.contains("{{") { - continue; - } - } - - let relative_path = path - .strip_prefix(&self.project_path) - .unwrap_or(path) - .to_string_lossy() - .to_string(); - - let name = info - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_else(|| { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - }) - .to_string(); - - services.push(ServiceInfo { - name, - path: relative_path, - package_type: pkg_type.to_string(), - info, - }); - } - } - } - - // Sort by path for consistent output - services.sort_by(|a, b| a.path.cmp(&b.path)); - - // Categorize services - let mut categorized: HashMap<&str, Vec<&ServiceInfo>> = HashMap::new(); - for service in &services { - let category = if service.path.starts_with("apps/") || service.path.starts_with("packages/apps/") { - "apps" - } else if service.path.starts_with("packages/") || service.path.starts_with("libs/") { - "packages" - } else if service.path.starts_with("services/") { - "services" - } else if service.path.starts_with("tools/") { - "tools" - } else { - "other" - }; - categorized.entry(category).or_default().push(service); - } - - let result = json!({ - "total_services": services.len(), - "categorized": { - "apps": categorized.get("apps").map(|v| v.len()).unwrap_or(0), - "packages": categorized.get("packages").map(|v| v.len()).unwrap_or(0), - "services": categorized.get("services").map(|v| v.len()).unwrap_or(0), - "tools": categorized.get("tools").map(|v| v.len()).unwrap_or(0), - "other": categorized.get("other").map(|v| v.len()).unwrap_or(0), - }, - "workspace_config": workspace_roots, - "services": services, - "tip": "Use analyze_project with path='' to get detailed analysis of each service" - }); - - serde_json::to_string_pretty(&result) - .map_err(|e| DiscoverServicesError(format!("Serialization error: {}", e))) - } -} diff --git a/src/agent/tools/file_ops.rs b/src/agent/tools/file_ops.rs index 27241c8f..4ea2dc03 100644 --- a/src/agent/tools/file_ops.rs +++ b/src/agent/tools/file_ops.rs @@ -1,4 +1,4 @@ -//! File operation tools using Rig's Tool trait +//! File operation tools for reading and exploring the project using Rig's Tool trait use rig::completion::ToolDefinition; use rig::tool::Tool; @@ -14,12 +14,12 @@ use std::path::PathBuf; #[derive(Debug, Deserialize)] pub struct ReadFileArgs { pub path: String, - pub start_line: Option, - pub end_line: Option, + pub start_line: Option, + pub end_line: Option, } #[derive(Debug, thiserror::Error)] -#[error("File read error: {0}")] +#[error("Read file error: {0}")] pub struct ReadFileError(String); #[derive(Debug, Clone, Serialize, Deserialize)] @@ -32,18 +32,21 @@ impl ReadFileTool { Self { project_path } } - fn validate_path(&self, requested: &str) -> Result { - let canonical_project = self.project_path - .canonicalize() + fn validate_path(&self, requested: &PathBuf) -> Result { + let canonical_project = self.project_path.canonicalize() .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?; + + let target = if requested.is_absolute() { + requested.clone() + } else { + self.project_path.join(requested) + }; - let target = self.project_path.join(requested); - let canonical_target = target - .canonicalize() + let canonical_target = target.canonicalize() .map_err(|e| ReadFileError(format!("File not found: {}", e)))?; if !canonical_target.starts_with(&canonical_project) { - return Err(ReadFileError("Access denied: path is outside project".to_string())); + return Err(ReadFileError("Access denied: path is outside project directory".to_string())); } Ok(canonical_target) @@ -60,13 +63,13 @@ impl Tool for ReadFileTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), - description: "Read the contents of a file in the project.".to_string(), + description: "Read the contents of a file in the project. Use this to examine source code, configuration files, or any text file.".to_string(), parameters: json!({ "type": "object", "properties": { "path": { "type": "string", - "description": "Path to the file (relative to project root)" + "description": "Path to the file to read (relative to project root)" }, "start_line": { "type": "integer", @@ -74,7 +77,7 @@ impl Tool for ReadFileTool { }, "end_line": { "type": "integer", - "description": "Optional ending line number (inclusive)" + "description": "Optional ending line number (1-based, inclusive)" } }, "required": ["path"] @@ -83,34 +86,31 @@ impl Tool for ReadFileTool { } async fn call(&self, args: Self::Args) -> Result { - let file_path = self.validate_path(&args.path)?; + let requested_path = PathBuf::from(&args.path); + let file_path = self.validate_path(&requested_path)?; let metadata = fs::metadata(&file_path) .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?; - - const MAX_SIZE: u64 = 1024 * 1024; // 1MB + + const MAX_SIZE: u64 = 1024 * 1024; if metadata.len() > MAX_SIZE { - return Err(ReadFileError(format!( - "File too large ({} bytes). Max: {} bytes.", - metadata.len(), - MAX_SIZE - ))); + return Ok(json!({ + "error": format!("File too large ({} bytes). Maximum size is {} bytes.", metadata.len(), MAX_SIZE) + }).to_string()); } let content = fs::read_to_string(&file_path) - .map_err(|e| ReadFileError(format!("Failed to read: {}", e)))?; + .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?; let output = if let Some(start) = args.start_line { let lines: Vec<&str> = content.lines().collect(); - let start_idx = start.saturating_sub(1); - let end_idx = args.end_line.map(|e| e.min(lines.len())).unwrap_or(lines.len()); - + let start_idx = (start as usize).saturating_sub(1); + let end_idx = args.end_line.map(|e| (e as usize).min(lines.len())).unwrap_or(lines.len()); + if start_idx >= lines.len() { - return Err(ReadFileError(format!( - "Start line {} exceeds file length ({})", - start, - lines.len() - ))); + return Ok(json!({ + "error": format!("Start line {} exceeds file length ({})", start, lines.len()) + }).to_string()); } let selected: Vec = lines[start_idx..end_idx] @@ -134,7 +134,7 @@ impl Tool for ReadFileTool { }; serde_json::to_string_pretty(&output) - .map_err(|e| ReadFileError(format!("Serialization error: {}", e))) + .map_err(|e| ReadFileError(format!("Failed to serialize: {}", e))) } } @@ -149,7 +149,7 @@ pub struct ListDirectoryArgs { } #[derive(Debug, thiserror::Error)] -#[error("Directory list error: {0}")] +#[error("List directory error: {0}")] pub struct ListDirectoryError(String); #[derive(Debug, Clone, Serialize, Deserialize)] @@ -162,23 +162,21 @@ impl ListDirectoryTool { Self { project_path } } - fn validate_path(&self, requested: &str) -> Result { - let canonical_project = self.project_path - .canonicalize() + fn validate_path(&self, requested: &PathBuf) -> Result { + let canonical_project = self.project_path.canonicalize() .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?; - - let target = if requested.is_empty() || requested == "." { - self.project_path.clone() + + let target = if requested.is_absolute() { + requested.clone() } else { self.project_path.join(requested) }; - let canonical_target = target - .canonicalize() + let canonical_target = target.canonicalize() .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?; if !canonical_target.starts_with(&canonical_project) { - return Err(ListDirectoryError("Access denied: path is outside project".to_string())); + return Err(ListDirectoryError("Access denied: path is outside project directory".to_string())); } Ok(canonical_target) @@ -193,13 +191,10 @@ impl ListDirectoryTool { max_depth: usize, entries: &mut Vec, ) -> Result<(), ListDirectoryError> { - let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "dist", "build"]; - - let dir_name = current_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - + let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build"]; + + let dir_name = current_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if depth > 0 && skip_dirs.contains(&dir_name) { return Ok(()); } @@ -211,13 +206,8 @@ impl ListDirectoryTool { let entry = entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?; let path = entry.path(); let metadata = entry.metadata().ok(); - - let relative_path = path - .strip_prefix(base_path) - .unwrap_or(&path) - .to_string_lossy() - .to_string(); - + + let relative_path = path.strip_prefix(base_path).unwrap_or(&path).to_string_lossy().to_string(); let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); @@ -225,7 +215,7 @@ impl ListDirectoryTool { "name": entry.file_name().to_string_lossy(), "path": relative_path, "type": if is_dir { "directory" } else { "file" }, - "size": if is_dir { serde_json::Value::Null } else { json!(size) } + "size": if is_dir { None:: } else { Some(size) } })); if recursive && is_dir && depth < max_depth { @@ -247,17 +237,17 @@ impl Tool for ListDirectoryTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), - description: "List the contents of a directory in the project.".to_string(), + description: "List the contents of a directory in the project. Returns file and subdirectory names with their types and sizes.".to_string(), parameters: json!({ "type": "object", "properties": { "path": { "type": "string", - "description": "Path to directory (relative to project root). Use '.' for project root." + "description": "Path to the directory to list (relative to project root). Use '.' for root." }, "recursive": { "type": "boolean", - "description": "If true, list contents recursively (max depth 3)" + "description": "If true, list contents recursively (max depth 3). Default is false." } } }), @@ -266,7 +256,14 @@ impl Tool for ListDirectoryTool { async fn call(&self, args: Self::Args) -> Result { let path_str = args.path.as_deref().unwrap_or("."); - let dir_path = self.validate_path(path_str)?; + + let requested_path = if path_str.is_empty() || path_str == "." { + self.project_path.clone() + } else { + PathBuf::from(path_str) + }; + + let dir_path = self.validate_path(&requested_path)?; let recursive = args.recursive.unwrap_or(false); let mut entries = Vec::new(); @@ -279,6 +276,6 @@ impl Tool for ListDirectoryTool { }); serde_json::to_string_pretty(&result) - .map_err(|e| ListDirectoryError(format!("Serialization error: {}", e))) + .map_err(|e| ListDirectoryError(format!("Failed to serialize: {}", e))) } } diff --git a/src/agent/tools/generate.rs b/src/agent/tools/generate.rs deleted file mode 100644 index 3f8d2050..00000000 --- a/src/agent/tools/generate.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! IaC Generation tool for the agent -//! -//! Wraps the existing generator functionality for the agent to use. - -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::path::PathBuf; - -use crate::analyzer::analyze_monorepo; -use crate::generator; - -/// Arguments for the generate IaC tool -#[derive(Debug, Deserialize)] -pub struct GenerateIaCArgs { - /// Type of IaC to generate: "dockerfile", "compose", "terraform", or "all" - pub generate_type: String, - /// Optional subdirectory to generate for - pub path: Option, -} - -/// Error type for generate tool -#[derive(Debug, thiserror::Error)] -#[error("Generation error: {0}")] -pub struct GenerateIaCError(String); - -/// Tool to generate Infrastructure as Code -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GenerateIaCTool { - project_path: PathBuf, -} - -impl GenerateIaCTool { - pub fn new(project_path: PathBuf) -> Self { - Self { project_path } - } -} - -impl Tool for GenerateIaCTool { - const NAME: &'static str = "generate_iac"; - - type Error = GenerateIaCError; - type Args = GenerateIaCArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Generate Infrastructure as Code files based on project analysis. Can generate Dockerfiles, Docker Compose configurations, or Terraform files. Returns the generated content as a preview without writing to disk.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "generate_type": { - "type": "string", - "enum": ["dockerfile", "compose", "terraform", "all"], - "description": "Type of IaC to generate: 'dockerfile' for container config, 'compose' for Docker Compose, 'terraform' for infrastructure, 'all' for everything" - }, - "path": { - "type": "string", - "description": "Optional subdirectory to analyze for generation (relative to project root)" - } - }, - "required": ["generate_type"] - }), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let path = if let Some(subpath) = args.path { - self.project_path.join(subpath) - } else { - self.project_path.clone() - }; - - // Run analysis - let monorepo_analysis = analyze_monorepo(&path) - .map_err(|e| GenerateIaCError(format!("Analysis failed: {}", e)))?; - - // Get the main project analysis - let main_project = &monorepo_analysis.projects[0]; - let analysis = &main_project.analysis; - - let generate_type = args.generate_type.to_lowercase(); - let generate_all = generate_type == "all"; - - let mut results = Vec::new(); - - // Generate Dockerfile - if generate_all || generate_type == "dockerfile" { - match generator::generate_dockerfile(analysis) { - Ok(content) => { - results.push(json!({ - "type": "Dockerfile", - "content": content, - "filename": "Dockerfile" - })); - } - Err(e) => { - results.push(json!({ - "type": "Dockerfile", - "error": e.to_string() - })); - } - } - } - - // Generate Docker Compose - if generate_all || generate_type == "compose" { - match generator::generate_compose(analysis) { - Ok(content) => { - results.push(json!({ - "type": "Docker Compose", - "content": content, - "filename": "docker-compose.yml" - })); - } - Err(e) => { - results.push(json!({ - "type": "Docker Compose", - "error": e.to_string() - })); - } - } - } - - // Generate Terraform - if generate_all || generate_type == "terraform" { - match generator::generate_terraform(analysis) { - Ok(content) => { - results.push(json!({ - "type": "Terraform", - "content": content, - "filename": "main.tf" - })); - } - Err(e) => { - results.push(json!({ - "type": "Terraform", - "error": e.to_string() - })); - } - } - } - - // Add project context to help the agent - let project_info = json!({ - "project_name": main_project.name, - "languages": monorepo_analysis.technology_summary.languages, - "frameworks": monorepo_analysis.technology_summary.frameworks, - "is_monorepo": monorepo_analysis.is_monorepo, - "project_count": monorepo_analysis.projects.len() - }); - - let result = json!({ - "generated": results, - "project_info": project_info, - "note": "This is a preview. The content has not been written to disk. Share with the user and ask if they want to save these files." - }); - - serde_json::to_string_pretty(&result) - .map_err(|e| GenerateIaCError(format!("Serialization error: {}", e))) - } -} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index bf6824f4..ebe6b053 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -1,33 +1,11 @@ //! Agent tools using Rig's Tool trait //! //! These tools wrap existing CLI functionality for the agent to use. -//! -//! ## Available Tools -//! -//! ### Analysis & Understanding -//! - `AnalyzeTool` - Comprehensive project analysis (languages, frameworks, dependencies) -//! - `SearchCodeTool` - Grep-like code search with regex support -//! - `FindFilesTool` - Find files by name pattern/extension -//! - `ReadFileTool` - Read file contents with line range support -//! - `ListDirectoryTool` - List directory contents recursively -//! -//! ### Security -//! - `SecurityScanTool` - Scan for secrets and security issues -//! - `VulnerabilitiesTool` - Check dependencies for known vulnerabilities -//! -//! ### Generation -//! - `GenerateIaCTool` - Generate Dockerfile, Docker Compose, Terraform mod analyze; -mod discover; mod file_ops; -mod generate; -mod search; mod security; pub use analyze::AnalyzeTool; -pub use discover::DiscoverServicesTool; pub use file_ops::{ListDirectoryTool, ReadFileTool}; -pub use generate::GenerateIaCTool; -pub use search::{FindFilesTool, SearchCodeTool}; pub use security::{SecurityScanTool, VulnerabilitiesTool}; diff --git a/src/agent/tools/search.rs b/src/agent/tools/search.rs deleted file mode 100644 index 1270ec9b..00000000 --- a/src/agent/tools/search.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Search tools for agentic code exploration -//! -//! Provides grep-like code search and file finding capabilities. - -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::fs; -use std::path::PathBuf; -use walkdir::WalkDir; -use regex::Regex; - -// ============================================================================ -// Search Code Tool (grep-like) -// ============================================================================ - -#[derive(Debug, Deserialize)] -pub struct SearchCodeArgs { - /// Search pattern (regex or literal string) - pub pattern: String, - /// Optional path to search within (relative to project root) - pub path: Option, - /// File extension filter (e.g., "rs", "ts", "py") - pub extension: Option, - /// Whether to treat pattern as regex (default: false = literal) - pub regex: Option, - /// Case insensitive search (default: true) - pub case_insensitive: Option, - /// Maximum number of results (default: 50) - pub max_results: Option, -} - -#[derive(Debug, thiserror::Error)] -#[error("Search error: {0}")] -pub struct SearchCodeError(String); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchCodeTool { - project_path: PathBuf, -} - -impl SearchCodeTool { - pub fn new(project_path: PathBuf) -> Self { - Self { project_path } - } - - fn should_skip_dir(name: &str) -> bool { - matches!( - name, - "node_modules" - | ".git" - | "target" - | "__pycache__" - | ".venv" - | "dist" - | "build" - | ".next" - | ".nuxt" - | "vendor" - | ".cache" - | "coverage" - ) - } - - fn is_text_file(path: &PathBuf) -> bool { - let text_extensions = [ - "rs", "go", "js", "ts", "jsx", "tsx", "py", "java", "kt", "scala", - "rb", "php", "cs", "cpp", "c", "h", "hpp", "swift", "dart", "elm", - "clj", "hs", "ml", "r", "sh", "bash", "zsh", "ps1", "bat", "cmd", - "json", "yaml", "yml", "toml", "xml", "html", "css", "scss", "sass", - "less", "md", "txt", "sql", "graphql", "prisma", "env", "dockerfile", - "makefile", "cmake", "gradle", "sbt", "ex", "exs", "erl", "hrl", - ]; - - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - return text_extensions.contains(&ext.to_lowercase().as_str()); - } - - // Check for extensionless files like Dockerfile, Makefile - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - let lower = name.to_lowercase(); - return matches!(lower.as_str(), "dockerfile" | "makefile" | "rakefile" | "gemfile" | "procfile" | "justfile"); - } - - false - } -} - -#[derive(Debug, Serialize)] -struct SearchMatch { - file: String, - line_number: usize, - line: String, - context_before: Vec, - context_after: Vec, -} - -impl Tool for SearchCodeTool { - const NAME: &'static str = "search_code"; - - type Error = SearchCodeError; - type Args = SearchCodeArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Search for code patterns, function names, variables, or any text across the codebase. Returns matching lines with context. Use this to find where something is defined, used, or imported.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Search pattern - can be a function name, variable, string literal, or regex pattern" - }, - "path": { - "type": "string", - "description": "Optional subdirectory to search within (e.g., 'src', 'backend/api')" - }, - "extension": { - "type": "string", - "description": "Filter by file extension (e.g., 'rs', 'ts', 'py'). Omit for all file types." - }, - "regex": { - "type": "boolean", - "description": "Treat pattern as regex. Default: false (literal string match)" - }, - "case_insensitive": { - "type": "boolean", - "description": "Case insensitive search. Default: true" - }, - "max_results": { - "type": "integer", - "description": "Maximum results to return. Default: 50" - } - }, - "required": ["pattern"] - }), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let search_root = if let Some(ref subpath) = args.path { - self.project_path.join(subpath) - } else { - self.project_path.clone() - }; - - if !search_root.exists() { - return Err(SearchCodeError(format!( - "Path does not exist: {}", - args.path.unwrap_or_default() - ))); - } - - let case_insensitive = args.case_insensitive.unwrap_or(true); - let is_regex = args.regex.unwrap_or(false); - let max_results = args.max_results.unwrap_or(50); - - // Build the search pattern - let pattern_str = if is_regex { - if case_insensitive { - format!("(?i){}", args.pattern) - } else { - args.pattern.clone() - } - } else { - let escaped = regex::escape(&args.pattern); - if case_insensitive { - format!("(?i){}", escaped) - } else { - escaped - } - }; - - let regex = Regex::new(&pattern_str) - .map_err(|e| SearchCodeError(format!("Invalid pattern: {}", e)))?; - - let mut matches: Vec = Vec::new(); - - for entry in WalkDir::new(&search_root) - .into_iter() - .filter_entry(|e| { - if e.file_type().is_dir() { - if let Some(name) = e.file_name().to_str() { - return !Self::should_skip_dir(name); - } - } - true - }) - .filter_map(|e| e.ok()) - { - if matches.len() >= max_results { - break; - } - - let path = entry.path(); - if !path.is_file() { - continue; - } - - // Extension filter - if let Some(ref ext_filter) = args.extension { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext.to_lowercase() != ext_filter.to_lowercase() { - continue; - } - } else { - continue; - } - } - - // Only search text files - let path_buf = path.to_path_buf(); - if !Self::is_text_file(&path_buf) { - continue; - } - - // Read and search file - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => continue, // Skip binary/unreadable files - }; - - let lines: Vec<&str> = content.lines().collect(); - for (line_idx, line) in lines.iter().enumerate() { - if matches.len() >= max_results { - break; - } - - if regex.is_match(line) { - let relative_path = path - .strip_prefix(&self.project_path) - .unwrap_or(path) - .to_string_lossy() - .to_string(); - - // Get 1 line of context before/after - let context_before = if line_idx > 0 { - vec![lines[line_idx - 1].to_string()] - } else { - vec![] - }; - - let context_after = if line_idx + 1 < lines.len() { - vec![lines[line_idx + 1].to_string()] - } else { - vec![] - }; - - matches.push(SearchMatch { - file: relative_path, - line_number: line_idx + 1, - line: line.to_string(), - context_before, - context_after, - }); - } - } - } - - let result = json!({ - "pattern": args.pattern, - "total_matches": matches.len(), - "matches": matches, - "truncated": matches.len() >= max_results - }); - - serde_json::to_string_pretty(&result) - .map_err(|e| SearchCodeError(format!("Serialization error: {}", e))) - } -} - -// ============================================================================ -// Find Files Tool -// ============================================================================ - -#[derive(Debug, Deserialize)] -pub struct FindFilesArgs { - /// File name pattern (supports * and ? wildcards) - pub pattern: String, - /// Optional subdirectory to search in - pub path: Option, - /// File extension filter - pub extension: Option, - /// Include directories in results (default: false) - pub include_dirs: Option, - /// Maximum results (default: 100) - pub max_results: Option, -} - -#[derive(Debug, thiserror::Error)] -#[error("Find files error: {0}")] -pub struct FindFilesError(String); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FindFilesTool { - project_path: PathBuf, -} - -impl FindFilesTool { - pub fn new(project_path: PathBuf) -> Self { - Self { project_path } - } - - fn matches_pattern(name: &str, pattern: &str) -> bool { - let pattern_lower = pattern.to_lowercase(); - let name_lower = name.to_lowercase(); - - // Handle simple wildcards - if pattern == "*" { - return true; - } - - // Convert simple wildcards to regex-like matching - if pattern.contains('*') || pattern.contains('?') { - let regex_pattern = pattern_lower - .replace('.', r"\.") - .replace('*', ".*") - .replace('?', "."); - - if let Ok(re) = Regex::new(&format!("^{}$", regex_pattern)) { - return re.is_match(&name_lower); - } - } - - // Plain substring match - name_lower.contains(&pattern_lower) - } -} - -#[derive(Debug, Serialize)] -struct FileInfo { - name: String, - path: String, - file_type: String, - size: Option, - extension: Option, -} - -impl Tool for FindFilesTool { - const NAME: &'static str = "find_files"; - - type Error = FindFilesError; - type Args = FindFilesArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Find files by name pattern. Use wildcards (* for any characters, ? for single character). Great for locating config files, finding all files of a type, or discovering project structure.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "File name pattern with optional wildcards. Examples: 'package.json', '*.config.ts', 'Dockerfile*', 'api*.rs'" - }, - "path": { - "type": "string", - "description": "Subdirectory to search in (e.g., 'src', 'backend')" - }, - "extension": { - "type": "string", - "description": "Filter by extension (e.g., 'ts', 'rs', 'yaml')" - }, - "include_dirs": { - "type": "boolean", - "description": "Include directories in results. Default: false" - }, - "max_results": { - "type": "integer", - "description": "Maximum results. Default: 100" - } - }, - "required": ["pattern"] - }), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let search_root = if let Some(ref subpath) = args.path { - self.project_path.join(subpath) - } else { - self.project_path.clone() - }; - - if !search_root.exists() { - return Err(FindFilesError(format!( - "Path does not exist: {}", - args.path.unwrap_or_default() - ))); - } - - let include_dirs = args.include_dirs.unwrap_or(false); - let max_results = args.max_results.unwrap_or(100); - let skip_dirs = [ - "node_modules", ".git", "target", "__pycache__", ".venv", - "dist", "build", ".next", ".nuxt", "vendor", ".cache", "coverage" - ]; - - let mut results: Vec = Vec::new(); - - for entry in WalkDir::new(&search_root) - .into_iter() - .filter_entry(|e| { - if e.file_type().is_dir() { - if let Some(name) = e.file_name().to_str() { - return !skip_dirs.contains(&name); - } - } - true - }) - .filter_map(|e| e.ok()) - { - if results.len() >= max_results { - break; - } - - let path = entry.path(); - let is_dir = path.is_dir(); - - // Skip dirs if not requested - if is_dir && !include_dirs { - continue; - } - - let file_name = match path.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => continue, - }; - - // Extension filter - if let Some(ref ext_filter) = args.extension { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext.to_lowercase() != ext_filter.to_lowercase() { - continue; - } - } else { - continue; - } - } - - // Pattern matching - if !Self::matches_pattern(file_name, &args.pattern) { - continue; - } - - let relative_path = path - .strip_prefix(&self.project_path) - .unwrap_or(path) - .to_string_lossy() - .to_string(); - - let metadata = path.metadata().ok(); - let size = if is_dir { None } else { metadata.as_ref().map(|m| m.len()) }; - - results.push(FileInfo { - name: file_name.to_string(), - path: relative_path, - file_type: if is_dir { "directory".to_string() } else { "file".to_string() }, - size, - extension: path.extension().and_then(|e| e.to_str()).map(|s| s.to_string()), - }); - } - - let result = json!({ - "pattern": args.pattern, - "total_found": results.len(), - "files": results, - "truncated": results.len() >= max_results - }); - - serde_json::to_string_pretty(&result) - .map_err(|e| FindFilesError(format!("Serialization error: {}", e))) - } -} diff --git a/src/agent/tools/security.rs b/src/agent/tools/security.rs index bb831806..7ddc545f 100644 --- a/src/agent/tools/security.rs +++ b/src/agent/tools/security.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::PathBuf; -use crate::analyzer::security::turbo::{TurboConfig, TurboSecurityAnalyzer, ScanMode}; +use crate::analyzer::security::turbo::{TurboSecurityAnalyzer, TurboConfig, ScanMode}; // ============================================================================ // Security Scan Tool @@ -79,26 +79,30 @@ impl Tool for SecurityScanTool { scan_mode, ..TurboConfig::default() }; + + let scanner = TurboSecurityAnalyzer::new(config) + .map_err(|e| SecurityScanError(format!("Failed to create scanner: {}", e)))?; - let analyzer = TurboSecurityAnalyzer::new(config) - .map_err(|e| SecurityScanError(format!("Failed to create analyzer: {}", e)))?; - - let report = analyzer.analyze_project(&path) + let report = scanner.analyze_project(&path) .map_err(|e| SecurityScanError(format!("Scan failed: {}", e)))?; - - let findings = report.findings; let result = json!({ - "total_findings": findings.len(), - "findings": findings.iter().take(50).map(|f| { + "total_findings": report.total_findings, + "overall_score": report.overall_score, + "risk_level": format!("{:?}", report.risk_level), + "files_scanned": report.files_scanned, + "findings": report.findings.iter().take(50).map(|f| { json!({ - "file": f.file_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(), - "line": f.line_number, "title": f.title, + "description": f.description, "severity": format!("{:?}", f.severity), - "evidence": f.evidence.as_ref().map(|e| e.chars().take(50).collect::()).unwrap_or_default(), + "category": format!("{:?}", f.category), + "file_path": f.file_path.as_ref().map(|p| p.display().to_string()), + "line_number": f.line_number, + "evidence": f.evidence.as_ref().map(|e| e.chars().take(100).collect::()), }) }).collect::>(), + "recommendations": report.recommendations.iter().take(10).collect::>(), "scan_mode": args.mode.as_deref().unwrap_or("balanced"), }); diff --git a/src/agent/ui.rs b/src/agent/ui.rs deleted file mode 100644 index 4c3301a2..00000000 --- a/src/agent/ui.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Beautiful terminal UI for the agent -//! -//! Provides colorful output, markdown rendering, and tool call animations. - -use console::{style, Emoji, Term}; -use indicatif::{ProgressBar, ProgressStyle}; -use std::time::Duration; - -// Emojis for different states -pub static ROBOT: Emoji<'_, '_> = Emoji("🤖 ", ""); -pub static THINKING: Emoji<'_, '_> = Emoji("💭 ", ""); -pub static TOOL: Emoji<'_, '_> = Emoji("🔧 ", ""); -pub static SUCCESS: Emoji<'_, '_> = Emoji("✅ ", "[OK] "); -pub static ERROR: Emoji<'_, '_> = Emoji("❌ ", "[ERR] "); -pub static SEARCH: Emoji<'_, '_> = Emoji("🔍 ", ""); -pub static SECURITY: Emoji<'_, '_> = Emoji("🛡️ ", ""); -pub static FILE: Emoji<'_, '_> = Emoji("📄 ", ""); -pub static FOLDER: Emoji<'_, '_> = Emoji("📁 ", ""); -pub static SPARKLES: Emoji<'_, '_> = Emoji("✨ ", ""); -pub static ARROW: Emoji<'_, '_> = Emoji("➜ ", "> "); - -/// Print the SYNCABLE ASCII art logo with gradient colors -pub fn print_logo() { - // Colors matching the logo gradient: purple → orange → pink - // Using ANSI 256 colors for better gradient - - // Purple shades for S, y - let purple = "\x1b[38;5;141m"; // Light purple - // Orange shades for n, c - let orange = "\x1b[38;5;216m"; // Peach/orange - // Pink shades for a, b, l, e - let pink = "\x1b[38;5;212m"; // Hot pink - let magenta = "\x1b[38;5;207m"; // Magenta - let reset = "\x1b[0m"; - - println!(); - println!( - "{} ███████╗{}{} ██╗ ██╗{}{}███╗ ██╗{}{} ██████╗{}{} █████╗ {}{}██████╗ {}{}██╗ {}{}███████╗{}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!( - "{} ██╔════╝{}{} ╚██╗ ██╔╝{}{}████╗ ██║{}{} ██╔════╝{}{} ██╔══██╗{}{}██╔══██╗{}{}██║ {}{}██╔════╝{}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!( - "{} ███████╗{}{} ╚████╔╝ {}{}██╔██╗ ██║{}{} ██║ {}{} ███████║{}{}██████╔╝{}{}██║ {}{}█████╗ {}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!( - "{} ╚════██║{}{} ╚██╔╝ {}{}██║╚██╗██║{}{} ██║ {}{} ██╔══██║{}{}██╔══██╗{}{}██║ {}{}██╔══╝ {}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!( - "{} ███████║{}{} ██║ {}{}██║ ╚████║{}{} ╚██████╗{}{} ██║ ██║{}{}██████╔╝{}{}███████╗{}{}███████╗{}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!( - "{} ╚══════╝{}{} ╚═╝ {}{}╚═╝ ╚═══╝{}{} ╚═════╝{}{} ╚═╝ ╚═╝{}{}╚═════╝ {}{}╚══════╝{}{}╚══════╝{}", - purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset - ); - println!(); -} - -/// Terminal UI handler for the agent -pub struct AgentUI { - #[allow(dead_code)] - term: Term, - spinner: Option, -} - -impl AgentUI { - pub fn new() -> Self { - Self { - term: Term::stderr(), - spinner: None, - } - } - - /// Pause the current spinner temporarily - pub fn pause_spinner(&mut self) { - if let Some(ref spinner) = self.spinner { - spinner.finish_and_clear(); - } - self.spinner = None; - } - - /// Print the welcome banner - pub fn print_welcome(&self, provider: &str, model: &str) { - // Print the gradient ASCII logo - print_logo(); - - // Print agent info - println!( - " {} {} powered by {}: {}", - ROBOT, - style("Syncable Agent").white().bold(), - style(provider).cyan(), - style(model).cyan() - ); - println!( - " {}", - style("Your AI-powered code analysis assistant").dim() - ); - println!(); - println!( - " {} Type your questions. Use {} to exit.\n", - style("→").cyan(), - style("exit").yellow().bold() - ); - } - - /// Print the prompt - pub fn print_prompt(&self) { - print!( - "\n{} {} ", - style("you").green().bold(), - style("›").green() - ); - use std::io::Write; - std::io::stdout().flush().ok(); - } - - /// Start a thinking spinner - pub fn start_thinking(&mut self) { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .tick_strings(&[ - "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", - ]) - .template("{spinner:.cyan} {msg}") - .unwrap(), - ); - spinner.set_message(format!("{} Thinking...", THINKING)); - spinner.enable_steady_tick(Duration::from_millis(80)); - self.spinner = Some(spinner); - } - - /// Update spinner with tool call info - pub fn show_tool_call(&mut self, tool_name: &str) { - let emoji = match tool_name { - "analyze_project" => SEARCH, - "security_scan" => SECURITY, - "check_vulnerabilities" => SECURITY, - "read_file" => FILE, - "list_directory" => FOLDER, - _ => TOOL, - }; - - let action = match tool_name { - "analyze_project" => "Analyzing project structure...", - "security_scan" => "Scanning for security issues...", - "check_vulnerabilities" => "Checking dependencies for vulnerabilities...", - "read_file" => "Reading file contents...", - "list_directory" => "Listing directory...", - _ => "Running tool...", - }; - - if let Some(ref spinner) = self.spinner { - spinner.set_message(format!("{} {}", emoji, style(action).cyan())); - } - } - - /// Stop the spinner - pub fn stop_thinking(&mut self) { - if let Some(spinner) = self.spinner.take() { - spinner.finish_and_clear(); - } - } - - /// Print the assistant header for streaming response - pub fn print_assistant_header(&self) { - println!(); - println!( - "{} {} ", - style("assistant").magenta().bold(), - style("›").magenta() - ); - } - - /// Start a streaming indicator - pub fn start_streaming(&mut self) { - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .tick_strings(&["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"]) - .template(" {spinner:.magenta} {msg}") - .unwrap(), - ); - spinner.set_message(style("Generating response...").dim().to_string()); - spinner.enable_steady_tick(Duration::from_millis(80)); - self.spinner = Some(spinner); - } - - /// Update streaming progress - pub fn update_streaming(&mut self, char_count: usize) { - if let Some(ref spinner) = self.spinner { - spinner.set_message( - style(format!("Generating... ({} chars)", char_count)).dim().to_string() - ); - } - } - - /// Stop streaming and print the response - pub fn finish_streaming_and_render(&mut self, response: &str) { - if let Some(spinner) = self.spinner.take() { - spinner.finish_and_clear(); - } - println!(); - self.render_markdown(response); - println!(); - } - - /// Print streaming text chunk (no newline) - real-time output - pub fn print_stream_chunk(&self, text: &str) { - print!("{}", text); - use std::io::Write; - std::io::stdout().flush().ok(); - } - - /// Print tool call notification during streaming - pub fn print_tool_call_notification(&self, tool_name: &str) { - let emoji = match tool_name { - "analyze_project" => SEARCH, - "security_scan" => SECURITY, - "check_vulnerabilities" => SECURITY, - "read_file" => FILE, - "list_directory" => FOLDER, - _ => TOOL, - }; - - let action = match tool_name { - "analyze_project" => "Analyzing project structure", - "security_scan" => "Scanning for security issues", - "check_vulnerabilities" => "Checking dependencies for vulnerabilities", - "read_file" => "Reading file contents", - "list_directory" => "Listing directory", - _ => tool_name, - }; - - println!(); - println!( - " {} {} {}", - style("┌─").dim(), - emoji, - style(format!("Calling: {}", action)).cyan().bold() - ); - } - - /// Print tool call completion - pub fn print_tool_call_complete(&self, tool_name: &str) { - let emoji = match tool_name { - "analyze_project" => SEARCH, - "security_scan" => SECURITY, - "check_vulnerabilities" => SECURITY, - "read_file" => FILE, - "list_directory" => FOLDER, - _ => TOOL, - }; - - println!( - " {} {} {}", - style("└─").dim(), - emoji, - style(format!("{} completed", tool_name)).green() - ); - println!(); - } - - /// End the streaming response - pub fn end_stream(&self) { - println!(); - println!(); - } - - /// Print the assistant's response with markdown rendering - pub fn print_response(&self, response: &str) { - println!(); - println!( - "{} {} ", - style("assistant").magenta().bold(), - style("›").magenta() - ); - println!(); - - // Render markdown - self.render_markdown(response); - - println!(); - } - - /// Render markdown content beautifully - fn render_markdown(&self, content: &str) { - use termimad::MadSkin; - use termimad::crossterm::style::Color; - - let mut skin = MadSkin::default(); - - // Customize colors using crossterm colors - skin.set_headers_fg(Color::Cyan); - skin.bold.set_fg(Color::White); - skin.italic.set_fg(Color::Magenta); - skin.inline_code.set_bg(Color::DarkGrey); - skin.inline_code.set_fg(Color::Yellow); - skin.code_block.set_bg(Color::DarkGrey); - skin.code_block.set_fg(Color::Green); - - // Print markdown to terminal - skin.print_text(content); - } - - /// Print an error message - pub fn print_error(&self, message: &str) { - println!( - "\n {} {}", - ERROR, - style(message).red() - ); - } - - /// Print a success message - pub fn print_success(&self, message: &str) { - println!( - "\n {} {}", - SUCCESS, - style(message).green() - ); - } - - /// Print tool execution result summary - pub fn print_tool_result(&self, tool_name: &str, success: bool) { - let emoji = if success { SUCCESS } else { ERROR }; - let status = if success { - style("completed").green() - } else { - style("failed").red() - }; - - println!( - " {} {} {}", - style("│").dim(), - emoji, - style(format!("{} {}", tool_name, status)).dim() - ); - } -} - -impl Default for AgentUI { - fn default() -> Self { - Self::new() - } -} - -/// Format tool calls for display -pub fn format_tool_summary(tools_called: &[&str]) -> String { - if tools_called.is_empty() { - return String::new(); - } - - let mut summary = String::from("\n "); - summary.push_str(&style("Tools used: ").dim().to_string()); - - for (i, tool) in tools_called.iter().enumerate() { - if i > 0 { - summary.push_str(", "); - } - summary.push_str(&style(*tool).cyan().to_string()); - } - - summary -} - -/// Create a simple progress bar for long operations -pub fn create_progress_bar(len: u64, message: &str) -> ProgressBar { - let pb = ProgressBar::new(len); - pb.set_style( - ProgressStyle::default_bar() - .template(" {spinner:.cyan} [{bar:40.cyan/dim}] {pos}/{len} {msg}") - .unwrap() - .progress_chars("━━╸"), - ); - pb.set_message(message.to_string()); - pb -} diff --git a/src/agent/ui/colors.rs b/src/agent/ui/colors.rs new file mode 100644 index 00000000..2bf490ac --- /dev/null +++ b/src/agent/ui/colors.rs @@ -0,0 +1,112 @@ +//! Color theme and styling utilities for terminal UI +//! +//! Provides semantic colors and ANSI escape codes for consistent styling. + +use colored::Colorize; + +/// Status icons for different states +pub mod icons { + pub const PENDING: &str = "○"; + pub const EXECUTING: &str = "◐"; + pub const SUCCESS: &str = "✓"; + pub const ERROR: &str = "✗"; + pub const CANCELED: &str = "⊘"; + pub const CONFIRMING: &str = "⏳"; + pub const ARROW: &str = "→"; + pub const THINKING: &str = "💭"; + pub const ROBOT: &str = "🤖"; + pub const TOOL: &str = "🔧"; + pub const FILE: &str = "📄"; + pub const FOLDER: &str = "📁"; + pub const SECURITY: &str = "🔒"; + pub const SEARCH: &str = "🔍"; +} + +/// ANSI escape codes for direct terminal control +pub mod ansi { + /// Clear current line + pub const CLEAR_LINE: &str = "\x1b[2K\r"; + /// Move cursor up one line + pub const CURSOR_UP: &str = "\x1b[1A"; + /// Hide cursor + pub const HIDE_CURSOR: &str = "\x1b[?25l"; + /// Show cursor + pub const SHOW_CURSOR: &str = "\x1b[?25h"; + /// Reset all styles + pub const RESET: &str = "\x1b[0m"; + /// Bold + pub const BOLD: &str = "\x1b[1m"; + /// Dim + pub const DIM: &str = "\x1b[2m"; + + // 256-color codes for Syncable brand + pub const PURPLE: &str = "\x1b[38;5;141m"; + pub const ORANGE: &str = "\x1b[38;5;216m"; + pub const PINK: &str = "\x1b[38;5;212m"; + pub const MAGENTA: &str = "\x1b[38;5;207m"; + pub const CYAN: &str = "\x1b[38;5;51m"; + pub const GRAY: &str = "\x1b[38;5;245m"; + pub const SUCCESS: &str = "\x1b[38;5;114m"; // Green for success +} + +/// Format a tool name for display +pub fn format_tool_name(name: &str) -> String { + name.cyan().bold().to_string() +} + +/// Format a status message based on success/failure +pub fn format_status(success: bool, message: &str) -> String { + if success { + format!("{} {}", icons::SUCCESS.green(), message.green()) + } else { + format!("{} {}", icons::ERROR.red(), message.red()) + } +} + +/// Format elapsed time for display +pub fn format_elapsed(seconds: u64) -> String { + if seconds < 60 { + format!("{}s", seconds) + } else { + let mins = seconds / 60; + let secs = seconds % 60; + format!("{}m {}s", mins, secs) + } +} + +/// Format a thinking/reasoning message +pub fn format_thinking(subject: &str) -> String { + format!( + "{} {}", + icons::THINKING, + subject.cyan().italic() + ) +} + +/// Format an info message +pub fn format_info(message: &str) -> String { + format!("{} {}", icons::ARROW.cyan(), message) +} + +/// Format a warning message +pub fn format_warning(message: &str) -> String { + format!("⚠ {}", message.yellow()) +} + +/// Format an error message +pub fn format_error(message: &str) -> String { + format!("{} {}", icons::ERROR.red(), message.red()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_elapsed() { + assert_eq!(format_elapsed(5), "5s"); + assert_eq!(format_elapsed(30), "30s"); + assert_eq!(format_elapsed(65), "1m 5s"); + assert_eq!(format_elapsed(125), "2m 5s"); + } +} diff --git a/src/agent/ui/hooks.rs b/src/agent/ui/hooks.rs new file mode 100644 index 00000000..9efdd49a --- /dev/null +++ b/src/agent/ui/hooks.rs @@ -0,0 +1,179 @@ +//! Rig PromptHook implementations for UI updates +//! +//! Provides hooks that update the UI when tools are called during agent execution. + +use crate::agent::ui::Spinner; +use rig::agent::CancelSignal; +use rig::completion::CompletionModel; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// A hook that updates the spinner when tools are executed +#[derive(Clone)] +pub struct ToolDisplayHook { + sender: mpsc::Sender, +} + +/// Events sent from the hook to the UI +#[derive(Debug, Clone)] +pub enum ToolEvent { + ToolStart { name: String, args: String }, + ToolComplete { name: String, result: String }, +} + +impl ToolDisplayHook { + /// Create a new hook with a channel to send tool events + pub fn new() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(32); + (Self { sender }, receiver) + } + + /// Create a hook from an existing sender + pub fn from_sender(sender: mpsc::Sender) -> Self { + Self { sender } + } +} + +impl Default for ToolDisplayHook { + fn default() -> Self { + let (hook, _) = Self::new(); + hook + } +} + +impl rig::agent::PromptHook for ToolDisplayHook +where + M: CompletionModel, +{ + fn on_tool_call( + &self, + tool_name: &str, + args: &str, + _cancel: CancelSignal, + ) -> impl std::future::Future + Send { + let sender = self.sender.clone(); + let name = tool_name.to_string(); + let args_str = args.to_string(); + + async move { + let _ = sender + .send(ToolEvent::ToolStart { + name, + args: args_str, + }) + .await; + } + } + + fn on_tool_result( + &self, + tool_name: &str, + _args: &str, + result: &str, + _cancel: CancelSignal, + ) -> impl std::future::Future + Send { + let sender = self.sender.clone(); + let name = tool_name.to_string(); + let result_str = result.to_string(); + + async move { + let _ = sender + .send(ToolEvent::ToolComplete { + name, + result: result_str, + }) + .await; + } + } +} + +/// Spawns a task that listens for tool events and updates the spinner +pub fn spawn_tool_display_handler( + mut receiver: mpsc::Receiver, + spinner: Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(event) = receiver.recv().await { + match event { + ToolEvent::ToolStart { name, args } => { + // Format a nice description from the tool name + let description = format_tool_description(&name, &args); + spinner.tool_executing(&name, &description).await; + } + ToolEvent::ToolComplete { name, .. } => { + spinner.tool_complete(&name).await; + } + } + } + }) +} + +/// Format a user-friendly description for a tool based on its name and args +fn format_tool_description(name: &str, args: &str) -> String { + match name { + "analyze_project" => "Analyzing project structure...".to_string(), + "security_scan" => "Running security scan...".to_string(), + "check_vulnerabilities" => "Checking for vulnerabilities...".to_string(), + "read_file" => { + // Try to extract the file path from args + if let Ok(args_value) = serde_json::from_str::(args) { + if let Some(path) = args_value.get("path").and_then(|p| p.as_str()) { + return format!("Reading {}", truncate_path(path)); + } + } + "Reading file...".to_string() + } + "list_directory" => { + if let Ok(args_value) = serde_json::from_str::(args) { + if let Some(path) = args_value.get("path").and_then(|p| p.as_str()) { + return format!("Listing {}", truncate_path(path)); + } + } + "Listing directory...".to_string() + } + "search_code" => { + if let Ok(args_value) = serde_json::from_str::(args) { + if let Some(pattern) = args_value.get("pattern").and_then(|p| p.as_str()) { + return format!("Searching for '{}'...", truncate_text(pattern, 30)); + } + } + "Searching code...".to_string() + } + "find_files" => "Finding files...".to_string(), + "generate_iac" => "Generating infrastructure config...".to_string(), + "discover_services" => "Discovering services...".to_string(), + _ => format!("Executing {}...", name), + } +} + +/// Truncate a path for display +fn truncate_path(path: &str) -> String { + if path.len() <= 40 { + path.to_string() + } else { + // Show last 40 chars with ... + format!("...{}", &path[path.len() - 37..]) + } +} + +/// Truncate text for display +fn truncate_text(text: &str, max_len: usize) -> String { + if text.len() <= max_len { + text.to_string() + } else { + format!("{}...", &text[..max_len - 3]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_path() { + assert_eq!(truncate_path("short.txt"), "short.txt"); + let long_path = "/very/long/path/that/exceeds/forty/characters/file.rs"; + assert!(truncate_path(long_path).len() <= 40); + assert!(truncate_path(long_path).starts_with("...")); + } +} diff --git a/src/agent/ui/mod.rs b/src/agent/ui/mod.rs new file mode 100644 index 00000000..079efe67 --- /dev/null +++ b/src/agent/ui/mod.rs @@ -0,0 +1,23 @@ +//! Terminal UI module for agent interactions +//! +//! Provides a rich terminal UI experience with Syncable's brand colors: +//! - Beautiful response formatting with markdown rendering +//! - Real-time streaming response display +//! - Visible tool call execution with status indicators +//! - Animated spinners with witty phrases during processing +//! - Thinking/reasoning indicators +//! - Elapsed time tracking + +pub mod colors; +pub mod hooks; +pub mod response; +pub mod spinner; +pub mod streaming; +pub mod tool_display; + +pub use colors::*; +pub use hooks::*; +pub use response::*; +pub use spinner::*; +pub use streaming::*; +pub use tool_display::*; diff --git a/src/agent/ui/response.rs b/src/agent/ui/response.rs new file mode 100644 index 00000000..39b4571e --- /dev/null +++ b/src/agent/ui/response.rs @@ -0,0 +1,425 @@ +//! Beautiful response formatting for AI outputs +//! +//! Renders AI responses with Syncable's brand colors (purple/magenta theme) +//! and nice markdown-like formatting. + +// Note: colored crate is used in other modules, here we use custom ANSI codes + +/// Syncable brand colors using ANSI 256-color codes +pub mod brand { + /// Primary purple (like the S in logo) + pub const PURPLE: &str = "\x1b[38;5;141m"; + /// Accent magenta + pub const MAGENTA: &str = "\x1b[38;5;207m"; + /// Light purple for headers + pub const LIGHT_PURPLE: &str = "\x1b[38;5;183m"; + /// Cyan for code/technical + pub const CYAN: &str = "\x1b[38;5;51m"; + /// Soft white for body text + pub const TEXT: &str = "\x1b[38;5;252m"; + /// Dim gray for secondary info + pub const DIM: &str = "\x1b[38;5;245m"; + /// Green for success + pub const SUCCESS: &str = "\x1b[38;5;114m"; + /// Yellow for warnings + pub const YELLOW: &str = "\x1b[38;5;221m"; + /// Reset + pub const RESET: &str = "\x1b[0m"; + /// Bold + pub const BOLD: &str = "\x1b[1m"; + /// Italic + pub const ITALIC: &str = "\x1b[3m"; +} + +/// Response formatter with beautiful rendering +pub struct ResponseFormatter; + +impl ResponseFormatter { + /// Format and print a complete AI response with nice styling + pub fn print_response(text: &str) { + // Print the response header + println!(); + Self::print_header(); + println!(); + + // Parse and format the markdown content + Self::format_markdown(text); + + // Print footer separator + println!(); + Self::print_separator(); + } + + /// Print the response header with Syncable styling + fn print_header() { + print!( + "{}{}╭─ {} Syncable AI {}{}", + brand::PURPLE, + brand::BOLD, + "🤖", + brand::RESET, + brand::DIM + ); + println!("─────────────────────────────────────────────────────╮{}", brand::RESET); + } + + /// Print a separator line + fn print_separator() { + println!( + "{}╰───────────────────────────────────────────────────────────────────╯{}", + brand::DIM, + brand::RESET + ); + } + + /// Format and print markdown content with nice styling + fn format_markdown(text: &str) { + let mut in_code_block = false; + let mut code_lang = String::new(); + let mut list_depth = 0; + + for line in text.lines() { + let trimmed = line.trim(); + + // Handle code blocks + if trimmed.starts_with("```") { + if in_code_block { + // End code block + println!( + "{} └────────────────────────────────────────────────────────────┘{}", + brand::DIM, + brand::RESET + ); + in_code_block = false; + code_lang.clear(); + } else { + // Start code block + code_lang = trimmed.strip_prefix("```").unwrap_or("").to_string(); + let lang_display = if code_lang.is_empty() { + "code".to_string() + } else { + code_lang.clone() + }; + println!( + "{} ┌─ {}{}{} ──────────────────────────────────────────────────────┐{}", + brand::DIM, + brand::CYAN, + lang_display, + brand::DIM, + brand::RESET + ); + in_code_block = true; + } + continue; + } + + if in_code_block { + // Code content with syntax highlighting hint + println!("{} │ {}{}{} │", brand::DIM, brand::CYAN, line, brand::RESET); + continue; + } + + // Handle headers + if let Some(header) = Self::parse_header(trimmed) { + Self::print_formatted_header(header.0, header.1); + continue; + } + + // Handle bullet points + if let Some(bullet) = Self::parse_bullet(trimmed) { + Self::print_bullet(bullet.0, bullet.1, &mut list_depth); + continue; + } + + // Handle bold and inline code in regular text + Self::print_formatted_text(line); + } + } + + /// Parse header level and content + fn parse_header(line: &str) -> Option<(usize, &str)> { + if line.starts_with("### ") { + Some((3, line.strip_prefix("### ").unwrap())) + } else if line.starts_with("## ") { + Some((2, line.strip_prefix("## ").unwrap())) + } else if line.starts_with("# ") { + Some((1, line.strip_prefix("# ").unwrap())) + } else { + None + } + } + + /// Print a formatted header + fn print_formatted_header(level: usize, content: &str) { + match level { + 1 => { + println!(); + println!( + "{}{} ▓▓ {} {}", + brand::PURPLE, + brand::BOLD, + content.to_uppercase(), + brand::RESET + ); + println!( + "{} ════════════════════════════════════════════════════════{}", + brand::PURPLE, + brand::RESET + ); + } + 2 => { + println!(); + println!( + "{}{} ▸ {} {}", + brand::LIGHT_PURPLE, + brand::BOLD, + content, + brand::RESET + ); + println!( + "{} ────────────────────────────────────────────────────────{}", + brand::DIM, + brand::RESET + ); + } + _ => { + println!(); + println!( + "{}{} ◦ {} {}", + brand::MAGENTA, + brand::BOLD, + content, + brand::RESET + ); + } + } + } + + /// Parse bullet point + fn parse_bullet(line: &str) -> Option<(usize, &str)> { + let trimmed = line.trim_start(); + let indent = line.len() - trimmed.len(); + let depth = indent / 2; + + if trimmed.starts_with("- ") { + Some((depth, trimmed.strip_prefix("- ").unwrap())) + } else if trimmed.starts_with("* ") { + Some((depth, trimmed.strip_prefix("* ").unwrap())) + } else if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) + && trimmed.chars().nth(1) == Some('.') + { + Some((depth, trimmed.split_once(". ").map(|(_, rest)| rest).unwrap_or(trimmed))) + } else { + None + } + } + + /// Print a bullet point with proper indentation + fn print_bullet(depth: usize, content: &str, _list_depth: &mut usize) { + let indent = " ".repeat(depth + 1); + let bullet_char = match depth { + 0 => "●", + 1 => "○", + _ => "◦", + }; + let bullet_color = match depth { + 0 => brand::PURPLE, + 1 => brand::MAGENTA, + _ => brand::DIM, + }; + + // Format the content with inline styles + let formatted = Self::format_inline(content); + println!("{}{}{} {}{}", indent, bullet_color, bullet_char, brand::TEXT, formatted); + print!("{}", brand::RESET); + } + + /// Print formatted text with inline styles + fn print_formatted_text(line: &str) { + if line.trim().is_empty() { + println!(); + return; + } + + let formatted = Self::format_inline(line); + println!("{} {}{}", brand::TEXT, formatted, brand::RESET); + } + + /// Format inline markdown (bold, italic, code) + fn format_inline(text: &str) -> String { + let mut result = String::new(); + let chars: Vec = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + // Handle **bold** + if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { + if let Some(end) = Self::find_closing(&chars, i + 2, "**") { + let bold_text: String = chars[i + 2..end].iter().collect(); + result.push_str(brand::BOLD); + result.push_str(brand::LIGHT_PURPLE); + result.push_str(&bold_text); + result.push_str(brand::RESET); + result.push_str(brand::TEXT); + i = end + 2; + continue; + } + } + + // Handle `code` + if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') { + if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') { + let code_text: String = chars[i + 1..i + 1 + end].iter().collect(); + result.push_str(brand::CYAN); + result.push_str("`"); + result.push_str(&code_text); + result.push_str("`"); + result.push_str(brand::RESET); + result.push_str(brand::TEXT); + i = i + 2 + end; + continue; + } + } + + result.push(chars[i]); + i += 1; + } + + result + } + + /// Find closing marker + fn find_closing(chars: &[char], start: usize, marker: &str) -> Option { + let marker_chars: Vec = marker.chars().collect(); + let marker_len = marker_chars.len(); + + for i in start..=chars.len() - marker_len { + let matches = (0..marker_len).all(|j| chars[i + j] == marker_chars[j]); + if matches { + return Some(i); + } + } + None + } +} + +/// Simple response printer for when we just want colored output +pub struct SimpleResponse; + +impl SimpleResponse { + /// Print a simple AI response with minimal formatting + pub fn print(text: &str) { + println!(); + println!("{}{}🤖 Syncable AI:{}", brand::PURPLE, brand::BOLD, brand::RESET); + println!("{}{}{}", brand::TEXT, text, brand::RESET); + println!(); + } +} + +/// Tool execution display during processing +pub struct ToolProgress { + tools_executed: Vec, +} + +#[derive(Clone)] +struct ToolExecution { + name: String, + description: String, + status: ToolStatus, +} + +#[derive(Clone, Copy)] +enum ToolStatus { + Running, + Success, + Error, +} + +impl ToolProgress { + pub fn new() -> Self { + Self { + tools_executed: Vec::new(), + } + } + + /// Mark a tool as starting execution + pub fn tool_start(&mut self, name: &str, description: &str) { + self.tools_executed.push(ToolExecution { + name: name.to_string(), + description: description.to_string(), + status: ToolStatus::Running, + }); + self.redraw(); + } + + /// Mark the last tool as complete + pub fn tool_complete(&mut self, success: bool) { + if let Some(tool) = self.tools_executed.last_mut() { + tool.status = if success { ToolStatus::Success } else { ToolStatus::Error }; + } + self.redraw(); + } + + /// Redraw the tool progress display + fn redraw(&self) { + // Clear previous lines and redraw + for tool in &self.tools_executed { + let (icon, color) = match tool.status { + ToolStatus::Running => ("◐", brand::YELLOW), + ToolStatus::Success => ("✓", brand::SUCCESS), + ToolStatus::Error => ("✗", "\x1b[38;5;196m"), + }; + println!( + " {} {}{}{} {}{}{}", + icon, + color, + tool.name, + brand::RESET, + brand::DIM, + tool.description, + brand::RESET + ); + } + } + + /// Print final summary after all tools complete + pub fn print_summary(&self) { + if !self.tools_executed.is_empty() { + let success_count = self.tools_executed + .iter() + .filter(|t| matches!(t.status, ToolStatus::Success)) + .count(); + println!( + "\n{} {} tools executed successfully{}", + brand::DIM, + success_count, + brand::RESET + ); + } + } +} + +impl Default for ToolProgress { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_header() { + assert_eq!(ResponseFormatter::parse_header("# Hello"), Some((1, "Hello"))); + assert_eq!(ResponseFormatter::parse_header("## World"), Some((2, "World"))); + assert_eq!(ResponseFormatter::parse_header("### Test"), Some((3, "Test"))); + assert_eq!(ResponseFormatter::parse_header("Not a header"), None); + } + + #[test] + fn test_format_inline_bold() { + let result = ResponseFormatter::format_inline("This is **bold** text"); + assert!(result.contains("bold")); + } +} diff --git a/src/agent/ui/spinner.rs b/src/agent/ui/spinner.rs new file mode 100644 index 00000000..8381b5a2 --- /dev/null +++ b/src/agent/ui/spinner.rs @@ -0,0 +1,315 @@ +//! Animated spinner for terminal UI +//! +//! Provides a Gemini-style spinner that updates in place with elapsed time +//! and cycles through witty/informative phrases. + +use crate::agent::ui::colors::{ansi, format_elapsed}; +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Spinner animation frames (dots pattern like Gemini CLI) +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Animation interval in milliseconds +const ANIMATION_INTERVAL_MS: u64 = 80; + +/// Phrase change interval in seconds (like Gemini's 15 seconds) +const PHRASE_CHANGE_INTERVAL_SECS: u64 = 8; + +/// Witty loading phrases inspired by Gemini CLI +const WITTY_PHRASES: &[&str] = &[ + "Analyzing your codebase...", + "Consulting the digital spirits...", + "Warming up the AI hamsters...", + "Polishing the algorithms...", + "Brewing fresh bytes...", + "Engaging cognitive processors...", + "Compiling brilliance...", + "Untangling neural nets...", + "Converting coffee into insights...", + "Scanning for patterns...", + "Traversing the AST...", + "Checking dependencies...", + "Looking for security issues...", + "Mapping the architecture...", + "Detecting frameworks...", + "Parsing configurations...", + "Analyzing code patterns...", + "Deep diving into your code...", + "Searching for vulnerabilities...", + "Exploring the codebase...", + "Processing your request...", + "Thinking deeply about this...", + "Gathering context...", + "Reading documentation...", + "Inspecting files...", +]; + +/// Informative tips shown occasionally +const TIPS: &[&str] = &[ + "Tip: Use /model to switch AI models...", + "Tip: Use /provider to change providers...", + "Tip: Type /help for available commands...", + "Tip: Use /clear to reset conversation...", + "Tip: Try 'sync-ctl analyze' for full analysis...", + "Tip: Security scans support 5 modes (lightning to paranoid)...", +]; + +/// Message types for spinner control +#[derive(Debug)] +pub enum SpinnerMessage { + /// Update the spinner text + UpdateText(String), + /// Update to show a tool is executing + ToolExecuting { name: String, description: String }, + /// Tool completed successfully + ToolComplete { name: String }, + /// Show thinking/reasoning + Thinking(String), + /// Stop the spinner + Stop, +} + +/// An animated spinner that runs in the background +pub struct Spinner { + sender: mpsc::Sender, + is_running: Arc, +} + +impl Spinner { + /// Create and start a new spinner with initial text + pub fn new(initial_text: &str) -> Self { + let (sender, receiver) = mpsc::channel(32); + let is_running = Arc::new(AtomicBool::new(true)); + let is_running_clone = is_running.clone(); + let initial = initial_text.to_string(); + + tokio::spawn(async move { + run_spinner(receiver, is_running_clone, initial).await; + }); + + Self { sender, is_running } + } + + /// Update the spinner text + pub async fn set_text(&self, text: &str) { + let _ = self.sender.send(SpinnerMessage::UpdateText(text.to_string())).await; + } + + /// Show tool executing status + pub async fn tool_executing(&self, name: &str, description: &str) { + let _ = self + .sender + .send(SpinnerMessage::ToolExecuting { + name: name.to_string(), + description: description.to_string(), + }) + .await; + } + + /// Mark a tool as complete (will be shown in the completed list) + pub async fn tool_complete(&self, name: &str) { + let _ = self + .sender + .send(SpinnerMessage::ToolComplete { + name: name.to_string(), + }) + .await; + } + + /// Show thinking status + pub async fn thinking(&self, subject: &str) { + let _ = self.sender.send(SpinnerMessage::Thinking(subject.to_string())).await; + } + + /// Stop the spinner and clear the line + pub async fn stop(&self) { + let _ = self.sender.send(SpinnerMessage::Stop).await; + // Give the spinner task time to clean up + tokio::time::sleep(Duration::from_millis(50)).await; + } + + /// Check if spinner is still running + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } +} + +/// Internal spinner loop with phrase cycling +async fn run_spinner( + mut receiver: mpsc::Receiver, + is_running: Arc, + initial_text: String, +) { + use rand::{Rng, SeedableRng}; + use rand::rngs::StdRng; + + let start_time = Instant::now(); + let mut frame_index = 0; + let mut current_text = initial_text; + let mut last_phrase_change = Instant::now(); + let mut phrase_index = 0; + let mut current_tool: Option = None; + let mut tools_completed: usize = 0; + let mut interval = tokio::time::interval(Duration::from_millis(ANIMATION_INTERVAL_MS)); + let mut rng = StdRng::from_entropy(); + + // Hide cursor during spinner + print!("{}", ansi::HIDE_CURSOR); + let _ = io::stdout().flush(); + + loop { + tokio::select! { + _ = interval.tick() => { + if !is_running.load(Ordering::SeqCst) { + break; + } + + let elapsed = start_time.elapsed().as_secs(); + let frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.len()]; + frame_index += 1; + + // Cycle phrases every PHRASE_CHANGE_INTERVAL_SECS if not showing tool activity + if current_tool.is_none() && last_phrase_change.elapsed().as_secs() >= PHRASE_CHANGE_INTERVAL_SECS { + if rng.gen_bool(0.25) && !TIPS.is_empty() { + let tip_idx = rng.gen_range(0..TIPS.len()); + current_text = TIPS[tip_idx].to_string(); + } else { + phrase_index = (phrase_index + 1) % WITTY_PHRASES.len(); + current_text = WITTY_PHRASES[phrase_index].to_string(); + } + last_phrase_change = Instant::now(); + } + + // Build compact single-line display + let display = if let Some(ref tool) = current_tool { + // Currently executing a tool + if tools_completed > 0 { + format!("{}{}{} {}✓{}{} {}🔧 {}{} {}", + ansi::CYAN, frame, ansi::RESET, + ansi::SUCCESS, tools_completed, ansi::RESET, + ansi::PURPLE, tool, ansi::RESET, + current_text) + } else { + format!("{}{}{} {}🔧 {}{} {}", + ansi::CYAN, frame, ansi::RESET, + ansi::PURPLE, tool, ansi::RESET, + current_text) + } + } else if tools_completed > 0 { + // Between tools, show completed count + format!("{}{}{} {}✓{}{} {}", + ansi::CYAN, frame, ansi::RESET, + ansi::SUCCESS, tools_completed, ansi::RESET, + current_text) + } else { + // Initial state, just thinking + format!("{}{}{} {}", + ansi::CYAN, frame, ansi::RESET, + current_text) + }; + + // Update the SAME line (no newlines!) + print!("\r{}{} {}{}({}){}", + ansi::CLEAR_LINE, + display, + ansi::GRAY, + ansi::DIM, + format_elapsed(elapsed), + ansi::RESET + ); + let _ = io::stdout().flush(); + } + Some(msg) = receiver.recv() => { + match msg { + SpinnerMessage::UpdateText(text) => { + current_text = text; + } + SpinnerMessage::ToolExecuting { name, description } => { + current_tool = Some(name); + current_text = description; + last_phrase_change = Instant::now(); + } + SpinnerMessage::ToolComplete { name: _ } => { + tools_completed += 1; + current_tool = None; + phrase_index = (phrase_index + 1) % WITTY_PHRASES.len(); + current_text = WITTY_PHRASES[phrase_index].to_string(); + } + SpinnerMessage::Thinking(subject) => { + current_text = format!("💭 {}", subject); + current_tool = None; + } + SpinnerMessage::Stop => { + is_running.store(false, Ordering::SeqCst); + break; + } + } + } + } + } + + // Clear the spinner line and show cursor + // Optionally print a summary if tools were used + print!("\r{}", ansi::CLEAR_LINE); + if tools_completed > 0 { + println!(" {}✓{} {} tool{} used", + ansi::SUCCESS, ansi::RESET, + tools_completed, + if tools_completed == 1 { "" } else { "s" } + ); + } + print!("{}", ansi::SHOW_CURSOR); + let _ = io::stdout().flush(); +} + +/// A simple inline spinner for synchronous contexts +pub struct InlineSpinner { + frames: Vec<&'static str>, + current: usize, +} + +impl InlineSpinner { + pub fn new() -> Self { + Self { + frames: SPINNER_FRAMES.to_vec(), + current: 0, + } + } + + /// Get the next frame + pub fn next_frame(&mut self) -> &'static str { + let frame = self.frames[self.current % self.frames.len()]; + self.current += 1; + frame + } + + /// Print a spinner update inline (clears and rewrites) + pub fn print(&mut self, message: &str) { + let frame = self.next_frame(); + print!("{}{} {}", ansi::CLEAR_LINE, frame, message); + let _ = io::stdout().flush(); + } +} + +impl Default for InlineSpinner { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inline_spinner() { + let mut spinner = InlineSpinner::new(); + assert_eq!(spinner.next_frame(), "⠋"); + assert_eq!(spinner.next_frame(), "⠙"); + assert_eq!(spinner.next_frame(), "⠹"); + } +} diff --git a/src/agent/ui/streaming.rs b/src/agent/ui/streaming.rs new file mode 100644 index 00000000..146f0b94 --- /dev/null +++ b/src/agent/ui/streaming.rs @@ -0,0 +1,277 @@ +//! Streaming response display for real-time AI output +//! +//! Handles streaming tokens from the AI and displaying them in real-time. + +use crate::agent::ui::colors::{ansi, icons}; +use crate::agent::ui::spinner::Spinner; +use crate::agent::ui::tool_display::{ToolCallDisplay, ToolCallInfo, ToolCallStatus}; +use colored::Colorize; +use std::io::{self, Write}; +use std::time::Instant; + +/// State of the streaming response +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamingState { + /// Ready for input + Idle, + /// AI is generating response + Responding, + /// Waiting for tool confirmation + WaitingForConfirmation, + /// Tools are executing + ExecutingTools, +} + +/// Manages the display of streaming AI responses +pub struct StreamingDisplay { + state: StreamingState, + start_time: Option, + current_text: String, + tool_calls: Vec, + chars_displayed: usize, +} + +impl StreamingDisplay { + pub fn new() -> Self { + Self { + state: StreamingState::Idle, + start_time: None, + current_text: String::new(), + tool_calls: Vec::new(), + chars_displayed: 0, + } + } + + /// Start a new response + pub fn start_response(&mut self) { + self.state = StreamingState::Responding; + self.start_time = Some(Instant::now()); + self.current_text.clear(); + self.tool_calls.clear(); + self.chars_displayed = 0; + + // Print AI label + print!("\n{} ", "AI:".blue().bold()); + let _ = io::stdout().flush(); + } + + /// Append text chunk to the response + pub fn append_text(&mut self, text: &str) { + self.current_text.push_str(text); + + // Print new text directly (streaming effect) + print!("{}", text); + let _ = io::stdout().flush(); + self.chars_displayed += text.len(); + } + + /// Record a tool call starting + pub fn tool_call_started(&mut self, name: &str, description: &str) { + self.state = StreamingState::ExecutingTools; + + let info = ToolCallInfo::new(name, description).executing(); + self.tool_calls.push(info.clone()); + + // Print tool call notification + ToolCallDisplay::print_start(name, description); + } + + /// Record a tool call completed + pub fn tool_call_completed(&mut self, name: &str, result: Option) { + if let Some(info) = self.tool_calls.iter_mut().find(|t| t.name == name) { + *info = info.clone().success(result); + ToolCallDisplay::print_status(info); + } + + // Check if all tools are done + if self.tool_calls.iter().all(|t| { + matches!( + t.status, + ToolCallStatus::Success | ToolCallStatus::Error | ToolCallStatus::Canceled + ) + }) { + self.state = StreamingState::Responding; + } + } + + /// Record a tool call failed + pub fn tool_call_failed(&mut self, name: &str, error: String) { + if let Some(info) = self.tool_calls.iter_mut().find(|t| t.name == name) { + *info = info.clone().error(error); + ToolCallDisplay::print_status(info); + } + } + + /// Show thinking/reasoning indicator + pub fn show_thinking(&self, subject: &str) { + print!( + "{}{} {} {}{}", + ansi::CLEAR_LINE, + icons::THINKING, + "Thinking:".cyan(), + subject.dimmed(), + ansi::RESET + ); + let _ = io::stdout().flush(); + } + + /// End the current response + pub fn end_response(&mut self) { + self.state = StreamingState::Idle; + + // Ensure newline after response + if !self.current_text.is_empty() && !self.current_text.ends_with('\n') { + println!(); + } + + // Print summary if there were tool calls + if !self.tool_calls.is_empty() { + ToolCallDisplay::print_summary(&self.tool_calls); + } + + // Print elapsed time if significant + if let Some(start) = self.start_time { + let elapsed = start.elapsed(); + if elapsed.as_secs() >= 2 { + println!( + "\n{} {:.1}s", + "Response time:".dimmed(), + elapsed.as_secs_f64() + ); + } + } + + println!(); + let _ = io::stdout().flush(); + } + + /// Handle an error during streaming + pub fn handle_error(&mut self, error: &str) { + self.state = StreamingState::Idle; + println!("\n{} {}", icons::ERROR.red(), error.red()); + let _ = io::stdout().flush(); + } + + /// Get the current state + pub fn state(&self) -> StreamingState { + self.state + } + + /// Get elapsed time since start + pub fn elapsed_secs(&self) -> u64 { + self.start_time + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0) + } + + /// Get the accumulated text + pub fn text(&self) -> &str { + &self.current_text + } + + /// Get tool calls + pub fn tool_calls(&self) -> &[ToolCallInfo] { + &self.tool_calls + } +} + +impl Default for StreamingDisplay { + fn default() -> Self { + Self::new() + } +} + +/// A simpler streaming helper for basic use cases +pub struct SimpleStreamer { + started: bool, +} + +impl SimpleStreamer { + pub fn new() -> Self { + Self { started: false } + } + + /// Print the AI label (call once at start) + pub fn start(&mut self) { + if !self.started { + print!("\n{} ", "AI:".blue().bold()); + let _ = io::stdout().flush(); + self.started = true; + } + } + + /// Stream a text chunk + pub fn stream(&mut self, text: &str) { + self.start(); + print!("{}", text); + let _ = io::stdout().flush(); + } + + /// End the stream + pub fn end(&mut self) { + if self.started { + println!(); + println!(); + self.started = false; + } + } + + /// Print a tool call notification + pub fn tool_call(&self, name: &str, description: &str) { + println!(); + ToolCallDisplay::print_start(name, description); + } + + /// Print tool call completed + pub fn tool_complete(&self, name: &str) { + let info = ToolCallInfo::new(name, "").success(None); + ToolCallDisplay::print_status(&info); + } +} + +impl Default for SimpleStreamer { + fn default() -> Self { + Self::new() + } +} + +/// Print a "thinking" indicator with optional spinner +pub async fn show_thinking_with_spinner(message: &str) -> Spinner { + Spinner::new(&format!("💭 {}", message)) +} + +/// Print a static thinking message +pub fn print_thinking(subject: &str) { + println!( + "{} {} {}", + icons::THINKING, + "Thinking about:".cyan(), + subject.white() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_streaming_display_state() { + let mut display = StreamingDisplay::new(); + assert_eq!(display.state(), StreamingState::Idle); + + display.start_response(); + assert_eq!(display.state(), StreamingState::Responding); + + display.tool_call_started("test", "testing"); + assert_eq!(display.state(), StreamingState::ExecutingTools); + } + + #[test] + fn test_append_text() { + let mut display = StreamingDisplay::new(); + display.start_response(); + display.append_text("Hello "); + display.append_text("World"); + assert_eq!(display.text(), "Hello World"); + } +} diff --git a/src/agent/ui/tool_display.rs b/src/agent/ui/tool_display.rs new file mode 100644 index 00000000..e699b102 --- /dev/null +++ b/src/agent/ui/tool_display.rs @@ -0,0 +1,227 @@ +//! Tool call display for visible tool execution feedback +//! +//! Shows tool calls with status indicators, names, descriptions, and results. + +use crate::agent::ui::colors::{ansi, icons}; +use colored::Colorize; +use std::io::{self, Write}; + +/// Status of a tool call +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallStatus { + Pending, + Executing, + Success, + Error, + Canceled, +} + +impl ToolCallStatus { + /// Get the icon for this status + pub fn icon(&self) -> &'static str { + match self { + ToolCallStatus::Pending => icons::PENDING, + ToolCallStatus::Executing => icons::EXECUTING, + ToolCallStatus::Success => icons::SUCCESS, + ToolCallStatus::Error => icons::ERROR, + ToolCallStatus::Canceled => icons::CANCELED, + } + } + + /// Get the color code for this status + pub fn color(&self) -> &'static str { + match self { + ToolCallStatus::Pending => ansi::GRAY, + ToolCallStatus::Executing => ansi::CYAN, + ToolCallStatus::Success => "\x1b[32m", // Green + ToolCallStatus::Error => "\x1b[31m", // Red + ToolCallStatus::Canceled => ansi::GRAY, + } + } +} + +/// Represents a tool call for display +#[derive(Debug, Clone)] +pub struct ToolCallInfo { + pub name: String, + pub description: String, + pub status: ToolCallStatus, + pub result: Option, + pub error: Option, +} + +impl ToolCallInfo { + pub fn new(name: &str, description: &str) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + status: ToolCallStatus::Pending, + result: None, + error: None, + } + } + + pub fn executing(mut self) -> Self { + self.status = ToolCallStatus::Executing; + self + } + + pub fn success(mut self, result: Option) -> Self { + self.status = ToolCallStatus::Success; + self.result = result; + self + } + + pub fn error(mut self, error: String) -> Self { + self.status = ToolCallStatus::Error; + self.error = Some(error); + self + } +} + +/// Display manager for tool calls +pub struct ToolCallDisplay; + +impl ToolCallDisplay { + /// Print a tool call start message + pub fn print_start(name: &str, description: &str) { + println!( + "\n{} {} {}", + icons::TOOL.cyan(), + name.cyan().bold(), + description.dimmed() + ); + let _ = io::stdout().flush(); + } + + /// Print a tool call with status + pub fn print_status(info: &ToolCallInfo) { + let status_icon = info.status.icon(); + let color = info.status.color(); + + print!( + "{}{}{} {} {} {}{}", + ansi::CLEAR_LINE, + color, + status_icon, + ansi::RESET, + info.name.cyan().bold(), + info.description.dimmed(), + ansi::RESET + ); + + match info.status { + ToolCallStatus::Success => { + println!(" {}", "[done]".green()); + } + ToolCallStatus::Error => { + if let Some(ref err) = info.error { + println!(" {} {}", "[error]".red(), err.red()); + } else { + println!(" {}", "[error]".red()); + } + } + ToolCallStatus::Canceled => { + println!(" {}", "[canceled]".yellow()); + } + _ => { + println!(); + } + } + + let _ = io::stdout().flush(); + } + + /// Print a tool call result (for verbose output) + pub fn print_result(name: &str, result: &str, truncate: bool) { + let display_result = if truncate && result.len() > 200 { + format!("{}... (truncated)", &result[..200]) + } else { + result.to_string() + }; + + println!( + " {} {} {}", + icons::ARROW.dimmed(), + name.cyan(), + display_result.dimmed() + ); + let _ = io::stdout().flush(); + } + + /// Print a summary of tool calls + pub fn print_summary(tools: &[ToolCallInfo]) { + if tools.is_empty() { + return; + } + + let success_count = tools.iter().filter(|t| t.status == ToolCallStatus::Success).count(); + let error_count = tools.iter().filter(|t| t.status == ToolCallStatus::Error).count(); + + println!(); + if error_count == 0 { + println!( + "{} {} tool{} executed successfully", + icons::SUCCESS.green(), + success_count, + if success_count == 1 { "" } else { "s" } + ); + } else { + println!( + "{} {}/{} tools succeeded, {} failed", + icons::ERROR.red(), + success_count, + tools.len(), + error_count + ); + } + } +} + +/// Print a tool call inline (single line, updating) +pub fn print_tool_inline(status: ToolCallStatus, name: &str, description: &str) { + let icon = status.icon(); + let color = status.color(); + + print!( + "{}{}{} {} {} {}{}", + ansi::CLEAR_LINE, + color, + icon, + ansi::RESET, + name, + description, + ansi::RESET + ); + let _ = io::stdout().flush(); +} + +/// Print a tool group header +pub fn print_tool_group_header(count: usize) { + println!("\n{} {} tool{}:", icons::TOOL, count, if count == 1 { "" } else { "s" }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_call_info() { + let info = ToolCallInfo::new("read_file", "reading src/main.rs"); + assert_eq!(info.status, ToolCallStatus::Pending); + + let info = info.executing(); + assert_eq!(info.status, ToolCallStatus::Executing); + + let info = info.success(Some("file contents".to_string())); + assert_eq!(info.status, ToolCallStatus::Success); + assert!(info.result.is_some()); + } + + #[test] + fn test_status_icons() { + assert_eq!(ToolCallStatus::Pending.icon(), icons::PENDING); + assert_eq!(ToolCallStatus::Success.icon(), icons::SUCCESS); + assert_eq!(ToolCallStatus::Error.icon(), icons::ERROR); + } +} diff --git a/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png b/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png new file mode 100644 index 0000000000000000000000000000000000000000..aceeb82d07a4e611bf704572e529f0ae9f07391b GIT binary patch literal 93181 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

A>`j3R{iv7K^(?Sq^QjH@1LX4Z!M}Knm-oO= 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 literal 0 HcmV?d00001 diff --git a/src/analyzer/frameworks/go.rs b/src/analyzer/frameworks/go.rs index d55d5ea9..adaf98cc 100644 --- a/src/analyzer/frameworks/go.rs +++ b/src/analyzer/frameworks/go.rs @@ -210,38 +210,24 @@ fn get_go_technology_rules() -> Vec { name: "Hertz".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["github.com/cloudwego/hertz".to_string()], + dependency_patterns: vec!["github.com/cloudwego/hertz".to_string(), "cloudwego/hertz".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["cloudwego/hertz".to_string()], + alternative_names: vec!["cloudwego".to_string()], file_indicators: vec![], }, - // Encore (Go) - Cloud development platform - TechnologyRule { - name: "Encore".to_string(), - category: TechnologyCategory::BackendFramework, - confidence: 0.95, - dependency_patterns: vec!["encore.dev".to_string()], - requires: vec![], - conflicts_with: vec![], - is_primary_indicator: true, - alternative_names: vec![], - file_indicators: vec!["encore.app".to_string()], - }, - // DATABASE/ORM TechnologyRule { name: "GORM".to_string(), category: TechnologyCategory::Database, confidence: 0.90, - // Only match the specific gorm.io path, not just "gorm" - dependency_patterns: vec!["gorm.io/gorm".to_string()], + dependency_patterns: vec!["gorm.io/gorm".to_string(), "gorm".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec![], + alternative_names: vec!["entgo".to_string()], file_indicators: vec![], }, TechnologyRule { @@ -259,7 +245,7 @@ fn get_go_technology_rules() -> Vec { name: "Xorm".to_string(), category: TechnologyCategory::Database, confidence: 0.85, - dependency_patterns: vec!["xorm.io/xorm".to_string()], + dependency_patterns: vec!["xorm.io/xorm".to_string(), "xorm".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -316,7 +302,7 @@ fn get_go_technology_rules() -> Vec { name: "Ginkgo".to_string(), category: TechnologyCategory::Testing, confidence: 0.85, - dependency_patterns: vec!["github.com/onsi/ginkgo".to_string()], + dependency_patterns: vec!["github.com/onsi/ginkgo".to_string(), "ginkgo".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -329,7 +315,7 @@ fn get_go_technology_rules() -> Vec { name: "Cobra".to_string(), category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, - dependency_patterns: vec!["github.com/spf13/cobra".to_string()], + dependency_patterns: vec!["github.com/spf13/cobra".to_string(), "cobra".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -342,7 +328,7 @@ fn get_go_technology_rules() -> Vec { name: "Viper".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.80, - dependency_patterns: vec!["github.com/spf13/viper".to_string()], + dependency_patterns: vec!["github.com/spf13/viper".to_string(), "viper".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -355,22 +341,22 @@ fn get_go_technology_rules() -> Vec { name: "Logrus".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["github.com/sirupsen/logrus".to_string()], + dependency_patterns: vec!["github.com/sirupsen/logrus".to_string(), "sirupsen/logrus".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec![], + alternative_names: vec!["logrus".to_string()], file_indicators: vec![], }, TechnologyRule { name: "Zap".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["go.uber.org/zap".to_string()], + dependency_patterns: vec!["go.uber.org/zap".to_string(), "zap".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec![], + alternative_names: vec!["zap".to_string()], file_indicators: vec![], }, diff --git a/src/analyzer/frameworks/java.rs b/src/analyzer/frameworks/java.rs index aa07fb08..fd96f346 100644 --- a/src/analyzer/frameworks/java.rs +++ b/src/analyzer/frameworks/java.rs @@ -34,12 +34,12 @@ fn get_jvm_technology_rules() -> Vec { name: "Spring Boot".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["org.springframework.boot:spring-boot".to_string(), "spring-boot-starter".to_string()], + dependency_patterns: vec!["spring-boot".to_string(), "org.springframework.boot".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec!["spring".to_string()], - file_indicators: vec!["application.properties".to_string(), "application.yml".to_string(), "application.yaml".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Spring Framework".to_string(), @@ -179,31 +179,31 @@ fn get_jvm_technology_rules() -> Vec { name: "Quarkus".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["io.quarkus:quarkus-core".to_string(), "io.quarkus:quarkus".to_string()], + dependency_patterns: vec!["quarkus".to_string(), "io.quarkus".to_string()], requires: vec![], - conflicts_with: vec!["Spring Boot".to_string()], + conflicts_with: vec![], is_primary_indicator: true, - file_indicators: vec!["application.properties".to_string(), "src/main/resources/META-INF/microprofile-config.properties".to_string()], + file_indicators: vec![], alternative_names: vec![], }, TechnologyRule { name: "Micronaut".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["io.micronaut:micronaut-core".to_string(), "io.micronaut:micronaut-runtime".to_string()], + dependency_patterns: vec!["micronaut".to_string(), "io.micronaut".to_string()], requires: vec![], - conflicts_with: vec!["Spring Boot".to_string(), "Quarkus".to_string()], + conflicts_with: vec![], is_primary_indicator: true, - file_indicators: vec!["micronaut-cli.yml".to_string()], + file_indicators: vec![], alternative_names: vec![], }, TechnologyRule { name: "Helidon".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["io.helidon:helidon-webserver".to_string(), "io.helidon:helidon-microprofile".to_string()], + dependency_patterns: vec!["helidon".to_string(), "io.helidon".to_string()], requires: vec![], - conflicts_with: vec!["Spring Boot".to_string()], + conflicts_with: vec![], is_primary_indicator: true, file_indicators: vec![], alternative_names: vec![], @@ -212,7 +212,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Vert.x".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["io.vertx:vertx-core".to_string(), "io.vertx:vertx-web".to_string()], + dependency_patterns: vec!["vertx".to_string(), "io.vertx".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -317,12 +317,12 @@ fn get_jvm_technology_rules() -> Vec { name: "Play Framework".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["com.typesafe.play:play".to_string(), "com.typesafe.play:play-java".to_string()], + dependency_patterns: vec!["play".to_string(), "com.typesafe.play".to_string()], requires: vec![], - conflicts_with: vec!["Spring Boot".to_string()], + conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec!["play".to_string()], - file_indicators: vec!["conf/application.conf".to_string(), "conf/routes".to_string()], + file_indicators: vec![], }, // ORM/DATABASE - EXPANDED @@ -521,7 +521,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Jakarta EE".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["jakarta.platform:jakarta.jakartaee-api".to_string(), "jakarta.servlet:jakarta.servlet-api".to_string()], + dependency_patterns: vec!["jakarta.".to_string(), "jakarta-ee".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -628,12 +628,12 @@ fn get_jvm_technology_rules() -> Vec { name: "Ktor".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["io.ktor:ktor-server-core".to_string(), "io.ktor:ktor-server-netty".to_string()], + dependency_patterns: vec!["ktor".to_string(), "io.ktor".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["application.conf".to_string()], + file_indicators: vec![], }, // MESSAGE BROKERS & MESSAGING (Critical for infrastructure) @@ -641,7 +641,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Apache Kafka".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.95, - dependency_patterns: vec!["org.apache.kafka:kafka-clients".to_string(), "spring-kafka".to_string(), "reactor-kafka".to_string()], + dependency_patterns: vec!["kafka".to_string(), "org.apache.kafka".to_string(), "kafka-clients".to_string(), "spring-kafka".to_string(), "reactor-kafka".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -1081,7 +1081,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Apache Spark".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.90, - dependency_patterns: vec!["org.apache.spark:spark-core".to_string(), "org.apache.spark:spark-sql".to_string()], + dependency_patterns: vec!["spark".to_string(), "org.apache.spark".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -1092,7 +1092,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Apache Flink".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.90, - dependency_patterns: vec!["org.apache.flink:flink-core".to_string(), "org.apache.flink:flink-streaming-java".to_string()], + dependency_patterns: vec!["flink".to_string(), "org.apache.flink".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -1103,7 +1103,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Apache Storm".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["org.apache.storm:storm-core".to_string(), "org.apache.storm:storm-client".to_string()], + dependency_patterns: vec!["storm".to_string(), "org.apache.storm".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -1149,7 +1149,7 @@ fn get_jvm_technology_rules() -> Vec { name: "Apache Commons".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.75, - dependency_patterns: vec!["org.apache.commons:commons-lang3".to_string(), "org.apache.commons:commons-io".to_string(), "org.apache.commons:commons-collections4".to_string()], + dependency_patterns: vec!["commons-".to_string(), "org.apache.commons".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, diff --git a/src/analyzer/frameworks/javascript.rs b/src/analyzer/frameworks/javascript.rs index 59b421dd..3eda1715 100644 --- a/src/analyzer/frameworks/javascript.rs +++ b/src/analyzer/frameworks/javascript.rs @@ -813,20 +813,16 @@ fn get_js_technology_rules() -> Vec { alternative_names: vec!["tanstack-start".to_string(), "TanStack Start".to_string()], file_indicators: vec!["app.config.ts".to_string(), "app.config.js".to_string(), "app/routes/".to_string(), "vite.config.ts".to_string()], }, - // React Router v7 as a framework (not just routing library) requires: - // - @react-router/dev (the framework CLI) OR react-router.config.ts - // - Just having react-router-dom is NOT enough (that's library usage) TechnologyRule { name: "React Router v7".to_string(), category: TechnologyCategory::MetaFramework, confidence: 0.95, - // ONLY match the framework package, not just the routing library - dependency_patterns: vec!["@react-router/dev".to_string(), "@react-router/node".to_string(), "@react-router/serve".to_string()], + dependency_patterns: vec!["react-router".to_string(), "react-dom".to_string(), "react-router-dom".to_string()], requires: vec!["React".to_string()], - conflicts_with: vec!["Next.js".to_string(), "Tanstack Start".to_string(), "SvelteKit".to_string(), "Nuxt.js".to_string(), "React Native".to_string(), "Expo".to_string(), "Encore".to_string()], + conflicts_with: vec!["Next.js".to_string(), "Tanstack Start".to_string(), "SvelteKit".to_string(), "Nuxt.js".to_string(), "React Native".to_string(), "Expo".to_string()], is_primary_indicator: true, - alternative_names: vec!["remix".to_string()], - file_indicators: vec!["react-router.config.ts".to_string(), "react-router.config.js".to_string()], + alternative_names: vec!["remix".to_string(), "react-router".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "SvelteKit".to_string(), @@ -837,18 +833,18 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec!["Next.js".to_string(), "Tanstack Start".to_string(), "React Router v7".to_string(), "Nuxt.js".to_string()], is_primary_indicator: true, alternative_names: vec!["svelte-kit".to_string()], - file_indicators: vec!["svelte.config.js".to_string(), "svelte.config.ts".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Nuxt.js".to_string(), category: TechnologyCategory::MetaFramework, confidence: 0.95, - dependency_patterns: vec!["nuxt".to_string()], + dependency_patterns: vec!["nuxt".to_string(), "@nuxt/core".to_string()], requires: vec!["Vue.js".to_string()], conflicts_with: vec!["Next.js".to_string(), "Tanstack Start".to_string(), "React Router v7".to_string(), "SvelteKit".to_string()], is_primary_indicator: true, alternative_names: vec!["nuxtjs".to_string()], - file_indicators: vec!["nuxt.config.ts".to_string(), "nuxt.config.js".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Astro".to_string(), @@ -859,18 +855,18 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["astro.config.mjs".to_string(), "astro.config.ts".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "SolidStart".to_string(), category: TechnologyCategory::MetaFramework, confidence: 0.95, - dependency_patterns: vec!["solid-start".to_string(), "@solidjs/start".to_string()], + dependency_patterns: vec!["solid-start".to_string()], requires: vec!["SolidJS".to_string()], conflicts_with: vec!["Next.js".to_string(), "Tanstack Start".to_string(), "React Router v7".to_string(), "SvelteKit".to_string()], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["app.config.ts".to_string(), "app.config.js".to_string()], + file_indicators: vec![], }, // MOBILE FRAMEWORKS (React Native/Expo) @@ -907,7 +903,7 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec!["angular".to_string()], - file_indicators: vec!["angular.json".to_string(), "angular.cli.json".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Svelte".to_string(), @@ -918,20 +914,6 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: false, // SvelteKit would be primary alternative_names: vec![], - file_indicators: vec!["svelte.config.js".to_string()], - }, - - // ROUTING LIBRARIES (Not frameworks! Just client-side routing) - TechnologyRule { - name: "React Router".to_string(), - category: TechnologyCategory::Library(LibraryType::Routing), - confidence: 0.85, - // This is the routing LIBRARY, not the framework - dependency_patterns: vec!["react-router-dom".to_string()], - requires: vec!["React".to_string()], - conflicts_with: vec![], - is_primary_indicator: false, - alternative_names: vec![], file_indicators: vec![], }, @@ -991,7 +973,7 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec!["express".to_string()], - file_indicators: vec!["app.js".to_string(), "server.js".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Fastify".to_string(), @@ -1002,7 +984,7 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["fastify.config.js".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Nest.js".to_string(), @@ -1013,7 +995,7 @@ fn get_js_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec!["nestjs".to_string()], - file_indicators: vec!["nest-cli.json".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Hono".to_string(), @@ -1037,17 +1019,15 @@ fn get_js_technology_rules() -> Vec { alternative_names: vec![], file_indicators: vec![], }, - // Encore.ts - TypeScript backend framework - // ONLY match encore.dev package, not just "encore" which is too generic TechnologyRule { name: "Encore".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["encore.dev".to_string()], + dependency_patterns: vec!["encore.dev".to_string(), "encore".to_string()], requires: vec![], - conflicts_with: vec!["Next.js".to_string(), "React Router v7".to_string(), "Tanstack Start".to_string()], + conflicts_with: vec!["Next.js".to_string()], is_primary_indicator: true, - alternative_names: vec![], + alternative_names: vec!["encore-ts-starter".to_string()], file_indicators: vec!["encore.app".to_string(), "encore.service.ts".to_string(), "encore.service.js".to_string()], }, diff --git a/src/analyzer/frameworks/python.rs b/src/analyzer/frameworks/python.rs index f80f7798..82f0fb1a 100644 --- a/src/analyzer/frameworks/python.rs +++ b/src/analyzer/frameworks/python.rs @@ -34,12 +34,12 @@ fn get_python_technology_rules() -> Vec { name: "Django".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["django".to_string()], + dependency_patterns: vec!["django".to_string(), "Django".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["manage.py".to_string(), "settings.py".to_string(), "urls.py".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Django REST Framework".to_string(), @@ -58,29 +58,29 @@ fn get_python_technology_rules() -> Vec { name: "Flask".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["flask".to_string()], + dependency_patterns: vec!["flask".to_string(), "Flask".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["app.py".to_string(), "wsgi.py".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "FastAPI".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.95, - dependency_patterns: vec!["fastapi".to_string()], + dependency_patterns: vec!["fastapi".to_string(), "FastAPI".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["main.py".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Starlette".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["starlette".to_string()], + dependency_patterns: vec!["starlette".to_string(), "Starlette".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -91,7 +91,7 @@ fn get_python_technology_rules() -> Vec { name: "Quart".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["quart".to_string()], + dependency_patterns: vec!["quart".to_string(), "Quart".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -102,7 +102,7 @@ fn get_python_technology_rules() -> Vec { name: "Sanic".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["sanic".to_string()], + dependency_patterns: vec!["sanic".to_string(), "Sanic".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -113,7 +113,7 @@ fn get_python_technology_rules() -> Vec { name: "Bottle".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.85, - dependency_patterns: vec!["bottle".to_string()], + dependency_patterns: vec!["bottle".to_string(), "Bottle".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -124,7 +124,7 @@ fn get_python_technology_rules() -> Vec { name: "Falcon".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.85, - dependency_patterns: vec!["falcon".to_string()], + dependency_patterns: vec!["falcon".to_string(), "Falcon".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -135,7 +135,7 @@ fn get_python_technology_rules() -> Vec { name: "Hug".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.85, - dependency_patterns: vec!["hug".to_string()], + dependency_patterns: vec!["hug".to_string(), "Hug".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -176,21 +176,21 @@ fn get_python_technology_rules() -> Vec { file_indicators: vec![], }, TechnologyRule { - name: "ASGI Server".to_string(), - category: TechnologyCategory::Runtime, + name: "Asgi".to_string(), + category: TechnologyCategory::BackendFramework, confidence: 0.85, - dependency_patterns: vec!["uvicorn".to_string(), "hypercorn".to_string(), "daphne".to_string(), "asgiref".to_string()], + dependency_patterns: vec!["asgi".to_string(), "Asgi".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["asgi.py".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Tornado".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["tornado".to_string()], + dependency_patterns: vec!["tornado".to_string(), "Tornado".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -223,23 +223,23 @@ fn get_python_technology_rules() -> Vec { name: "Pyramid".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.90, - dependency_patterns: vec!["pyramid".to_string()], + dependency_patterns: vec!["pyramid".to_string(), "Pyramid".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["development.ini".to_string(), "production.ini".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "TurboGears".to_string(), category: TechnologyCategory::BackendFramework, confidence: 0.85, - dependency_patterns: vec!["turbogears".to_string(), "tg.devtools".to_string()], + dependency_patterns: vec!["tg".to_string(), "TurboGears".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["development.ini".to_string(), "production.ini".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Klein".to_string(), @@ -359,18 +359,18 @@ fn get_python_technology_rules() -> Vec { name: "Streamlit".to_string(), category: TechnologyCategory::FrontendFramework, confidence: 0.95, - dependency_patterns: vec!["streamlit".to_string()], + dependency_patterns: vec!["streamlit".to_string(), "Streamlit".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec![".streamlit/config.toml".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Gradio".to_string(), category: TechnologyCategory::FrontendFramework, confidence: 0.95, - dependency_patterns: vec!["gradio".to_string()], + dependency_patterns: vec!["gradio".to_string(), "Gradio".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -381,22 +381,22 @@ fn get_python_technology_rules() -> Vec { name: "Dash".to_string(), category: TechnologyCategory::FrontendFramework, confidence: 0.90, - dependency_patterns: vec!["dash".to_string(), "dash-core-components".to_string(), "dash-html-components".to_string()], + dependency_patterns: vec!["dash".to_string(), "Dash".to_string()], requires: vec!["Flask".to_string()], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["plotly-dash".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { name: "Panel".to_string(), category: TechnologyCategory::FrontendFramework, confidence: 0.90, - dependency_patterns: vec!["panel".to_string(), "holoviz".to_string()], + dependency_patterns: vec!["panel".to_string(), "Panel".to_string()], requires: vec!["Bokeh".to_string()], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["holoviews".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { @@ -762,14 +762,14 @@ fn get_python_technology_rules() -> Vec { file_indicators: vec![], }, TechnologyRule { - name: "Python Fire".to_string(), + name: "Fire".to_string(), category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, - dependency_patterns: vec!["python-fire".to_string()], + dependency_patterns: vec!["fire".to_string(), "Fire".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["fire".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { @@ -811,22 +811,22 @@ fn get_python_technology_rules() -> Vec { name: "Celery".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.90, - dependency_patterns: vec!["celery".to_string()], + dependency_patterns: vec!["celery".to_string(), "Celery".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, alternative_names: vec![], - file_indicators: vec!["celery.py".to_string(), "celeryconfig.py".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "RQ".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["rq".to_string(), "django-rq".to_string()], + dependency_patterns: vec!["rq".to_string(), "RQ".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["python-rq".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { diff --git a/src/analyzer/frameworks/rust.rs b/src/analyzer/frameworks/rust.rs index 414ec941..704ce342 100644 --- a/src/analyzer/frameworks/rust.rs +++ b/src/analyzer/frameworks/rust.rs @@ -38,7 +38,7 @@ fn get_rust_technology_rules() -> Vec { requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec![], + alternative_names: vec!["actix".to_string()], file_indicators: vec![], }, TechnologyRule { @@ -58,18 +58,18 @@ fn get_rust_technology_rules() -> Vec { confidence: 0.95, dependency_patterns: vec!["rocket".to_string()], requires: vec![], - conflicts_with: vec!["Actix Web".to_string(), "Axum".to_string()], + conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["Rocket.toml".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Warp".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.90, + confidence: 0.95, dependency_patterns: vec!["warp".to_string()], - requires: vec!["Tokio".to_string()], - conflicts_with: vec!["Actix Web".to_string(), "Rocket".to_string()], + requires: vec![], + conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], file_indicators: vec![], @@ -77,9 +77,9 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "Tide".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.85, + confidence: 0.90, dependency_patterns: vec!["tide".to_string()], - requires: vec!["async-std".to_string()], + requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], @@ -99,9 +99,9 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "Poem".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.85, + confidence: 0.90, dependency_patterns: vec!["poem".to_string()], - requires: vec!["Tokio".to_string()], + requires: vec![], conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], @@ -116,13 +116,13 @@ fn get_rust_technology_rules() -> Vec { conflicts_with: vec![], is_primary_indicator: true, alternative_names: vec![], - file_indicators: vec!["rwf.toml".to_string()], + file_indicators: vec![], }, TechnologyRule { name: "Salvo".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.90, - dependency_patterns: vec!["salvo".to_string(), "salvo_core".to_string()], + confidence: 0.95, + dependency_patterns: vec!["salvo".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -132,8 +132,8 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "Gotham".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.90, - dependency_patterns: vec!["gotham".to_string(), "gotham_derive".to_string()], + confidence: 0.95, + dependency_patterns: vec!["gotham".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, @@ -143,23 +143,23 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "Iron".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.85, + confidence: 0.95, dependency_patterns: vec!["iron".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["iron-web".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { name: "Nickel".to_string(), category: TechnologyCategory::BackendFramework, - confidence: 0.90, + confidence: 0.95, dependency_patterns: vec!["nickel".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["nickel-web".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { @@ -361,7 +361,7 @@ fn get_rust_technology_rules() -> Vec { name: "Serde".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["serde".to_string(), "serde_derive".to_string()], + dependency_patterns: vec!["serde".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -393,23 +393,23 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "toml".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["toml".to_string()], - requires: vec!["Serde".to_string()], + requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["toml-rs".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { name: "ron".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["ron".to_string()], - requires: vec!["Serde".to_string()], + requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["rusty-object-notation".to_string()], + alternative_names: vec![], file_indicators: vec![], }, @@ -417,12 +417,12 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "clap".to_string(), category: TechnologyCategory::Library(LibraryType::CLI), - confidence: 0.90, + confidence: 0.85, dependency_patterns: vec!["clap".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: true, - alternative_names: vec!["clap-rs".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { @@ -464,7 +464,7 @@ fn get_rust_technology_rules() -> Vec { name: "tracing".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), confidence: 0.85, - dependency_patterns: vec!["tracing".to_string(), "tracing-subscriber".to_string()], + dependency_patterns: vec!["tracing".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, @@ -474,12 +474,12 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "log".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["log".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["rust-log".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { @@ -691,10 +691,10 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "time".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["time".to_string()], requires: vec![], - conflicts_with: vec!["chrono".to_string()], + conflicts_with: vec![], is_primary_indicator: false, alternative_names: vec![], file_indicators: vec![], @@ -822,12 +822,12 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "image".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["image".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["image-rs".to_string()], + alternative_names: vec![], file_indicators: vec![], }, @@ -835,19 +835,19 @@ fn get_rust_technology_rules() -> Vec { TechnologyRule { name: "nom".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, + confidence: 0.85, dependency_patterns: vec!["nom".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, - alternative_names: vec!["nom-parser".to_string()], + alternative_names: vec![], file_indicators: vec![], }, TechnologyRule { name: "pest".to_string(), category: TechnologyCategory::Library(LibraryType::Utility), - confidence: 0.80, - dependency_patterns: vec!["pest".to_string(), "pest_derive".to_string()], + confidence: 0.85, + dependency_patterns: vec!["pest".to_string()], requires: vec![], conflicts_with: vec![], is_primary_indicator: false, diff --git a/src/cli.rs b/src/cli.rs index 03a6224d..0cf4f583 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -236,9 +236,9 @@ pub enum Commands { #[arg(value_name = "PROJECT_PATH", default_value = ".")] path: PathBuf, - /// LLM provider to use (omit to use saved default or prompt for setup) - #[arg(long, value_enum)] - provider: Option, + /// LLM provider to use + #[arg(long, value_enum, default_value = "openai")] + provider: ChatProvider, /// Model to use (e.g., gpt-4o, claude-3-5-sonnet-latest, llama3.2) #[arg(long)] @@ -247,10 +247,6 @@ pub enum Commands { /// Run a single query instead of interactive mode #[arg(long)] query: Option, - - /// Run the setup wizard to configure API keys - #[arg(long)] - setup: bool, }, } diff --git a/src/config/mod.rs b/src/config/mod.rs index 4b1e672e..ac17374a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,10 +1,77 @@ pub mod types; use crate::error::Result; -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; + +const CONFIG_FILE_NAME: &str = ".syncable.toml"; + +/// Get the global config file path (~/.syncable.toml) +pub fn global_config_path() -> Option { + dirs::home_dir().map(|h| h.join(CONFIG_FILE_NAME)) +} + +/// Get the local config file path (project/.syncable.toml) +pub fn local_config_path(project_path: &Path) -> PathBuf { + project_path.join(CONFIG_FILE_NAME) +} /// Load configuration from file or use defaults -pub fn load_config(_path: Option<&Path>) -> Result { - // TODO: Implement configuration loading +/// Checks local config first, then global config +pub fn load_config(project_path: Option<&Path>) -> Result { + // Try local config first + if let Some(path) = project_path { + let local = local_config_path(path); + if local.exists() { + if let Ok(content) = fs::read_to_string(&local) { + if let Ok(config) = toml::from_str(&content) { + return Ok(config); + } + } + } + } + + // Try global config + if let Some(global) = global_config_path() { + if global.exists() { + if let Ok(content) = fs::read_to_string(&global) { + if let Ok(config) = toml::from_str(&content) { + return Ok(config); + } + } + } + } + Ok(types::Config::default()) +} + +/// Save configuration to global config file +pub fn save_global_config(config: &types::Config) -> Result<()> { + if let Some(path) = global_config_path() { + let content = toml::to_string_pretty(config) + .map_err(|e| crate::error::ConfigError::ParsingFailed(e.to_string()))?; + fs::write(&path, content)?; + } + Ok(()) +} + +/// Load only the agent config section (for API keys) +pub fn load_agent_config() -> types::AgentConfig { + if let Some(global) = global_config_path() { + if global.exists() { + if let Ok(content) = fs::read_to_string(&global) { + if let Ok(config) = toml::from_str::(&content) { + return config.agent; + } + } + } + } + types::AgentConfig::default() +} + +/// Save agent config, preserving other config sections +pub fn save_agent_config(agent: &types::AgentConfig) -> Result<()> { + let mut config = load_config(None)?; + config.agent = agent.clone(); + save_global_config(&config) } \ No newline at end of file diff --git a/src/config/types.rs b/src/config/types.rs index c158a674..3cdd2d5d 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -6,7 +6,9 @@ pub struct Config { pub analysis: AnalysisConfig, pub generation: GenerationConfig, pub output: OutputConfig, - pub telemetry: TelemetryConfig, // New field for telemetry configuration + pub telemetry: TelemetryConfig, + #[serde(default)] + pub agent: AgentConfig, } /// Analysis configuration @@ -72,6 +74,27 @@ pub struct TelemetryConfig { pub enabled: bool, } +/// Agent/Chat configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AgentConfig { + /// OpenAI API key + #[serde(skip_serializing_if = "Option::is_none")] + pub openai_api_key: Option, + /// Anthropic API key + #[serde(skip_serializing_if = "Option::is_none")] + pub anthropic_api_key: Option, + /// Default provider (openai or anthropic) + #[serde(default = "default_provider")] + pub default_provider: String, + /// Default model + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + +fn default_provider() -> String { + "openai".to_string() +} + impl Default for Config { fn default() -> Self { Self { @@ -110,8 +133,9 @@ impl Default for Config { create_backup: true, }, telemetry: TelemetryConfig { - enabled: true, // Telemetry enabled by default + enabled: true, }, + agent: AgentConfig::default(), } } } \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index ee4c9467..962cd2a2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,6 +23,9 @@ pub enum IaCGeneratorError { #[error("Security error: {0}")] Security(#[from] SecurityError), + + #[error("Agent error: {0}")] + Agent(#[from] crate::agent::AgentError), } #[derive(Error, Debug)] diff --git a/src/lib.rs b/src/lib.rs index 46cd2d9f..ad899055 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,54 +104,27 @@ pub async fn run_command(command: Commands) -> Result<()> { .map(|_| ()) // Map Result to Result<()> } Commands::Tools { command } => handlers::handle_tools(command).await, - Commands::Chat { path, provider, model, query, setup } => { - use agent::{run_interactive, run_query, ProviderType}; - use agent::config::{ensure_credentials, run_setup_wizard}; + Commands::Chat { path, provider, model, query } => { + use agent::ProviderType; use cli::ChatProvider; - - // If setup flag is passed, run the wizard - if setup { - run_setup_wizard() - .map(|_| ()) - .map_err(|e| error::IaCGeneratorError::Config( - error::ConfigError::ParsingFailed(e.to_string()), - ))?; - return Ok(()); - } - + let project_path = path.canonicalize().unwrap_or(path); - - // Convert CLI provider to agent provider type - let cli_provider = provider.map(|p| match p { + let provider_type = match provider { ChatProvider::Openai => ProviderType::OpenAI, ChatProvider::Anthropic => ProviderType::Anthropic, - ChatProvider::Ollama => ProviderType::OpenAI, // Fallback - }); - - // Ensure credentials are available (prompts if needed) - let (agent_provider, default_model) = ensure_credentials(cli_provider) - .map_err(|e| error::IaCGeneratorError::Config( - error::ConfigError::ParsingFailed(e.to_string()), - ))?; - - // Use provided model, or default from config - let model = model.or(default_model); - + ChatProvider::Ollama => { + eprintln!("Ollama support coming soon. Using OpenAI as fallback."); + ProviderType::OpenAI + } + }; + if let Some(q) = query { - run_query(&project_path, &q, agent_provider, model) - .await - .map(|response| { - println!("{}", response); - }) - .map_err(|e| error::IaCGeneratorError::Config( - error::ConfigError::ParsingFailed(e.to_string()), - )) + let response = agent::run_query(&project_path, &q, provider_type, model).await?; + println!("{}", response); + Ok(()) } else { - run_interactive(&project_path, agent_provider, model) - .await - .map_err(|e| error::IaCGeneratorError::Config( - error::ConfigError::ParsingFailed(e.to_string()), - )) + agent::run_interactive(&project_path, provider_type, model).await?; + Ok(()) } } } diff --git a/src/main.rs b/src/main.rs index e713e4c4..29b1820e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,8 +5,7 @@ use syncable_cli::{ analyze_monorepo, vulnerability::VulnerabilitySeverity, }, cli::{ - Cli, ColorScheme, Commands, DisplayFormat, OutputFormat, SecurityScanMode, - SeverityThreshold, ToolsCommand, + ChatProvider, Cli, ColorScheme, Commands, DisplayFormat, OutputFormat, SecurityScanMode, SeverityThreshold, ToolsCommand }, config, generator, telemetry::{self}, @@ -483,33 +482,30 @@ async fn run() -> syncable_cli::Result<()> { handle_tools(command).await }, - Commands::Chat { path, provider, model, query, setup } => { - // Create telemetry properties + Commands::Chat { path, provider, model, query } => { let mut properties = HashMap::new(); - let provider_str = provider.as_ref().map(|p| match p { - syncable_cli::cli::ChatProvider::Openai => "openai", - syncable_cli::cli::ChatProvider::Anthropic => "anthropic", - syncable_cli::cli::ChatProvider::Ollama => "ollama", - }).unwrap_or("auto"); + + let provider_str = match provider { + ChatProvider::Openai => "openai", + ChatProvider::Anthropic => "anthropic", + ChatProvider::Ollama => "ollama", + }; properties.insert("provider".to_string(), json!(provider_str)); - if let Some(ref m) = model { + + if let Some(m) = &model { properties.insert("model".to_string(), json!(m)); } - if setup { - properties.insert("mode".to_string(), json!("setup")); - } else if query.is_some() { - properties.insert("mode".to_string(), json!("single_query")); - } else { - properties.insert("mode".to_string(), json!("interactive")); - } + + properties.insert("mode".to_string(), json!(if query.is_some() { "query" } else { "interactive" })); // Track Chat command if let Some(telemetry_client) = telemetry::get_telemetry_client() { - telemetry_client.track_event("chat", properties); + telemetry_client.track_event("chat", properties.clone()); } - handle_chat(path, provider, model, query, setup).await + syncable_cli::run_command(Commands::Chat { path, provider, model, query }).await }, + }; // Flush telemetry events before exiting @@ -1189,69 +1185,3 @@ pub fn handle_security( async fn handle_tools(command: ToolsCommand) -> syncable_cli::Result<()> { syncable_cli::handlers::tools::handle_tools(command).await } - -async fn handle_chat( - path: PathBuf, - provider: Option, - model: Option, - query: Option, - setup: bool, -) -> syncable_cli::Result<()> { - use syncable_cli::agent::{run_interactive, run_query, ProviderType}; - use syncable_cli::agent::config::{ensure_credentials, run_setup_wizard}; - - // If setup flag is passed, run the wizard - if setup { - run_setup_wizard() - .map(|_| ()) - .map_err(|e| syncable_cli::error::IaCGeneratorError::Config( - syncable_cli::error::ConfigError::ParsingFailed(e.to_string()), - ))?; - return Ok(()); - } - - let project_path = path.canonicalize().unwrap_or(path); - - // Convert CLI provider to agent provider type - let cli_provider = provider.map(|p| match p { - syncable_cli::cli::ChatProvider::Openai => ProviderType::OpenAI, - syncable_cli::cli::ChatProvider::Anthropic => ProviderType::Anthropic, - syncable_cli::cli::ChatProvider::Ollama => ProviderType::OpenAI, // Fallback - }); - - // Ensure credentials are available (prompts if needed) - let (agent_provider, default_model) = ensure_credentials(cli_provider) - .map_err(|e| syncable_cli::error::IaCGeneratorError::Config( - syncable_cli::error::ConfigError::ParsingFailed(e.to_string()), - ))?; - - // Use provided model, or default from config - let model = model.or(default_model); - - if let Some(q) = query { - // Single query mode - match run_query(&project_path, &q, agent_provider, model).await { - Ok(response) => { - println!("{}", response); - Ok(()) - } - Err(e) => { - eprintln!("Agent error: {}", e); - Err(syncable_cli::error::IaCGeneratorError::Config( - syncable_cli::error::ConfigError::ParsingFailed(e.to_string()), - )) - } - } - } else { - // Interactive mode - match run_interactive(&project_path, agent_provider, model).await { - Ok(()) => Ok(()), - Err(e) => { - eprintln!("Agent error: {}", e); - Err(syncable_cli::error::IaCGeneratorError::Config( - syncable_cli::error::ConfigError::ParsingFailed(e.to_string()), - )) - } - } - } -}