feat: add skills settings and gateway skills management

This commit is contained in:
Peter Steinberger
2025-12-20 13:33:06 +01:00
parent 4b44a75bc1
commit cc0075e988
19 changed files with 1142 additions and 546 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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"

View 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]?
}

View 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

View File

@@ -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

View File

@@ -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?