From dd90ba7b871b5a59490cf21037d8c0ef210a8b3c Mon Sep 17 00:00:00 2001 From: aniongithub Date: Sat, 25 Apr 2026 22:33:21 -0700 Subject: [PATCH] feat: add file editing tools for all backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 12 new MCP tools (4 per backend) that provide proper file reading, writing, editing, and listing inside containers. These mirror the familiar view/edit/create pattern used by AI coding agents, eliminating the need to construct fragile shell commands via exec/ssh. New tools per backend (devpod_, devcontainer_, codespaces_): - file_read: Read file content with optional line range (1-based) - file_write: Create/overwrite files with automatic parent dir creation - file_edit: Surgical old_str → new_str replacement (must match once) - file_list: List directory contents (non-hidden, up to 2 levels) Implementation: - New file_ops module with server-side edit logic (apply_edit) and shell command builders (base64-encoded writes for safe transport) - Backend wrappers in devpod.rs, devcontainer.rs, codespaces.rs - FileRead and FileEdit error variants in error.rs - Unit tests for edit logic, line formatting, and shell escaping --- Cargo.lock | 1 + README.md | 22 +- SKILL.md | 55 +++ crates/devcontainer-mcp-core/Cargo.toml | 1 + .../devcontainer-mcp-core/src/codespaces.rs | 64 +++ .../devcontainer-mcp-core/src/devcontainer.rs | 60 +++ crates/devcontainer-mcp-core/src/devpod.rs | 64 +++ crates/devcontainer-mcp-core/src/error.rs | 6 + crates/devcontainer-mcp-core/src/file_ops.rs | 128 ++++++ crates/devcontainer-mcp-core/src/lib.rs | 1 + crates/devcontainer-mcp/src/tools.rs | 384 +++++++++++++++++- 11 files changed, 780 insertions(+), 6 deletions(-) create mode 100644 crates/devcontainer-mcp-core/src/file_ops.rs diff --git a/Cargo.lock b/Cargo.lock index 8049d55..35e53c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ name = "devcontainer-mcp-core" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", "bollard", "futures-util", "serde", diff --git a/README.md b/README.md index 7e69d04..f1931ac 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ When AI agents write code, they need to run it somewhere. Today that means your The [devcontainer spec](https://containers.dev/) already defines reproducible, container-based dev environments. Every major project ships a `.devcontainer/devcontainer.json`. But AI agents can't use them — until now. -`devcontainer-mcp` exposes **33 MCP tools** that let any AI agent: +`devcontainer-mcp` exposes **45 MCP tools** that let any AI agent: 1. **Spin up** a dev container from any repo — locally, on a cloud VM, or in Codespaces 2. **Run commands** inside the container — builds, tests, linting, anything @@ -98,7 +98,7 @@ Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP s Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** -## MCP Tools (33 total) +## MCP Tools (45 total) ### Auth (4 tools) @@ -109,7 +109,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `auth_select` | Switch the active account for a provider | | `auth_logout` | Revoke credentials for an account | -### DevPod (15 tools) +### DevPod (19 tools) | Tool | Description | |------|-------------| @@ -128,8 +128,12 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devpod_context_use` | Switch to a different context | | `devpod_container_inspect` | Docker inspect — labels, ports, mounts, state | | `devpod_container_logs` | Stream container logs via Docker API | +| `devpod_file_read` | Read file content with optional line range | +| `devpod_file_write` | Create or overwrite a file (auto-creates parent dirs) | +| `devpod_file_edit` | Surgical string replacement — old_str → new_str | +| `devpod_file_list` | List directory contents (non-hidden, 2 levels deep) | -### devcontainer CLI (7 tools) +### devcontainer CLI (11 tools) | Tool | Description | |------|-------------| @@ -140,8 +144,12 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devcontainer_stop` | Stop a dev container (via Docker API) | | `devcontainer_remove` | Remove a dev container and its resources | | `devcontainer_status` | Get dev container state by workspace folder | +| `devcontainer_file_read` | Read file content with optional line range | +| `devcontainer_file_write` | Create or overwrite a file (auto-creates parent dirs) | +| `devcontainer_file_edit` | Surgical string replacement — old_str → new_str | +| `devcontainer_file_list` | List directory contents (non-hidden, 2 levels deep) | -### GitHub Codespaces (7 tools) — require `auth` handle +### GitHub Codespaces (11 tools) — require `auth` handle | Tool | Description | |------|-------------| @@ -152,6 +160,10 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `codespaces_delete` | Delete a codespace | | `codespaces_view` | View detailed codespace info (state, machine, config) | | `codespaces_ports` | List forwarded ports with visibility and URLs | +| `codespaces_file_read` | Read file content with optional line range | +| `codespaces_file_write` | Create or overwrite a file (auto-creates parent dirs) | +| `codespaces_file_edit` | Surgical string replacement — old_str → new_str | +| `codespaces_file_list` | List directory contents (non-hidden, 2 levels deep) | ## MCP Server Configuration diff --git a/SKILL.md b/SKILL.md index d8101d0..9d7e29e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -35,6 +35,18 @@ tools: - codespaces_delete - codespaces_view - codespaces_ports + - devpod_file_read + - devpod_file_write + - devpod_file_edit + - devpod_file_list + - devcontainer_file_read + - devcontainer_file_write + - devcontainer_file_edit + - devcontainer_file_list + - codespaces_file_read + - codespaces_file_write + - codespaces_file_edit + - codespaces_file_list --- # DevContainer MCP Skill @@ -163,3 +175,46 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: - ✅ DO ask the user which account/machine type to use - ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything - ✅ DO check `.devcontainer/devcontainer.json` first + +## File Operations + +**All backends support built-in file operations — no need to construct shell commands.** + +These tools mirror familiar editing tools (read, write, edit, list) and handle escaping, encoding, and directory creation automatically. + +### Reading files +``` +devpod_file_read(workspace: "my-ws", path: "/workspaces/project/src/main.rs") +devcontainer_file_read(workspace_folder: "/path/to/project", path: "/workspaces/project/src/main.rs") +codespaces_file_read(auth: "github-user", codespace: "name", path: "src/main.rs") +``` +Supports optional `start_line` and `end_line` for reading specific ranges. + +### Writing files +``` +devpod_file_write(workspace: "my-ws", path: "/workspaces/project/new_file.rs", content: "fn main() {}") +devcontainer_file_write(workspace_folder: "/path/to/project", path: "new_file.rs", content: "fn main() {}") +codespaces_file_write(auth: "github-user", codespace: "name", path: "src/new.rs", content: "...") +``` +Creates parent directories automatically. + +### Editing files (surgical replacement) +``` +devpod_file_edit(workspace: "my-ws", path: "src/main.rs", old_str: "fn old()", new_str: "fn new()") +devcontainer_file_edit(workspace_folder: "/path/to/project", path: "src/lib.rs", old_str: "v1", new_str: "v2") +codespaces_file_edit(auth: "github-user", codespace: "name", path: "src/lib.rs", old_str: "TODO", new_str: "DONE") +``` +`old_str` must match exactly once in the file. Include surrounding context to make it unique. + +### Listing directories +``` +devpod_file_list(workspace: "my-ws", path: "/workspaces/project/src") +devcontainer_file_list(workspace_folder: "/path/to/project", path: "src") +codespaces_file_list(auth: "github-user", codespace: "name", path: ".") +``` +Shows non-hidden files up to 2 levels deep. + +### When to use file tools vs exec/ssh +- ✅ **Use file tools** for reading, writing, and editing source files +- ✅ **Use exec/ssh** for running builds, tests, and commands +- ❌ **Don't** construct `sed`, `cat`, or `echo` commands via exec for file editing diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml index ba6f344..6ab462c 100644 --- a/crates/devcontainer-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -15,3 +15,4 @@ bollard = { workspace = true } tracing = { workspace = true } futures-util = "0.3" async-trait = "0.1" +base64 = "0.22" diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs index 0342d95..2324f2a 100644 --- a/crates/devcontainer-mcp-core/src/codespaces.rs +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -107,3 +107,67 @@ pub async fn ports(env: &HashMap, codespace: &str) -> Result, + codespace: &str, + path: &str, +) -> Result { + let cmd = crate::file_ops::read_file_command(path); + ssh_exec(env, codespace, &cmd).await +} + +/// Write (create or overwrite) a file in a Codespace. +pub async fn file_write( + env: &HashMap, + codespace: &str, + path: &str, + content: &str, +) -> Result { + let cmd = crate::file_ops::write_file_command(path, content); + ssh_exec(env, codespace, &cmd).await +} + +/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. +pub async fn file_edit( + env: &HashMap, + codespace: &str, + path: &str, + old_str: &str, + new_str: &str, +) -> Result { + let read_output = file_read(env, codespace, path).await?; + if read_output.exit_code != 0 { + return Err(crate::error::Error::FileRead(format!( + "Failed to read {path}: {}", + read_output.stderr.trim() + ))); + } + + let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; + + let write_output = file_write(env, codespace, path, &modified).await?; + if write_output.exit_code != 0 { + return Err(crate::error::Error::FileEdit(format!( + "Failed to write {path}: {}", + write_output.stderr.trim() + ))); + } + + Ok(format!("Edit applied to {path}")) +} + +/// List directory contents in a Codespace. +pub async fn file_list( + env: &HashMap, + codespace: &str, + path: &str, +) -> Result { + let cmd = crate::file_ops::list_dir_command(path); + ssh_exec(env, codespace, &cmd).await +} diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs index ed888a3..5a586aa 100644 --- a/crates/devcontainer-mcp-core/src/devcontainer.rs +++ b/crates/devcontainer-mcp-core/src/devcontainer.rs @@ -94,3 +94,63 @@ pub async fn status(workspace_folder: &str) -> Result Result { + let cmd = crate::file_ops::read_file_command(path); + exec(workspace_folder, "sh", &["-c", &cmd]).await +} + +/// Write (create or overwrite) a file in a dev container. +pub async fn file_write( + workspace_folder: &str, + path: &str, + content: &str, +) -> Result { + let cmd = crate::file_ops::write_file_command(path, content); + exec(workspace_folder, "sh", &["-c", &cmd]).await +} + +/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. +pub async fn file_edit( + workspace_folder: &str, + path: &str, + old_str: &str, + new_str: &str, +) -> Result { + let read_output = file_read(workspace_folder, path).await?; + if read_output.exit_code != 0 { + return Err(crate::error::Error::FileRead(format!( + "Failed to read {path}: {}", + read_output.stderr.trim() + ))); + } + + let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; + + let write_output = file_write(workspace_folder, path, &modified).await?; + if write_output.exit_code != 0 { + return Err(crate::error::Error::FileEdit(format!( + "Failed to write {path}: {}", + write_output.stderr.trim() + ))); + } + + Ok(format!("Edit applied to {path}")) +} + +/// List directory contents in a dev container. +pub async fn file_list( + workspace_folder: &str, + path: &str, +) -> Result { + let cmd = crate::file_ops::list_dir_command(path); + exec(workspace_folder, "sh", &["-c", &cmd]).await +} diff --git a/crates/devcontainer-mcp-core/src/devpod.rs b/crates/devcontainer-mcp-core/src/devpod.rs index 393cfe6..2a53fc6 100644 --- a/crates/devcontainer-mcp-core/src/devpod.rs +++ b/crates/devcontainer-mcp-core/src/devpod.rs @@ -167,3 +167,67 @@ pub async fn import(args: &[&str]) -> Result { pub async fn export(workspace: &str) -> Result { run_devpod(&["export", workspace], false).await } + +// --------------------------------------------------------------------------- +// File operations +// --------------------------------------------------------------------------- + +/// Read a file from a DevPod workspace. +pub async fn file_read( + workspace: &str, + path: &str, + user: Option<&str>, +) -> Result { + let cmd = crate::file_ops::read_file_command(path); + ssh_exec(workspace, &cmd, user, None).await +} + +/// Write (create or overwrite) a file in a DevPod workspace. +pub async fn file_write( + workspace: &str, + path: &str, + content: &str, + user: Option<&str>, +) -> Result { + let cmd = crate::file_ops::write_file_command(path, content); + ssh_exec(workspace, &cmd, user, None).await +} + +/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. +pub async fn file_edit( + workspace: &str, + path: &str, + old_str: &str, + new_str: &str, + user: Option<&str>, +) -> Result { + let read_output = file_read(workspace, path, user).await?; + if read_output.exit_code != 0 { + return Err(Error::FileRead(format!( + "Failed to read {path}: {}", + read_output.stderr.trim() + ))); + } + + let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; + + let write_output = file_write(workspace, path, &modified, user).await?; + if write_output.exit_code != 0 { + return Err(Error::FileEdit(format!( + "Failed to write {path}: {}", + write_output.stderr.trim() + ))); + } + + Ok(format!("Edit applied to {path}")) +} + +/// List directory contents in a DevPod workspace. +pub async fn file_list( + workspace: &str, + path: &str, + user: Option<&str>, +) -> Result { + let cmd = crate::file_ops::list_dir_command(path); + ssh_exec(workspace, &cmd, user, None).await +} diff --git a/crates/devcontainer-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs index 549501c..ec3105c 100644 --- a/crates/devcontainer-mcp-core/src/error.rs +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -30,6 +30,12 @@ pub enum Error { #[error("DevPod command failed (exit code {exit_code}): {stderr}")] DevPodCommand { exit_code: i32, stderr: String }, + #[error("File read error: {0}")] + FileRead(String), + + #[error("File edit error: {0}")] + FileEdit(String), + #[error("IO error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/devcontainer-mcp-core/src/file_ops.rs b/crates/devcontainer-mcp-core/src/file_ops.rs new file mode 100644 index 0000000..2799376 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/file_ops.rs @@ -0,0 +1,128 @@ +//! Shared logic for file operations inside containers. +//! +//! Provides server-side editing (old_str → new_str replacement), line-number +//! formatting, and helpers to build shell commands for reading/writing files +//! through any backend (DevPod SSH, devcontainer exec, Codespaces SSH). + +use base64::{engine::general_purpose::STANDARD, Engine}; + +use crate::error::{Error, Result}; + +/// Format file content with line numbers, optionally restricting to a range. +/// +/// Line numbers are 1-based. Passing `None` for both bounds returns the +/// entire file. +pub fn format_with_line_numbers( + content: &str, + start_line: Option, + end_line: Option, +) -> String { + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len(); + let start = start_line.unwrap_or(1).max(1); + let end = end_line.unwrap_or(total).min(total); + + let mut output = String::new(); + for (i, line) in lines.iter().enumerate() { + let n = i + 1; + if n >= start && n <= end { + output.push_str(&format!("{n}. {line}\n")); + } + } + output +} + +/// Apply a surgical edit: find **exactly one** occurrence of `old_str` and +/// replace it with `new_str`. +/// +/// Returns an error if `old_str` is not found or appears more than once. +pub fn apply_edit(content: &str, old_str: &str, new_str: &str) -> Result { + let count = content.matches(old_str).count(); + if count == 0 { + return Err(Error::FileEdit( + "old_str not found in file content".to_string(), + )); + } + if count > 1 { + return Err(Error::FileEdit(format!( + "old_str found {count} times — must match exactly once. \ + Include more surrounding context to make it unique." + ))); + } + Ok(content.replacen(old_str, new_str, 1)) +} + +/// Build a shell command that reads a file via `cat`. +pub fn read_file_command(path: &str) -> String { + format!("cat '{}'", shell_escape(path)) +} + +/// Build a shell command that writes base64-encoded content to a file, +/// creating parent directories as needed. +pub fn write_file_command(path: &str, content: &str) -> String { + let escaped = shell_escape(path); + let encoded = STANDARD.encode(content.as_bytes()); + format!( + "mkdir -p \"$(dirname '{escaped}')\" && printf '%s' '{encoded}' | base64 -d > '{escaped}'" + ) +} + +/// Build a shell command that lists a directory (non-hidden, up to 2 levels). +pub fn list_dir_command(path: &str) -> String { + format!( + "find '{}' -maxdepth 2 -not -path '*/.*' | sort", + shell_escape(path) + ) +} + +/// Minimal single-quote escaping for shell arguments. +fn shell_escape(s: &str) -> String { + s.replace('\'', "'\\''") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_line_numbers_full() { + let content = "line1\nline2\nline3\n"; + let result = format_with_line_numbers(content, None, None); + assert_eq!(result, "1. line1\n2. line2\n3. line3\n"); + } + + #[test] + fn test_format_line_numbers_range() { + let content = "a\nb\nc\nd\ne\n"; + let result = format_with_line_numbers(content, Some(2), Some(4)); + assert_eq!(result, "2. b\n3. c\n4. d\n"); + } + + #[test] + fn test_apply_edit_success() { + let content = "fn old_name() {\n 42\n}\n"; + let result = apply_edit(content, "old_name", "new_name").unwrap(); + assert_eq!(result, "fn new_name() {\n 42\n}\n"); + } + + #[test] + fn test_apply_edit_not_found() { + let content = "hello world"; + let result = apply_edit(content, "xyz", "abc"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); + } + + #[test] + fn test_apply_edit_multiple_matches() { + let content = "aaa bbb aaa"; + let result = apply_edit(content, "aaa", "ccc"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("2 times")); + } + + #[test] + fn test_shell_escape() { + assert_eq!(shell_escape("it's"), "it'\\''s"); + } +} diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs index 962721a..3189d28 100644 --- a/crates/devcontainer-mcp-core/src/lib.rs +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -5,3 +5,4 @@ pub mod devcontainer; pub mod devpod; pub mod docker; pub mod error; +pub mod file_ops; diff --git a/crates/devcontainer-mcp/src/tools.rs b/crates/devcontainer-mcp/src/tools.rs index e81730b..3a46ac1 100644 --- a/crates/devcontainer-mcp/src/tools.rs +++ b/crates/devcontainer-mcp/src/tools.rs @@ -1,7 +1,7 @@ use rmcp::model::ServerInfo; use rmcp::{tool, ServerHandler}; -use devcontainer_mcp_core::{auth, cli::CliOutput, codespaces, devcontainer, devpod, docker}; +use devcontainer_mcp_core::{auth, cli::CliOutput, codespaces, devcontainer, devpod, docker, file_ops}; #[derive(Debug, Clone)] pub struct DevContainerMcp; @@ -311,6 +311,132 @@ impl DevContainerMcp { } } + // ----------------------------------------------------------------------- + // DevPod file operations + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_file_read", + description = "Read file content from a DevPod workspace. Returns content with line numbers. Supports optional line range." + )] + async fn devpod_file_read( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[tool(param)] + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[tool(param)] + #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] + end_line: Option, + #[tool(param)] + #[schemars(description = "User to run the command as")] + user: Option, + ) -> String { + match devpod::file_read(&workspace, &path, user.as_deref()).await { + Ok(output) => { + if output.exit_code != 0 { + return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); + } + let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_write", + description = "Create or overwrite a file in a DevPod workspace. Creates parent directories automatically." + )] + async fn devpod_file_write( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[tool(param)] + #[schemars(description = "File content to write")] + content: String, + #[tool(param)] + #[schemars(description = "User to run the command as")] + user: Option, + ) -> String { + match devpod::file_write(&workspace, &path, &content, user.as_deref()).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + format!("File written: {path}") + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_edit", + description = "Make a surgical edit to a file in a DevPod workspace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." + )] + async fn devpod_file_edit( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[tool(param)] + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[tool(param)] + #[schemars(description = "The new string to replace old_str with")] + new_str: String, + #[tool(param)] + #[schemars(description = "User to run the command as")] + user: Option, + ) -> String { + match devpod::file_edit(&workspace, &path, &old_str, &new_str, user.as_deref()).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_list", + description = "List directory contents in a DevPod workspace. Shows non-hidden files up to 2 levels deep." + )] + async fn devpod_file_list( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Path to the directory inside the workspace (defaults to '.')")] + path: Option, + #[tool(param)] + #[schemars(description = "User to run the command as")] + user: Option, + ) -> String { + let dir = path.as_deref().unwrap_or("."); + match devpod::file_list(&workspace, dir, user.as_deref()).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } + // ======================================================================= // devcontainer CLI tools // ======================================================================= @@ -475,6 +601,120 @@ impl DevContainerMcp { } } + // ----------------------------------------------------------------------- + // devcontainer file operations + // ----------------------------------------------------------------------- + + #[tool( + name = "devcontainer_file_read", + description = "Read file content from a local dev container. Returns content with line numbers. Supports optional line range." + )] + async fn devcontainer_file_read( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the container")] + path: String, + #[tool(param)] + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[tool(param)] + #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] + end_line: Option, + ) -> String { + match devcontainer::file_read(&workspace_folder, &path).await { + Ok(output) => { + if output.exit_code != 0 { + return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); + } + let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_write", + description = "Create or overwrite a file in a local dev container. Creates parent directories automatically." + )] + async fn devcontainer_file_write( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the container")] + path: String, + #[tool(param)] + #[schemars(description = "File content to write")] + content: String, + ) -> String { + match devcontainer::file_write(&workspace_folder, &path, &content).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + format!("File written: {path}") + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_edit", + description = "Make a surgical edit to a file in a local dev container. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." + )] + async fn devcontainer_file_edit( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the container")] + path: String, + #[tool(param)] + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[tool(param)] + #[schemars(description = "The new string to replace old_str with")] + new_str: String, + ) -> String { + match devcontainer::file_edit(&workspace_folder, &path, &old_str, &new_str).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_list", + description = "List directory contents in a local dev container. Shows non-hidden files up to 2 levels deep." + )] + async fn devcontainer_file_list( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Path to the directory inside the container (defaults to '.')")] + path: Option, + ) -> String { + let dir = path.as_deref().unwrap_or("."); + match devcontainer::file_list(&workspace_folder, dir).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } + // ======================================================================= // Auth tools // ======================================================================= @@ -773,6 +1013,148 @@ impl DevContainerMcp { Err(e) => format!("Error: {e}"), } } + + // ----------------------------------------------------------------------- + // Codespaces file operations + // ----------------------------------------------------------------------- + + #[tool( + name = "codespaces_file_read", + description = "Read file content from a GitHub Codespace. Returns content with line numbers. Supports optional line range. Requires a GitHub auth handle." + )] + async fn codespaces_file_read( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[tool(param)] + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[tool(param)] + #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] + end_line: Option, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_read(&env, &codespace, &path).await { + Ok(output) => { + if output.exit_code != 0 { + return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); + } + let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_write", + description = "Create or overwrite a file in a GitHub Codespace. Creates parent directories automatically. Requires a GitHub auth handle." + )] + async fn codespaces_file_write( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[tool(param)] + #[schemars(description = "File content to write")] + content: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_write(&env, &codespace, &path, &content).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + format!("File written: {path}") + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_edit", + description = "Make a surgical edit to a file in a GitHub Codespace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique. Requires a GitHub auth handle." + )] + async fn codespaces_file_edit( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[tool(param)] + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[tool(param)] + #[schemars(description = "The new string to replace old_str with")] + new_str: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_edit(&env, &codespace, &path, &old_str, &new_str).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_list", + description = "List directory contents in a GitHub Codespace. Shows non-hidden files up to 2 levels deep. Requires a GitHub auth handle." + )] + async fn codespaces_file_list( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Path to the directory inside the codespace (defaults to '.')")] + path: Option, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + let dir = path.as_deref().unwrap_or("."); + match codespaces::file_list(&env, &codespace, dir).await { + Ok(output) => { + if output.exit_code != 0 { + format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } } #[tool(tool_box)]