From a344cfadeec0071cb9cc35f22a1e2863bde9b1f5 Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 12 Apr 2026 13:16:45 +0000 Subject: [PATCH] huskies: merge 544_story_add_run_build_and_run_lint_mcp_tools_backed_by_script_build_and_script_lint --- server/src/http/mcp/mod.rs | 34 ++++++- server/src/http/mcp/shell_tools.rs | 152 +++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index bfa1e126..fd5eaf88 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1161,6 +1161,34 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "required": [] } }, + { + "name": "run_build", + "description": "Run the project's build script (script/build) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to a worktree to run the build in. Must be inside .huskies/worktrees/. Defaults to the project root." + } + }, + "required": [] + } + }, + { + "name": "run_lint", + "description": "Run the project's lint script (script/lint) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Optional absolute path to a worktree to run lint in. Must be inside .huskies/worktrees/. Defaults to the project root." + } + }, + "required": [] + } + }, { "name": "git_status", "description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", @@ -1418,6 +1446,8 @@ async fn handle_tools_call( "run_command" => shell_tools::tool_run_command(&args, ctx).await, "run_tests" => shell_tools::tool_run_tests(&args, ctx).await, "get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await, + "run_build" => shell_tools::tool_run_build(&args, ctx).await, + "run_lint" => shell_tools::tool_run_lint(&args, ctx).await, // Git operations "git_status" => git_tools::tool_git_status(&args, ctx).await, "git_diff" => git_tools::tool_git_diff(&args, ctx).await, @@ -1543,6 +1573,8 @@ mod tests { assert!(names.contains(&"run_command")); assert!(names.contains(&"run_tests")); assert!(names.contains(&"get_test_result")); + assert!(names.contains(&"run_build")); + assert!(names.contains(&"run_lint")); assert!(names.contains(&"git_status")); assert!(names.contains(&"git_diff")); assert!(names.contains(&"git_add")); @@ -1551,7 +1583,7 @@ mod tests { assert!(names.contains(&"status")); assert!(names.contains(&"loc_file")); assert!(names.contains(&"dump_crdt")); - assert_eq!(tools.len(), 60); + assert_eq!(tools.len(), 62); } #[test] diff --git a/server/src/http/mcp/shell_tools.rs b/server/src/http/mcp/shell_tools.rs index 2e0805dd..eb0a4542 100644 --- a/server/src/http/mcp/shell_tools.rs +++ b/server/src/http/mcp/shell_tools.rs @@ -639,6 +639,69 @@ fn collect_child_result( } } +/// Shared implementation for run_build and run_lint: runs a named script +/// (`script/`) in the working directory, captures output, and returns +/// truncated JSON with `passed`, `exit_code`, and `output`. +async fn run_script_tool( + script_name: &str, + args: &Value, + ctx: &AppContext, +) -> Result { + let project_root = ctx.agents.get_project_root(&ctx.state)?; + + let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) { + Some(wt) => validate_working_dir(wt, ctx)?, + None => project_root + .canonicalize() + .map_err(|e| format!("Cannot canonicalize project root: {e}"))?, + }; + + let script_path = working_dir.join("script").join(script_name); + if !script_path.exists() { + return Err(format!( + "{script_name} script not found: {}", + script_path.display() + )); + } + + let result = tokio::task::spawn_blocking({ + let script = script_path.clone(); + let dir = working_dir.clone(); + move || { + std::process::Command::new("bash") + .arg(&script) + .current_dir(&dir) + .output() + } + }) + .await + .map_err(|e| format!("Task join error: {e}"))? + .map_err(|e| format!("Failed to spawn {script_name} script: {e}"))?; + + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + let combined = format!("{stdout}{stderr}"); + let output = truncate_output(&combined, MAX_OUTPUT_LINES); + let exit_code = result.status.code().unwrap_or(-1); + + serde_json::to_string_pretty(&json!({ + "passed": result.status.success(), + "exit_code": exit_code, + "output": output, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +/// Run the project's build script (`script/build`) and return the result. +pub(super) async fn tool_run_build(args: &Value, ctx: &AppContext) -> Result { + run_script_tool("build", args, ctx).await +} + +/// Run the project's lint script (`script/lint`) and return the result. +pub(super) async fn tool_run_lint(args: &Value, ctx: &AppContext) -> Result { + run_script_tool("lint", args, ctx).await +} + /// Format a `TestJobResult` as the JSON string returned to the agent. fn format_test_result( result: &crate::http::context::TestJobResult, @@ -1019,6 +1082,95 @@ mod tests { ); } + // ── tool_run_build / tool_run_lint ──────────────────────────────── + + #[tokio::test] + async fn tool_run_build_missing_script_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_run_build(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[tokio::test] + async fn tool_run_build_passes_when_script_exits_zero() { + let tmp = tempfile::tempdir().unwrap(); + let script_dir = tmp.path().join("script"); + std::fs::create_dir_all(&script_dir).unwrap(); + let script_path = script_dir.join("build"); + std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let ctx = test_ctx(tmp.path()); + let result = tool_run_build(&json!({}), &ctx).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["passed"], true); + assert_eq!(parsed["exit_code"], 0); + } + + #[tokio::test] + async fn tool_run_build_worktree_path_must_be_inside_worktrees() { + let tmp = tempfile::tempdir().unwrap(); + let wt_dir = tmp.path().join(".huskies").join("worktrees"); + std::fs::create_dir_all(&wt_dir).unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_run_build(&json!({"worktree_path": tmp.path().to_str().unwrap()}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("worktrees")); + } + + #[tokio::test] + async fn tool_run_lint_missing_script_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_run_lint(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[tokio::test] + async fn tool_run_lint_passes_when_script_exits_zero() { + let tmp = tempfile::tempdir().unwrap(); + let script_dir = tmp.path().join("script"); + std::fs::create_dir_all(&script_dir).unwrap(); + let script_path = script_dir.join("lint"); + std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let ctx = test_ctx(tmp.path()); + let result = tool_run_lint(&json!({}), &ctx).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["passed"], true); + assert_eq!(parsed["exit_code"], 0); + } + + #[tokio::test] + async fn tool_run_lint_fails_when_script_exits_nonzero() { + let tmp = tempfile::tempdir().unwrap(); + let script_dir = tmp.path().join("script"); + std::fs::create_dir_all(&script_dir).unwrap(); + let script_path = script_dir.join("lint"); + std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let ctx = test_ctx(tmp.path()); + let result = tool_run_lint(&json!({}), &ctx).await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["passed"], false); + assert_eq!(parsed["exit_code"], 1); + } + // ── truncate_output ─────────────────────────────────────────────── #[test]