diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index a86a093c2..c3ac7777d 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -335,8 +335,8 @@ enum PermissionManager { case .notifications: let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() - results[cap] = settings.authorizationStatus == .authorized || settings - .authorizationStatus == .provisional + results[cap] = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional case .accessibility: results[cap] = AXIsProcessTrusted() @@ -1765,6 +1765,10 @@ struct SettingsRootView: View { .tabItem { Label("General", systemImage: "gearshape") } .tag(SettingsTab.general) + ToolsSettings() + .tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") } + .tag(SettingsTab.tools) + SessionsSettings() .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } .tag(SettingsTab.sessions) @@ -1852,12 +1856,13 @@ struct SettingsRootView: View { } enum SettingsTab: CaseIterable { - case general, sessions, config, voiceWake, permissions, debug, about + case general, tools, sessions, config, voiceWake, permissions, debug, about static let windowWidth: CGFloat = 520 static let windowHeight: CGFloat = 624 var title: String { switch self { case .general: "General" + case .tools: "Tools" case .sessions: "Sessions" case .config: "Config" case .voiceWake: "Voice Wake" @@ -2821,11 +2826,10 @@ struct DebugSettings: View { private func chooseCatalogFile() { let panel = NSOpenPanel() panel.title = "Select models.generated.ts" - if let tsType = UTType(filenameExtension: "ts") { - panel.allowedContentTypes = [tsType] - } else { - panel.allowedFileTypes = ["ts"] // fallback - } + let tsType = UTType(filenameExtension: "ts") + ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) + ?? .item + panel.allowedContentTypes = [tsType] panel.allowsMultipleSelection = false panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() if panel.runModal() == .OK, let url = panel.url { @@ -2996,23 +3000,7 @@ struct PermissionStatusList: View { @MainActor private func handle(_ cap: Capability) async { Task { - switch cap { - case .notifications: - let center = UNUserNotificationCenter.current() - _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) - - case .accessibility: - self.openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") - - case .screenRecording: - self.openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") - - case .microphone: - self.openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") - - case .speechRecognition: - self.openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition") - } + _ = await PermissionManager.ensure([cap], interactive: true) await self.refresh() } } diff --git a/apps/macos/Sources/Clawdis/ToolsSettings.swift b/apps/macos/Sources/Clawdis/ToolsSettings.swift new file mode 100644 index 000000000..dd61bf942 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ToolsSettings.swift @@ -0,0 +1,391 @@ +import SwiftUI + +// MARK: - Data models + +private enum InstallMethod: Equatable { + case brew(formula: String, binary: String) + case npm(package: String, binary: String) + case go(module: String, binary: String) + case pnpm(repoPath: String, script: String, binary: String) + case gitClone(url: String, destination: String) + case mcporter(name: String, command: String, summary: String) + + var binary: String? { + switch self { + case let .brew(_, binary), + let .npm(_, binary), + let .go(_, binary), + let .pnpm(_, _, binary): + return binary + case .gitClone: + return nil + case .mcporter: + return "mcporter" + } + } +} + +private struct ToolEntry: Identifiable, Equatable { + let id: String + let name: String + let url: URL + let description: String + let method: InstallMethod + let kind: Kind + + enum Kind: String { + case tool = "Tools" + case mcp = "MCP Servers" + } +} + +private enum InstallState: Equatable { + case checking + case notInstalled + case installed + case installing + case failed(String) +} + +// MARK: - View + +struct ToolsSettings: View { + private let tools: [ToolEntry] = [ + ToolEntry( + id: "mcporter", + name: "mcporter", + url: URL(string: "https://github.com/steipete/mcporter")!, + description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.", + method: .npm(package: "mcporter", binary: "mcporter"), + kind: .tool + ), + ToolEntry( + id: "peekaboo", + name: "Peekaboo", + url: URL(string: "https://github.com/steipete/Peekaboo")!, + description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.", + method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"), + kind: .tool + ), + ToolEntry( + id: "camsnap", + name: "camsnap", + url: URL(string: "https://github.com/steipete/camsnap")!, + description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.", + method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"), + kind: .tool + ), + ToolEntry( + id: "oracle", + name: "oracle", + url: URL(string: "https://github.com/steipete/oracle")!, + description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.", + method: .npm(package: "@steipete/oracle", binary: "oracle"), + kind: .tool + ), + ToolEntry( + id: "eightctl", + name: "eightctl", + url: URL(string: "https://github.com/steipete/eightctl")!, + description: "Control Eight Sleep Pods (temp, alarms, schedules, metrics) from scripts or cron.", + method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"), + kind: .tool + ), + ToolEntry( + id: "imsg", + name: "imsg", + url: URL(string: "https://github.com/steipete/imsg")!, + description: "CLI for macOS Messages: read/tail chats and send iMessage/SMS with attachments.", + method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"), + kind: .tool + ), + ToolEntry( + id: "spotify-player", + name: "spotify-player", + url: URL(string: "https://github.com/aome510/spotify-player")!, + description: "Terminal Spotify client to queue, search, and control playback without leaving chat.", + method: .brew(formula: "spotify_player", binary: "spotify_player"), + kind: .tool + ), + ToolEntry( + id: "openhue-cli", + name: "OpenHue CLI", + url: URL(string: "https://github.com/openhue/openhue-cli")!, + description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.", + method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"), + kind: .tool + ), + ToolEntry( + id: "openai-whisper", + name: "OpenAI Whisper", + url: URL(string: "https://github.com/openai/whisper")!, + description: "On-device speech-to-text for quick note taking or voicemail transcription.", + method: .brew(formula: "openai-whisper", binary: "whisper"), + kind: .tool + ), + ToolEntry( + id: "gemini-cli", + name: "Gemini CLI", + url: URL(string: "https://github.com/google-gemini/gemini-cli")!, + description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.", + method: .brew(formula: "gemini-cli", binary: "gemini"), + kind: .tool + ), + ToolEntry( + id: "bird", + name: "bird", + url: URL(string: "https://github.com/steipete/bird")!, + description: "Fast X/Twitter CLI to tweet, reply, read threads, and search without a browser.", + method: .pnpm( + repoPath: "\(NSHomeDirectory())/Projects/bird", + script: "binary", + binary: "bird" + ), + kind: .tool + ), + ToolEntry( + id: "agent-tools", + name: "agent-tools", + url: URL(string: "https://github.com/badlogic/agent-tools")!, + description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.", + method: .gitClone( + url: "https://github.com/badlogic/agent-tools.git", + destination: "\(NSHomeDirectory())/agent-tools" + ), + kind: .tool + ), + ToolEntry( + id: "gmail-mcp", + name: "Gmail MCP", + url: URL(string: "https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp")!, + description: "Model Context Protocol server that exposes Gmail search, read, and send tools.", + method: .mcporter( + name: "gmail", + command: "npx -y @gongrzhe/server-gmail-autoauth-mcp", + summary: "Adds Gmail MCP via mcporter (stdio transport, auto-auth)." + ), + kind: .mcp + ), + ToolEntry( + id: "google-calendar-mcp", + name: "Google Calendar MCP", + url: URL(string: "https://www.npmjs.com/package/@cocal/google-calendar-mcp")!, + description: "MCP server to list, create, and update calendar events for scheduling automations.", + method: .mcporter( + name: "google-calendar", + command: "npx -y @cocal/google-calendar-mcp", + summary: "Adds Google Calendar MCP via mcporter (stdio transport)." + ), + kind: .mcp + ), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Tools & MCP servers") + .font(.title3.weight(.semibold)) + .padding(.top, 8) + + ScrollView { + LazyVStack(spacing: 12) { + section(for: .tool, title: "CLI Tools") + section(for: .mcp, title: "MCP Servers") + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 12) + } + + private func section(for kind: ToolEntry.Kind, title: String) -> some View { + let filtered = tools.filter { $0.kind == kind } + return VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.callout.weight(.semibold)) + .padding(.top, 6) + + VStack(spacing: 8) { + ForEach(filtered) { tool in + ToolRow(tool: tool) + .padding(10) + .background(Color(.textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + ) + } + } + } + } +} + +// MARK: - Row + +private struct ToolRow: View { + let tool: ToolEntry + @State private var state: InstallState = .checking + @State private var statusMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Link(tool.name, destination: tool.url) + .font(.headline) + Text(tool.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + actionButton + } + + if let statusMessage, !statusMessage.isEmpty { + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onAppear { refresh() } + } + + private var actionButton: some View { + VStack { + switch state { + case .installed: + Label("Installed", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.subheadline) + case .installing: + ProgressView().controlSize(.small) + case .failed: + Button("Retry") { install() } + .buttonStyle(.borderedProminent) + case .checking: + ProgressView().controlSize(.small) + case .notInstalled: + Button("Install") { install() } + .buttonStyle(.borderedProminent) + } + } + } + + private func refresh() { + Task { + state = .checking + let installed = await ToolInstaller.isInstalled(tool.method) + await MainActor.run { + state = installed ? .installed : .notInstalled + } + } + } + + private func install() { + Task { + state = .installing + let result = await ToolInstaller.install(tool.method) + await MainActor.run { + statusMessage = result.message + state = result.installed ? .installed : .failed(result.message) + } + } + } +} + +// MARK: - Installer + +private enum ToolInstaller { + struct InstallResult { + let installed: Bool + let message: String + } + + static func isInstalled(_ method: InstallMethod) async -> Bool { + switch method { + case let .brew(formula, _): + return await shellSucceeds("brew list --versions \(formula)") + case let .npm(_, binary), + let .go(_, binary), + let .pnpm(_, _, binary): + return await commandExists(binary) + case let .gitClone(_, destination): + return FileManager.default.fileExists(atPath: destination) + case let .mcporter(name, _, _): + guard await commandExists("mcporter") else { return false } + return await shellSucceeds("mcporter config get \(name) --json") + } + } + + static func install(_ method: InstallMethod) async -> InstallResult { + switch method { + case let .brew(formula, _): + return await runInstall("brew install \(formula)") + case let .npm(package, _): + return await runInstall("npm install -g \(package)") + case let .go(module, _): + return await runInstall("GO111MODULE=on go install \(module)") + case let .pnpm(repoPath, script, _): + let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)" + return await runInstall(cmd) + case let .gitClone(url, destination): + let cmd = """ + if [ -d \(escape(destination)) ]; then + echo "Already cloned" + else + git clone \(url) \(escape(destination)) + fi + """ + return await runInstall(cmd) + case let .mcporter(name, command, summary): + let cmd = """ + mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)" + """ + return await runInstall(cmd) + } + } + + // MARK: - Helpers + + private static func commandExists(_ binary: String) async -> Bool { + await shellSucceeds("command -v \(binary)") + } + + private static func shellSucceeds(_ command: String) async -> Bool { + let status = await run(command).status + return status == 0 + } + + private static func runInstall(_ command: String) async -> InstallResult { + let result = await run(command) + let success = result.status == 0 + let message = result.output.isEmpty ? (success ? "Installed" : "Install failed") : result.output + return InstallResult(installed: success, message: message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func escape(_ path: String) -> String { + "\"\(path.replacingOccurrences(of: "\"", with: "\\\""))\"" + } + + private static func run(_ command: String) async -> (status: Int32, output: String) { + await withCheckedContinuation { continuation in + let process = Process() + process.launchPath = "/bin/zsh" + process.arguments = ["-lc", command] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + process.terminationHandler = { proc in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + continuation.resume(returning: (proc.terminationStatus, output)) + } + do { + try process.run() + } catch { + continuation.resume(returning: (1, error.localizedDescription)) + } + } + } +} diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md new file mode 100644 index 000000000..886cd20a4 --- /dev/null +++ b/docs/AGENTS.default.md @@ -0,0 +1,30 @@ +# AGENTS.md — Clawdis Personal Assistant (default) + +## What Clawdis Does +- Runs WhatsApp relay + Pi/Tau coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac. +- macOS app manages permissions (screen recording, notifications, microphone) and exposes a CLI helper `clawdis-mac` for scripts. +- Sessions are per-sender; heartbeats keep background tasks alive. + +## Core Tools (enable in Settings → Tools) +- **mcporter** — MCP runtime/CLI to list, call, and sync Model Context Protocol servers. +- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis. +- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams. +- **oracle** — OpenAI-ready agent runner with session replay and browser control. +- **eightctl** — Control Eight Sleep Pod temperature, alarms, schedules, and metrics. +- **imsg** — macOS Messages CLI to read/tail chats and send iMessage/SMS. +- **spotify-player** — Terminal Spotify client to search/queue/control playback. +- **OpenHue CLI** — Philips Hue lighting control for scenes and automations. +- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts. +- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A. +- **bird** — X/Twitter CLI to tweet, reply, read threads, and search without a browser. +- **agent-tools** — Utility toolkit for automations and MCP-friendly scripts. + +## MCP Servers (added via mcporter) +- **Gmail MCP** (`gmail`) — Search, read, and send Gmail messages. +- **Google Calendar MCP** (`google-calendar`) — List, create, and update events. + +## Usage Notes +- Prefer the `clawdis-mac` CLI for scripting; mac app handles permissions. +- Run installs from the Tools tab; it hides the button if a tool is already present. +- For MCPs, mcporter writes to the home-scope config; re-run installs if you rotate tokens. +- Keep heartbeats enabled so the assistant can schedule reminders, monitor inboxes, and trigger camera captures.