anveesa 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,7 +105,10 @@ pub async fn ask(
105
105
  usage_requested = false;
106
106
  continue;
107
107
  }
108
- bail!("provider '{provider_name}' returned HTTP {status}: {response_body}");
108
+ bail!(
109
+ "provider '{provider_name}' HTTP {status}: {}",
110
+ extract_api_error(&response_body)
111
+ );
109
112
  }
110
113
 
111
114
  let mut state = StreamState::default();
@@ -1017,6 +1020,49 @@ fn is_stream_options_error(body: &str) -> bool {
1017
1020
  lower.contains("stream_options") || lower.contains("include_usage")
1018
1021
  }
1019
1022
 
1023
+ /// Extract a concise, human-readable error message from a provider HTTP error body.
1024
+ /// Parses `{"error":{"message":"..."}}`, strips verbose class prefixes (e.g. litellm.*),
1025
+ /// takes only the first line, and truncates to 120 chars.
1026
+ fn extract_api_error(body: &str) -> String {
1027
+ // Try to pull error.message out of the JSON
1028
+ let extracted = serde_json::from_str::<Value>(body)
1029
+ .ok()
1030
+ .and_then(|v| {
1031
+ v.pointer("/error/message")
1032
+ .or_else(|| v.get("message"))
1033
+ .and_then(|m| m.as_str())
1034
+ .map(str::to_string)
1035
+ });
1036
+
1037
+ let raw = extracted.as_deref().unwrap_or(body);
1038
+
1039
+ // First line only
1040
+ let line = raw.lines().next().unwrap_or(raw).trim();
1041
+
1042
+ // Strip a leading "Namespace.ErrorClass: " or "ClassName: " prefix once
1043
+ // (covers litellm.BadRequestError, OpenAIException, etc.)
1044
+ let stripped = if let Some(colon) = line.find(": ") {
1045
+ let prefix = &line[..colon];
1046
+ if !prefix.is_empty()
1047
+ && prefix
1048
+ .chars()
1049
+ .all(|c| c.is_alphanumeric() || c == '.' || c == '_')
1050
+ {
1051
+ line[colon + 2..].trim_start()
1052
+ } else {
1053
+ line
1054
+ }
1055
+ } else {
1056
+ line
1057
+ };
1058
+
1059
+ if stripped.len() > 120 {
1060
+ format!("{}…", &stripped[..120])
1061
+ } else {
1062
+ stripped.to_string()
1063
+ }
1064
+ }
1065
+
1020
1066
  #[cfg(test)]
1021
1067
  mod tests {
1022
1068
  use super::*;
package/src/tools.rs CHANGED
@@ -3,6 +3,7 @@ use std::{
3
3
  fs,
4
4
  path::{Path, PathBuf},
5
5
  process::Stdio,
6
+ sync::OnceLock,
6
7
  time::Duration,
7
8
  };
8
9
 
@@ -303,6 +304,7 @@ async fn list_dir(arguments: &str) -> Result<Value> {
303
304
  }
304
305
 
305
306
  let mut entries = Vec::new();
307
+ let mut truncated = false;
306
308
  for entry in
307
309
  fs::read_dir(&path).with_context(|| format!("failed to read {}", path.display()))?
308
310
  {
@@ -319,6 +321,7 @@ async fn list_dir(arguments: &str) -> Result<Value> {
319
321
  "kind": path_kind(&entry_path),
320
322
  }));
321
323
  if entries.len() >= MAX_DIR_ENTRIES {
324
+ truncated = true;
322
325
  break;
323
326
  }
324
327
  }
@@ -326,7 +329,8 @@ async fn list_dir(arguments: &str) -> Result<Value> {
326
329
  Ok(json!({
327
330
  "ok": true,
328
331
  "path": path.display().to_string(),
329
- "entries": entries
332
+ "entries": entries,
333
+ "truncated": truncated,
330
334
  }))
331
335
  }
332
336
 
@@ -339,6 +343,7 @@ async fn find_files(arguments: &str) -> Result<Value> {
339
343
  }
340
344
 
341
345
  let mut results = Vec::new();
