diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 1f47e5b..a9abad1 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -28,6 +28,8 @@ struct ContentView: View { MemoryView() case .skills: SkillsView() + case .tools: + ToolsView() case .cron: CronView() case .logs: diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift new file mode 100644 index 0000000..21f6943 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -0,0 +1,28 @@ +import Foundation + +struct HermesToolset: Identifiable, Sendable { + var id: String { name } + let name: String + let description: String + let icon: String + var enabled: Bool +} + +struct HermesToolPlatform: Identifiable, Sendable { + var id: String { name } + let name: String + let displayName: String + let icon: String +} + +enum KnownPlatforms { + static let all: [HermesToolPlatform] = [ + HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"), + HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), + HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), + HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"), + HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"), + HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"), + HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"), + ] +} diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift new file mode 100644 index 0000000..db31c22 --- /dev/null +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -0,0 +1,134 @@ +import Foundation + +@Observable +final class ToolsViewModel { + var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0] + var toolsets: [HermesToolset] = [] + var mcpStatus: String = "" + var isLoading = false + var availablePlatforms: [HermesToolPlatform] = [] + + func load() { + loadPlatforms() + loadTools(for: selectedPlatform) + loadMCPStatus() + } + + func switchPlatform(_ platform: HermesToolPlatform) { + selectedPlatform = platform + loadTools(for: platform) + } + + func toggleTool(_ tool: HermesToolset) { + let action = tool.enabled ? "disable" : "enable" + let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name]) + if result.exitCode == 0 { + if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) { + toolsets[idx].enabled.toggle() + } + } + } + + private func loadPlatforms() { + let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + var platforms: [HermesToolPlatform] = [] + var inSection = false + for line in config.components(separatedBy: "\n") { + if line.hasPrefix("platform_toolsets:") { + inSection = true + continue + } + if inSection { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) { + if !trimmed.isEmpty { break } + continue + } + if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") { + let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces) + if let known = KnownPlatforms.all.first(where: { $0.name == name }) { + platforms.append(known) + } else { + platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left")) + } + } + } + } + availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms + if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) { + selectedPlatform = availablePlatforms[0] + } + } + + private func loadTools(for platform: HermesToolPlatform) { + isLoading = true + let result = runHermes(["tools", "list", "--platform", platform.name]) + toolsets = parseToolsList(result.output) + isLoading = false + } + + private func loadMCPStatus() { + let result = runHermes(["mcp", "list"]) + mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func parseToolsList(_ output: String) -> [HermesToolset] { + var tools: [HermesToolset] = [] + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let isEnabled: Bool + if trimmed.hasPrefix("✓ enabled") { + isEnabled = true + } else if trimmed.hasPrefix("✗ disabled") { + isEnabled = false + } else { + continue + } + let rest = trimmed + .replacingOccurrences(of: "✓ enabled", with: "") + .replacingOccurrences(of: "✗ disabled", with: "") + .trimmingCharacters(in: .whitespaces) + + let parts = rest.split(separator: " ", maxSplits: 1) + guard let namePart = parts.first else { continue } + let name = String(namePart) + let rawDesc = parts.count > 1 ? String(parts[1]) : name + + let icon = extractEmoji(from: rawDesc) + let description = rawDesc + .unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII } + .map { String($0) }.joined() + .trimmingCharacters(in: .whitespaces) + + tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled)) + } + return tools + } + + private func extractEmoji(from text: String) -> String { + for scalar in text.unicodeScalars { + if scalar.properties.isEmoji && !scalar.isASCII { + return String(scalar) + } + } + return "🔧" + } + + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, process.terminationStatus) + } catch { + return ("", -1) + } + } +} diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift new file mode 100644 index 0000000..749ed69 --- /dev/null +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct ToolsView: View { + @State private var viewModel = ToolsViewModel() + + var body: some View { + VStack(spacing: 0) { + platformPicker + Divider() + toolsList + if !viewModel.mcpStatus.isEmpty { + Divider() + mcpSection + } + } + .navigationTitle("Tools") + .onAppear { viewModel.load() } + } + + private var platformPicker: some View { + HStack(spacing: 16) { + ForEach(viewModel.availablePlatforms) { platform in + Button { + viewModel.switchPlatform(platform) + } label: { + HStack(spacing: 4) { + Image(systemName: platform.icon) + Text(platform.displayName) + .font(.caption) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + Spacer() + Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var toolsList: some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(viewModel.toolsets) { tool in + ToolRow(tool: tool) { + viewModel.toggleTool(tool) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + private var mcpSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("MCP Servers") + .font(.caption.bold()) + .foregroundStyle(.secondary) + if viewModel.mcpStatus.contains("No MCP servers") { + Label("No MCP servers configured", systemImage: "server.rack") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(viewModel.mcpStatus) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct ToolRow: View { + let tool: HermesToolset + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(tool.icon) + .font(.title3) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(tool.name) + .font(.system(.body, design: .monospaced, weight: .medium)) + Text(tool.description) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { tool.enabled }, + set: { _ in onToggle() } + )) + .toggleStyle(.switch) + .labelsHidden() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index b9b4995..afae5c9 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -8,6 +8,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case chat = "Chat" case memory = "Memory" case skills = "Skills" + case tools = "Tools" case cron = "Cron" case logs = "Logs" case settings = "Settings" @@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .chat: return "text.bubble" case .memory: return "brain" case .skills: return "lightbulb" + case .tools: return "wrench.and.screwdriver" case .cron: return "clock.arrow.2.circlepath" case .logs: return "doc.text" case .settings: return "gearshape" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 372292f..59f6634 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) }