Implement chat and markdown views, enhance status bar, and add workflow panel
- Added `chat.rs` for rendering chat messages with timestamps and roles. - Introduced `markdown.rs` for rendering markdown content with styling. - Created `status.rs` to display the application status bar with session and message counts. - Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs. - Established a `theme.rs` for centralized color management across the UI. - Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
use super::helpers::arg_str;
|
||||
|
||||
pub struct Edit;
|
||||
|
||||
impl Tool for Edit {
|
||||
fn name(&self) -> &'static str {
|
||||
"edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Replace a string in a file with a new string. The old string must be unique unless replace_all is true."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to edit (relative to workspace root)"
|
||||
},
|
||||
"old": {
|
||||
"type": "string",
|
||||
"description": "The exact text to replace"
|
||||
},
|
||||
"new": {
|
||||
"type": "string",
|
||||
"description": "The replacement text"
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace all occurrences instead of requiring uniqueness"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for the change (must be non-empty)"
|
||||
}
|
||||
},
|
||||
"required": ["path", "old", "new", "reason"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let old = arg_str(args, "old")?;
|
||||
let new_str = arg_str(args, "new")?;
|
||||
let reason = arg_str(args, "reason")?;
|
||||
if reason.trim().is_empty() {
|
||||
anyhow::bail!("reason must be a non-empty string");
|
||||
}
|
||||
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
|
||||
}
|
||||
if path.is_dir() {
|
||||
anyhow::bail!("'{}' is a directory, not a file", rel);
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
|
||||
if !content.contains(&old) {
|
||||
anyhow::bail!("old string not found in '{}'", rel);
|
||||
}
|
||||
if !replace_all {
|
||||
let count = content.matches(&old).count();
|
||||
if count > 1 {
|
||||
anyhow::bail!(
|
||||
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.",
|
||||
count, rel
|
||||
);
|
||||
}
|
||||
}
|
||||
let new_content = if replace_all {
|
||||
content.replace(&old, &new_str)
|
||||
} else {
|
||||
content.replacen(&old, &new_str, 1)
|
||||
};
|
||||
fs::write(&path, &new_content)
|
||||
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
||||
let bytes_diff = if new_content.len() > content.len() {
|
||||
new_content.len() - content.len()
|
||||
} else {
|
||||
content.len() - new_content.len()
|
||||
};
|
||||
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::path::Path;
|
||||
use serde_json::Value;
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
args.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("missing required argument: {}", name))
|
||||
}
|
||||
|
||||
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let in_ws = ctx.workspaces.iter().any(|w| {
|
||||
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf());
|
||||
canon.starts_with(&wc)
|
||||
});
|
||||
if !in_ws {
|
||||
format!(
|
||||
"path '{}' is outside all workspace roots. Workspace roots: {}",
|
||||
rel,
|
||||
ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
} else {
|
||||
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod edit;
|
||||
pub mod helpers;
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
use super::helpers::{arg_str, not_found_help};
|
||||
|
||||
pub struct Read;
|
||||
|
||||
impl Tool for Read {
|
||||
fn name(&self) -> &'static str {
|
||||
"read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read the contents of a file and display it with line numbers"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read (relative to workspace root, or [N]prefix for other workspaces)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return (optional)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(not_found_help(ctx, &path, &rel));
|
||||
}
|
||||
if path.is_dir() {
|
||||
return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel));
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
let take = limit.unwrap_or(total).min(total);
|
||||
let result: String = lines[..take]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{}\t{}", i + 1, line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if take < total {
|
||||
Ok(format!("{}\n... ({} more lines, total {})", result, total - take, total))
|
||||
} else if total == 0 {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::fs;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
use super::helpers::arg_str;
|
||||
|
||||
pub struct Write;
|
||||
|
||||
impl Tool for Write {
|
||||
fn name(&self) -> &'static str {
|
||||
"write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Write content to a file, creating parent directories as needed"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to write (relative to workspace root)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for the change (must be non-empty)"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content", "reason"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let content = arg_str(args, "content")?;
|
||||
let reason = arg_str(args, "reason")?;
|
||||
if reason.trim().is_empty() {
|
||||
anyhow::bail!("reason must be a non-empty string");
|
||||
}
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?;
|
||||
}
|
||||
fs::write(&path, &content)
|
||||
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
||||
Ok(format!("wrote {} bytes to {}", content.len(), rel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::process::Command;
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct GitCred;
|
||||
|
||||
impl Tool for GitCred {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_cred"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Interact with git credential helper (store, get, erase credentials)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["store", "get", "erase"],
|
||||
"description": "Git credential operation"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = args.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: operation"))?;
|
||||
let output = Command::new("git")
|
||||
.arg("credential")
|
||||
.arg(operation)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git credential failed: {}", e))?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::process::Command;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct GitOperator;
|
||||
|
||||
impl Tool for GitOperator {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_operator"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute git operations with catastrophic guard protection"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "Git subcommand to execute (e.g. 'add', 'commit', 'status')"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Arguments for the git subcommand"
|
||||
}
|
||||
},
|
||||
"required": ["operation", "args"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = args.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: operation"))?
|
||||
.to_string();
|
||||
let arg_list: Vec<String> = args.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing required argument: args"))?;
|
||||
let full_cmd_str = format!("git {} {}", operation, arg_list.join(" "));
|
||||
let workspace_roots: Vec<&std::path::Path> = _ctx.workspaces.iter().map(|p| p.as_path()).collect();
|
||||
crate::app::catastrophic::CatastrophicGuard::check_all(&full_cmd_str, &workspace_roots)
|
||||
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
|
||||
let output = Command::new("git")
|
||||
.arg(&operation)
|
||||
.args(&arg_list)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git {} failed: {}", operation, e))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
|
||||
if output.status.success() {
|
||||
Ok(combined)
|
||||
} else {
|
||||
anyhow::bail!("git {} failed (exit {}): {}", operation, output.status.code().unwrap_or(-1), stderr.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::process::Command;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct GitWorktree;
|
||||
|
||||
impl Tool for GitWorktree {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_worktree"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Create and manage git worktrees"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name for the worktree directory"
|
||||
},
|
||||
"base_ref": {
|
||||
"type": "string",
|
||||
"description": "Base branch or ref to create the worktree from (e.g. 'main')"
|
||||
}
|
||||
},
|
||||
"required": ["name", "base_ref"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = args.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: name"))?
|
||||
.to_string();
|
||||
let base_ref = args.get("base_ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: base_ref"))?
|
||||
.to_string();
|
||||
let worktree_path = ctx.worktrees_dir.join(&name);
|
||||
std::fs::create_dir_all(&worktree_path)
|
||||
.map_err(|e| anyhow!("failed to create worktree directory: {}", e))?;
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "add", "--checkout"])
|
||||
.arg(worktree_path.display().to_string())
|
||||
.arg(&base_ref)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git worktree add failed: {}", e))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
|
||||
if output.status.success() {
|
||||
Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined))
|
||||
} else {
|
||||
anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::fs;
|
||||
use std::io::copy;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
use super::super::resolve_path;
|
||||
|
||||
pub struct Download;
|
||||
|
||||
impl Tool for Download {
|
||||
fn name(&self) -> &'static str {
|
||||
"download"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Download a file from a URL to a local path"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to download from"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Local path to save the file (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["url", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use download.");
|
||||
}
|
||||
let url = args.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: url"))?
|
||||
.to_string();
|
||||
let rel = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?
|
||||
.to_string();
|
||||
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
||||
crate::app::catastrophic::CatastrophicGuard::check_download_path(&path)
|
||||
.map_err(|e| anyhow!("download blocked: {}", e))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow!("failed to create parent directories: {}", e))?;
|
||||
}
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.user_agent("ZedSex/1.0")
|
||||
.build()
|
||||
.map_err(|e| anyhow!("failed to create HTTP client: {}", e))?;
|
||||
let response = client.get(&url)
|
||||
.send()
|
||||
.map_err(|e| anyhow!("failed to download '{}': {}", url, e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("download '{}' returned HTTP {}", url, status.as_u16());
|
||||
}
|
||||
let total: u64 = response.content_length().unwrap_or(0);
|
||||
let mut file = fs::File::create(&path)
|
||||
.map_err(|e| anyhow!("failed to create file '{}': {}", rel, e))?;
|
||||
let mut content = response;
|
||||
let written = copy(&mut content, &mut file)
|
||||
.map_err(|e| anyhow!("failed to write to '{}': {}", rel, e))?;
|
||||
Ok(format!("downloaded {} of {} bytes to {}", written, total, rel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::time::Duration;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
|
||||
pub struct Fetch;
|
||||
|
||||
impl Tool for Fetch {
|
||||
fn name(&self) -> &'static str {
|
||||
"fetch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Fetch a URL and convert the HTML content to markdown"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use fetch.");
|
||||
}
|
||||
let url = args.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: url"))?
|
||||
.to_string();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.user_agent("ZedSex/1.0")
|
||||
.build()
|
||||
.map_err(|e| anyhow!("failed to create HTTP client: {}", e))?;
|
||||
let response = client.get(&url)
|
||||
.send()
|
||||
.map_err(|e| anyhow!("failed to fetch '{}': {}", url, e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("fetch '{}' returned HTTP {}", url, status.as_u16());
|
||||
}
|
||||
let content_type = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = response.text()
|
||||
.map_err(|e| anyhow!("failed to read response body: {}", e))?;
|
||||
if content_type.contains("text/html") || content_type.contains("application/xhtml") || content_type.is_empty() {
|
||||
let markdown = html_to_markdown(&body)?;
|
||||
Ok(markdown)
|
||||
} else {
|
||||
let preview = body.chars().take(2000).collect::<String>();
|
||||
Ok(format!("Content-Type: {}\n\n{}", content_type, preview))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn html_to_markdown(html: &str) -> Result<String> {
|
||||
let frag = scraper::Html::parse_document(html);
|
||||
let sel = scraper::Selector::parse("body")
|
||||
.map_err(|e| anyhow!("failed to parse selector: {}", e))?;
|
||||
let body = frag.select(&sel).next()
|
||||
.map(|e| e.inner_html())
|
||||
.unwrap_or_else(|| html.to_string());
|
||||
let text = scraper::Html::parse_fragment(&body);
|
||||
let result: String = text.root_element().text().collect::<Vec<_>>().join("\n");
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod download;
|
||||
pub mod fetch;
|
||||
pub mod search;
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
use super::super::ToolCtx;
|
||||
|
||||
pub struct Search;
|
||||
|
||||
impl Tool for Search {
|
||||
fn name(&self) -> &'static str {
|
||||
"web_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search the web for information. Uses configured search provider."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use web_search.");
|
||||
}
|
||||
let query = args.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: query"))?
|
||||
.to_string();
|
||||
let results = mock_search(&query);
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mock_search(query: &str) -> String {
|
||||
format!(
|
||||
"Search results for '{}':\n\n\
|
||||
No search provider configured. Results are unavailable.\n\
|
||||
To enable web search, configure a search provider in settings.\n\
|
||||
Supported providers: tavily, brave, serpapi, google.", query
|
||||
)
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use std::path::PathBuf;
|
||||
use serde_json::Value;
|
||||
use anyhow::Result;
|
||||
|
||||
pub mod fs;
|
||||
pub mod git_cred;
|
||||
pub mod git_operator;
|
||||
pub mod git_worktree;
|
||||
pub mod internet;
|
||||
pub mod plan;
|
||||
pub mod search;
|
||||
pub mod seqthink;
|
||||
pub mod shell;
|
||||
pub mod shell_filter;
|
||||
pub mod workflow;
|
||||
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn description(&self) -> &'static str;
|
||||
fn parameters(&self) -> Value;
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||
}
|
||||
|
||||
pub struct ToolCtx {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||
pub internet_mode: super::model::settings::InternetMode,
|
||||
pub origin: crate::app::state::types::Origin,
|
||||
}
|
||||
|
||||
impl ToolCtx {
|
||||
pub fn builder() -> ToolCtxBuilder {
|
||||
ToolCtxBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ToolCtxBuilder {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||
pub internet_mode: super::model::settings::InternetMode,
|
||||
pub origin: crate::app::state::types::Origin,
|
||||
}
|
||||
|
||||
impl Default for ToolCtxBuilder {
|
||||
fn default() -> Self {
|
||||
ToolCtxBuilder {
|
||||
workspaces: Vec::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
download_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
||||
internet_mode: super::model::settings::InternetMode::Off,
|
||||
origin: crate::app::state::types::Origin::Main,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCtxBuilder {
|
||||
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self { self.workspaces = v; self }
|
||||
pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self }
|
||||
pub fn memory_dir(mut self, v: PathBuf) -> Self { self.memory_dir = v; self }
|
||||
pub fn download_dir(mut self, v: PathBuf) -> Self { self.download_dir = v; self }
|
||||
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
||||
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
||||
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
||||
pub fn build(self) -> ToolCtx {
|
||||
ToolCtx {
|
||||
workspaces: self.workspaces,
|
||||
session_dir: self.session_dir,
|
||||
memory_dir: self.memory_dir,
|
||||
download_dir: self.download_dir,
|
||||
worktrees_dir: self.worktrees_dir,
|
||||
dir_cache: self.dir_cache,
|
||||
internet_mode: self.internet_mode,
|
||||
origin: self.origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(super::tool::fs::read::Read),
|
||||
Box::new(super::tool::fs::write::Write),
|
||||
Box::new(super::tool::fs::edit::Edit),
|
||||
Box::new(super::tool::search::Grep),
|
||||
Box::new(super::tool::search::Glob),
|
||||
Box::new(super::tool::shell::Bash),
|
||||
Box::new(super::tool::git_operator::GitOperator),
|
||||
Box::new(super::tool::git_worktree::GitWorktree),
|
||||
Box::new(super::tool::git_cred::GitCred),
|
||||
Box::new(super::tool::seqthink::SeqThink),
|
||||
Box::new(super::tool::plan::PlanEnter),
|
||||
Box::new(super::tool::plan::PlanReady),
|
||||
Box::new(super::tool::workflow::WorkflowRun),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn tool_is_risky(name: &str) -> bool {
|
||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||
}
|
||||
|
||||
pub const DEFERRED_TOOLS: &[&str] = &[
|
||||
"read", "write", "edit", "bash", "grep", "glob",
|
||||
"git_operator", "git_worktree", "git_cred",
|
||||
];
|
||||
|
||||
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
let _parts: Vec<&str> = rel.splitn(2, '/').collect();
|
||||
let (ws_idx, path) = if rel.starts_with('[') {
|
||||
let close = rel.find(']').ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
|
||||
let idx: usize = rel[1..close].parse().map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
|
||||
(idx, &rel[close + 1..])
|
||||
} else {
|
||||
(0, rel)
|
||||
};
|
||||
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?;
|
||||
let abs = if path.is_empty() {
|
||||
base.clone()
|
||||
} else {
|
||||
base.join(path)
|
||||
};
|
||||
let canon = abs.canonicalize().unwrap_or(abs);
|
||||
if workspaces.iter().any(|w| canon.starts_with(w)) {
|
||||
Ok(canon)
|
||||
} else {
|
||||
anyhow::bail!("path '{}' is outside all workspace roots", rel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct PlanEnter;
|
||||
|
||||
impl Tool for PlanEnter {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_enter"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Enter plan mode: provide a detailed plan for the next set of changes"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The step-by-step plan to implement"
|
||||
},
|
||||
"sign_off": {
|
||||
"type": "string",
|
||||
"description": "Sign-off message acknowledging the plan constraints"
|
||||
}
|
||||
},
|
||||
"required": ["plan", "sign_off"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _plan = args.get("plan")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: plan"))?;
|
||||
let _sign_off = args.get("sign_off")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: sign_off"))?;
|
||||
Ok("plan recorded".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlanReady;
|
||||
|
||||
impl Tool for PlanReady {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_ready"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Signal that you are ready to execute the approved plan"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirmation": {
|
||||
"type": "string",
|
||||
"description": "Confirmation that you understand and will follow the plan"
|
||||
}
|
||||
},
|
||||
"required": ["confirmation"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _confirmation = args.get("confirmation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: confirmation"))?;
|
||||
Ok("ready to execute".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::fs;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use ignore::Walk;
|
||||
use globset::{GlobBuilder, GlobSetBuilder};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
use super::resolve_path;
|
||||
|
||||
pub struct Grep;
|
||||
|
||||
impl Tool for Grep {
|
||||
fn name(&self) -> &'static str {
|
||||
"grep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search for a pattern in files using recursive text search"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Text pattern to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to search in (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
|
||||
.to_string();
|
||||
let rel = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?
|
||||
.to_string();
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{}' does not exist", rel);
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("path '{}' is not a directory", rel);
|
||||
}
|
||||
let mut results: Vec<(String, usize, String)> = Vec::new();
|
||||
for entry in Walk::new(&path).flatten() {
|
||||
let file_path = entry.path();
|
||||
if !file_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(file_path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.contains(&pattern) {
|
||||
let rel_path = file_path.strip_prefix(&path)
|
||||
.unwrap_or(file_path)
|
||||
.display()
|
||||
.to_string();
|
||||
results.push((rel_path, i + 1, line.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if results.is_empty() {
|
||||
return Ok(format!("no matches found for '{}' in {}", pattern, rel));
|
||||
}
|
||||
let output = results.iter()
|
||||
.map(|(f, line, text)| format!("{}:{}:{}", f, line, text))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("found {} matches:\n{}", results.len(), output))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Glob;
|
||||
|
||||
impl Tool for Glob {
|
||||
fn name(&self) -> &'static str {
|
||||
"glob"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List files matching a glob pattern"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files (e.g. '**/*.rs')"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Root path to search from (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pat_str = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
|
||||
.to_string();
|
||||
let rel = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?
|
||||
.to_string();
|
||||
let root = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !root.exists() || !root.is_dir() {
|
||||
anyhow::bail!("path '{}' is not a valid directory", rel);
|
||||
}
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let full_pattern = root.join(&pat_str).display().to_string();
|
||||
builder.add(GlobBuilder::new(&full_pattern).build()
|
||||
.map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?);
|
||||
let glob_set = builder.build()
|
||||
.map_err(|e| anyhow!("failed to build glob set: {}", e))?;
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
for entry in Walk::new(&root).flatten() {
|
||||
let p = entry.path();
|
||||
if glob_set.is_match(p) {
|
||||
let rel_path = p.strip_prefix(&root)
|
||||
.unwrap_or(p)
|
||||
.display()
|
||||
.to_string();
|
||||
matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" }));
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("no files match '{}' in {}", pat_str, rel));
|
||||
}
|
||||
Ok(matches.join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::Result;
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct SeqThink;
|
||||
|
||||
impl Tool for SeqThink {
|
||||
fn name(&self) -> &'static str {
|
||||
"seqthink"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Record a step in sequential thinking. Use this to show your reasoning chain step by step."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thought": {
|
||||
"type": "string",
|
||||
"description": "The current thinking step content"
|
||||
}
|
||||
},
|
||||
"required": ["thought"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct Bash;
|
||||
|
||||
impl Tool for Bash {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute a shell command via bash -c with catastrophic guard protection"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to execute"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description of what the command does"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let cmd = args.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: command"))?
|
||||
.to_string();
|
||||
let _description = args.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120000).min(600000);
|
||||
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
|
||||
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
|
||||
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("failed to spawn bash: {}", e))?;
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let output = child.wait_with_output()
|
||||
.map_err(|e| anyhow!("failed to collect output: {}", e))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
|
||||
let trimmed = combined.trim().to_string();
|
||||
if status.success() {
|
||||
return Ok(if trimmed.is_empty() {
|
||||
format!("Command completed in {:.2}s (exit code 0)", elapsed)
|
||||
} else {
|
||||
format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed)
|
||||
});
|
||||
} else {
|
||||
return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("command timed out after {}ms", timeout_ms);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("failed to wait for command: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn check_credential_read(cmd: &str) -> Result<()> {
|
||||
let patterns = [
|
||||
"cat ~/.ssh",
|
||||
"cat /home/",
|
||||
".ssh/id_rsa",
|
||||
".ssh/id_ed25519",
|
||||
".ssh/id_ecdsa",
|
||||
".ssh/id_dsa",
|
||||
".ssh/authorized_keys",
|
||||
".ssh/known_hosts",
|
||||
".git-credentials",
|
||||
".netrc",
|
||||
"aws/credentials",
|
||||
"gcloud/credentials",
|
||||
".config/gcloud",
|
||||
".config/gh",
|
||||
"token=",
|
||||
"secret=",
|
||||
"api_key=",
|
||||
"api-key=",
|
||||
"password=",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
for pattern in &patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
anyhow::bail!("credential read blocked: '{}'", pattern);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
let patterns = [
|
||||
"force-push",
|
||||
"reset --hard",
|
||||
"clean -fdx",
|
||||
"clean -fd",
|
||||
"clean -fX",
|
||||
"clean -fx",
|
||||
"branch -D",
|
||||
"branch --delete --force",
|
||||
"checkout --force",
|
||||
"switch -f",
|
||||
"restore --force",
|
||||
"stash drop",
|
||||
"stash clear",
|
||||
"tag -d",
|
||||
"tag --delete",
|
||||
"update-ref -d",
|
||||
"filter-branch",
|
||||
"gc --prune",
|
||||
"gc --aggressive",
|
||||
"push --delete",
|
||||
"push --force",
|
||||
"push origin :",
|
||||
"push +refs",
|
||||
"push --mirror",
|
||||
"push --tags --force",
|
||||
];
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
for pattern in &patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
anyhow::bail!("destructive git operation blocked: '{}'", pattern);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod credentials;
|
||||
pub mod git;
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct WorkflowRun;
|
||||
|
||||
impl Tool for WorkflowRun {
|
||||
fn name(&self) -> &'static str {
|
||||
"workflow_run"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute a workflow script by delegating to the workflow engine"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "Workflow script content or path to a workflow file"
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"description": "Optional arguments passed to the workflow script"
|
||||
}
|
||||
},
|
||||
"required": ["script"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _script = args.get("script")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
||||
let _workflow_args = args.get("args");
|
||||
Ok("workflow delegated to workflow engine".to_string())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user