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
7 changes: 7 additions & 0 deletions Cargo.lock

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

11 changes: 10 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ You have access to `devcontainer-mcp`, an MCP server that manages dev container
- **devcontainer CLI** (`devcontainer_*` tools) — local Docker via the official CLI
- **GitHub Codespaces** (`codespaces_*` tools) — cloud-hosted environments

## Core Rule
## Core Rules

**If a project has `.devcontainer/devcontainer.json`, ALL work MUST happen inside a dev container — never install dependencies, run builds, or execute code directly on the host.**

**Use ONLY the MCP tools listed here.** Do not invoke `docker`, `devcontainer`, `devpod`, `gh`, or `wsl` CLI commands directly — the MCP tools wrap these CLIs with proper error handling, auth resolution, and escaping. Direct CLI usage bypasses these safeguards.

## Authentication

**Before using Codespaces tools, you MUST obtain an auth handle.**
Expand Down Expand Up @@ -96,6 +98,8 @@ Supported auth providers: `github`, `aws`, `azure`, `gcloud`, `kubernetes`

## Workflow: DevPod

> **Use these tools — not raw `devpod` CLI commands.**

### 1. Create or start the workspace
```
devpod_up(args: "/path/to/project --id my-project --provider docker")
Expand All @@ -118,6 +122,8 @@ devpod_stop(workspace: "my-project")

## Workflow: devcontainer CLI

> **Use these tools — not raw `devcontainer` or `docker` CLI commands.**

### 1. Start the dev container
```
devcontainer_up(workspace_folder: "/path/to/project")
Expand All @@ -135,6 +141,8 @@ devcontainer_stop(workspace_folder: "/path/to/project")

## Workflow: Codespaces

> **Use these tools — not raw `gh codespace` CLI commands.**

