feat: add skills settings and gateway skills management
This commit is contained in:
@@ -50,6 +50,9 @@ actor GatewayConnection {
|
|||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
|
case skillsStatus = "skills.status"
|
||||||
|
case skillsInstall = "skills.install"
|
||||||
|
case skillsUpdate = "skills.update"
|
||||||
case voicewakeGet = "voicewake.get"
|
case voicewakeGet = "voicewake.get"
|
||||||
case voicewakeSet = "voicewake.set"
|
case voicewakeSet = "voicewake.set"
|
||||||
case nodePairApprove = "node.pair.approve"
|
case nodePairApprove = "node.pair.approve"
|
||||||
@@ -355,6 +358,42 @@ extension GatewayConnection {
|
|||||||
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
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
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||||
|
|||||||
@@ -1132,10 +1132,10 @@ struct OnboardingView: View {
|
|||||||
systemImage: "bell.badge")
|
systemImage: "bell.badge")
|
||||||
self.featureActionRow(
|
self.featureActionRow(
|
||||||
title: "Give your agent more powers",
|
title: "Give your agent more powers",
|
||||||
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
|
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||||
systemImage: "wrench.and.screwdriver")
|
systemImage: "sparkles")
|
||||||
{
|
{
|
||||||
self.openSettings(tab: .tools)
|
self.openSettings(tab: .skills)
|
||||||
}
|
}
|
||||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||||
@@ -1259,7 +1259,7 @@ struct OnboardingView: View {
|
|||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button("Open Settings → Tools", action: action)
|
Button("Open Settings → Skills", action: action)
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||||
.tag(SettingsTab.cron)
|
.tag(SettingsTab.cron)
|
||||||
|
|
||||||
ToolsSettings()
|
SkillsSettings()
|
||||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||||
.tag(SettingsTab.tools)
|
.tag(SettingsTab.skills)
|
||||||
|
|
||||||
PermissionsSettings(
|
PermissionsSettings(
|
||||||
status: self.permissionMonitor.status,
|
status: self.permissionMonitor.status,
|
||||||
@@ -125,13 +125,13 @@ struct SettingsRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsTab: CaseIterable {
|
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 windowWidth: CGFloat = 658 // +10% (tabs fit better)
|
||||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
case .tools: "Tools"
|
case .skills: "Skills"
|
||||||
case .sessions: "Sessions"
|
case .sessions: "Sessions"
|
||||||
case .cron: "Cron"
|
case .cron: "Cron"
|
||||||
case .config: "Config"
|
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 struct CronJob: Codable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
|||||||
@@ -144,12 +144,8 @@ struct SettingsViewSmokeTests {
|
|||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func toolsSettingsBuildsBody() {
|
@Test func skillsSettingsBuildsBody() {
|
||||||
let view = ToolsSettings()
|
let view = SkillsSettings()
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func toolsSettingsIncludesSummarize() {
|
|
||||||
#expect(ToolsSettings.toolIDsForTests.contains("summarize"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
summary: "Default Clawdis agent instructions and tool roster for the personal assistant setup"
|
summary: "Default Clawdis agent instructions and skills roster for the personal assistant setup"
|
||||||
read_when:
|
read_when:
|
||||||
- Starting a new Clawdis agent session
|
- Starting a new Clawdis agent session
|
||||||
- Enabling or auditing default tools
|
- Enabling or auditing default skills
|
||||||
---
|
---
|
||||||
# AGENTS.md — Clawdis Personal Assistant (default)
|
# AGENTS.md — Clawdis Personal Assistant (default)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ cp docs/templates/SOUL.md ~/.clawdis/workspace/SOUL.md
|
|||||||
cp docs/templates/TOOLS.md ~/.clawdis/workspace/TOOLS.md
|
cp docs/templates/TOOLS.md ~/.clawdis/workspace/TOOLS.md
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Optional: if you want the personal assistant tool roster, replace AGENTS.md with this file:
|
3) Optional: if you want the personal assistant skill roster, replace AGENTS.md with this file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md
|
cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md
|
||||||
@@ -62,16 +62,16 @@ git commit -m "Add Clawd workspace"
|
|||||||
```
|
```
|
||||||
|
|
||||||
## What Clawdis Does
|
## What Clawdis Does
|
||||||
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run tools via the host Mac.
|
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
|
||||||
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary.
|
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary.
|
||||||
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:<jid>`; heartbeats keep background tasks alive.
|
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:<jid>`; heartbeats keep background tasks alive.
|
||||||
|
|
||||||
## Core Tools (enable in Settings → Tools)
|
## Core Skills (enable in Settings → Skills)
|
||||||
- **mcporter** — MCP runtime/CLI to list, call, and sync Model Context Protocol servers.
|
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||||
- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis.
|
- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis.
|
||||||
- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams.
|
- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams.
|
||||||
- **oracle** — OpenAI-ready agent CLI with session replay and browser control.
|
- **oracle** — OpenAI-ready agent CLI with session replay and browser control.
|
||||||
- **qmd** — Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.
|
- **qmd** — Hybrid markdown search (BM25 + vectors + rerank) with a local server for agents.
|
||||||
- **eightctl** — Control your sleep, from the terminal.
|
- **eightctl** — Control your sleep, from the terminal.
|
||||||
- **imsg** — Send, read, stream iMessage & SMS.
|
- **imsg** — Send, read, stream iMessage & SMS.
|
||||||
- **wacli** — WhatsApp CLI: sync, search, send.
|
- **wacli** — WhatsApp CLI: sync, search, send.
|
||||||
@@ -84,16 +84,11 @@ git commit -m "Add Clawd workspace"
|
|||||||
- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts.
|
- **OpenAI Whisper** — Local speech-to-text for quick dictation and voicemail transcripts.
|
||||||
- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A.
|
- **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.
|
- **bird** — X/Twitter CLI to tweet, reply, read threads, and search without a browser.
|
||||||
- **agent-tools** — Utility toolkit for automations and MCP-friendly scripts.
|
- **agent-tools** — Utility toolkit for automations and helper 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
|
## Usage Notes
|
||||||
- Prefer the `clawdis` CLI for scripting; mac app handles permissions.
|
- Prefer the `clawdis` CLI for scripting; mac app handles permissions.
|
||||||
- Run installs from the Tools tab; it hides the button if a tool is already present.
|
- Run installs from the Skills tab; it hides the button if a binary 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.
|
- Keep heartbeats enabled so the assistant can schedule reminders, monitor inboxes, and trigger camera captures.
|
||||||
- For browser-driven verification, use `clawdis browser` (tabs/status/screenshot) with the clawd-managed Chrome profile.
|
- For browser-driven verification, use `clawdis browser` (tabs/status/screenshot) with the clawd-managed Chrome profile.
|
||||||
- For DOM inspection, use `clawdis browser eval|query|dom|snapshot` (and `--json`/`--out` when you need machine output).
|
- For DOM inspection, use `clawdis browser eval|query|dom|snapshot` (and `--json`/`--out` when you need machine output).
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Playwright vs Puppeteer; the key is the **contract** and the **separation guaran
|
|||||||
|
|
||||||
## User-facing settings
|
## User-facing settings
|
||||||
|
|
||||||
Add a dedicated settings section (preferably under **Tools** or its own "Browser" tab):
|
Add a dedicated settings section (preferably under **Skills** or its own "Browser" tab):
|
||||||
|
|
||||||
- **Enable clawd browser** (`default: on`)
|
- **Enable clawd browser** (`default: on`)
|
||||||
- When off: no browser is launched, and browser tools return "disabled".
|
- When off: no browser is launched, and browser tools return "disabled".
|
||||||
|
|||||||
24
docs/mac/skills.md
Normal file
24
docs/mac/skills.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
summary: "macOS Skills settings UI and gateway-backed status"
|
||||||
|
read_when:
|
||||||
|
- Updating the macOS Skills settings UI
|
||||||
|
- Changing skills gating or install behavior
|
||||||
|
---
|
||||||
|
# Skills (macOS)
|
||||||
|
|
||||||
|
The macOS app surfaces Clawdis skills via the gateway; it does not parse skills locally.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements.
|
||||||
|
- Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`.
|
||||||
|
|
||||||
|
## Install actions
|
||||||
|
- `metadata.clawdis.install` defines install options (brew/node/go/pnpm/git/shell).
|
||||||
|
- The app calls `skills.install` to run installers on the gateway host.
|
||||||
|
|
||||||
|
## Env/API keys
|
||||||
|
- The app stores keys in `~/.clawdis/clawdis.json` under `skills.<skillKey>`.
|
||||||
|
- `skills.update` patches `enabled`, `apiKey`, and `env`.
|
||||||
|
|
||||||
|
## Remote mode
|
||||||
|
- Install + config updates happen on the gateway host (not the local Mac).
|
||||||
@@ -58,6 +58,17 @@ Fields under `metadata.clawdis`:
|
|||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
- `requires.config` — list of `clawdis.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.<name>.apiKey`.
|
||||||
|
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/pnpm/git/shell).
|
||||||
|
|
||||||
|
Installer example:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: gemini
|
||||||
|
description: Use Gemini CLI for coding assistance and Google search lookups.
|
||||||
|
metadata: {"clawdis":{"requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config).
|
If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config).
|
||||||
|
|
||||||
|
|||||||
147
src/agents/skills-install.ts
Normal file
147
src/agents/skills-install.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import {
|
||||||
|
loadWorkspaceSkillEntries,
|
||||||
|
type SkillEntry,
|
||||||
|
type SkillInstallSpec,
|
||||||
|
} from "./skills.js";
|
||||||
|
|
||||||
|
export type SkillInstallRequest = {
|
||||||
|
workspaceDir: string;
|
||||||
|
skillName: string;
|
||||||
|
installId: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillInstallResult = {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveInstallId(spec: SkillInstallSpec, index: number): string {
|
||||||
|
return (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInstallSpec(
|
||||||
|
entry: SkillEntry,
|
||||||
|
installId: string,
|
||||||
|
): SkillInstallSpec | undefined {
|
||||||
|
const specs = entry.clawdis?.install ?? [];
|
||||||
|
for (const [index, spec] of specs.entries()) {
|
||||||
|
if (resolveInstallId(spec, index) === installId) return spec;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runShell(command: string, timeoutMs: number) {
|
||||||
|
return runCommandWithTimeout(["/bin/zsh", "-lc", command], { timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstallCommand(spec: SkillInstallSpec): {
|
||||||
|
argv: string[] | null;
|
||||||
|
shell: string | null;
|
||||||
|
cwd?: string;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
switch (spec.kind) {
|
||||||
|
case "brew": {
|
||||||
|
if (!spec.formula) return { argv: null, shell: null, error: "missing brew formula" };
|
||||||
|
return { argv: ["brew", "install", spec.formula], shell: null };
|
||||||
|
}
|
||||||
|
case "node": {
|
||||||
|
if (!spec.package) return { argv: null, shell: null, error: "missing node package" };
|
||||||
|
return { argv: ["npm", "install", "-g", spec.package], shell: null };
|
||||||
|
}
|
||||||
|
case "go": {
|
||||||
|
if (!spec.module) return { argv: null, shell: null, error: "missing go module" };
|
||||||
|
return { argv: ["go", "install", spec.module], shell: null };
|
||||||
|
}
|
||||||
|
case "pnpm": {
|
||||||
|
if (!spec.repoPath || !spec.script) {
|
||||||
|
return { argv: null, shell: null, error: "missing pnpm repoPath/script" };
|
||||||
|
}
|
||||||
|
const repoPath = resolveUserPath(spec.repoPath);
|
||||||
|
const cmd = `cd ${JSON.stringify(repoPath)} && pnpm install && pnpm run ${JSON.stringify(spec.script)}`;
|
||||||
|
return { argv: null, shell: cmd };
|
||||||
|
}
|
||||||
|
case "git": {
|
||||||
|
if (!spec.url || !spec.destination) {
|
||||||
|
return { argv: null, shell: null, error: "missing git url/destination" };
|
||||||
|
}
|
||||||
|
const dest = resolveUserPath(spec.destination);
|
||||||
|
const cmd = `if [ -d ${JSON.stringify(dest)} ]; then echo "Already cloned"; else git clone ${JSON.stringify(spec.url)} ${JSON.stringify(dest)}; fi`;
|
||||||
|
return { argv: null, shell: cmd };
|
||||||
|
}
|
||||||
|
case "shell": {
|
||||||
|
if (!spec.command) return { argv: null, shell: null, error: "missing shell command" };
|
||||||
|
return { argv: null, shell: spec.command };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { argv: null, shell: null, error: "unsupported installer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installSkill(
|
||||||
|
params: SkillInstallRequest,
|
||||||
|
): Promise<SkillInstallResult> {
|
||||||
|
const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 300_000, 1_000), 900_000);
|
||||||
|
const workspaceDir = resolveUserPath(params.workspaceDir);
|
||||||
|
const entries = loadWorkspaceSkillEntries(workspaceDir);
|
||||||
|
const entry = entries.find((item) => item.skill.name === params.skillName);
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Skill not found: ${params.skillName}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = findInstallSpec(entry, params.installId);
|
||||||
|
if (!spec) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Installer not found: ${params.installId}`,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = buildInstallCommand(spec);
|
||||||
|
if (command.error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: command.error,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!command.shell && (!command.argv || command.argv.length === 0)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "invalid install command",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = command.shell
|
||||||
|
? await runShell(command.shell, timeoutMs)
|
||||||
|
: await runCommandWithTimeout(command.argv, { timeoutMs, cwd: command.cwd });
|
||||||
|
|
||||||
|
const success = result.code === 0;
|
||||||
|
return {
|
||||||
|
ok: success,
|
||||||
|
message: success ? "Installed" : "Install failed",
|
||||||
|
stdout: result.stdout.trim(),
|
||||||
|
stderr: result.stderr.trim(),
|
||||||
|
code: result.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
174
src/agents/skills-status.ts
Normal file
174
src/agents/skills-status.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
|
import { CONFIG_DIR } from "../utils.js";
|
||||||
|
import {
|
||||||
|
hasBinary,
|
||||||
|
isConfigPathTruthy,
|
||||||
|
loadWorkspaceSkillEntries,
|
||||||
|
resolveConfigPath,
|
||||||
|
resolveSkillConfig,
|
||||||
|
type SkillEntry,
|
||||||
|
type SkillInstallSpec,
|
||||||
|
} from "./skills.js";
|
||||||
|
|
||||||
|
export type SkillStatusConfigCheck = {
|
||||||
|
path: string;
|
||||||
|
value: unknown;
|
||||||
|
satisfied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillInstallOption = {
|
||||||
|
id: string;
|
||||||
|
kind: SkillInstallSpec["kind"];
|
||||||
|
label: string;
|
||||||
|
bins: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillStatusEntry = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
source: string;
|
||||||
|
filePath: string;
|
||||||
|
baseDir: string;
|
||||||
|
skillKey: string;
|
||||||
|
primaryEnv?: string;
|
||||||
|
always: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
eligible: boolean;
|
||||||
|
requirements: {
|
||||||
|
bins: string[];
|
||||||
|
env: string[];
|
||||||
|
config: string[];
|
||||||
|
};
|
||||||
|
missing: {
|
||||||
|
bins: string[];
|
||||||
|
env: string[];
|
||||||
|
config: string[];
|
||||||
|
};
|
||||||
|
configChecks: SkillStatusConfigCheck[];
|
||||||
|
install: SkillInstallOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillStatusReport = {
|
||||||
|
workspaceDir: string;
|
||||||
|
managedSkillsDir: string;
|
||||||
|
skills: SkillStatusEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSkillKey(entry: SkillEntry): string {
|
||||||
|
return entry.clawdis?.skillKey ?? entry.skill.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInstallOptions(entry: SkillEntry): SkillInstallOption[] {
|
||||||
|
const install = entry.clawdis?.install ?? [];
|
||||||
|
if (install.length === 0) return [];
|
||||||
|
return install.map((spec, index) => {
|
||||||
|
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||||
|
const bins = spec.bins ?? [];
|
||||||
|
let label = (spec.label ?? "").trim();
|
||||||
|
if (!label) {
|
||||||
|
if (spec.kind === "brew" && spec.formula) {
|
||||||
|
label = `Install ${spec.formula} (brew)`;
|
||||||
|
} else if (spec.kind === "node" && spec.package) {
|
||||||
|
label = `Install ${spec.package} (node)`;
|
||||||
|
} else if (spec.kind === "go" && spec.module) {
|
||||||
|
label = `Install ${spec.module} (go)`;
|
||||||
|
} else if (spec.kind === "pnpm" && spec.repoPath) {
|
||||||
|
label = `Install ${spec.repoPath} (pnpm)`;
|
||||||
|
} else if (spec.kind === "git" && spec.url) {
|
||||||
|
label = `Clone ${spec.url}`;
|
||||||
|
} else {
|
||||||
|
label = "Run installer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: spec.kind,
|
||||||
|
label,
|
||||||
|
bins,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillStatus(entry: SkillEntry, config?: ClawdisConfig): SkillStatusEntry {
|
||||||
|
const skillKey = resolveSkillKey(entry);
|
||||||
|
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||||
|
const disabled = skillConfig?.enabled === false;
|
||||||
|
const always = entry.clawdis?.always === true;
|
||||||
|
|
||||||
|
const requiredBins = entry.clawdis?.requires?.bins ?? [];
|
||||||
|
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
||||||
|
const requiredConfig = entry.clawdis?.requires?.config ?? [];
|
||||||
|
|
||||||
|
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
|
||||||
|
|
||||||
|
const missingEnv: string[] = [];
|
||||||
|
for (const envName of requiredEnv) {
|
||||||
|
if (process.env[envName]) continue;
|
||||||
|
if (skillConfig?.env?.[envName]) continue;
|
||||||
|
if (skillConfig?.apiKey && entry.clawdis?.primaryEnv === envName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
missingEnv.push(envName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configChecks: SkillStatusConfigCheck[] = requiredConfig.map((pathStr) => {
|
||||||
|
const value = resolveConfigPath(config, pathStr);
|
||||||
|
const satisfied = isConfigPathTruthy(config, pathStr);
|
||||||
|
return { path: pathStr, value, satisfied };
|
||||||
|
});
|
||||||
|
const missingConfig = configChecks
|
||||||
|
.filter((check) => !check.satisfied)
|
||||||
|
.map((check) => check.path);
|
||||||
|
|
||||||
|
const missing = always
|
||||||
|
? { bins: [], env: [], config: [] }
|
||||||
|
: { bins: missingBins, env: missingEnv, config: missingConfig };
|
||||||
|
const eligible =
|
||||||
|
!disabled &&
|
||||||
|
(always ||
|
||||||
|
(missing.bins.length === 0 &&
|
||||||
|
missing.env.length === 0 &&
|
||||||
|
missing.config.length === 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.skill.name,
|
||||||
|
description: entry.skill.description,
|
||||||
|
source: entry.skill.source,
|
||||||
|
filePath: entry.skill.filePath,
|
||||||
|
baseDir: entry.skill.baseDir,
|
||||||
|
skillKey,
|
||||||
|
primaryEnv: entry.clawdis?.primaryEnv,
|
||||||
|
always,
|
||||||
|
disabled,
|
||||||
|
eligible,
|
||||||
|
requirements: {
|
||||||
|
bins: requiredBins,
|
||||||
|
env: requiredEnv,
|
||||||
|
config: requiredConfig,
|
||||||
|
},
|
||||||
|
missing,
|
||||||
|
configChecks,
|
||||||
|
install: normalizeInstallOptions(entry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceSkillStatus(
|
||||||
|
workspaceDir: string,
|
||||||
|
opts?: {
|
||||||
|
config?: ClawdisConfig;
|
||||||
|
managedSkillsDir?: string;
|
||||||
|
entries?: SkillEntry[];
|
||||||
|
},
|
||||||
|
): SkillStatusReport {
|
||||||
|
const managedSkillsDir =
|
||||||
|
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||||
|
const skillEntries =
|
||||||
|
opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts);
|
||||||
|
return {
|
||||||
|
workspaceDir,
|
||||||
|
managedSkillsDir,
|
||||||
|
skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
buildWorkspaceSkillsPrompt,
|
buildWorkspaceSkillsPrompt,
|
||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
|
import { buildWorkspaceSkillStatus } from "./skills-status.js";
|
||||||
|
|
||||||
async function writeSkill(params: {
|
async function writeSkill(params: {
|
||||||
dir: string;
|
dir: string;
|
||||||
@@ -295,6 +296,34 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildWorkspaceSkillStatus", () => {
|
||||||
|
it("reports missing requirements and install options", async () => {
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
const skillDir = path.join(workspaceDir, "skills", "status-skill");
|
||||||
|
|
||||||
|
await writeSkill({
|
||||||
|
dir: skillDir,
|
||||||
|
name: "status-skill",
|
||||||
|
description: "Needs setup",
|
||||||
|
metadata:
|
||||||
|
'{"clawdis":{"requires":{"bins":["fakebin"],"env":["ENV_KEY"],"config":["browser.enabled"]},"install":[{"id":"brew","kind":"brew","formula":"fakebin","bins":["fakebin"],"label":"Install fakebin"}]}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||||
|
config: { browser: { enabled: false } },
|
||||||
|
});
|
||||||
|
const skill = report.skills.find((entry) => entry.name === "status-skill");
|
||||||
|
|
||||||
|
expect(skill).toBeDefined();
|
||||||
|
expect(skill?.eligible).toBe(false);
|
||||||
|
expect(skill?.missing.bins).toContain("fakebin");
|
||||||
|
expect(skill?.missing.env).toContain("ENV_KEY");
|
||||||
|
expect(skill?.missing.config).toContain("browser.enabled");
|
||||||
|
expect(skill?.install[0]?.id).toBe("brew");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("applySkillEnvOverrides", () => {
|
describe("applySkillEnvOverrides", () => {
|
||||||
it("sets and restores env vars", async () => {
|
it("sets and restores env vars", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ function resolveBundledSkillsDir(): string | undefined {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFrontmatterValue(
|
function getFrontmatterValue(
|
||||||
frontmatter: ParsedSkillFrontmatter,
|
frontmatter: ParsedSkillFrontmatter,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -180,7 +179,10 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
|||||||
"browser.enabled": true,
|
"browser.enabled": true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
export function resolveConfigPath(
|
||||||
|
config: ClawdisConfig | undefined,
|
||||||
|
pathStr: string,
|
||||||
|
) {
|
||||||
const parts = pathStr.split(".").filter(Boolean);
|
const parts = pathStr.split(".").filter(Boolean);
|
||||||
let current: unknown = config;
|
let current: unknown = config;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -190,7 +192,7 @@ function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfigPathTruthy(
|
export function isConfigPathTruthy(
|
||||||
config: ClawdisConfig | undefined,
|
config: ClawdisConfig | undefined,
|
||||||
pathStr: string,
|
pathStr: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -201,7 +203,7 @@ function isConfigPathTruthy(
|
|||||||
return isTruthy(value);
|
return isTruthy(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSkillConfig(
|
export function resolveSkillConfig(
|
||||||
config: ClawdisConfig | undefined,
|
config: ClawdisConfig | undefined,
|
||||||
skillKey: string,
|
skillKey: string,
|
||||||
): SkillConfig | undefined {
|
): SkillConfig | undefined {
|
||||||
@@ -212,7 +214,7 @@ function resolveSkillConfig(
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBinary(bin: string): boolean {
|
export function hasBinary(bin: string): boolean {
|
||||||
const pathEnv = process.env.PATH ?? "";
|
const pathEnv = process.env.PATH ?? "";
|
||||||
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -277,6 +279,7 @@ function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
|
|||||||
return entry?.clawdis?.skillKey ?? skill.name;
|
return entry?.clawdis?.skillKey ?? skill.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function shouldIncludeSkill(params: {
|
function shouldIncludeSkill(params: {
|
||||||
entry: SkillEntry;
|
entry: SkillEntry;
|
||||||
config?: ClawdisConfig;
|
config?: ClawdisConfig;
|
||||||
@@ -326,6 +329,7 @@ function filterSkillEntries(
|
|||||||
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function applySkillEnvOverrides(params: {
|
export function applySkillEnvOverrides(params: {
|
||||||
skills: SkillEntry[];
|
skills: SkillEntry[];
|
||||||
config?: ClawdisConfig;
|
config?: ClawdisConfig;
|
||||||
@@ -435,11 +439,11 @@ function loadSkillEntries(
|
|||||||
const managedSkills = loadSkillsFromDir({
|
const managedSkills = loadSkillsFromDir({
|
||||||
dir: managedSkillsDir,
|
dir: managedSkillsDir,
|
||||||
source: "clawdis-managed",
|
source: "clawdis-managed",
|
||||||
});
|
}).skills;
|
||||||
const workspaceSkills = loadSkillsFromDir({
|
const workspaceSkills = loadSkillsFromDir({
|
||||||
dir: workspaceSkillsDir,
|
dir: workspaceSkillsDir,
|
||||||
source: "clawdis-workspace",
|
source: "clawdis-workspace",
|
||||||
});
|
}).skills;
|
||||||
|
|
||||||
const merged = new Map<string, Skill>();
|
const merged = new Map<string, Skill>();
|
||||||
// Precedence: extra < bundled < managed < workspace
|
// Precedence: extra < bundled < managed < workspace
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
type ConfigSetParams,
|
type ConfigSetParams,
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
|
type SkillsInstallParams,
|
||||||
|
SkillsInstallParamsSchema,
|
||||||
|
type SkillsStatusParams,
|
||||||
|
SkillsStatusParamsSchema,
|
||||||
|
type SkillsUpdateParams,
|
||||||
|
SkillsUpdateParamsSchema,
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
type CronAddParams,
|
type CronAddParams,
|
||||||
@@ -135,6 +141,15 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
|||||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
|
||||||
|
SkillsStatusParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateSkillsInstallParams = ajv.compile<SkillsInstallParams>(
|
||||||
|
SkillsInstallParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(
|
||||||
|
SkillsUpdateParamsSchema,
|
||||||
|
);
|
||||||
export const validateCronListParams =
|
export const validateCronListParams =
|
||||||
ajv.compile<CronListParams>(CronListParamsSchema);
|
ajv.compile<CronListParams>(CronListParamsSchema);
|
||||||
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
|
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
|
||||||
@@ -193,6 +208,9 @@ export {
|
|||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
|
SkillsStatusParamsSchema,
|
||||||
|
SkillsInstallParamsSchema,
|
||||||
|
SkillsUpdateParamsSchema,
|
||||||
CronJobSchema,
|
CronJobSchema,
|
||||||
CronListParamsSchema,
|
CronListParamsSchema,
|
||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
@@ -232,6 +250,9 @@ export type {
|
|||||||
NodePairApproveParams,
|
NodePairApproveParams,
|
||||||
ConfigGetParams,
|
ConfigGetParams,
|
||||||
ConfigSetParams,
|
ConfigSetParams,
|
||||||
|
SkillsStatusParams,
|
||||||
|
SkillsInstallParams,
|
||||||
|
SkillsUpdateParams,
|
||||||
NodePairRejectParams,
|
NodePairRejectParams,
|
||||||
NodePairVerifyParams,
|
NodePairVerifyParams,
|
||||||
NodeListParams,
|
NodeListParams,
|
||||||
|
|||||||
@@ -305,6 +305,30 @@ export const ConfigSetParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SkillsStatusParamsSchema = Type.Object(
|
||||||
|
{},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SkillsInstallParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
name: NonEmptyString,
|
||||||
|
installId: NonEmptyString,
|
||||||
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SkillsUpdateParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
skillKey: NonEmptyString,
|
||||||
|
enabled: Type.Optional(Type.Boolean()),
|
||||||
|
apiKey: Type.Optional(Type.String()),
|
||||||
|
env: Type.Optional(Type.Record(NonEmptyString, Type.String())),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const CronScheduleSchema = Type.Union([
|
export const CronScheduleSchema = Type.Union([
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
@@ -557,6 +581,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
ConfigGetParams: ConfigGetParamsSchema,
|
ConfigGetParams: ConfigGetParamsSchema,
|
||||||
ConfigSetParams: ConfigSetParamsSchema,
|
ConfigSetParams: ConfigSetParamsSchema,
|
||||||
|
SkillsStatusParams: SkillsStatusParamsSchema,
|
||||||
|
SkillsInstallParams: SkillsInstallParamsSchema,
|
||||||
|
SkillsUpdateParams: SkillsUpdateParamsSchema,
|
||||||
CronJob: CronJobSchema,
|
CronJob: CronJobSchema,
|
||||||
CronListParams: CronListParamsSchema,
|
CronListParams: CronListParamsSchema,
|
||||||
CronStatusParams: CronStatusParamsSchema,
|
CronStatusParams: CronStatusParamsSchema,
|
||||||
@@ -600,6 +627,9 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
|||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||||
|
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
|
||||||
|
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
|
||||||
|
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
|
||||||
export type CronJob = Static<typeof CronJobSchema>;
|
export type CronJob = Static<typeof CronJobSchema>;
|
||||||
export type CronListParams = Static<typeof CronListParamsSchema>;
|
export type CronListParams = Static<typeof CronListParamsSchema>;
|
||||||
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;
|
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import chalk from "chalk";
|
|||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
||||||
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
|
import { installSkill } from "../agents/skills-install.js";
|
||||||
|
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
||||||
import {
|
import {
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
normalizeVerboseLevel,
|
||||||
@@ -90,7 +93,7 @@ import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164, resolveUserPath } from "../utils.js";
|
||||||
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
|
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
|
||||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||||
@@ -150,6 +153,9 @@ import {
|
|||||||
validateSendParams,
|
validateSendParams,
|
||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
validateSessionsPatchParams,
|
validateSessionsPatchParams,
|
||||||
|
validateSkillsInstallParams,
|
||||||
|
validateSkillsStatusParams,
|
||||||
|
validateSkillsUpdateParams,
|
||||||
validateWakeParams,
|
validateWakeParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||||
@@ -210,6 +216,9 @@ const METHODS = [
|
|||||||
"status",
|
"status",
|
||||||
"config.get",
|
"config.get",
|
||||||
"config.set",
|
"config.set",
|
||||||
|
"skills.status",
|
||||||
|
"skills.install",
|
||||||
|
"skills.update",
|
||||||
"voicewake.get",
|
"voicewake.get",
|
||||||
"voicewake.set",
|
"voicewake.set",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
@@ -3063,6 +3072,119 @@ export async function startGatewayServer(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "skills.status": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateSkillsStatusParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const workspaceDirRaw =
|
||||||
|
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
|
const workspaceDir = resolveUserPath(workspaceDirRaw);
|
||||||
|
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
respond(true, report, undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "skills.install": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateSkillsInstallParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const p = params as {
|
||||||
|
name: string;
|
||||||
|
installId: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const workspaceDirRaw =
|
||||||
|
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
|
const result = await installSkill({
|
||||||
|
workspaceDir: workspaceDirRaw,
|
||||||
|
skillName: p.name,
|
||||||
|
installId: p.installId,
|
||||||
|
timeoutMs: p.timeoutMs,
|
||||||
|
});
|
||||||
|
respond(
|
||||||
|
result.ok,
|
||||||
|
result,
|
||||||
|
result.ok
|
||||||
|
? undefined
|
||||||
|
: errorShape(ErrorCodes.UNAVAILABLE, result.message),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "skills.update": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateSkillsUpdateParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const p = params as {
|
||||||
|
skillKey: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
apiKey?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const skills = { ...(cfg.skills ?? {}) };
|
||||||
|
const current = { ...(skills[p.skillKey] ?? {}) };
|
||||||
|
if (typeof p.enabled === "boolean") {
|
||||||
|
current.enabled = p.enabled;
|
||||||
|
}
|
||||||
|
if (typeof p.apiKey === "string") {
|
||||||
|
const trimmed = p.apiKey.trim();
|
||||||
|
if (trimmed) current.apiKey = trimmed;
|
||||||
|
else delete current.apiKey;
|
||||||
|
}
|
||||||
|
if (p.env && typeof p.env === "object") {
|
||||||
|
const nextEnv = { ...(current.env ?? {}) };
|
||||||
|
for (const [key, value] of Object.entries(p.env)) {
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (!trimmedKey) continue;
|
||||||
|
const trimmedVal = String(value ?? "").trim();
|
||||||
|
if (!trimmedVal) delete nextEnv[trimmedKey];
|
||||||
|
else nextEnv[trimmedKey] = trimmedVal;
|
||||||
|
}
|
||||||
|
current.env = nextEnv;
|
||||||
|
}
|
||||||
|
skills[p.skillKey] = current;
|
||||||
|
const nextConfig: ClawdisConfig = {
|
||||||
|
...cfg,
|
||||||
|
skills,
|
||||||
|
};
|
||||||
|
await writeConfigFile(nextConfig);
|
||||||
|
respond(
|
||||||
|
true,
|
||||||
|
{ ok: true, skillKey: p.skillKey, config: current },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "sessions.list": {
|
case "sessions.list": {
|
||||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
if (!validateSessionsListParams(params)) {
|
if (!validateSessionsListParams(params)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user