Files
CxIDE/Agent/Tools/FileOpsTools.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge)
- Agentic AI loop: LLM plans → tool calls → execution → feedback loop
- 15 agent tools: file ops, terminal, git, xcode build, code intel
- 7 persistent terminal tools with background session management
- Chat sidebar with agent step rendering and auto-apply
- NVIDIA NIM API integration (Llama 3.3 70B default)
- OpenAI tool_calls format with prompt-based fallback
- Code editor with syntax highlighting and multi-tab support
- File tree, console view, terminal view
- Git integration and workspace management
2026-04-21 16:05:52 -05:00

404 lines
19 KiB
Swift

// FileOpsTools.swift
// CxSwiftAgent File Operations MCP Tools
//
// 10 tools: file_read, file_write, file_patch, file_search, file_list,
// file_tree, file_info, file_delete, file_move, file_find
import Foundation
enum FileOpsTools {
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
// file_read
server.registerTool(
"file_read",
description: "Read file contents. Supports optional line range (start_line, end_line, 1-indexed).",
inputSchema: [
"type": "object",
"required": ["path"],
"properties": [
"path": ["type": "string", "description": "File path within workspace."],
"start_line": ["type": "integer", "description": "First line (1-indexed, default: 1)."],
"end_line": ["type": "integer", "description": "Last line (1-indexed, default: EOF)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
return err("Path not allowed or outside workspace")
}
let fm = FileManager.default
guard fm.fileExists(atPath: path) else { return err("File not found") }
guard let attrs = try? fm.attributesOfItem(atPath: path),
let size = attrs[.size] as? Int, size <= config.maxFileSize else {
return err("File too large (max \(config.maxFileSize / 1_048_576)MB)")
}
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
return err("Cannot read file (binary or encoding error)")
}
let lines = content.components(separatedBy: "\n")
let start = max(0, (args["start_line"] as? Int ?? 1) - 1)
let end = min(lines.count - 1, (args["end_line"] as? Int ?? lines.count) - 1)
var output = ""
for i in start...max(start, end) {
output += String(format: "%4d | %@\n", i + 1, lines[i])
}
Task { await memory.recordFileAccess(path: path, action: "read") }
return ok(output)
}
// file_write
server.registerTool(
"file_write",
description: "Write content to a file. Creates parent directories if needed.",
inputSchema: [
"type": "object",
"required": ["path", "content"],
"properties": [
"path": ["type": "string", "description": "File path within workspace."],
"content": ["type": "string", "description": "Full file content to write."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
return err("Path not allowed or outside workspace")
}
guard let content = args["content"] as? String else {
return err("Missing content parameter")
}
let dir = (path as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
let lines = content.components(separatedBy: "\n").count
Task { await memory.recordFileAccess(path: path, action: "write") }
return ok("Written \(content.utf8.count) bytes (\(lines) lines) to \(args["path"] as? String ?? path)")
} catch {
return err("Write failed: \(error.localizedDescription)")
}
}
// file_patch
server.registerTool(
"file_patch",
description: "Apply search-and-replace patch to a file. Finds exact match of 'search' and replaces with 'replace'.",
inputSchema: [
"type": "object",
"required": ["path", "search", "replace"],
"properties": [
"path": ["type": "string", "description": "File path."],
"search": ["type": "string", "description": "Exact text to find."],
"replace": ["type": "string", "description": "Replacement text."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
return err("Path not allowed or outside workspace")
}
guard let search = args["search"] as? String, !search.isEmpty else {
return err("Missing search parameter")
}
let replace = args["replace"] as? String ?? ""
guard var content = try? String(contentsOfFile: path, encoding: .utf8) else {
return err("Cannot read file")
}
let occurrences = content.components(separatedBy: search).count - 1
guard occurrences > 0 else {
return err("Search text not found in file")
}
content = content.replacingOccurrences(of: search, with: replace)
do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
Task { await memory.recordFileAccess(path: path, action: "patch") }
return ok("Patched \(occurrences) occurrence(s) in \(args["path"] as? String ?? path)")
} catch {
return err("Write failed: \(error.localizedDescription)")
}
}
// file_search
server.registerTool(
"file_search",
description: "Search files by content (grep). Returns matching lines with file paths and line numbers.",
inputSchema: [
"type": "object",
"required": ["pattern"],
"properties": [
"pattern": ["type": "string", "description": "Search pattern (substring match)."],
"path": ["type": "string", "description": "Directory to search (default: workspace root)."],
"include": ["type": "string", "description": "File extension filter (e.g. '.swift')."],
"max_results": ["type": "integer", "description": "Maximum results (default: 50)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let pattern = args["pattern"] as? String ?? ""
guard !pattern.isEmpty else { return err("Missing pattern") }
let searchPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let includeExt = args["include"] as? String
let maxResults = args["max_results"] as? Int ?? 50
var results: [String] = []
let fm = FileManager.default
guard let enumerator = fm.enumerator(atPath: searchPath) else {
return err("Cannot enumerate directory")
}
while let file = enumerator.nextObject() as? String {
guard results.count < maxResults else { break }
if config.isExcluded(file) { enumerator.skipDescendants(); continue }
if let ext = includeExt, !file.hasSuffix(ext) { continue }
let fullPath = (searchPath as NSString).appendingPathComponent(file)
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue }
let lines = content.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
guard results.count < maxResults else { break }
if line.localizedCaseInsensitiveContains(pattern) {
results.append("\(file):\(i + 1): \(line.trimmingCharacters(in: .whitespaces))")
}
}
}
return ok(results.isEmpty ? "No matches found" : results.joined(separator: "\n"))
}
// file_list
server.registerTool(
"file_list",
description: "List directory contents with file sizes and types.",
inputSchema: [
"type": "object",
"properties": [
"path": ["type": "string", "description": "Directory path (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let dirPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let fm = FileManager.default
guard let items = try? fm.contentsOfDirectory(atPath: dirPath) else {
return err("Cannot list directory")
}
var output = ""
for item in items.sorted() {
if item.hasPrefix(".") && item != ".env" { continue }
let fullPath = (dirPath as NSString).appendingPathComponent(item)
var isDir: ObjCBool = false
fm.fileExists(atPath: fullPath, isDirectory: &isDir)
if isDir.boolValue {
output += " \(item)/\n"
} else {
let size = (try? fm.attributesOfItem(atPath: fullPath)[.size] as? Int) ?? 0
output += " \(item) (\(formatSize(size)))\n"
}
}
return ok(output.isEmpty ? "(empty directory)" : output)
}
// file_tree
server.registerTool(
"file_tree",
description: "Recursive directory tree with depth limit.",
inputSchema: [
"type": "object",
"properties": [
"path": ["type": "string", "description": "Root directory (default: workspace root)."],
"max_depth": ["type": "integer", "description": "Maximum depth (default: 3)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let rootPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let maxDepth = args["max_depth"] as? Int ?? 3
let tree = buildTree(rootPath, depth: 0, maxDepth: maxDepth, config: config)
return ok(tree)
}
// file_info
server.registerTool(
"file_info",
description: "File metadata: size, modification date, permissions, type.",
inputSchema: [
"type": "object",
"required": ["path"],
"properties": [
"path": ["type": "string", "description": "File path."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
return err("Path not allowed")
}
let fm = FileManager.default
guard let attrs = try? fm.attributesOfItem(atPath: path) else {
return err("File not found")
}
var isDir: ObjCBool = false
fm.fileExists(atPath: path, isDirectory: &isDir)
let info: [String: Any] = [
"path": args["path"] as? String ?? path,
"type": isDir.boolValue ? "directory" : "file",
"size": attrs[.size] as? Int ?? 0,
"modified": (attrs[.modificationDate] as? Date)?.description ?? "unknown",
"permissions": String(format: "%o", attrs[.posixPermissions] as? Int ?? 0),
]
return ok(JSON.serialize(info, pretty: true))
}
// file_delete
server.registerTool(
"file_delete",
description: "Delete a file. Requires sandbox mode to be disabled.",
inputSchema: [
"type": "object",
"required": ["path"],
"properties": [
"path": ["type": "string", "description": "File path to delete."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
if config.sandboxMode { return err("Delete disabled in sandbox mode") }
guard let path = config.resolvePath(args["path"] as? String ?? "") else {
return err("Path not allowed")
}
do {
try FileManager.default.removeItem(atPath: path)
Task { await memory.recordFileAccess(path: path, action: "delete") }
return ok("Deleted: \(args["path"] as? String ?? path)")
} catch {
return err("Delete failed: \(error.localizedDescription)")
}
}
// file_move
server.registerTool(
"file_move",
description: "Move or rename a file.",
inputSchema: [
"type": "object",
"required": ["source", "destination"],
"properties": [
"source": ["type": "string", "description": "Source file path."],
"destination": ["type": "string", "description": "Destination file path."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let src = config.resolvePath(args["source"] as? String ?? ""),
let dst = config.resolvePath(args["destination"] as? String ?? "") else {
return err("Path not allowed")
}
let dstDir = (dst as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dstDir, withIntermediateDirectories: true)
do {
try FileManager.default.moveItem(atPath: src, toPath: dst)
Task { await memory.recordFileAccess(path: dst, action: "move") }
return ok("Moved to \(args["destination"] as? String ?? dst)")
} catch {
return err("Move failed: \(error.localizedDescription)")
}
}
// file_find
server.registerTool(
"file_find",
description: "Find files by name pattern (substring match).",
inputSchema: [
"type": "object",
"required": ["pattern"],
"properties": [
"pattern": ["type": "string", "description": "Filename pattern (substring)."],
"path": ["type": "string", "description": "Search directory (default: workspace root)."],
"max_results": ["type": "integer", "description": "Maximum results (default: 50)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let pattern = args["pattern"] as? String ?? ""
let searchPath = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let maxResults = args["max_results"] as? Int ?? 50
var results: [String] = []
let fm = FileManager.default
guard let enumerator = fm.enumerator(atPath: searchPath) else {
return err("Cannot enumerate directory")
}
while let file = enumerator.nextObject() as? String {
guard results.count < maxResults else { break }
if config.isExcluded(file) { enumerator.skipDescendants(); continue }
if file.localizedCaseInsensitiveContains(pattern) {
results.append(file)
}
}
return ok(results.isEmpty ? "No files found" : results.joined(separator: "\n"))
}
}
// MARK: - Helpers
private static func ok(_ text: String) -> [[String: Any]] {
[["type": "text", "text": text]]
}
private static func err(_ message: String) -> [[String: Any]] {
[["type": "text", "text": "Error: \(message)"]]
}
private static func formatSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1_048_576 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
}
private static func buildTree(_ path: String, depth: Int, maxDepth: Int, config: AgentConfig, prefix: String = "") -> String {
guard depth < maxDepth else { return "" }
let fm = FileManager.default
guard let items = try? fm.contentsOfDirectory(atPath: path).sorted() else { return "" }
var output = ""
for (i, item) in items.enumerated() {
if item.hasPrefix(".") { continue }
if config.isExcluded(item) { continue }
let isLast = i == items.count - 1
let connector = isLast ? "└── " : "├── "
let fullPath = (path as NSString).appendingPathComponent(item)
var isDir: ObjCBool = false
fm.fileExists(atPath: fullPath, isDirectory: &isDir)
if isDir.boolValue {
output += "\(prefix)\(connector)\(item)/\n"
let childPrefix = prefix + (isLast ? " " : "")
output += buildTree(fullPath, depth: depth + 1, maxDepth: maxDepth, config: config, prefix: childPrefix)
} else {
output += "\(prefix)\(connector)\(item)\n"
}
}
return output
}
}