346
+ let mut truncated = false;
342
347
  walk_paths(&root, |path| {
343
348
  let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
344
349
  return Ok(true);
@@ -348,15 +353,20 @@ async fn find_files(arguments: &str) -> Result<Value> {
348
353
  "path": path.display().to_string(),
349
354
  "kind": path_kind(path),
350
355
  }));
356
+ if results.len() >= MAX_SEARCH_RESULTS {
357
+ truncated = true;
358
+ return Ok(false);
359
+ }
351
360
  }
352
- Ok(results.len() < MAX_SEARCH_RESULTS)
361
+ Ok(true)
353
362
  })?;
354
363
 
355
364
  Ok(json!({
356
365
  "ok": true,
357
366
  "root": root.display().to_string(),
358
367
  "query": args.query,
359
- "results": results
368
+ "results": results,
369
+ "truncated": truncated,
360
370
  }))
361
371
  }
362
372
 
@@ -369,6 +379,7 @@ async fn search_text(arguments: &str) -> Result<Value> {
369
379
  }
370
380
 
371
381
  let mut results = Vec::new();
382
+ let mut truncated = false;
372
383
  walk_paths(&root, |path| {
373
384
  if !path.is_file() || is_sensitive_path(path) || !is_small_text_candidate(path) {
374
385
  return Ok(true);
@@ -377,29 +388,29 @@ async fn search_text(arguments: &str) -> Result<Value> {
377
388
  let Ok(content) = fs::read_to_string(path) else {
378
389
  return Ok(true);
379
390
  };
380
- let lower = content.to_lowercase();
381
- if let Some(byte_index) = lower.find(&query) {
382
- let line_number = content[..byte_index].lines().count() + 1;
383
- let line = content
384
- .lines()
385
- .nth(line_number.saturating_sub(1))
386
- .unwrap_or_default()
387
- .trim();
388
- results.push(json!({
389
- "path": path.display().to_string(),
390
- "line": line_number,
391
- "preview": truncate(line, 240),
392
- }));
391
+ for (i, line) in content.lines().enumerate() {
392
+ if line.to_lowercase().contains(&query) {
393
+ results.push(json!({
394
+ "path": path.display().to_string(),
395
+ "line": i + 1,
396
+ "preview": truncate(line.trim(), 240),
397
+ }));
398
+ if results.len() >= MAX_SEARCH_RESULTS {
399
+ truncated = true;
400
+ return Ok(false);
401
+ }
402
+ }
393
403
  }
394
404
 
395
- Ok(results.len() < MAX_SEARCH_RESULTS)
405
+ Ok(true)
396
406
  })?;
397
407
 
398
408
  Ok(json!({
399
409
  "ok": true,
400
410
  "root": root.display().to_string(),
401
411
  "query": args.query,
402
- "results": results
412
+ "results": results,
413
+ "truncated": truncated,
403
414
  }))
404
415
  }
405
416
 
@@ -441,6 +452,17 @@ async fn read_file(arguments: &str) -> Result<Value> {
441
452
  }))
442
453
  }
443
454
 
