feat(macos): add tools tab installers
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
391
apps/macos/Sources/Clawdis/ToolsSettings.swift
Normal file
391
apps/macos/Sources/Clawdis/ToolsSettings.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
docs/AGENTS.default.md
Normal file
30
docs/AGENTS.default.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user