Files
CxIDE/Agent/Tools/ProjectTools.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

313 lines
14 KiB
Swift

// ProjectTools.swift
// CxSwiftAgent Project Management MCP Tools
//
// 5 tools: project_detect, project_run, project_deps, project_config, project_create
import Foundation
enum ProjectTools {
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
// project_detect
server.registerTool(
"project_detect",
description: "Detect project type, language, build system, and key files.",
inputSchema: [
"type": "object",
"properties": [
"path": ["type": "string", "description": "Project root (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let fm = FileManager.default
var info: [String: Any] = ["path": root]
var languages: [String] = []
var buildSystems: [String] = []
var keyFiles: [String] = []
let detectors: [(String, String, String)] = [
("Package.swift", "swift", "spm"),
("Makefile", "", "make"),
("CMakeLists.txt", "c++", "cmake"),
("package.json", "javascript", "npm"),
("composer.json", "php", "composer"),
("Cargo.toml", "rust", "cargo"),
("go.mod", "go", "go-modules"),
("pyproject.toml", "python", "pip"),
("requirements.txt", "python", "pip"),
("Gemfile", "ruby", "bundler"),
("build.gradle", "java", "gradle"),
("pom.xml", "java", "maven"),
("cpanfile", "perl", "cpanm"),
]
for (file, lang, build) in detectors {
let path = (root as NSString).appendingPathComponent(file)
if fm.fileExists(atPath: path) {
keyFiles.append(file)
if !lang.isEmpty && !languages.contains(lang) { languages.append(lang) }
if !buildSystems.contains(build) { buildSystems.append(build) }
}
}
// Xcode detection
if let items = try? fm.contentsOfDirectory(atPath: root) {
for item in items {
if item.hasSuffix(".xcodeproj") || item.hasSuffix(".xcworkspace") {
keyFiles.append(item)
if !languages.contains("swift") { languages.append("swift") }
if !buildSystems.contains("xcode") { buildSystems.append("xcode") }
}
}
}
info["languages"] = languages
info["build_systems"] = buildSystems
info["key_files"] = keyFiles
info["type"] = languages.first ?? "unknown"
return ok(JSON.serialize(info, pretty: true))
}
// project_run
server.registerTool(
"project_run",
description: "Run project build/test/lint commands based on detected project type.",
inputSchema: [
"type": "object",
"required": ["action"],
"properties": [
"action": ["type": "string", "description": "Action: build, test, lint, run, clean."],
"path": ["type": "string", "description": "Project root (default: workspace root)."],
] as [String: Any],
] as [String: Any]
) { args in
let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let action = args["action"] as? String ?? "build"
let fm = FileManager.default
// Detect project type and run appropriate command
if fm.fileExists(atPath: (root as NSString).appendingPathComponent("Package.swift")) {
switch action {
case "build": return runCmd("swift", ["build"], in: root)
case "test": return runCmd("swift", ["test"], in: root)
case "clean": return runCmd("swift", ["package", "clean"], in: root)
case "run": return runCmd("swift", ["run"], in: root)
default: return err("Unknown action: \(action)")
}
} else if fm.fileExists(atPath: (root as NSString).appendingPathComponent("package.json")) {
switch action {
case "build": return runCmd("npm", ["run", "build"], in: root)
case "test": return runCmd("npm", ["test"], in: root)
case "lint": return runCmd("npm", ["run", "lint"], in: root)
case "run": return runCmd("npm", ["start"], in: root)
default: return err("Unknown action: \(action)")
}
} else if fm.fileExists(atPath: (root as NSString).appendingPathComponent("Makefile")) {
switch action {
case "build": return runCmd("make", [], in: root)
case "test": return runCmd("make", ["test"], in: root)
case "clean": return runCmd("make", ["clean"], in: root)
default: return err("Unknown action: \(action)")
}
} else if fm.fileExists(atPath: (root as NSString).appendingPathComponent("composer.json")) {
switch action {
case "test": return runCmd("vendor/bin/phpunit", [], in: root)
case "lint": return runCmd("vendor/bin/phpcs", ["src/"], in: root)
default: return err("Unknown action: \(action)")
}
}
// Check for Xcode project
let xcodeProjects = (try? fm.contentsOfDirectory(atPath: root))?.filter { $0.hasSuffix(".xcodeproj") } ?? []
if let xcodeProj = xcodeProjects.first {
let fullProj = (root as NSString).appendingPathComponent(xcodeProj)
switch action {
case "build": return runCmd("/usr/bin/xcodebuild", ["build", "-project", fullProj, "-quiet"], in: root)
case "test": return runCmd("/usr/bin/xcodebuild", ["test", "-project", fullProj, "-quiet"], in: root)
case "clean": return runCmd("/usr/bin/xcodebuild", ["clean", "-project", fullProj, "-quiet"], in: root)
case "run": return runCmd("/usr/bin/xcodebuild", ["build", "-project", fullProj, "-quiet"], in: root)
default: return err("Unknown action: \(action)")
}
}
return err("Cannot detect project type at \(root)")
}
// project_deps
server.registerTool(
"project_deps",
description: "List project dependencies from manifest files.",
inputSchema: [
"type": "object",
"properties": [
"path": ["type": "string", "description": "Project root (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let fm = FileManager.default
// Try Package.swift
let spmPath = (root as NSString).appendingPathComponent("Package.swift")
if fm.fileExists(atPath: spmPath) {
return runCmd("swift", ["package", "show-dependencies"], in: root)
}
// Try package.json
let npmPath = (root as NSString).appendingPathComponent("package.json")
if fm.fileExists(atPath: npmPath) {
if let content = try? String(contentsOfFile: npmPath, encoding: .utf8) {
return ok(content)
}
}
// Try composer.json
let composerPath = (root as NSString).appendingPathComponent("composer.json")
if fm.fileExists(atPath: composerPath) {
if let content = try? String(contentsOfFile: composerPath, encoding: .utf8) {
return ok(content)
}
}
return err("No dependency manifest found")
}
// project_config
server.registerTool(
"project_config",
description: "Read or list project configuration files.",
inputSchema: [
"type": "object",
"properties": [
"path": ["type": "string", "description": "Project root (default: workspace root)."],
"file": ["type": "string", "description": "Specific config file to read."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let root = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let fm = FileManager.default
if let file = args["file"] as? String {
let filePath = (root as NSString).appendingPathComponent(file)
guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else {
return err("Cannot read \(file)")
}
return ok(content)
}
// List config files
let configFiles = [
".env", ".env.example", ".editorconfig", ".gitignore",
".swiftlint.yml", ".swift-format", "tsconfig.json",
".eslintrc.json", "phpunit.xml", "pyproject.toml",
"Dockerfile", "docker-compose.yml", ".github/workflows",
]
var found: [String] = []
for file in configFiles {
let path = (root as NSString).appendingPathComponent(file)
if fm.fileExists(atPath: path) {
found.append(file)
}
}
return ok(found.isEmpty ? "No config files found" : "Config files:\n" + found.map { " \($0)" }.joined(separator: "\n"))
}
// project_create
server.registerTool(
"project_create",
description: "Create a new project from template (Swift Package, Node, etc.).",
inputSchema: [
"type": "object",
"required": ["name", "type"],
"properties": [
"name": ["type": "string", "description": "Project name."],
"type": ["type": "string", "description": "Project type: swift-package, swift-executable, node, python."],
"path": ["type": "string", "description": "Parent directory (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
if config.sandboxMode { return err("Project creation disabled in sandbox mode") }
guard let name = args["name"] as? String, !name.isEmpty else {
return err("Missing project name")
}
let projectType = args["type"] as? String ?? "swift-executable"
let parent = config.resolvePath(args["path"] as? String ?? ".") ?? config.workspaceRoot
let projectPath = (parent as NSString).appendingPathComponent(name)
let fm = FileManager.default
try? fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true)
switch projectType {
case "swift-package", "swift-executable":
let pkgType = projectType == "swift-package" ? "library" : "executable"
return runCmd("swift", ["package", "init", "--type", pkgType, "--name", name], in: projectPath)
case "node":
return runCmd("npm", ["init", "-y"], in: projectPath)
default:
return err("Unknown project type: \(projectType). Supported: swift-package, swift-executable, node, python")
}
}
}
// MARK: - Helpers
private static func runCmd(_ executable: String, _ args: [String], in directory: String) -> [[String: Any]] {
let process = Process()
let exePath: String
if executable.hasPrefix("/") || executable.hasPrefix(".") {
exePath = executable
} else if executable.contains("/") {
exePath = (directory as NSString).appendingPathComponent(executable)
} else {
exePath = "/usr/bin/\(executable)"
}
process.executableURL = URL(fileURLWithPath: exePath)
process.arguments = args
process.currentDirectoryURL = URL(fileURLWithPath: directory)
process.environment = ProcessInfo.processInfo.environment
let pipe = Pipe()
let errPipe = Pipe()
process.standardOutput = pipe
process.standardError = errPipe
do {
try process.run()
process.waitUntilExit()
let outData = pipe.fileHandleForReading.readDataToEndOfFile()
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outData, encoding: .utf8) ?? ""
let errOutput = String(data: errData, encoding: .utf8) ?? ""
if process.terminationStatus != 0 {
return err("Exit \(process.terminationStatus): \(errOutput.isEmpty ? output : errOutput)")
}
return ok(output.isEmpty ? "(success)" : output)
} catch {
return err("Failed to run \(executable): \(error.localizedDescription)")
}
}
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)"]]
}
}