feat: add skills settings and gateway skills management
This commit is contained in:
@@ -50,6 +50,9 @@ actor GatewayConnection {
|
||||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
case chatAbort = "chat.abort"
|
||||
case skillsStatus = "skills.status"
|
||||
case skillsInstall = "skills.install"
|
||||
case skillsUpdate = "skills.update"
|
||||
case voicewakeGet = "voicewake.get"
|
||||
case voicewakeSet = "voicewake.set"
|
||||
case nodePairApprove = "node.pair.approve"
|
||||
@@ -355,6 +358,42 @@ extension GatewayConnection {
|
||||
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
func skillsStatus() async throws -> SkillsStatusReport {
|
||||
try await self.requestDecoded(method: .skillsStatus)
|
||||
}
|
||||
|
||||
func skillsInstall(
|
||||
name: String,
|
||||
installId: String,
|
||||
timeoutMs: Int? = nil) async throws -> SkillInstallResult
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"name": AnyCodable(name),
|
||||
"installId": AnyCodable(installId),
|
||||
]
|
||||
if let timeoutMs {
|
||||
params["timeoutMs"] = AnyCodable(timeoutMs)
|
||||
}
|
||||
return try await self.requestDecoded(method: .skillsInstall, params: params)
|
||||
}
|
||||
|
||||
func skillsUpdate(
|
||||
skillKey: String,
|
||||
enabled: Bool? = nil,
|
||||
apiKey: String? = nil,
|
||||
env: [String: String]? = nil) async throws -> SkillUpdateResult
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"skillKey": AnyCodable(skillKey),
|
||||
]
|
||||
if let enabled { params["enabled"] = AnyCodable(enabled) }
|
||||
if let apiKey { params["apiKey"] = AnyCodable(apiKey) }
|
||||
if let env, !env.isEmpty { params["env"] = AnyCodable(env) }
|
||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||
|
||||
@@ -1132,10 +1132,10 @@ struct OnboardingView: View {
|
||||
systemImage: "bell.badge")
|
||||
self.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
|
||||
systemImage: "wrench.and.screwdriver")
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
systemImage: "sparkles")
|
||||
{
|
||||
self.openSettings(tab: .tools)
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||
@@ -1259,7 +1259,7 @@ struct OnboardingView: View {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Open Settings → Tools", action: action)
|
||||
Button("Open Settings → Skills", action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ struct SettingsRootView: View {
|
||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||
.tag(SettingsTab.cron)
|
||||
|
||||
ToolsSettings()
|
||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
||||
.tag(SettingsTab.tools)
|
||||
SkillsSettings()
|
||||
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||
.tag(SettingsTab.skills)
|
||||
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
@@ -125,13 +125,13 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 658 // +10% (tabs fit better)
|
||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .tools: "Tools"
|
||||
case .skills: "Skills"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
case .config: "Config"
|
||||
|
||||
68
apps/macos/Sources/Clawdis/SkillsModels.swift
Normal file
68
apps/macos/Sources/Clawdis/SkillsModels.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
|
||||
struct SkillsStatusReport: Codable {
|
||||
let workspaceDir: String
|
||||
let managedSkillsDir: String
|
||||
let skills: [SkillStatus]
|
||||
}
|
||||
|
||||
struct SkillStatus: Codable, Identifiable {
|
||||
let name: String
|
||||
let description: String
|
||||
let source: String
|
||||
let filePath: String
|
||||
let baseDir: String
|
||||
let skillKey: String
|
||||
let primaryEnv: String?
|
||||
let always: Bool
|
||||
let disabled: Bool
|
||||
let eligible: Bool
|
||||
let requirements: SkillRequirements
|
||||
let missing: SkillMissing
|
||||
let configChecks: [SkillStatusConfigCheck]
|
||||
let install: [SkillInstallOption]
|
||||
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
struct SkillRequirements: Codable {
|
||||
let bins: [String]
|
||||
let env: [String]
|
||||
let config: [String]
|
||||
}
|
||||
|
||||
struct SkillMissing: Codable {
|
||||
let bins: [String]
|
||||
let env: [String]
|
||||
let config: [String]
|
||||
}
|
||||
|
||||
struct SkillStatusConfigCheck: Codable, Identifiable {
|
||||
let path: String
|
||||
let value: AnyCodable?
|
||||
let satisfied: Bool
|
||||
|
||||
var id: String { path }
|
||||
}
|
||||
|
||||
struct SkillInstallOption: Codable, Identifiable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let label: String
|
||||
let bins: [String]
|
||||
}
|
||||
|
||||
struct SkillInstallResult: Codable {
|
||||
let ok: Bool
|
||||
let message: String
|
||||
let stdout: String?
|
||||
let stderr: String?
|
||||
let code: Int?
|
||||
}
|
||||
|
||||
struct SkillUpdateResult: Codable {
|
||||
let ok: Bool
|
||||
let skillKey: String
|
||||
let config: [String: AnyCodable]?
|
||||
}
|
||||
395
apps/macos/Sources/Clawdis/SkillsSettings.swift
Normal file
395
apps/macos/Sources/Clawdis/SkillsSettings.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
import ClawdisProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SkillsSettings: View {
|
||||
@State private var model = SkillsSettingsModel()
|
||||
@State private var envEditor: EnvEditorState?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.statusBanner
|
||||
self.skillsList
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.task { await self.model.refresh() }
|
||||
.sheet(item: self.$envEditor) { editor in
|
||||
EnvEditorView(editor: editor) { value in
|
||||
Task {
|
||||
await self.model.updateEnv(
|
||||
skillKey: editor.skillKey,
|
||||
envKey: editor.envKey,
|
||||
value: value,
|
||||
isPrimary: editor.isPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Skills")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Skills are enabled when requirements are met (binaries, env, config).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Refresh") { Task { await self.model.refresh() } }
|
||||
.disabled(self.model.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let error = self.model.error {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
} else if let message = self.model.statusMessage {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var skillsList: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(self.model.skills) { skill in
|
||||
SkillRow(
|
||||
skill: skill,
|
||||
isBusy: self.model.isBusy(skill: skill),
|
||||
onToggleEnabled: { enabled in
|
||||
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
||||
},
|
||||
onInstall: { option in
|
||||
Task { await self.model.install(skill: skill, option: option) }
|
||||
},
|
||||
onSetEnv: { envKey, isPrimary in
|
||||
self.envEditor = EnvEditorState(
|
||||
skillKey: skill.skillKey,
|
||||
skillName: skill.name,
|
||||
envKey: envKey,
|
||||
isPrimary: isPrimary)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SkillRow: View {
|
||||
let skill: SkillStatus
|
||||
let isBusy: Bool
|
||||
let onToggleEnabled: (Bool) -> Void
|
||||
let onInstall: (SkillInstallOption) -> Void
|
||||
let onSetEnv: (String, Bool) -> Void
|
||||
|
||||
private var missingBins: [String] { self.skill.missing.bins }
|
||||
private var missingEnv: [String] { self.skill.missing.env }
|
||||
private var missingConfig: [String] { self.skill.missing.config }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.skill.name)
|
||||
.font(.headline)
|
||||
Text(self.skill.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.sourceLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
self.statusBadge
|
||||
}
|
||||
|
||||
if self.skill.disabled {
|
||||
Text("Disabled in config")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.skill.eligible {
|
||||
Text("Enabled")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
self.missingSummary
|
||||
}
|
||||
|
||||
if !self.skill.configChecks.isEmpty {
|
||||
self.configChecksView
|
||||
}
|
||||
|
||||
self.actionRow
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var sourceLabel: String {
|
||||
self.skill.source.replacingOccurrences(of: "clawdis-", with: "")
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
Group {
|
||||
if self.skill.disabled {
|
||||
Label("Disabled", systemImage: "slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.skill.eligible {
|
||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Needs setup", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var missingSummary: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if !self.missingBins.isEmpty {
|
||||
Text("Missing binaries: \(self.missingBins.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !self.missingEnv.isEmpty {
|
||||
Text("Missing env: \(self.missingEnv.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !self.missingConfig.isEmpty {
|
||||
Text("Requires config: \(self.missingConfig.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var configChecksView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(self.skill.configChecks) { check in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundStyle(check.satisfied ? .green : .secondary)
|
||||
Text(check.path)
|
||||
.font(.caption)
|
||||
Text(self.formatConfigValue(check.value))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
if self.skill.disabled {
|
||||
Button("Enable") { self.onToggleEnabled(true) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isBusy)
|
||||
} else {
|
||||
Button("Disable") { self.onToggleEnabled(false) }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
ForEach(self.installOptions) { option in
|
||||
Button(option.label) { self.onInstall(option) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
ForEach(self.missingEnv, id: \.self) { envKey in
|
||||
let isPrimary = envKey == self.skill.primaryEnv
|
||||
Button(isPrimary ? "Set API Key" : "Set \(envKey)") {
|
||||
self.onSetEnv(envKey, isPrimary)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var installOptions: [SkillInstallOption] {
|
||||
guard !self.missingBins.isEmpty else { return [] }
|
||||
let missing = Set(self.missingBins)
|
||||
return self.skill.install.filter { option in
|
||||
if option.bins.isEmpty { return true }
|
||||
return !missing.isDisjoint(with: option.bins)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
||||
guard let value else { return "" }
|
||||
switch value.value {
|
||||
case let bool as Bool:
|
||||
return bool ? "true" : "false"
|
||||
case let int as Int:
|
||||
return String(int)
|
||||
case let double as Double:
|
||||
return String(double)
|
||||
case let string as String:
|
||||
return string
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnvEditorState: Identifiable {
|
||||
let skillKey: String
|
||||
let skillName: String
|
||||
let envKey: String
|
||||
let isPrimary: Bool
|
||||
|
||||
var id: String { "\(self.skillKey)::\(self.envKey)" }
|
||||
}
|
||||
|
||||
private struct EnvEditorView: View {
|
||||
let editor: EnvEditorState
|
||||
let onSave: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var value: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
Text(self.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField(self.editor.envKey, text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("Cancel") { self.dismiss() }
|
||||
Spacer()
|
||||
Button("Save") {
|
||||
self.onSave(self.value)
|
||||
self.dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 420)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
"Skill: \(self.editor.skillName)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SkillsSettingsModel {
|
||||
var skills: [SkillStatus] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var statusMessage: String?
|
||||
private var busySkills: Set<String> = []
|
||||
|
||||
func isBusy(skill: SkillStatus) -> Bool {
|
||||
self.busySkills.contains(skill.skillKey)
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !self.isLoading else { return }
|
||||
self.isLoading = true
|
||||
self.error = nil
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
self.skills = report.skills.sorted { $0.name < $1.name }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
func install(skill: SkillStatus, option: SkillInstallOption) async {
|
||||
await self.withBusy(skill.skillKey) {
|
||||
do {
|
||||
let result = try await GatewayConnection.shared.skillsInstall(
|
||||
name: skill.name,
|
||||
installId: option.id,
|
||||
timeoutMs: 300_000)
|
||||
self.statusMessage = result.message
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func setEnabled(skillKey: String, enabled: Bool) async {
|
||||
await self.withBusy(skillKey) {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
enabled: enabled)
|
||||
self.statusMessage = enabled ? "Skill enabled" : "Skill disabled"
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async {
|
||||
await self.withBusy(skillKey) {
|
||||
do {
|
||||
if isPrimary {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
apiKey: value)
|
||||
self.statusMessage = "Saved API key"
|
||||
} else {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
env: [envKey: value])
|
||||
self.statusMessage = "Saved \(envKey)"
|
||||
}
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func withBusy(_ id: String, _ work: @escaping () async -> Void) async {
|
||||
self.busySkills.insert(id)
|
||||
defer { self.busySkills.remove(id) }
|
||||
await work()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SkillsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SkillsSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,508 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum NodePackageManager: String, CaseIterable, Identifiable {
|
||||
case npm
|
||||
case pnpm
|
||||
case bun
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .npm: "NPM"
|
||||
case .pnpm: "PNPM"
|
||||
case .bun: "Bun"
|
||||
}
|
||||
}
|
||||
|
||||
var installCommandPrefix: String {
|
||||
switch self {
|
||||
case .npm: "npm install -g"
|
||||
case .pnpm: "pnpm add -g"
|
||||
case .bun: "bun add -g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data models
|
||||
|
||||
private enum InstallMethod: Equatable {
|
||||
case brew(formula: String, binary: String)
|
||||
case node(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 .node(_, binary),
|
||||
let .go(_, binary),
|
||||
let .pnpm(_, _, binary):
|
||||
binary
|
||||
case .gitClone:
|
||||
nil
|
||||
case .mcporter:
|
||||
"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] = Self.makeTools()
|
||||
|
||||
static var toolIDsForTests: [String] {
|
||||
makeTools().map(\.id)
|
||||
}
|
||||
|
||||
private static func makeTools() -> [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: .node(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: .node(package: "@steipete/oracle", binary: "oracle"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "summarize",
|
||||
name: "🧾 summarize",
|
||||
url: URL(string: "https://github.com/steipete/summarize")!,
|
||||
description: "Link → clean text → summary (web pages, YouTube, and local/remote files).",
|
||||
method: .brew(formula: "steipete/tap/summarize", binary: "summarize"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "qmd",
|
||||
name: "🔎 qmd",
|
||||
url: URL(string: "https://github.com/tobi/qmd")!,
|
||||
description: "Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.",
|
||||
method: .node(package: "https://github.com/tobi/qmd", binary: "qmd"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "eightctl",
|
||||
name: "🛏️ eightctl",
|
||||
url: URL(string: "https://github.com/steipete/eightctl")!,
|
||||
description: "Control your sleep, from the terminal.",
|
||||
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: "Send, read, stream iMessage & SMS.",
|
||||
method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "wacli",
|
||||
name: "🗃️ wacli",
|
||||
url: URL(string: "https://github.com/steipete/wacli")!,
|
||||
description: "WhatsApp CLI: sync, search, send.",
|
||||
method: .go(module: "github.com/steipete/wacli/cmd/wacli@latest", binary: "wacli"),
|
||||
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: "sonoscli",
|
||||
name: "🔊 sonoscli",
|
||||
url: URL(string: "https://github.com/steipete/sonoscli")!,
|
||||
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
|
||||
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "blucli",
|
||||
name: "🫐 blucli",
|
||||
url: URL(string: "https://github.com/steipete/blucli")!,
|
||||
description: "Play, group, and automate BluOS players from scripts.",
|
||||
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "sag",
|
||||
name: "🗣️ sag",
|
||||
url: URL(string: "https://github.com/steipete/sag")!,
|
||||
description: "ElevenLabs speech with mac-style say UX; streams to speakers by default.",
|
||||
method: .brew(formula: "steipete/tap/sag", binary: "sag"),
|
||||
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: "Local speech-to-text for quick dictation and voicemail transcripts.",
|
||||
method: .brew(formula: "openai-whisper", binary: "whisper"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "gog",
|
||||
name: "📮 gog",
|
||||
url: URL(string: "https://github.com/steipete/gogcli")!,
|
||||
description: "Google Suite CLI: Gmail, Calendar, Drive, Contacts.",
|
||||
method: .brew(formula: "steipete/tap/gogcli", binary: "gog"),
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
@AppStorage("tools.packageManager") private var packageManagerRaw = NodePackageManager.npm.rawValue
|
||||
@State private var installStates: [String: InstallState] = [:]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.packageManagerPicker
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
self.section(for: .tool, title: "CLI Tools")
|
||||
self.section(for: .mcp, title: "MCP Servers")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, 12)
|
||||
.onChange(of: self.packageManagerRaw) { _, _ in
|
||||
self.refreshAll()
|
||||
}
|
||||
.task { self.refreshAll() }
|
||||
}
|
||||
|
||||
private var packageManager: NodePackageManager {
|
||||
NodePackageManager(rawValue: self.packageManagerRaw) ?? .npm
|
||||
}
|
||||
|
||||
private var packageManagerPicker: some View {
|
||||
Picker("Preferred package manager", selection: self.$packageManagerRaw) {
|
||||
ForEach(NodePackageManager.allCases) { manager in
|
||||
Text(manager.label).tag(manager.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 340)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
|
||||
let filtered = self.tools.filter { $0.kind == kind }
|
||||
if filtered.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(.top, 6)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(filtered) { tool in
|
||||
ToolRow(
|
||||
tool: tool,
|
||||
state: self.binding(for: tool),
|
||||
packageManager: self.packageManager,
|
||||
refreshState: { await self.refresh(tool: tool) })
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func binding(for tool: ToolEntry) -> Binding<InstallState> {
|
||||
let current = self.installStates[tool.id] ?? .checking
|
||||
return Binding(
|
||||
get: { self.installStates[tool.id] ?? current },
|
||||
set: { self.installStates[tool.id] = $0 })
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
Task {
|
||||
for tool in self.tools {
|
||||
await self.refresh(tool: tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refresh(tool: ToolEntry) async {
|
||||
let installed = await ToolInstaller.isInstalled(tool.method, packageManager: self.packageManager)
|
||||
self.installStates[tool.id] = installed ? .installed : .notInstalled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private struct ToolRow: View {
|
||||
let tool: ToolEntry
|
||||
@Binding var state: InstallState
|
||||
@State private var statusMessage: String?
|
||||
@State private var linkHovering = false
|
||||
let packageManager: NodePackageManager
|
||||
let refreshState: () async -> Void
|
||||
|
||||
private enum Layout {
|
||||
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
|
||||
static let actionWidth: CGFloat = 96
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Link(destination: self.tool.url) {
|
||||
Text(self.tool.name)
|
||||
.font(.headline)
|
||||
.underline(self.linkHovering, color: .accentColor)
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.onHover { self.linkHovering = $0 }
|
||||
.pointingHandCursor()
|
||||
Text(self.tool.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
self.actionButton
|
||||
}
|
||||
|
||||
if let statusMessage, !statusMessage.isEmpty {
|
||||
Text(statusMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear { self.refresh() }
|
||||
}
|
||||
|
||||
private var actionButton: some View {
|
||||
VStack {
|
||||
switch self.state {
|
||||
case .installed:
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.subheadline)
|
||||
case .installing:
|
||||
ProgressView().controlSize(.small)
|
||||
case .failed:
|
||||
Button("Retry") { self.install() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .checking:
|
||||
ProgressView().controlSize(.small)
|
||||
case .notInstalled:
|
||||
Button("Install") { self.install() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(width: Layout.actionWidth, alignment: .trailing)
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
Task {
|
||||
self.state = .checking
|
||||
let installed = await ToolInstaller.isInstalled(self.tool.method, packageManager: self.packageManager)
|
||||
await MainActor.run {
|
||||
self.state = installed ? .installed : .notInstalled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func install() {
|
||||
Task {
|
||||
self.state = .installing
|
||||
let result = await ToolInstaller.install(self.tool.method, packageManager: self.packageManager)
|
||||
await MainActor.run {
|
||||
self.statusMessage = result.message
|
||||
self.state = result.installed ? .installed : .failed(result.message)
|
||||
if result.installed { Task { await self.refreshState() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Installer
|
||||
|
||||
private enum ToolInstaller {
|
||||
struct InstallResult {
|
||||
let installed: Bool
|
||||
let message: String
|
||||
}
|
||||
|
||||
static func isInstalled(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> Bool {
|
||||
switch method {
|
||||
case let .brew(formula, _):
|
||||
return await self.shellSucceeds("brew list --versions \(formula)")
|
||||
case let .node(_, binary),
|
||||
let .go(_, binary),
|
||||
let .pnpm(_, _, binary):
|
||||
return await self.commandExists(binary)
|
||||
case let .gitClone(_, destination):
|
||||
return FileManager.default.fileExists(atPath: destination)
|
||||
case let .mcporter(name, _, _):
|
||||
guard await self.commandExists("mcporter") else { return false }
|
||||
return await self.shellSucceeds("mcporter config get \(name) --json")
|
||||
}
|
||||
}
|
||||
|
||||
static func install(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> InstallResult {
|
||||
switch method {
|
||||
case let .brew(formula, _):
|
||||
return await self.runInstall("brew install \(formula)")
|
||||
case let .node(package, _):
|
||||
return await self.runInstall("\(packageManager.installCommandPrefix) \(package)")
|
||||
case let .go(module, _):
|
||||
return await self.runInstall("GO111MODULE=on go install \(module)")
|
||||
case let .pnpm(repoPath, script, _):
|
||||
let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)"
|
||||
return await self.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 self.runInstall(cmd)
|
||||
case let .mcporter(name, command, summary):
|
||||
let cmd = """
|
||||
mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)"
|
||||
"""
|
||||
return await self.runInstall(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func commandExists(_ binary: String) async -> Bool {
|
||||
await self.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.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
continuation.resume(returning: (proc.terminationStatus, output))
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(returning: (1, error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ToolsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ToolsSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -621,6 +621,55 @@ public struct ConfigSetParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
installid: String,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.name = name
|
||||
self.installid = installid
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case installid = "installId"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
public let env: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
skillkey: String,
|
||||
enabled: Bool?,
|
||||
apikey: String?,
|
||||
env: [String: AnyCodable]?
|
||||
) {
|
||||
self.skillkey = skillkey
|
||||
self.enabled = enabled
|
||||
self.apikey = apikey
|
||||
self.env = env
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case skillkey = "skillKey"
|
||||
case enabled
|
||||
case apikey = "apiKey"
|
||||
case env
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
|
||||
Reference in New Issue
Block a user