// WebsiteTools.swift // CxIDE Agent — Website building, deployment, and DNS management tools // // 8 tools: website_create, website_preview, website_deploy, website_templates, // dns_list, dns_set, domain_check, credential_set import Foundation enum WebsiteTools { static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) { // ── website_create ──────────────────────────────────────────── server.registerTool( "website_create", description: "Create a new website project from a template. Templates: blank, landing, portfolio, blog, docs, dashboard.", inputSchema: [ "type": "object", "required": ["name", "template"], "properties": [ "name": ["type": "string", "description": "Project name (used as directory name)."], "template": ["type": "string", "description": "Template ID: blank, landing, portfolio, blog, docs, dashboard."], "directory": ["type": "string", "description": "Parent directory (default: workspace root)."], ] as [String: Any], ] as [String: Any] ) { args in let name = args["name"] as? String ?? "my-website" let templateId = args["template"] as? String ?? "blank" let dirPath = args["directory"] as? String let parentDir: URL if let path = dirPath, let resolved = config.resolvePath(path) { parentDir = URL(fileURLWithPath: resolved) } else { parentDir = URL(fileURLWithPath: config.workspaceRoot) } guard let template = WebsiteTemplate.allTemplates.first(where: { $0.id == templateId }) else { return err("Unknown template '\(templateId)'. Available: blank, landing, portfolio, blog, docs, dashboard") } do { let service = await WebsiteService() let project = try await service.createProject(name: name, template: template, at: parentDir) let files = template.generateFiles(name) let fileList = files.map { " \($0.path)" }.joined(separator: "\n") return ok("Created website '\(name)' using '\(template.name)' template at:\n\(project.directory.path)\n\nFiles:\n\(fileList)") } catch { return err(error.localizedDescription) } } // ── website_templates ───────────────────────────────────────── server.registerTool( "website_templates", description: "List available website templates with descriptions.", inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { _ in var output = "Available Website Templates:\n\n" for t in WebsiteTemplate.allTemplates { output += " \(t.id) — \(t.name)\n \(t.description)\n Category: \(t.category)\n\n" } return ok(output) } // ── website_preview ─────────────────────────────────────────── server.registerTool( "website_preview", description: "Start or stop a local preview server for a website directory.", inputSchema: [ "type": "object", "required": ["action"], "properties": [ "action": ["type": "string", "description": "start or stop"], "directory": ["type": "string", "description": "Website directory path (for start)."], "port": ["type": "integer", "description": "Port number (default: 8080)."], ] as [String: Any], ] as [String: Any] ) { args in let action = args["action"] as? String ?? "start" if action == "stop" { let service = await WebsiteService() await service.stopPreview() return ok("Preview server stopped.") } let dirPath = args["directory"] as? String ?? config.workspaceRoot guard let resolved = config.resolvePath(dirPath) else { return err("Invalid directory path") } let port = args["port"] as? Int ?? 8080 let dir = URL(fileURLWithPath: resolved) do { let service = await WebsiteService() try await service.startPreview(projectDir: dir, port: port) return ok("Preview server started at http://localhost:\(port)\nServing: \(dir.path)") } catch { return err("Failed to start preview: \(error.localizedDescription)") } } // ── website_deploy ──────────────────────────────────────────── server.registerTool( "website_deploy", description: "Deploy a website to a free hosting provider. Providers: netlify, surge, github-pages, vercel, rsync.", inputSchema: [ "type": "object", "required": ["directory", "provider"], "properties": [ "directory": ["type": "string", "description": "Website directory to deploy."], "provider": ["type": "string", "description": "Hosting provider: netlify, surge, github-pages, vercel, rsync."], "site_name": ["type": "string", "description": "Site/project name for the provider."], "domain": ["type": "string", "description": "Custom domain to use."], "git_repo": ["type": "string", "description": "Git repository URL (for github-pages)."], "git_username": ["type": "string", "description": "GitHub username (for github-pages URL)."], "rsync_target": ["type": "string", "description": "rsync target (e.g., user@host:/path)."], ] as [String: Any], ] as [String: Any] ) { args in guard let dirPath = args["directory"] as? String, let resolved = config.resolvePath(dirPath) else { return err("Invalid directory path") } let providerStr = args["provider"] as? String ?? "netlify" let provider: WebsiteDeployService.Provider switch providerStr.lowercased() { case "netlify": provider = .netlify case "surge", "surge.sh": provider = .surge case "github-pages", "gh-pages", "github": provider = .githubPages case "vercel": provider = .vercel case "rsync", "sftp": provider = .rsync default: return err("Unknown provider '\(providerStr)'. Available: netlify, surge, github-pages, vercel, rsync") } // Check CLI availability let deployService = WebsiteDeployService.shared if !deployService.checkCLI(for: provider) { return err("\(provider.rawValue) CLI not found. Install it first.") } let options = DeployOptions( siteName: args["site_name"] as? String, domain: args["domain"] as? String, gitRepo: args["git_repo"] as? String, gitUsername: args["git_username"] as? String, rsyncTarget: args["rsync_target"] as? String ) let result = await deployService.deploy( directory: URL(fileURLWithPath: resolved), provider: provider, options: options ) if result.success { var msg = "Deployed to \(provider.rawValue) successfully!" if let url = result.url { msg += "\nURL: \(url)" } msg += "\nDuration: \(String(format: "%.1f", result.duration))s" return ok(msg) } else { return err("Deploy failed: \(result.output)") } } // ── dns_list ────────────────────────────────────────────────── server.registerTool( "dns_list", description: "List DNS records for a domain using GoDaddy API.", inputSchema: [ "type": "object", "required": ["domain"], "properties": [ "domain": ["type": "string", "description": "Domain name (e.g., example.com)."], "type": ["type": "string", "description": "Record type filter: A, AAAA, CNAME, MX, TXT, NS, etc."], "environment": ["type": "string", "description": "ote or production (default: ote)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let domain = args["domain"] as? String ?? "" guard !domain.isEmpty else { return err("Domain name required") } let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote let service = GoDaddyService(environment: env) do { let records: [DNSRecord] if let typeStr = args["type"] as? String, let recordType = DNSRecordType(rawValue: typeStr.uppercased()) { records = try await service.getDNSRecords(domain: domain, type: recordType) } else { records = try await service.getDNSRecords(domain: domain) } if records.isEmpty { return ok("No DNS records found for \(domain)") } var output = "DNS Records for \(domain) (\(env.rawValue)):\n\n" output += String(format: "%-8s %-20s %-40s %s\n", "Type", "Name", "Data", "TTL") output += String(repeating: "-", count: 80) + "\n" for r in records { output += String(format: "%-8s %-20s %-40s %d\n", r.type.rawValue, r.name, r.data, r.ttl ?? 0) } return ok(output) } catch { return err(error.localizedDescription) } } // ── dns_set ─────────────────────────────────────────────────── server.registerTool( "dns_set", description: "Set DNS records for a domain via GoDaddy API. Can point to hosting providers.", inputSchema: [ "type": "object", "required": ["domain", "action"], "properties": [ "domain": ["type": "string", "description": "Domain name."], "action": ["type": "string", "description": "Action: set-a, set-cname, netlify, github-pages, vercel, cloudflare."], "name": ["type": "string", "description": "Record name (default: @)."], "value": ["type": "string", "description": "Record value (IP or target)."], "ttl": ["type": "integer", "description": "TTL in seconds (default: 600)."], "github_username": ["type": "string", "description": "GitHub username (for github-pages)."], "netlify_subdomain": ["type": "string", "description": "Netlify subdomain (for netlify)."], "cloudflare_project": ["type": "string", "description": "Cloudflare project name."], "environment": ["type": "string", "description": "ote or production (default: ote)."], ] as [String: Any], ] as [String: Any] ) { args in let domain = args["domain"] as? String ?? "" let action = args["action"] as? String ?? "" guard !domain.isEmpty, !action.isEmpty else { return err("domain and action required") } let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote let service = GoDaddyService(environment: env) do { switch action.lowercased() { case "set-a": guard let ip = args["value"] as? String else { return err("IP address required (value)") } let name = args["name"] as? String ?? "@" let ttl = args["ttl"] as? Int ?? 600 let record = DNSRecord(type: .A, name: name, data: ip, ttl: ttl) try await service.replaceDNSRecord(domain: domain, type: .A, name: name, records: [record]) return ok("Set A record: \(name).\(domain) → \(ip) (TTL: \(ttl))") case "set-cname": guard let target = args["value"] as? String else { return err("CNAME target required (value)") } let name = args["name"] as? String ?? "www" let ttl = args["ttl"] as? Int ?? 3600 try await service.setCNAME(domain: domain, name: name, target: target, ttl: ttl) return ok("Set CNAME: \(name).\(domain) → \(target) (TTL: \(ttl))") case "netlify": let sub = args["netlify_subdomain"] as? String ?? domain.split(separator: ".").first.map(String.init) ?? "site" try await service.configureForNetlify(domain: domain, netlifySubdomain: sub) return ok("Configured \(domain) for Netlify hosting (\(sub).netlify.app)") case "github-pages": guard let username = args["github_username"] as? String else { return err("github_username required") } try await service.configureForGitHubPages(domain: domain, githubUsername: username) return ok("Configured \(domain) for GitHub Pages (\(username).github.io)") case "vercel": try await service.configureForVercel(domain: domain) return ok("Configured \(domain) for Vercel hosting") case "cloudflare": let project = args["cloudflare_project"] as? String ?? domain.split(separator: ".").first.map(String.init) ?? "site" try await service.configureForCloudflare(domain: domain, projectName: project) return ok("Configured \(domain) for Cloudflare Pages (\(project).pages.dev)") default: return err("Unknown action '\(action)'. Available: set-a, set-cname, netlify, github-pages, vercel, cloudflare") } } catch { return err(error.localizedDescription) } } // ── domain_check ────────────────────────────────────────────── server.registerTool( "domain_check", description: "Check domain availability and get suggestions via GoDaddy API.", inputSchema: [ "type": "object", "required": ["domain"], "properties": [ "domain": ["type": "string", "description": "Domain to check (e.g., example.com)."], "suggest": ["type": "boolean", "description": "Also return domain suggestions (default: true)."], "environment": ["type": "string", "description": "ote or production (default: ote)."], ] as [String: Any], ] as [String: Any], annotations: ToolAnnotations(readOnlyHint: true) ) { args in let domain = args["domain"] as? String ?? "" guard !domain.isEmpty else { return err("Domain name required") } let env: GoDaddyService.Environment = (args["environment"] as? String)?.lowercased() == "production" ? .production : .ote let service = GoDaddyService(environment: env) let suggest = args["suggest"] as? Bool ?? true do { let availability = try await service.checkAvailability(domain: domain) var output = "Domain: \(domain)\n" output += "Available: \(availability.available ? "✅ Yes" : "❌ No")\n" if let price = availability.price, let currency = availability.currency { output += "Price: \(price / 1_000_000) \(currency)/year\n" } if suggest { let query = domain.split(separator: ".").first.map(String.init) ?? domain let suggestions = try await service.suggestDomains(query: query, limit: 5) if !suggestions.isEmpty { output += "\nSuggested alternatives:\n" for s in suggestions { output += " • \(s.domain)\n" } } } return ok(output) } catch { return err(error.localizedDescription) } } // ── credential_set ──────────────────────────────────────────── server.registerTool( "credential_set", description: "Set or list stored credentials (GoDaddy API keys, deploy tokens). Credentials are AES-256 encrypted.", inputSchema: [ "type": "object", "required": ["action"], "properties": [ "action": ["type": "string", "description": "list, set, or remove."], "key": ["type": "string", "description": "Credential key name."], "value": ["type": "string", "description": "Credential value (for set)."], ] as [String: Any], ] as [String: Any] ) { args in let action = args["action"] as? String ?? "list" let store = CredentialStore.shared let wsDir = URL(fileURLWithPath: config.workspaceRoot) // Load existing credentials try? store.load(from: wsDir) switch action.lowercased() { case "list": let keys = store.keys() if keys.isEmpty { return ok("No credentials stored. Use action='set' to add credentials.") } var output = "Stored credentials (\(keys.count)):\n" for key in keys { output += " • \(key)\n" } return ok(output) case "set": guard let key = args["key"] as? String, !key.isEmpty else { return err("Credential key required") } guard let value = args["value"] as? String, !value.isEmpty else { return err("Credential value required") } store.set(key, value: value) do { try store.save(to: wsDir) return ok("Credential '\(key)' saved (encrypted).") } catch { return err("Failed to save: \(error.localizedDescription)") } case "remove": guard let key = args["key"] as? String, !key.isEmpty else { return err("Credential key required") } store.remove(key) do { try store.save(to: wsDir) return ok("Credential '\(key)' removed.") } catch { return err("Failed to save: \(error.localizedDescription)") } default: return err("Unknown action '\(action)'. Available: list, set, remove") } } } // 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)"]] } }