### 1. Authenticate
```
auth_status(provider: "github")
Expand Down Expand Up @@ -171,6 +179,7 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors:
- ❌ Do NOT install packages on the host
- ❌ Do NOT run builds on the host
- ❌ Do NOT modify the host's global config
- ❌ Do NOT run `docker`, `devcontainer`, `devpod`, `gh`, or `wsl` CLI commands directly — use the MCP tools
- ✅ DO authenticate before using codespaces tools
- ✅ DO ask the user which account/machine type to use
- ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything
Expand Down
1 change: 1 addition & 0 deletions crates/devcontainer-mcp-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ tracing = { workspace = true }
futures-util = "0.3"
async-trait = "0.1"
base64 = "0.22"
shell-escape = "0.1"
49 changes: 35 additions & 14 deletions crates/devcontainer-mcp-core/src/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
//! formatting, and helpers to build shell commands for reading/writing files
//! through any backend (DevPod SSH, devcontainer exec, Codespaces SSH).

use std::borrow::Cow;

use base64::{engine::general_purpose::STANDARD, Engine};
use shell_escape::escape;

use crate::error::{Error, Result};

Expand Down Expand Up @@ -52,34 +55,32 @@ pub fn apply_edit(content: &str, old_str: &str, new_str: &str) -> Result<String>
Ok(content.replacen(old_str, new_str, 1))
}

/// Shell-escape a string for safe embedding in a shell command.
fn quote(s: &str) -> String {
escape(Cow::Borrowed(s)).into_owned()
}

/// Build a shell command that reads a file via `cat`.
pub fn read_file_command(path: &str) -> String {
format!("cat '{}'", shell_escape(path))
format!("cat {}", quote(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 path = quote(path);
let encoded = STANDARD.encode(content.as_bytes());
format!(
"mkdir -p \"$(dirname '{escaped}')\" && printf '%s' '{encoded}' | base64 -d > '{escaped}'"
)
format!("mkdir -p \"$(dirname {path})\" && printf '%s' '{encoded}' | base64 -d > {path}")
}

/// 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)
"find {} -maxdepth 2 -not -path '*/.*' | sort",
quote(path)
)
}

/// Minimal single-quote escaping for shell arguments.
fn shell_escape(s: &str) -> String {
s.replace('\'', "'\\''")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -122,7 +123,27 @@ mod tests {
}

#[test]
fn test_shell_escape() {
assert_eq!(shell_escape("it's"), "it'\\''s");
fn test_quote_simple_path() {
assert_eq!(quote("simple"), "simple");
}

#[test]
fn test_quote_path_with_spaces() {
let result = quote("path with spaces");
assert!(result.contains('\'') || result.contains('\\'));
}

#[test]
fn test_quote_path_with_single_quote() {
let result = quote("it's");
// Should not break when used in a shell command
assert!(!result.contains("it's") || result.contains("\\'"));
}

#[test]
fn test_quote_path_with_dollar() {
let result = quote("$HOME/file");
// Should be escaped so $HOME is not expanded
assert_ne!(result, "$HOME/file");
}
}
202 changes: 137 additions & 65 deletions crates/devcontainer-mcp/build.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,139 @@
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

const FRONTMATTER_NAME: &str = "devcontainer-mcp";
const FRONTMATTER_DESC: &str =
"Manage dev container environments via MCP tools (DevPod, devcontainer CLI, Codespaces)";

/// Fragment order for the assembled SKILL.md body.
const FRAGMENTS: &[&str] = &[
"header.md",
"core-rule.md",
"auth.md",
"choosing-backend.md",
"devpod.md",
"devcontainer.md",
"codespaces.md",
// WSL fragment inserted here on Windows builds
"self-healing.md",
"footer.md",
"file-ops.md",
];
/// Resolve the set of active tags for the current build target.
fn active_tags() -> HashSet<String> {
let mut tags = HashSet::new();
tags.insert("core".to_string());

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
match target_os.as_str() {
"windows" => {
tags.insert("windows".to_string());
tags.insert("docker-desktop".to_string());
tags.insert("wsl".to_string());
}
"macos" => {
tags.insert("macos".to_string());
tags.insert("docker-desktop".to_string());
}
"linux" => {
tags.insert("linux".to_string());
}
_ => {}
}

tags
}

/// Parse YAML frontmatter from a file's content.
/// Returns (tags, order, body) where body is everything after the closing `---`.
/// If no frontmatter is found, returns empty tags, order 0, and the full content.
fn parse_frontmatter(content: &str) -> (Vec<String>, i64, &str) {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return (vec![], 0, content);
}

// Find the closing ---
let after_open = &trimmed[3..];
let close_pos = match after_open.find("\n---") {
Some(pos) => pos,
None => return (vec![], 0, content),
};

let frontmatter = &after_open[..close_pos];
let body_start = 3 + close_pos + 4; // "---" + frontmatter + "\n---"
let body = trimmed[body_start..].trim_start_matches('\n');

let mut tags = vec![];
let mut order: i64 = 0;

for line in frontmatter.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("tags:") {
// Parse [tag1, tag2] or tag1, tag2
let rest = rest.trim().trim_start_matches('[').trim_end_matches(']');
for tag in rest.split(',') {
let tag = tag.trim();
if !tag.is_empty() {
tags.push(tag.to_string());
}
}
} else if let Some(rest) = line.strip_prefix("order:") {
order = rest.trim().parse().unwrap_or(0);
}
}

(tags, order, body)
}

/// Check if a fragment's required tags are all present in the active set.
/// Empty tags means always included.
fn tags_match(required: &[String], active: &HashSet<String>) -> bool {
required.iter().all(|tag| active.contains(tag))
}

/// Discover and sort all .md files in a directory, filtering by active tags.
fn collect_fragments(dir: &Path, active: &HashSet<String>) -> Vec<(i64, String)> {
let mut entries: Vec<PathBuf> = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
entries.sort();

let mut fragments: Vec<(i64, String)> = Vec::new();

for path in entries {
let content = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let (tags, order, body) = parse_frontmatter(&content);

if tags_match(&tags, active) {
fragments.push((order, body.to_string()));
}
}

fragments.sort_by_key(|(order, _)| *order);
fragments
}

/// Discover all .txt tool lists, filtering by active tags.
fn collect_tools(dir: &Path, active: &HashSet<String>) -> Vec<String> {
let mut entries: Vec<PathBuf> = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "txt"))
.collect();
entries.sort();

let mut tools = Vec::new();

for path in entries {
let content = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let (tags, _, body) = parse_frontmatter(&content);

if tags_match(&tags, active) {
for line in body.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
tools.push(trimmed.to_string());
}
}
}
}

tools
}

fn main() {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
Expand All @@ -31,38 +146,10 @@ fn main() {
let tools_dir = skills_dir.join("_tools");
let output_path = workspace_root.join("SKILL.md");

// Use CARGO_CFG_TARGET_OS (the *target* platform, not the host) so that
// cross-compiling for Windows from Linux still includes WSL content.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let is_windows_target = target_os == "windows";
let active = active_tags();

// --- Collect tool names from _tools/*.txt -----------------------------------
let mut tools: Vec<String> = Vec::new();
let mut tool_files: Vec<PathBuf> = fs::read_dir(&tools_dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", tools_dir.display()))
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "txt"))
.collect();
tool_files.sort();

// On non-Windows targets, skip wsl.txt
for path in &tool_files {
let is_wsl = path
.file_stem()
.is_some_and(|s| s.to_str().is_some_and(|s| s == "wsl"));
if is_wsl && !is_windows_target {
continue;
}
let content = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
tools.push(trimmed.to_string());
}
}
}
// --- Collect tool names -----------------------------------------------------
let tools = collect_tools(&tools_dir, &active);

// --- Build YAML frontmatter -------------------------------------------------
let mut output = String::from("---\n");
Expand All @@ -74,26 +161,11 @@ fn main() {
}
output.push_str("---\n");

// --- Assemble markdown body -------------------------------------------------
let insert_wsl_after = "codespaces.md";

for &fragment_name in FRAGMENTS {
let path = skills_dir.join(fragment_name);
let content = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
// --- Assemble markdown body (ordered by frontmatter `order` field) -----------
let fragments = collect_fragments(&skills_dir, &active);
for (_, body) in &fragments {
output.push('\n');
output.push_str(&content);

// On Windows targets, insert WSL section right after codespaces
if is_windows_target && fragment_name == insert_wsl_after {
let wsl_path = skills_dir.join("wsl.md");
if wsl_path.exists() {
let wsl_content = fs::read_to_string(&wsl_path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", wsl_path.display()));
output.push('\n');
output.push_str(&wsl_content);
}
}
output.push_str(body);
}

// --- Write output -----------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions skills/_tools/auth.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
---
tags: [core]
---
auth_status
auth_login
auth_select
Expand Down
Loading