2 files changed
+197
-4
src/main.rs
+3
-3
@@ -67,9 +67,9 @@ Keep answers short. Write idiomatic code. \
67
Fix root causes, not symptoms.
68
69
You have access to tools that let you read files, list directories, search \
70
-code, create new files, edit existing files, and delete files. Use them \
71
-proactively to understand the codebase before answering questions or writing \
72
-code. Always ground your answers in the actual code.
70
+code, create new files, edit existing files, delete files, and run shell \
71
+commands. Use them proactively — read the code before answering, run builds \
72
+and tests after making changes. Always ground your answers in the actual code.
73
74
Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
75
root cause, not the symptom. Correct beats clever.";
src/tools.rs
+194
-1
@@ -17,11 +17,16 @@
17
//! - `create_file` — create a new file (fails if it already exists)
18
//! - `edit_file` — replace an exact old-text span with new text in an existing file
19
//! - `delete_file` — delete a file or empty directory at the given path
20
+//!
21
+//! # Shell Tools
22
+//!
23
+//! - `run_command` — run a shell command and return its combined stdout/stderr output
24
25
use regex::Regex;
26
use serde_json::{Value, json};
27
use std::fs;
28
use std::path::Path;
29
+use std::process::Command;
30
31
/// Maximum characters returned from `read_file` before truncation.
32
const READ_FILE_CHAR_LIMIT: usize = 10_000;
@@ -165,6 +170,30 @@ pub fn all_tools() -> Vec<AgentTool> {
170
"additionalProperties": false
171
}),
172
},
173
+ AgentTool {
174
+ name: "run_command",
175
+ description: "Run a shell command and return its combined stdout and stderr output. \
176
+ The command runs in the given working directory (defaults to \".\"). \
177
+ Use this for build tools (cargo, npm, make), version control (git), \
178
+ package managers, linters, test runners, and other CLI tasks. \
179
+ Commands that run indefinitely (servers, watchers) will be killed \
180
+ after 120 seconds.",
181
+ parameters_schema: json!({
182
+ "type": "object",
183
+ "properties": {
184
+ "command": {
185
+ "type": "string",
186
+ "description": "The shell command to execute (e.g. \"cargo update\", \"git status\")."
187
+ },
188
+ "cwd": {
189
+ "type": "string",
190
+ "description": "Working directory for the command. Defaults to \".\" (current directory)."
191
+ }
192
+ },
193
+ "required": ["command"],
194
+ "additionalProperties": false
195
+ }),
196
+ },
197
]
198
}
199
@@ -182,6 +211,7 @@ pub fn execute_tool(name: &str, arguments: &str) -> String {
211
"create_file" => exec_create_file(arguments),
212
"edit_file" => exec_edit_file(arguments),
213
"delete_file" => exec_delete_file(arguments),
214
+ "run_command" => exec_run_command(arguments),
215
_ => format!("Unknown tool: {name}"),
216
}
217
}
@@ -557,6 +587,114 @@ fn exec_delete_file(arguments: &str) -> String {
587
}
588
}
589
590
+// ── run_command ──────────────────────────────────────────────────────────────
591
+
592
+/// Maximum time a command is allowed to run before being killed.
593
+const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
594
+
595
+/// Maximum bytes of combined output returned from a command.
596
+const COMMAND_OUTPUT_LIMIT: usize = 50_000;
597
+
598
+/// Run a shell command and return its combined stdout + stderr output.
599
+///
600
+/// The command is executed via `sh -c` (Unix) or `cmd /C` (Windows) so shell
601
+/// features like pipes, redirects, and chaining work out of the box.
602
+///
603
+/// Long-running commands are killed after [`COMMAND_TIMEOUT`] seconds.
604
+fn exec_run_command(arguments: &str) -> String {
605
+ let args: Value = match serde_json::from_str(arguments) {
606
+ Ok(v) => v,
607
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
608
+ };
609
+
610
+ let command_str = match args.get("command").and_then(Value::as_str) {
611
+ Some(c) => c,
612
+ None => return "Error: missing required parameter \"command\"".to_string(),
613
+ };
614
+
615
+ let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or(".");
616
+
617
+ let cwd_path = Path::new(cwd);
618
+ if !cwd_path.exists() {
619
+ return format!("Error: working directory does not exist: {cwd}");
620
+ }
621
+
622
+ log::info!("run_command: `{command_str}` in `{cwd}`");
623
+
624
+ #[cfg(unix)]
625
+ let mut child = match Command::new("sh")
626
+ .arg("-c")
627
+ .arg(command_str)
628
+ .current_dir(cwd_path)
629
+ .stdout(std::process::Stdio::piped())
630
+ .stderr(std::process::Stdio::piped())
631
+ .spawn()
632
+ {
633
+ Ok(c) => c,
634
+ Err(err) => return format!("Error: failed to spawn command: {err}"),
635
+ };
636
+
637
+ #[cfg(windows)]
638
+ let mut child = match Command::new("cmd")
639
+ .arg("/C")
640
+ .arg(command_str)
641
+ .current_dir(cwd_path)
642
+ .stdout(std::process::Stdio::piped())
643
+ .stderr(std::process::Stdio::piped())
644
+ .spawn()
645
+ {
646
+ Ok(c) => c,
647
+ Err(err) => return format!("Error: failed to spawn command: {err}"),
648
+ };
649
+
650
+ // Wait with a timeout.
651
+ let start = std::time::Instant::now();
652
+ loop {
653
+ match child.try_wait() {
654
+ Ok(Some(_status)) => break,
655
+ Ok(None) => {
656
+ if start.elapsed() >= COMMAND_TIMEOUT {
657
+ let _ = child.kill();
658
+ return format!(
659
+ "Error: command timed out after {} seconds and was killed.",
660
+ COMMAND_TIMEOUT.as_secs()
661
+ );
662
+ }
663
+ std::thread::sleep(std::time::Duration::from_millis(100));
664
+ }
665
+ Err(err) => return format!("Error: failed to wait on command: {err}"),
666
+ }
667
+ }
668
+
669
+ let output = match child.wait_with_output() {
670
+ Ok(o) => o,
671
+ Err(err) => return format!("Error: failed to read command output: {err}"),
672
+ };
673
+
674
+ let exit_code = output.status.code().unwrap_or(-1);
675
+ let mut combined = String::new();
676
+ combined.push_str(&String::from_utf8_lossy(&output.stdout));
677
+ combined.push_str(&String::from_utf8_lossy(&output.stderr));
678
+
679
+ // Truncate if output is huge.
680
+ let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
681
+ let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
682
+ format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
683
+ } else {
684
+ combined
685
+ };
686
+
687
+ if output.status.success() {
688
+ if truncated.is_empty() {
689
+ format!("Command succeeded (exit code {exit_code}) with no output.")
690
+ } else {
691
+ format!("Exit code {exit_code}:\n{truncated}")
692
+ }
693
+ } else {
694
+ format!("Command failed (exit code {exit_code}):\n{truncated}")
695
+ }
696
+}
697
+
698
#[cfg(test)]
699
mod tests {
700
use super::*;
@@ -671,13 +809,14 @@ mod tests {
809
#[test]
810
fn test_all_tools_count() {
811
let tools = all_tools();
674
- assert_eq!(tools.len(), 6);
812
+ assert_eq!(tools.len(), 7);
813
assert_eq!(tools[0].name, "read_file");
814
assert_eq!(tools[1].name, "list_directory");
815
assert_eq!(tools[2].name, "search_files");
816
assert_eq!(tools[3].name, "create_file");
817
assert_eq!(tools[4].name, "edit_file");
818
assert_eq!(tools[5].name, "delete_file");
819
+ assert_eq!(tools[6].name, "run_command");
820
}
821
822
#[test]
@@ -899,4 +1038,58 @@ mod tests {
1038
1039
let _ = fs::remove_dir_all(&dir);
1040
}
1041
+
1042
+ // ── run_command tests ────────────────────────────────────────────────
1043
+
1044
+ #[test]
1045
+ fn test_run_command_missing_command() {
1046
+ let result = exec_run_command("{}");
1047
+ assert!(
1048
+ result.contains("missing required parameter"),
1049
+ "got: {result}"
1050
+ );
1051
+ }
1052
+
1053
+ #[test]
1054
+ fn test_run_command_success() {
1055
+ let result = exec_run_command(r#"{"command": "echo hello"}"#);
1056
+ assert!(result.contains("hello"), "got: {result}");
1057
+ assert!(result.contains("Exit code 0"), "got: {result}");
1058
+ }
1059
+
1060
+ #[test]
1061
+ fn test_run_command_failure() {
1062
+ let result = exec_run_command(r#"{"command": "false"}"#);
1063
+ assert!(result.contains("failed"), "got: {result}");
1064
+ }
1065
+
1066
+ #[test]
1067
+ fn test_run_command_with_cwd() {
1068
+ let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
1069
+ let _ = fs::remove_dir_all(&dir);
1070
+ fs::create_dir_all(&dir).unwrap();
1071
+
1072
+ let args = format!(r#"{{"command": "pwd", "cwd": "{}"}}"#, dir.display());
1073
+ let result = exec_run_command(&args);
1074
+ // The output should contain the temp dir path.
1075
+ assert!(
1076
+ result.contains(&dir.to_string_lossy().to_string()),
1077
+ "got: {result}"
1078
+ );
1079
+
1080
+ let _ = fs::remove_dir_all(&dir);
1081
+ }
1082
+
1083
+ #[test]
1084
+ fn test_run_command_bad_cwd() {
1085
+ let result =
1086
+ exec_run_command(r#"{"command": "echo hi", "cwd": "/tmp/sigit_no_such_dir_xyz"}"#);
1087
+ assert!(result.contains("does not exist"), "got: {result}");
1088
+ }
1089
+
1090
+ #[test]
1091
+ fn test_run_command_captures_stderr() {
1092
+ let result = exec_run_command(r#"{"command": "echo err >&2"}"#);
1093
+ assert!(result.contains("err"), "got: {result}");
1094
+ }
1095
}