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

184 lines
7.3 KiB
Swift

// AgentConfig.swift
// CxSwiftAgent Immutable configuration for the MCP coding agent.
//
// Mirrors CxCodingAgent::Config (Perl) with Swift patterns:
// - Environment variable loading
// - Config file parsing (cx-agent.conf)
// - Path traversal protection
// - Profile validation
import Foundation
struct AgentConfig: Sendable {
let host: String
let port: Int
let workspaceRoot: String
let logLevel: String
let maxFileSize: Int
let llmEndpoint: String
let llmModel: String
let nvidiaApiKey: String
let xcodePath: String
let sandboxMode: Bool
let profile: String
let authToken: String
let transport: String
let allowedOrigins: [String]
let excludePatterns: [String]
let rateLimit: Int
let maxRequestSize: Int
let toolTimeout: Int
let giteaUrl: String
let giteaToken: String
let giteaOrg: String
static let defaultExcludePatterns = [
".git", "node_modules", "vendor", "__pycache__",
".DS_Store", ".build", "DerivedData", ".next",
"dist", "build", "coverage", ".nyc_output",
]
static func fromEnvironment() -> AgentConfig {
let env = ProcessInfo.processInfo.environment
let ws = env["CX_WORKSPACE"] ?? env["PWD"] ?? FileManager.default.currentDirectoryPath
// Load config file first
let fileCfg = loadConfigFile(workspaceRoot: ws)
// Environment overrides
let transport: String = {
if CommandLine.arguments.contains("--stdio") { return "stdio" }
return env["CX_TRANSPORT"] ?? fileCfg["transport"] ?? "http"
}()
let profile = (env["CX_PROFILE"] ?? fileCfg["profile"] ?? "development").lowercased()
let validProfiles: Set<String> = ["development", "staging", "production"]
return AgentConfig(
host: env["MCP_HOST"] ?? fileCfg["host"] ?? "0.0.0.0",
port: Int(env["MCP_PORT"] ?? fileCfg["port"] ?? "") ?? 5100,
workspaceRoot: ws,
logLevel: {
let level = (env["MCP_LOG_LEVEL"] ?? fileCfg["log_level"] ?? "info").lowercased()
if profile == "production" && level == "debug" { return "warn" }
return level
}(),
maxFileSize: Int(env["CX_MAX_FILE_SIZE"] ?? fileCfg["max_file_size"] ?? "") ?? 10_485_760,
llmEndpoint: env["CX_LLM_ENDPOINT"] ?? fileCfg["llm_endpoint"] ?? "http://localhost:8080",
llmModel: env["CX_LLM_MODEL"] ?? fileCfg["llm_model"] ?? "cx-model-2.5",
nvidiaApiKey: env["NVIDIA_API_KEY"] ?? fileCfg["nvidia_api_key"] ?? "",
xcodePath: env["CX_XCODE_PATH"] ?? fileCfg["xcode_path"] ?? "/usr/bin/xcodebuild",
sandboxMode: (env["CX_SANDBOX"] ?? fileCfg["sandbox_mode"] ?? "0") == "1",
profile: validProfiles.contains(profile) ? profile : "development",
authToken: env["CX_AUTH_TOKEN"] ?? fileCfg["auth_token"] ?? "",
transport: (transport == "http" || transport == "stdio") ? transport : "http",
allowedOrigins: {
let raw = env["CX_ALLOWED_ORIGINS"] ?? fileCfg["allowed_origins"] ?? "*"
return raw.isEmpty ? ["*"] : raw.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}(),
excludePatterns: {
if let raw = env["CX_EXCLUDE_PATTERNS"] ?? fileCfg["exclude_patterns"] {
return raw.isEmpty ? [] : raw.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
return defaultExcludePatterns
}(),
rateLimit: Int(env["CX_RATE_LIMIT"] ?? fileCfg["rate_limit"] ?? "") ?? 0,
maxRequestSize: Int(env["CX_MAX_REQUEST_SIZE"] ?? "") ?? 16_777_216,
toolTimeout: Int(env["CX_TOOL_TIMEOUT"] ?? fileCfg["tool_timeout"] ?? "") ?? 300,
giteaUrl: env["CXGIT_API_URL"] ?? fileCfg["gitea_url"] ?? "https://git.cxllm-studio.com",
giteaToken: env["CXGIT_API_TOKEN"] ?? fileCfg["gitea_token"] ?? "",
giteaOrg: env["CXGIT_ORG"] ?? fileCfg["gitea_org"] ?? "CxAI-LLM"
)
}
// MARK: - Path Resolution
func resolvePath(_ path: String) -> String? {
guard !path.isEmpty else { return nil }
let abs: String
if path.hasPrefix("/") {
abs = path
} else {
abs = (workspaceRoot as NSString).appendingPathComponent(path)
}
let resolved = (abs as NSString).standardizingPath
// Path traversal protection
guard resolved == workspaceRoot || resolved.hasPrefix(workspaceRoot + "/") else {
return nil
}
return resolved
}
func isExcluded(_ path: String) -> Bool {
for pattern in excludePatterns {
if path.contains(pattern) { return true }
}
return false
}
var hasLLM: Bool { !nvidiaApiKey.isEmpty || !llmEndpoint.isEmpty }
var hasAuth: Bool { !authToken.isEmpty }
var hasGitea: Bool { !giteaUrl.isEmpty && !giteaToken.isEmpty }
var isProduction: Bool { profile == "production" }
func isOriginAllowed(_ origin: String?) -> Bool {
guard let origin else { return true }
return allowedOrigins.contains("*") || allowedOrigins.contains(origin)
}
func toDict() -> [String: Any] {
[
"host": host,
"port": port,
"workspace_root": workspaceRoot,
"log_level": logLevel,
"max_file_size": maxFileSize,
"llm_endpoint": llmEndpoint,
"llm_model": llmModel,
"has_nvidia_key": !nvidiaApiKey.isEmpty,
"xcode_path": xcodePath,
"sandbox_mode": sandboxMode,
"profile": profile,
"has_auth": hasAuth,
"has_gitea": hasGitea,
"gitea_url": giteaUrl,
"gitea_org": giteaOrg,
"transport": transport,
"allowed_origins": allowedOrigins,
"exclude_patterns": excludePatterns,
"rate_limit": rateLimit,
"tool_timeout": toolTimeout,
]
}
// MARK: - Config File Loader
private static func loadConfigFile(workspaceRoot: String) -> [String: String] {
var cfg: [String: String] = [:]
for name in ["cx-agent.conf", ".cx-agent.conf"] {
let path = (workspaceRoot as NSString).appendingPathComponent(name)
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
var val = String(parts[1]).trimmingCharacters(in: .whitespaces)
// Strip quotes
if (val.hasPrefix("\"") && val.hasSuffix("\"")) ||
(val.hasPrefix("'") && val.hasSuffix("'")) {
val = String(val.dropFirst().dropLast())
}
cfg[key] = val
}
}
break // only load first found
}
return cfg
}
}