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 00000000..aceeb82d Binary files /dev/null and b/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png differ 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()), - )) - } - } - } -}