455
+ fn http_client() -> &'static reqwest::Client {
456
+ static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
457
+ CLIENT.get_or_init(|| {
458
+ reqwest::Client::builder()
459
+ .timeout(Duration::from_secs(10))
460
+ .user_agent(concat!("anveesa-cli/", env!("CARGO_PKG_VERSION")))
461
+ .build()
462
+ .expect("failed to build HTTP client")
463
+ })
464
+ }
465
+
444
466
  async fn web_search(arguments: &str) -> Result<Value> {
445
467
  let args: WebSearchArgs = parse_args(arguments)?;
446
468
  let query = args.query.trim();
@@ -452,9 +474,8 @@ async fn web_search(arguments: &str) -> Result<Value> {
452
474
  "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
453
475
  percent_encode(query)
454
476
  );
455
- let response: Value = reqwest::Client::new()
477
+ let response: Value = http_client()
456
478
  .get(&url)
457
- .header("User-Agent", "anveesa-cli/0.1")
458
479
  .send()
459
480
  .await
460
481
  .context("web search request failed")?
@@ -592,6 +613,7 @@ async fn run_command(arguments: &str) -> Result<Value> {
592
613
  .stdin(Stdio::null())
593
614
  .stdout(Stdio::piped())
594
615
  .stderr(Stdio::piped())
616
+ .kill_on_drop(true)
595
617
  .spawn()
596
618
  .context("failed to spawn command")?;
597
619
 
@@ -772,16 +794,34 @@ fn is_small_text_candidate(path: &Path) -> bool {
772
794
 
773
795
  fn is_sensitive_path(path: &Path) -> bool {
774
796
  let lower = path.display().to_string().to_lowercase();
797
+ // Credential directories
775
798
  lower.contains("/.ssh/")
776
799
  || lower.contains("/.aws/")
777
800
  || lower.contains("/.gnupg/")
801
+ || lower.contains("/.kube/")
802
+ || lower.contains("/.docker/")
803
+ // Environment and secret files
778
804
  || lower.ends_with("/.env")
779
805
  || lower.contains("/.env.")
806
+ // SSH private key filenames
780
807
  || lower.ends_with("/id_rsa")
781
808
  || lower.ends_with("/id_dsa")
782
809
  || lower.ends_with("/id_ed25519")
810
+ || lower.ends_with("/id_ecdsa")
811
+ // Cloud/tool credential files
783
812
  || lower.ends_with("/credentials")
784
- || lower.contains("secret")
813
+ || lower.ends_with("/.netrc")
814
+ || lower.ends_with("/.npmrc")
815
+ || lower.ends_with("/.pypirc")
816
+ || lower.ends_with("/.git-credentials")
817
+ // System auth files
818
+ || lower.ends_with("/etc/shadow")
819
+ || lower.ends_with("/etc/passwd")
820
+ // Targeted secret patterns (narrower than a broad "secret" substring)
821
+ || lower.contains("secret_key")
822
+ || lower.contains("secretkey")
823
+ || lower.contains("/secrets.")
824
+ || lower.contains("/secrets/")
785
825
  || lower.contains("private_key")
786
826
  }
787
827
 
@@ -912,10 +952,23 @@ mod tests {
912
952
 
913
953
  #[test]
914
954
  fn flags_sensitive_paths() {
955
+ // Original cases
915
956
  assert!(is_sensitive_path(Path::new("/home/u/.ssh/id_rsa")));
916
957
  assert!(is_sensitive_path(Path::new("/proj/.env")));
917
- assert!(is_sensitive_path(Path::new("/proj/secret.txt")));
958
+ // New credential directories
959
+ assert!(is_sensitive_path(Path::new("/home/u/.kube/config")));
960
+ assert!(is_sensitive_path(Path::new("/home/u/.docker/config.json")));
961
+ assert!(is_sensitive_path(Path::new("/home/u/.git-credentials")));
962
+ assert!(is_sensitive_path(Path::new("/home/u/.netrc")));
963
+ assert!(is_sensitive_path(Path::new("/home/u/.npmrc")));
964
+ // Targeted secret patterns
965
+ assert!(is_sensitive_path(Path::new("/proj/config/secrets.yaml")));
966
+ assert!(is_sensitive_path(Path::new("/proj/secrets/db.json")));
967
+ assert!(is_sensitive_path(Path::new("/proj/config/secret_key.txt")));
968
+ // Non-sensitive paths — including the false-positive the old "secret" check caused
918
969
  assert!(!is_sensitive_path(Path::new("/proj/src/main.rs")));
970
+ assert!(!is_sensitive_path(Path::new("/proj/src/secret_manager.rs")));
971
+ assert!(!is_sensitive_path(Path::new("/proj/docs/secret_rotation.md")));
919
972
  }
920
973
 
921
974
  #[test]
@@ -1033,3 +1086,7 @@ mod tests {
1033
1086
  assert_eq!(result["exit_code"], json!(3));
1034
1087
  }
1035
1088
  }
1089
+
1090
+ #[cfg(test)]
1091
+ #[path = "tools_scenarios.rs"]
1092
+ mod scenarios;