feat: add skills settings and gateway skills management
parent
4b44a75bc1
commit
cc0075e988
|
|
@ -50,6 +50,9 @@ actor GatewayConnection {
|
|||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
case chatAbort = "chat.abort"
|
||||
case skillsStatus = "skills.status"
|
||||
case skillsInstall = "skills.install"
|
||||
case skillsUpdate = "skills.update"
|
||||
case voicewakeGet = "voicewake.get"
|
||||
case voicewakeSet = "voicewake.set"
|
||||
case nodePairApprove = "node.pair.approve"
|
||||
|
|
@ -355,6 +358,42 @@ extension GatewayConnection {
|
|||
return (try? self.decoder.decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
func skillsStatus() async throws -> SkillsStatusReport {
|
||||
try await self.requestDecoded(method: .skillsStatus)
|
||||
}
|
||||
|
||||
func skillsInstall(
|
||||
name: String,
|
||||
installId: String,
|
||||
timeoutMs: Int? = nil) async throws -> SkillInstallResult
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"name": AnyCodable(name),
|
||||
"installId": AnyCodable(installId),
|
||||
]
|
||||
if let timeoutMs {
|
||||
params["timeoutMs"] = AnyCodable(timeoutMs)
|
||||
}
|
||||
return try await self.requestDecoded(method: .skillsInstall, params: params)
|
||||
}
|
||||
|
||||
func skillsUpdate(
|
||||
skillKey: String,
|
||||
enabled: Bool? = nil,
|
||||
apiKey: String? = nil,
|
||||
env: [String: String]? = nil) async throws -> SkillUpdateResult
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"skillKey": AnyCodable(skillKey),
|
||||
]
|
||||
if let enabled { params["enabled"] = AnyCodable(enabled) }
|
||||
if let apiKey { params["apiKey"] = AnyCodable(apiKey) }
|
||||
if let env, !env.isEmpty { params["env"] = AnyCodable(env) }
|
||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
|
||||
|
|
|
|||
|
|
@ -1132,10 +1132,10 @@ struct OnboardingView: View {
|
|||
systemImage: "bell.badge")
|
||||
self.featureActionRow(
|
||||
title: "Give your agent more powers",
|
||||
subtitle: "Install optional tools (Peekaboo, oracle, camsnap, …) from Settings → Tools.",
|
||||
systemImage: "wrench.and.screwdriver")
|
||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
systemImage: "sparkles")
|
||||
{
|
||||
self.openSettings(tab: .tools)
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.onChange(of: self.state.launchAtLogin) { _, newValue in
|
||||
|
|
@ -1259,7 +1259,7 @@ struct OnboardingView: View {
|
|||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Open Settings → Tools", action: action)
|
||||
Button("Open Settings → Skills", action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ struct SettingsRootView: View {
|
|||
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||
.tag(SettingsTab.cron)
|
||||
|
||||
ToolsSettings()
|
||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
||||
.tag(SettingsTab.tools)
|
||||
SkillsSettings()
|
||||
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||
.tag(SettingsTab.skills)
|
||||
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
|
|
@ -125,13 +125,13 @@ struct SettingsRootView: View {
|
|||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
case general, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 658 // +10% (tabs fit better)
|
||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .tools: "Tools"
|
||||
case .skills: "Skills"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
case .config: "Config"
|
||||
|
|
|
|||
|
|
@ -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]?
|
||||
}
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
import ClawdisProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SkillsSettings: View {
|
||||
@State private var model = SkillsSettingsModel()
|
||||
@State private var envEditor: EnvEditorState?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.statusBanner
|
||||
self.skillsList
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.task { await self.model.refresh() }
|
||||
.sheet(item: self.$envEditor) { editor in
|
||||
EnvEditorView(editor: editor) { value in
|
||||
Task {
|
||||
await self.model.updateEnv(
|
||||
skillKey: editor.skillKey,
|
||||
envKey: editor.envKey,
|
||||
value: value,
|
||||
isPrimary: editor.isPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Skills")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Skills are enabled when requirements are met (binaries, env, config).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Refresh") { Task { await self.model.refresh() } }
|
||||
.disabled(self.model.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let error = self.model.error {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
} else if let message = self.model.statusMessage {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var skillsList: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(self.model.skills) { skill in
|
||||
SkillRow(
|
||||
skill: skill,
|
||||
isBusy: self.model.isBusy(skill: skill),
|
||||
onToggleEnabled: { enabled in
|
||||
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
||||
},
|
||||
onInstall: { option in
|
||||
Task { await self.model.install(skill: skill, option: option) }
|
||||
},
|
||||
onSetEnv: { envKey, isPrimary in
|
||||
self.envEditor = EnvEditorState(
|
||||
skillKey: skill.skillKey,
|
||||
skillName: skill.name,
|
||||
envKey: envKey,
|
||||
isPrimary: isPrimary)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SkillRow: View {
|
||||
let skill: SkillStatus
|
||||
let isBusy: Bool
|
||||
let onToggleEnabled: (Bool) -> Void
|
||||
let onInstall: (SkillInstallOption) -> Void
|
||||
let onSetEnv: (String, Bool) -> Void
|
||||
|
||||
private var missingBins: [String] { self.skill.missing.bins }
|
||||
private var missingEnv: [String] { self.skill.missing.env }
|
||||
private var missingConfig: [String] { self.skill.missing.config }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.skill.name)
|
||||
.font(.headline)
|
||||
Text(self.skill.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.sourceLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
self.statusBadge
|
||||
}
|
||||
|
||||
if self.skill.disabled {
|
||||
Text("Disabled in config")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.skill.eligible {
|
||||
Text("Enabled")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
self.missingSummary
|
||||
}
|
||||
|
||||
if !self.skill.configChecks.isEmpty {
|
||||
self.configChecksView
|
||||
}
|
||||
|
||||
self.actionRow
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var sourceLabel: String {
|
||||
self.skill.source.replacingOccurrences(of: "clawdis-", with: "")
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
Group {
|
||||
if self.skill.disabled {
|
||||
Label("Disabled", systemImage: "slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.skill.eligible {
|
||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Needs setup", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var missingSummary: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if !self.missingBins.isEmpty {
|
||||
Text("Missing binaries: \(self.missingBins.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !self.missingEnv.isEmpty {
|
||||
Text("Missing env: \(self.missingEnv.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !self.missingConfig.isEmpty {
|
||||
Text("Requires config: \(self.missingConfig.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var configChecksView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(self.skill.configChecks) { check in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundStyle(check.satisfied ? .green : .secondary)
|
||||
Text(check.path)
|
||||
.font(.caption)
|
||||
Text(self.formatConfigValue(check.value))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
if self.skill.disabled {
|
||||
Button("Enable") { self.onToggleEnabled(true) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isBusy)
|
||||
} else {
|
||||
Button("Disable") { self.onToggleEnabled(false) }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
ForEach(self.installOptions) { option in
|
||||
Button(option.label) { self.onInstall(option) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
ForEach(self.missingEnv, id: \.self) { envKey in
|
||||
let isPrimary = envKey == self.skill.primaryEnv
|
||||
Button(isPrimary ? "Set API Key" : "Set \(envKey)") {
|
||||
self.onSetEnv(envKey, isPrimary)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isBusy)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var installOptions: [SkillInstallOption] {
|
||||
guard !self.missingBins.isEmpty else { return [] }
|
||||
let missing = Set(self.missingBins)
|
||||
return self.skill.install.filter { option in
|
||||
if option.bins.isEmpty { return true }
|
||||
return !missing.isDisjoint(with: option.bins)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
||||
guard let value else { return "" }
|
||||
switch value.value {
|
||||
case let bool as Bool:
|
||||
return bool ? "true" : "false"
|
||||
case let int as Int:
|
||||
return String(int)
|
||||
case let double as Double:
|
||||
return String(double)
|
||||
case let string as String:
|
||||
return string
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnvEditorState: Identifiable {
|
||||
let skillKey: String
|
||||
let skillName: String
|
||||
let envKey: String
|
||||
let isPrimary: Bool
|
||||
|
||||
var id: String { "\(self.skillKey)::\(self.envKey)" }
|
||||
}
|
||||
|
||||
private struct EnvEditorView: View {
|
||||
let editor: EnvEditorState
|
||||
let onSave: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var value: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
Text(self.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField(self.editor.envKey, text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("Cancel") { self.dismiss() }
|
||||
Spacer()
|
||||
Button("Save") {
|
||||
self.onSave(self.value)
|
||||
self.dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 420)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
"Skill: \(self.editor.skillName)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SkillsSettingsModel {
|
||||
var skills: [SkillStatus] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var statusMessage: String?
|
||||
private var busySkills: Set<String> = []
|
||||
|
||||
func isBusy(skill: SkillStatus) -> Bool {
|
||||
self.busySkills.contains(skill.skillKey)
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !self.isLoading else { return }
|
||||
self.isLoading = true
|
||||
self.error = nil
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
self.skills = report.skills.sorted { $0.name < $1.name }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
func install(skill: SkillStatus, option: SkillInstallOption) async {
|
||||
await self.withBusy(skill.skillKey) {
|
||||
do {
|
||||
let result = try await GatewayConnection.shared.skillsInstall(
|
||||
name: skill.name,
|
||||
installId: option.id,
|
||||
timeoutMs: 300_000)
|
||||
self.statusMessage = result.message
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func setEnabled(skillKey: String, enabled: Bool) async {
|
||||
await self.withBusy(skillKey) {
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
enabled: enabled)
|
||||
self.statusMessage = enabled ? "Skill enabled" : "Skill disabled"
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async {
|
||||
await self.withBusy(skillKey) {
|
||||
do {
|
||||
if isPrimary {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
apiKey: value)
|
||||
self.statusMessage = "Saved API key"
|
||||
} else {
|
||||
_ = try await GatewayConnection.shared.skillsUpdate(
|
||||
skillKey: skillKey,
|
||||
env: [envKey: value])
|
||||
self.statusMessage = "Saved \(envKey)"
|
||||
}
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func withBusy(_ id: String, _ work: @escaping () async -> Void) async {
|
||||
self.busySkills.insert(id)
|
||||
defer { self.busySkills.remove(id) }
|
||||
await work()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct SkillsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SkillsSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum NodePackageManager: String, CaseIterable, Identifiable {
|
||||
case npm
|
||||
case pnpm
|
||||
case bun
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .npm: "NPM"
|
||||
case .pnpm: "PNPM"
|
||||
case .bun: "Bun"
|
||||
}
|
||||
}
|
||||
|
||||
var installCommandPrefix: String {
|
||||
switch self {
|
||||
case .npm: "npm install -g"
|
||||
case .pnpm: "pnpm add -g"
|
||||
case .bun: "bun add -g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data models
|
||||
|
||||
private enum InstallMethod: Equatable {
|
||||
case brew(formula: String, binary: String)
|
||||
case node(package: String, binary: String)
|
||||
case go(module: String, binary: String)
|
||||
case pnpm(repoPath: String, script: String, binary: String)
|
||||
case gitClone(url: String, destination: String)
|
||||
case mcporter(name: String, command: String, summary: String)
|
||||
|
||||
var binary: String? {
|
||||
switch self {
|
||||
case let .brew(_, binary),
|
||||
let .node(_, binary),
|
||||
let .go(_, binary),
|
||||
let .pnpm(_, _, binary):
|
||||
binary
|
||||
case .gitClone:
|
||||
nil
|
||||
case .mcporter:
|
||||
"mcporter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolEntry: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let url: URL
|
||||
let description: String
|
||||
let method: InstallMethod
|
||||
let kind: Kind
|
||||
|
||||
enum Kind: String {
|
||||
case tool = "Tools"
|
||||
case mcp = "MCP Servers"
|
||||
}
|
||||
}
|
||||
|
||||
private enum InstallState: Equatable {
|
||||
case checking
|
||||
case notInstalled
|
||||
case installed
|
||||
case installing
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct ToolsSettings: View {
|
||||
private let tools: [ToolEntry] = Self.makeTools()
|
||||
|
||||
static var toolIDsForTests: [String] {
|
||||
makeTools().map(\.id)
|
||||
}
|
||||
|
||||
private static func makeTools() -> [ToolEntry] {
|
||||
[
|
||||
ToolEntry(
|
||||
id: "mcporter",
|
||||
name: "🧳 mcporter",
|
||||
url: URL(string: "https://github.com/steipete/mcporter")!,
|
||||
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
|
||||
method: .node(package: "mcporter", binary: "mcporter"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "peekaboo",
|
||||
name: "🫣 Peekaboo",
|
||||
url: URL(string: "https://github.com/steipete/Peekaboo")!,
|
||||
description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.",
|
||||
method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "camsnap",
|
||||
name: "📸 camsnap",
|
||||
url: URL(string: "https://github.com/steipete/camsnap")!,
|
||||
description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.",
|
||||
method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "oracle",
|
||||
name: "🧿 oracle",
|
||||
url: URL(string: "https://github.com/steipete/oracle")!,
|
||||
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
|
||||
method: .node(package: "@steipete/oracle", binary: "oracle"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "summarize",
|
||||
name: "🧾 summarize",
|
||||
url: URL(string: "https://github.com/steipete/summarize")!,
|
||||
description: "Link → clean text → summary (web pages, YouTube, and local/remote files).",
|
||||
method: .brew(formula: "steipete/tap/summarize", binary: "summarize"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "qmd",
|
||||
name: "🔎 qmd",
|
||||
url: URL(string: "https://github.com/tobi/qmd")!,
|
||||
description: "Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.",
|
||||
method: .node(package: "https://github.com/tobi/qmd", binary: "qmd"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "eightctl",
|
||||
name: "🛏️ eightctl",
|
||||
url: URL(string: "https://github.com/steipete/eightctl")!,
|
||||
description: "Control your sleep, from the terminal.",
|
||||
method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "imsg",
|
||||
name: "💬 imsg",
|
||||
url: URL(string: "https://github.com/steipete/imsg")!,
|
||||
description: "Send, read, stream iMessage & SMS.",
|
||||
method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "wacli",
|
||||
name: "🗃️ wacli",
|
||||
url: URL(string: "https://github.com/steipete/wacli")!,
|
||||
description: "WhatsApp CLI: sync, search, send.",
|
||||
method: .go(module: "github.com/steipete/wacli/cmd/wacli@latest", binary: "wacli"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "spotify-player",
|
||||
name: "🎵 spotify-player",
|
||||
url: URL(string: "https://github.com/aome510/spotify-player")!,
|
||||
description: "Terminal Spotify client to queue, search, and control playback without leaving chat.",
|
||||
method: .brew(formula: "spotify_player", binary: "spotify_player"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "sonoscli",
|
||||
name: "🔊 sonoscli",
|
||||
url: URL(string: "https://github.com/steipete/sonoscli")!,
|
||||
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
|
||||
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "blucli",
|
||||
name: "🫐 blucli",
|
||||
url: URL(string: "https://github.com/steipete/blucli")!,
|
||||
description: "Play, group, and automate BluOS players from scripts.",
|
||||
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "sag",
|
||||
name: "🗣️ sag",
|
||||
url: URL(string: "https://github.com/steipete/sag")!,
|
||||
description: "ElevenLabs speech with mac-style say UX; streams to speakers by default.",
|
||||
method: .brew(formula: "steipete/tap/sag", binary: "sag"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "openhue-cli",
|
||||
name: "💡 OpenHue CLI",
|
||||
url: URL(string: "https://github.com/openhue/openhue-cli")!,
|
||||
description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.",
|
||||
method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "openai-whisper",
|
||||
name: "🎙️ OpenAI Whisper",
|
||||
url: URL(string: "https://github.com/openai/whisper")!,
|
||||
description: "Local speech-to-text for quick dictation and voicemail transcripts.",
|
||||
method: .brew(formula: "openai-whisper", binary: "whisper"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "gog",
|
||||
name: "📮 gog",
|
||||
url: URL(string: "https://github.com/steipete/gogcli")!,
|
||||
description: "Google Suite CLI: Gmail, Calendar, Drive, Contacts.",
|
||||
method: .brew(formula: "steipete/tap/gogcli", binary: "gog"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "gemini-cli",
|
||||
name: "♊️ Gemini CLI",
|
||||
url: URL(string: "https://github.com/google-gemini/gemini-cli")!,
|
||||
description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.",
|
||||
method: .brew(formula: "gemini-cli", binary: "gemini"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "bird",
|
||||
name: "🐦 bird",
|
||||
url: URL(string: "https://github.com/steipete/bird")!,
|
||||
description: "Fast X/Twitter CLI to tweet, reply, read threads, and search without a browser.",
|
||||
method: .pnpm(
|
||||
repoPath: "\(NSHomeDirectory())/Projects/bird",
|
||||
script: "binary",
|
||||
binary: "bird"),
|
||||
kind: .tool),
|
||||
ToolEntry(
|
||||
id: "agent-tools",
|
||||
name: "🧰 agent-tools",
|
||||
url: URL(string: "https://github.com/badlogic/agent-tools")!,
|
||||
description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.",
|
||||
method: .gitClone(
|
||||
url: "https://github.com/badlogic/agent-tools.git",
|
||||
destination: "\(NSHomeDirectory())/agent-tools"),
|
||||
kind: .tool),
|
||||
]
|
||||
}
|
||||
|
||||
@AppStorage("tools.packageManager") private var packageManagerRaw = NodePackageManager.npm.rawValue
|
||||
@State private var installStates: [String: InstallState] = [:]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.packageManagerPicker
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
self.section(for: .tool, title: "CLI Tools")
|
||||
self.section(for: .mcp, title: "MCP Servers")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, 12)
|
||||
.onChange(of: self.packageManagerRaw) { _, _ in
|
||||
self.refreshAll()
|
||||
}
|
||||
.task { self.refreshAll() }
|
||||
}
|
||||
|
||||
private var packageManager: NodePackageManager {
|
||||
NodePackageManager(rawValue: self.packageManagerRaw) ?? .npm
|
||||
}
|
||||
|
||||
private var packageManagerPicker: some View {
|
||||
Picker("Preferred package manager", selection: self.$packageManagerRaw) {
|
||||
ForEach(NodePackageManager.allCases) { manager in
|
||||
Text(manager.label).tag(manager.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 340)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
|
||||
let filtered = self.tools.filter { $0.kind == kind }
|
||||
if filtered.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(.top, 6)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(filtered) { tool in
|
||||
ToolRow(
|
||||
tool: tool,
|
||||
state: self.binding(for: tool),
|
||||
packageManager: self.packageManager,
|
||||
refreshState: { await self.refresh(tool: tool) })
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func binding(for tool: ToolEntry) -> Binding<InstallState> {
|
||||
let current = self.installStates[tool.id] ?? .checking
|
||||
return Binding(
|
||||
get: { self.installStates[tool.id] ?? current },
|
||||
set: { self.installStates[tool.id] = $0 })
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
Task {
|
||||
for tool in self.tools {
|
||||
await self.refresh(tool: tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refresh(tool: ToolEntry) async {
|
||||
let installed = await ToolInstaller.isInstalled(tool.method, packageManager: self.packageManager)
|
||||
self.installStates[tool.id] = installed ? .installed : .notInstalled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private struct ToolRow: View {
|
||||
let tool: ToolEntry
|
||||
@Binding var state: InstallState
|
||||
@State private var statusMessage: String?
|
||||
@State private var linkHovering = false
|
||||
let packageManager: NodePackageManager
|
||||
let refreshState: () async -> Void
|
||||
|
||||
private enum Layout {
|
||||
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
|
||||
static let actionWidth: CGFloat = 96
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Link(destination: self.tool.url) {
|
||||
Text(self.tool.name)
|
||||
.font(.headline)
|
||||
.underline(self.linkHovering, color: .accentColor)
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.onHover { self.linkHovering = $0 }
|
||||
.pointingHandCursor()
|
||||
Text(self.tool.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
self.actionButton
|
||||
}
|
||||
|
||||
if let statusMessage, !statusMessage.isEmpty {
|
||||
Text(statusMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear { self.refresh() }
|
||||
}
|
||||
|
||||
private var actionButton: some View {
|
||||
VStack {
|
||||
switch self.state {
|
||||
case .installed:
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.subheadline)
|
||||
case .installing:
|
||||
ProgressView().controlSize(.small)
|
||||
case .failed:
|
||||
Button("Retry") { self.install() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .checking:
|
||||
ProgressView().controlSize(.small)
|
||||
case .notInstalled:
|
||||
Button("Install") { self.install() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(width: Layout.actionWidth, alignment: .trailing)
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
Task {
|
||||
self.state = .checking
|
||||
let installed = await ToolInstaller.isInstalled(self.tool.method, packageManager: self.packageManager)
|
||||
await MainActor.run {
|
||||
self.state = installed ? .installed : .notInstalled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func install() {
|
||||
Task {
|
||||
self.state = .installing
|
||||
let result = await ToolInstaller.install(self.tool.method, packageManager: self.packageManager)
|
||||
await MainActor.run {
|
||||
self.statusMessage = result.message
|
||||
self.state = result.installed ? .installed : .failed(result.message)
|
||||
if result.installed { Task { await self.refreshState() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Installer
|
||||
|
||||
private enum ToolInstaller {
|
||||
struct InstallResult {
|
||||
let installed: Bool
|
||||
let message: String
|
||||
}
|
||||
|
||||
static func isInstalled(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> Bool {
|
||||
switch method {
|
||||
case let .brew(formula, _):
|
||||
return await self.shellSucceeds("brew list --versions \(formula)")
|
||||
case let .node(_, binary),
|
||||
let .go(_, binary),
|
||||
let .pnpm(_, _, binary):
|
||||
return await self.commandExists(binary)
|
||||
case let .gitClone(_, destination):
|
||||
return FileManager.default.fileExists(atPath: destination)
|
||||
case let .mcporter(name, _, _):
|
||||
guard await self.commandExists("mcporter") else { return false }
|
||||
return await self.shellSucceeds("mcporter config get \(name) --json")
|
||||
}
|
||||
}
|
||||
|
||||
static func install(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> InstallResult {
|
||||
switch method {
|
||||
case let .brew(formula, _):
|
||||
return await self.runInstall("brew install \(formula)")
|
||||
case let .node(package, _):
|
||||
return await self.runInstall("\(packageManager.installCommandPrefix) \(package)")
|
||||
case let .go(module, _):
|
||||
return await self.runInstall("GO111MODULE=on go install \(module)")
|
||||
case let .pnpm(repoPath, script, _):
|
||||
let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)"
|
||||
return await self.runInstall(cmd)
|
||||
case let .gitClone(url, destination):
|
||||
let cmd = """
|
||||
if [ -d \(escape(destination)) ]; then
|
||||
echo "Already cloned"
|
||||
else
|
||||
git clone \(url) \(escape(destination))
|
||||
fi
|
||||
"""
|
||||
return await self.runInstall(cmd)
|
||||
case let .mcporter(name, command, summary):
|
||||
let cmd = """
|
||||
mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)"
|
||||
"""
|
||||
return await self.runInstall(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func commandExists(_ binary: String) async -> Bool {
|
||||
await self.shellSucceeds("command -v \(binary)")
|
||||
}
|
||||
|
||||
private static func shellSucceeds(_ command: String) async -> Bool {
|
||||
let status = await run(command).status
|
||||
return status == 0
|
||||
}
|
||||
|
||||
private static func runInstall(_ command: String) async -> InstallResult {
|
||||
let result = await run(command)
|
||||
let success = result.status == 0
|
||||
let message = result.output.isEmpty ? (success ? "Installed" : "Install failed") : result.output
|
||||
return InstallResult(installed: success, message: message.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
private static func escape(_ path: String) -> String {
|
||||
"\"\(path.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
}
|
||||
|
||||
private static func run(_ command: String) async -> (status: Int32, output: String) {
|
||||
await withCheckedContinuation { continuation in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/zsh"
|
||||
process.arguments = ["-lc", command]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.terminationHandler = { proc in
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
continuation.resume(returning: (proc.terminationStatus, output))
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(returning: (1, error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ToolsSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ToolsSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -621,6 +621,55 @@ public struct ConfigSetParams: Codable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
installid: String,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.name = name
|
||||
self.installid = installid
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case installid = "installId"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
public let env: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
skillkey: String,
|
||||
enabled: Bool?,
|
||||
apikey: String?,
|
||||
env: [String: AnyCodable]?
|
||||
) {
|
||||
self.skillkey = skillkey
|
||||
self.enabled = enabled
|
||||
self.apikey = apikey
|
||||
self.env = env
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case skillkey = "skillKey"
|
||||
case enabled
|
||||
case apikey = "apiKey"
|
||||
case env
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
|
|
|
|||
|
|
@ -144,12 +144,8 @@ struct SettingsViewSmokeTests {
|
|||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func toolsSettingsBuildsBody() {
|
||||
let view = ToolsSettings()
|
||||
@Test func skillsSettingsBuildsBody() {
|
||||
let view = SkillsSettings()
|
||||
_ = 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:
|
||||
- Starting a new Clawdis agent session
|
||||
- Enabling or auditing default tools
|
||||
- Enabling or auditing default skills
|
||||
---
|
||||
# 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
|
||||
```
|
||||
|
||||
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
|
||||
cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md
|
||||
|
|
@ -62,16 +62,16 @@ git commit -m "Add Clawd workspace"
|
|||
```
|
||||
|
||||
## 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.
|
||||
- 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)
|
||||
- **mcporter** — MCP runtime/CLI to list, call, and sync Model Context Protocol servers.
|
||||
## Core Skills (enable in Settings → Skills)
|
||||
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||
- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis.
|
||||
- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams.
|
||||
- **oracle** — OpenAI-ready agent 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.
|
||||
- **imsg** — Send, read, stream iMessage & SMS.
|
||||
- **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.
|
||||
- **Gemini CLI** — Google Gemini models from the terminal for fast Q&A.
|
||||
- **bird** — X/Twitter CLI to tweet, reply, read threads, and search without a browser.
|
||||
- **agent-tools** — Utility toolkit for automations and MCP-friendly scripts.
|
||||
|
||||
## MCP Servers (added via mcporter)
|
||||
- **Gmail MCP** (`gmail`) — Search, read, and send Gmail messages.
|
||||
- **Google Calendar MCP** (`google-calendar`) — List, create, and update events.
|
||||
- **agent-tools** — Utility toolkit for automations and helper scripts.
|
||||
|
||||
## Usage Notes
|
||||
- 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.
|
||||
- For MCPs, mcporter writes to the home-scope config; re-run installs if you rotate tokens.
|
||||
- Run installs from the Skills tab; it hides the button if a binary is already present.
|
||||
- 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 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
|
||||
|
||||
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`)
|
||||
- When off: no browser is launched, and browser tools return "disabled".
|
||||
|
|
|
|||
|
|
@ -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.config` — list of `clawdis.json` paths that must be truthy.
|
||||
- `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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
loadWorkspaceSkillEntries,
|
||||
} from "./skills.js";
|
||||
import { buildWorkspaceSkillStatus } from "./skills-status.js";
|
||||
|
||||
async function writeSkill(params: {
|
||||
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", () => {
|
||||
it("sets and restores env vars", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ function resolveBundledSkillsDir(): string | undefined {
|
|||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFrontmatterValue(
|
||||
frontmatter: ParsedSkillFrontmatter,
|
||||
key: string,
|
||||
|
|
@ -180,7 +179,10 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
|||
"browser.enabled": true,
|
||||
};
|
||||
|
||||
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
||||
export function resolveConfigPath(
|
||||
config: ClawdisConfig | undefined,
|
||||
pathStr: string,
|
||||
) {
|
||||
const parts = pathStr.split(".").filter(Boolean);
|
||||
let current: unknown = config;
|
||||
for (const part of parts) {
|
||||
|
|
@ -190,7 +192,7 @@ function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
|||
return current;
|
||||
}
|
||||
|
||||
function isConfigPathTruthy(
|
||||
export function isConfigPathTruthy(
|
||||
config: ClawdisConfig | undefined,
|
||||
pathStr: string,
|
||||
): boolean {
|
||||
|
|
@ -201,7 +203,7 @@ function isConfigPathTruthy(
|
|||
return isTruthy(value);
|
||||
}
|
||||
|
||||
function resolveSkillConfig(
|
||||
export function resolveSkillConfig(
|
||||
config: ClawdisConfig | undefined,
|
||||
skillKey: string,
|
||||
): SkillConfig | undefined {
|
||||
|
|
@ -212,7 +214,7 @@ function resolveSkillConfig(
|
|||
return entry;
|
||||
}
|
||||
|
||||
function hasBinary(bin: string): boolean {
|
||||
export function hasBinary(bin: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
|
|
@ -277,6 +279,7 @@ function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
|
|||
return entry?.clawdis?.skillKey ?? skill.name;
|
||||
}
|
||||
|
||||
|
||||
function shouldIncludeSkill(params: {
|
||||
entry: SkillEntry;
|
||||
config?: ClawdisConfig;
|
||||
|
|
@ -326,6 +329,7 @@ function filterSkillEntries(
|
|||
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
||||
}
|
||||
|
||||
|
||||
export function applySkillEnvOverrides(params: {
|
||||
skills: SkillEntry[];
|
||||
config?: ClawdisConfig;
|
||||
|
|
@ -435,11 +439,11 @@ function loadSkillEntries(
|
|||
const managedSkills = loadSkillsFromDir({
|
||||
dir: managedSkillsDir,
|
||||
source: "clawdis-managed",
|
||||
});
|
||||
}).skills;
|
||||
const workspaceSkills = loadSkillsFromDir({
|
||||
dir: workspaceSkillsDir,
|
||||
source: "clawdis-workspace",
|
||||
});
|
||||
}).skills;
|
||||
|
||||
const merged = new Map<string, Skill>();
|
||||
// Precedence: extra < bundled < managed < workspace
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import {
|
|||
ConfigGetParamsSchema,
|
||||
type ConfigSetParams,
|
||||
ConfigSetParamsSchema,
|
||||
type SkillsInstallParams,
|
||||
SkillsInstallParamsSchema,
|
||||
type SkillsStatusParams,
|
||||
SkillsStatusParamsSchema,
|
||||
type SkillsUpdateParams,
|
||||
SkillsUpdateParamsSchema,
|
||||
type ConnectParams,
|
||||
ConnectParamsSchema,
|
||||
type CronAddParams,
|
||||
|
|
@ -135,6 +141,15 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
|||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||
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 =
|
||||
ajv.compile<CronListParams>(CronListParamsSchema);
|
||||
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
|
||||
|
|
@ -193,6 +208,9 @@ export {
|
|||
SessionsPatchParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
SkillsStatusParamsSchema,
|
||||
SkillsInstallParamsSchema,
|
||||
SkillsUpdateParamsSchema,
|
||||
CronJobSchema,
|
||||
CronListParamsSchema,
|
||||
CronStatusParamsSchema,
|
||||
|
|
@ -232,6 +250,9 @@ export type {
|
|||
NodePairApproveParams,
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
SkillsStatusParams,
|
||||
SkillsInstallParams,
|
||||
SkillsUpdateParams,
|
||||
NodePairRejectParams,
|
||||
NodePairVerifyParams,
|
||||
NodeListParams,
|
||||
|
|
|
|||
|
|
@ -305,6 +305,30 @@ export const ConfigSetParamsSchema = Type.Object(
|
|||
{ 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([
|
||||
Type.Object(
|
||||
{
|
||||
|
|
@ -557,6 +581,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
SkillsStatusParams: SkillsStatusParamsSchema,
|
||||
SkillsInstallParams: SkillsInstallParamsSchema,
|
||||
SkillsUpdateParams: SkillsUpdateParamsSchema,
|
||||
CronJob: CronJobSchema,
|
||||
CronListParams: CronListParamsSchema,
|
||||
CronStatusParams: CronStatusParamsSchema,
|
||||
|
|
@ -600,6 +627,9 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
|||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
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 CronListParams = Static<typeof CronListParamsSchema>;
|
||||
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import chalk from "chalk";
|
|||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { lookupContextTokens } from "../agents/context.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 {
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
|
|
@ -90,7 +93,7 @@ import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
|||
import { defaultRuntime } from "../runtime.js";
|
||||
import { monitorTelegramProvider } from "../telegram/monitor.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 { sendMessageWhatsApp } from "../web/outbound.js";
|
||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||
|
|
@ -150,6 +153,9 @@ import {
|
|||
validateSendParams,
|
||||
validateSessionsListParams,
|
||||
validateSessionsPatchParams,
|
||||
validateSkillsInstallParams,
|
||||
validateSkillsStatusParams,
|
||||
validateSkillsUpdateParams,
|
||||
validateWakeParams,
|
||||
} from "./protocol/index.js";
|
||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||
|
|
@ -210,6 +216,9 @@ const METHODS = [
|
|||
"status",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
"skills.update",
|
||||
"voicewake.get",
|
||||
"voicewake.set",
|
||||
"sessions.list",
|
||||
|
|
@ -3063,6 +3072,119 @@ export async function startGatewayServer(
|
|||
);
|
||||
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": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateSessionsListParams(params)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue