Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ regex = "1"
globset = "0.4"
ignore = "0.4"

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] }

[profile.release]
lto = true
strip = true
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,24 @@ CatDesk is a coding tool and a custom connector. Always use CatDesk if the user

# Tools

CatDesk has two local tool modes: `multi-tools` exposes 7 tools, and `read-only` exposes 3 tools.
CatDesk has two local tool modes: `multi-tools` exposes 10 tools, and `read-only` exposes 3 tools.

CatDesk's local tools in `multi-tools` mode are:

| Tool | Type | What it does |
| --------------------- | ----- | ------------------------------------------------------------- |
| `catdesk_instruction` | Guide | Returns CatDesk usage instructions and render Binagotchy |
| `read` | Read | Reads a text file from the workspace |
| `search` | Read | Searches workspace text with `rg`, `grep`, or built-in search |
| `write` | Write | Creates or overwrites a file |
| `edit` | Write | Replaces exact text inside a file |
| `delete` | Write | Deletes a file or directory |
| `run_command` | Shell | Runs a shell command inside the workspace. |
| Tool | Type | What it does |
| --------------------- | ----- | -------------------------------------------------------------------------- |
| `catdesk_instruction` | Guide | Returns CatDesk usage instructions and render Binagotchy |
| `read` | Read | Reads a text file from the workspace |
| `search` | Read | Searches workspace text with `rg`, `grep`, or built-in search |
| `write` | Write | Creates or overwrites a file |
| `edit` | Write | Replaces exact text inside a file |
| `delete` | Write | Deletes a file or directory |
| `run_command` | Shell | Runs a short shell command and waits for completion |
| `start_command` | Job | Starts a long-running shell command and immediately returns a job ID |
| `poll_command` | Job | Reads incremental output and status from a background command |
| `cancel_command` | Job | Stops a background command and its child process tree |

Long-running commands are deliberately decoupled from the lifetime of an MCP HTTP request. Builds, compilation, dependency installation, long test suites, and development servers should use `start_command`, then `poll_command` with the returned cursor. Poll responses are bounded; if `hasMoreOutput` is true, keep polling with `nextCursor` even after the command reaches a terminal state to drain the remaining buffered output. `run_command` remains the simpler path for short commands and has a 120-second maximum timeout.

If browser mode is enabled, CatDesk can also expose extra browser/devtools tools. Those are provided by the browser bridge, so the exact list depends on your environment.

Expand Down
84 changes: 23 additions & 61 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
use std::path::{Path, PathBuf};
use std::time::Instant;
use tokio::process::Command;
use tokio::time::{Duration, timeout};
use tree_sitter::{Node, Parser};
use tree_sitter_bash::LANGUAGE as BASH_LANGUAGE;

const MAX_BUFFER_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const MAX_TIMEOUT_MS: u64 = 120_000;
pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
pub const MAX_TIMEOUT_MS: u64 = 120_000;
pub const CATDESK_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: CatDesk";

#[derive(Debug)]
pub struct CommandResult {
pub stdout: String,
pub stderr: String,
pub success: bool,
pub exit_code: Option<i32>,
pub elapsed_ms: u64,
pub timed_out: bool,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -170,66 +171,27 @@ pub fn detect_move_path_intercept(command: &str) -> Option<InterceptedMovePathRe
detect_move_path_intercept_from_words(&words)
}

/// Execute a shell command via the platform shell.
/// Execute a short shell command via CatDesk's shared process runner.
///
/// The process runner owns the complete process tree. If this future is timed
/// out or dropped because the MCP request disappears, the child tree is
/// terminated instead of being left behind as an orphaned build.
pub async fn run_command(command: &str, cwd: &Path, timeout_ms: u64) -> CommandResult {
let start = Instant::now();
let mut shell = shell_command(command);
let fut = shell.current_dir(cwd).output();

match timeout(Duration::from_millis(timeout_ms), fut).await {
Ok(Ok(output)) => {
let elapsed_ms = start.elapsed().as_millis() as u64;
let stdout = String::from_utf8_lossy(
&output.stdout[..output.stdout.len().min(MAX_BUFFER_BYTES)],
)
.to_string();
let stderr = String::from_utf8_lossy(
&output.stderr[..output.stderr.len().min(MAX_BUFFER_BYTES)],
)
.to_string();
CommandResult {
stdout,
stderr,
success: output.status.success(),
elapsed_ms,
}
}
Ok(Err(e)) => CommandResult {
stdout: String::new(),
stderr: format!("Failed to execute: {e}"),
success: false,
elapsed_ms: start.elapsed().as_millis() as u64,
},
Err(_) => CommandResult {
stdout: String::new(),
stderr: format!("Command timed out after {timeout_ms} ms"),
success: false,
elapsed_ms: start.elapsed().as_millis() as u64,
},
let result =
crate::process_runner::run_shell_command(command, cwd, timeout_ms, MAX_BUFFER_BYTES).await;

CommandResult {
stdout: result.stdout,
stderr: result.stderr,
success: result.success,
exit_code: result.exit_code,
elapsed_ms: result.elapsed_ms,
timed_out: result.timed_out,
stdout_truncated: result.stdout_truncated,
stderr_truncated: result.stderr_truncated,
}
}

#[cfg(windows)]
fn shell_command(command: &str) -> Command {
let mut shell = Command::new("powershell.exe");
shell
.arg("-NoLogo")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-Command")
.arg(command);
shell
}

#[cfg(not(windows))]
fn shell_command(command: &str) -> Command {
let mut shell = Command::new("/bin/bash");
shell.arg("-c").arg(command);
shell
}

/// Format stdout+stderr into a single string.
pub fn format_result(r: &CommandResult) -> String {
let mut out = String::new();
Expand Down
Loading