feat: add skills settings and gateway skills management

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

View File

@@ -50,6 +50,9 @@ actor GatewayConnection {
case chatHistory = "chat.history" case 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 {

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import ClawdisProtocol
import Foundation
struct SkillsStatusReport: Codable {
let workspaceDir: String
let managedSkillsDir: String
let skills: [SkillStatus]
}
struct SkillStatus: Codable, Identifiable {
let name: String
let description: String
let source: String
let filePath: String
let baseDir: String
let skillKey: String
let primaryEnv: String?
let always: Bool
let disabled: Bool
let eligible: Bool
let requirements: SkillRequirements
let missing: SkillMissing
let configChecks: [SkillStatusConfigCheck]
let install: [SkillInstallOption]
var id: String { name }
}
struct SkillRequirements: Codable {
let bins: [String]
let env: [String]
let config: [String]
}
struct SkillMissing: Codable {
let bins: [String]
let env: [String]
let config: [String]
}
struct SkillStatusConfigCheck: Codable, Identifiable {
let path: String
let value: AnyCodable?
let satisfied: Bool
var id: String { path }
}
struct SkillInstallOption: Codable, Identifiable {
let id: String
let kind: String
let label: String
let bins: [String]
}
struct SkillInstallResult: Codable {
let ok: Bool
let message: String
let stdout: String?
let stderr: String?
let code: Int?
}
struct SkillUpdateResult: Codable {
let ok: Bool
let skillKey: String
let config: [String: AnyCodable]?
}

View File

@@ -0,0 +1,395 @@
import ClawdisProtocol
import Observation
import SwiftUI
struct SkillsSettings: View {
@State private var model = SkillsSettingsModel()
@State private var envEditor: EnvEditorState?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
self.statusBanner
self.skillsList
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
}
.task { await self.model.refresh() }
.sheet(item: self.$envEditor) { editor in
EnvEditorView(editor: editor) { value in
Task {
await self.model.updateEnv(
skillKey: editor.skillKey,
envKey: editor.envKey,
value: value,
isPrimary: editor.isPrimary)
}
}
}
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text("Skills")
.font(.title3.weight(.semibold))
Text("Skills are enabled when requirements are met (binaries, env, config).")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer()
Button("Refresh") { Task { await self.model.refresh() } }
.disabled(self.model.isLoading)
}
}
@ViewBuilder
private var statusBanner: some View {
if let error = self.model.error {
Text(error)
.font(.footnote)
.foregroundStyle(.orange)
} else if let message = self.model.statusMessage {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var skillsList: some View {
VStack(spacing: 10) {
ForEach(self.model.skills) { skill in
SkillRow(
skill: skill,
isBusy: self.model.isBusy(skill: skill),
onToggleEnabled: { enabled in
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
},
onInstall: { option in
Task { await self.model.install(skill: skill, option: option) }
},
onSetEnv: { envKey, isPrimary in
self.envEditor = EnvEditorState(
skillKey: skill.skillKey,
skillName: skill.name,
envKey: envKey,
isPrimary: isPrimary)
})
}
}
}
}
private struct SkillRow: View {
let skill: SkillStatus
let isBusy: Bool
let onToggleEnabled: (Bool) -> Void
let onInstall: (SkillInstallOption) -> Void
let onSetEnv: (String, Bool) -> Void
private var missingBins: [String] { self.skill.missing.bins }
private var missingEnv: [String] { self.skill.missing.env }
private var missingConfig: [String] { self.skill.missing.config }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(self.skill.name)
.font(.headline)
Text(self.skill.description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(self.sourceLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
self.statusBadge
}
if self.skill.disabled {
Text("Disabled in config")
.font(.caption)
.foregroundStyle(.secondary)
} else if self.skill.eligible {
Text("Enabled")
.font(.caption)
.foregroundStyle(.secondary)
} else {
self.missingSummary
}
if !self.skill.configChecks.isEmpty {
self.configChecksView
}
self.actionRow
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
}
private var sourceLabel: String {
self.skill.source.replacingOccurrences(of: "clawdis-", with: "")
}
private var statusBadge: some View {
Group {
if self.skill.disabled {
Label("Disabled", systemImage: "slash.circle")
.foregroundStyle(.secondary)
} else if self.skill.eligible {
Label("Ready", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Label("Needs setup", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
.font(.subheadline)
}
@ViewBuilder
private var missingSummary: some View {
VStack(alignment: .leading, spacing: 4) {
if !self.missingBins.isEmpty {
Text("Missing binaries: \(self.missingBins.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if !self.missingEnv.isEmpty {
Text("Missing env: \(self.missingEnv.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if !self.missingConfig.isEmpty {
Text("Requires config: \(self.missingConfig.joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var configChecksView: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(self.skill.configChecks) { check in
HStack(spacing: 6) {
Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(check.satisfied ? .green : .secondary)
Text(check.path)
.font(.caption)
Text(self.formatConfigValue(check.value))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var actionRow: some View {
HStack(spacing: 8) {
if self.skill.disabled {
Button("Enable") { self.onToggleEnabled(true) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
} else {
Button("Disable") { self.onToggleEnabled(false) }
.buttonStyle(.bordered)
.disabled(self.isBusy)
}
ForEach(self.installOptions) { option in
Button(option.label) { self.onInstall(option) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
}
ForEach(self.missingEnv, id: \.self) { envKey in
let isPrimary = envKey == self.skill.primaryEnv
Button(isPrimary ? "Set API Key" : "Set \(envKey)") {
self.onSetEnv(envKey, isPrimary)
}
.buttonStyle(.bordered)
.disabled(self.isBusy)
}
Spacer(minLength: 0)
}
}
private var installOptions: [SkillInstallOption] {
guard !self.missingBins.isEmpty else { return [] }
let missing = Set(self.missingBins)
return self.skill.install.filter { option in
if option.bins.isEmpty { return true }
return !missing.isDisjoint(with: option.bins)
}
}
private func formatConfigValue(_ value: AnyCodable?) -> String {
guard let value else { return "" }
switch value.value {
case let bool as Bool:
return bool ? "true" : "false"
case let int as Int:
return String(int)
case let double as Double:
return String(double)
case let string as String:
return string
default:
return ""
}
}
}
private struct EnvEditorState: Identifiable {
let skillKey: String
let skillName: String
let envKey: String
let isPrimary: Bool
var id: String { "\(self.skillKey)::\(self.envKey)" }
}
private struct EnvEditorView: View {
let editor: EnvEditorState
let onSave: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var value: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(self.title)
.font(.headline)
Text(self.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
SecureField(self.editor.envKey, text: self.$value)
.textFieldStyle(.roundedBorder)
HStack {
Button("Cancel") { self.dismiss() }
Spacer()
Button("Save") {
self.onSave(self.value)
self.dismiss()
}
.buttonStyle(.borderedProminent)
.disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(20)
.frame(width: 420)
}
private var title: String {
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
}
private var subtitle: String {
"Skill: \(self.editor.skillName)"
}
}
@MainActor
@Observable
final class SkillsSettingsModel {
var skills: [SkillStatus] = []
var isLoading = false
var error: String?
var statusMessage: String?
private var busySkills: Set<String> = []
func isBusy(skill: SkillStatus) -> Bool {
self.busySkills.contains(skill.skillKey)
}
func refresh() async {
guard !self.isLoading else { return }
self.isLoading = true
self.error = nil
do {
let report = try await GatewayConnection.shared.skillsStatus()
self.skills = report.skills.sorted { $0.name < $1.name }
} catch {
self.error = error.localizedDescription
}
self.isLoading = false
}
func install(skill: SkillStatus, option: SkillInstallOption) async {
await self.withBusy(skill.skillKey) {
do {
let result = try await GatewayConnection.shared.skillsInstall(
name: skill.name,
installId: option.id,
timeoutMs: 300_000)
self.statusMessage = result.message
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
func setEnabled(skillKey: String, enabled: Bool) async {
await self.withBusy(skillKey) {
do {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
enabled: enabled)
self.statusMessage = enabled ? "Skill enabled" : "Skill disabled"
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async {
await self.withBusy(skillKey) {
do {
if isPrimary {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
apiKey: value)
self.statusMessage = "Saved API key"
} else {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
env: [envKey: value])
self.statusMessage = "Saved \(envKey)"
}
} catch {
self.statusMessage = error.localizedDescription
}
await self.refresh()
}
}
private func withBusy(_ id: String, _ work: @escaping () async -> Void) async {
self.busySkills.insert(id)
defer { self.busySkills.remove(id) }
await work()
}
}
#if DEBUG
struct SkillsSettings_Previews: PreviewProvider {
static var previews: some View {
SkillsSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -1,508 +0,0 @@
import AppKit
import SwiftUI
private enum NodePackageManager: String, CaseIterable, Identifiable {
case npm
case pnpm
case bun
var id: String { self.rawValue }
var label: String {
switch self {
case .npm: "NPM"
case .pnpm: "PNPM"
case .bun: "Bun"
}
}
var installCommandPrefix: String {
switch self {
case .npm: "npm install -g"
case .pnpm: "pnpm add -g"
case .bun: "bun add -g"
}
}
}
// MARK: - Data models
private enum InstallMethod: Equatable {
case brew(formula: String, binary: String)
case node(package: String, binary: String)
case go(module: String, binary: String)
case pnpm(repoPath: String, script: String, binary: String)
case gitClone(url: String, destination: String)
case mcporter(name: String, command: String, summary: String)
var binary: String? {
switch self {
case let .brew(_, binary),
let .node(_, binary),
let .go(_, binary),
let .pnpm(_, _, binary):
binary
case .gitClone:
nil
case .mcporter:
"mcporter"
}
}
}
private struct ToolEntry: Identifiable, Equatable {
let id: String
let name: String
let url: URL
let description: String
let method: InstallMethod
let kind: Kind
enum Kind: String {
case tool = "Tools"
case mcp = "MCP Servers"
}
}
private enum InstallState: Equatable {
case checking
case notInstalled
case installed
case installing
case failed(String)
}
// MARK: - View
struct ToolsSettings: View {
private let tools: [ToolEntry] = Self.makeTools()
static var toolIDsForTests: [String] {
makeTools().map(\.id)
}
private static func makeTools() -> [ToolEntry] {
[
ToolEntry(
id: "mcporter",
name: "🧳 mcporter",
url: URL(string: "https://github.com/steipete/mcporter")!,
description: "MCP runtime/CLI to discover servers, run tools, and sync configs across AI clients.",
method: .node(package: "mcporter", binary: "mcporter"),
kind: .tool),
ToolEntry(
id: "peekaboo",
name: "🫣 Peekaboo",
url: URL(string: "https://github.com/steipete/Peekaboo")!,
description: "Lightning-fast macOS screenshots with AI vision helpers for step-by-step automation.",
method: .brew(formula: "steipete/tap/peekaboo", binary: "peekaboo"),
kind: .tool),
ToolEntry(
id: "camsnap",
name: "📸 camsnap",
url: URL(string: "https://github.com/steipete/camsnap")!,
description: "One command to grab frames, clips, or motion alerts from RTSP/ONVIF cameras.",
method: .brew(formula: "steipete/tap/camsnap", binary: "camsnap"),
kind: .tool),
ToolEntry(
id: "oracle",
name: "🧿 oracle",
url: URL(string: "https://github.com/steipete/oracle")!,
description: "Runs OpenAI-ready agent workflows from the CLI with session replay and browser control.",
method: .node(package: "@steipete/oracle", binary: "oracle"),
kind: .tool),
ToolEntry(
id: "summarize",
name: "🧾 summarize",
url: URL(string: "https://github.com/steipete/summarize")!,
description: "Link → clean text → summary (web pages, YouTube, and local/remote files).",
method: .brew(formula: "steipete/tap/summarize", binary: "summarize"),
kind: .tool),
ToolEntry(
id: "qmd",
name: "🔎 qmd",
url: URL(string: "https://github.com/tobi/qmd")!,
description: "Hybrid markdown search (BM25 + vectors + rerank) with an MCP server for agents.",
method: .node(package: "https://github.com/tobi/qmd", binary: "qmd"),
kind: .tool),
ToolEntry(
id: "eightctl",
name: "🛏️ eightctl",
url: URL(string: "https://github.com/steipete/eightctl")!,
description: "Control your sleep, from the terminal.",
method: .go(module: "github.com/steipete/eightctl/cmd/eightctl@latest", binary: "eightctl"),
kind: .tool),
ToolEntry(
id: "imsg",
name: "💬 imsg",
url: URL(string: "https://github.com/steipete/imsg")!,
description: "Send, read, stream iMessage & SMS.",
method: .go(module: "github.com/steipete/imsg/cmd/imsg@latest", binary: "imsg"),
kind: .tool),
ToolEntry(
id: "wacli",
name: "🗃️ wacli",
url: URL(string: "https://github.com/steipete/wacli")!,
description: "WhatsApp CLI: sync, search, send.",
method: .go(module: "github.com/steipete/wacli/cmd/wacli@latest", binary: "wacli"),
kind: .tool),
ToolEntry(
id: "spotify-player",
name: "🎵 spotify-player",
url: URL(string: "https://github.com/aome510/spotify-player")!,
description: "Terminal Spotify client to queue, search, and control playback without leaving chat.",
method: .brew(formula: "spotify_player", binary: "spotify_player"),
kind: .tool),
ToolEntry(
id: "sonoscli",
name: "🔊 sonoscli",
url: URL(string: "https://github.com/steipete/sonoscli")!,
description: "Control Sonos speakers (discover, status, play/pause, volume, grouping) from scripts.",
method: .go(module: "github.com/steipete/sonoscli/cmd/sonos@latest", binary: "sonos"),
kind: .tool),
ToolEntry(
id: "blucli",
name: "🫐 blucli",
url: URL(string: "https://github.com/steipete/blucli")!,
description: "Play, group, and automate BluOS players from scripts.",
method: .go(module: "github.com/steipete/blucli/cmd/blu@latest", binary: "blu"),
kind: .tool),
ToolEntry(
id: "sag",
name: "🗣️ sag",
url: URL(string: "https://github.com/steipete/sag")!,
description: "ElevenLabs speech with mac-style say UX; streams to speakers by default.",
method: .brew(formula: "steipete/tap/sag", binary: "sag"),
kind: .tool),
ToolEntry(
id: "openhue-cli",
name: "💡 OpenHue CLI",
url: URL(string: "https://github.com/openhue/openhue-cli")!,
description: "Control Philips Hue lights from scripts—scenes, dimming, and automations.",
method: .brew(formula: "openhue/cli/openhue-cli", binary: "openhue"),
kind: .tool),
ToolEntry(
id: "openai-whisper",
name: "🎙️ OpenAI Whisper",
url: URL(string: "https://github.com/openai/whisper")!,
description: "Local speech-to-text for quick dictation and voicemail transcripts.",
method: .brew(formula: "openai-whisper", binary: "whisper"),
kind: .tool),
ToolEntry(
id: "gog",
name: "📮 gog",
url: URL(string: "https://github.com/steipete/gogcli")!,
description: "Google Suite CLI: Gmail, Calendar, Drive, Contacts.",
method: .brew(formula: "steipete/tap/gogcli", binary: "gog"),
kind: .tool),
ToolEntry(
id: "gemini-cli",
name: "♊️ Gemini CLI",
url: URL(string: "https://github.com/google-gemini/gemini-cli")!,
description: "Google Gemini models from the terminal for fast Q&A and web-grounded summaries.",
method: .brew(formula: "gemini-cli", binary: "gemini"),
kind: .tool),
ToolEntry(
id: "bird",
name: "🐦 bird",
url: URL(string: "https://github.com/steipete/bird")!,
description: "Fast X/Twitter CLI to tweet, reply, read threads, and search without a browser.",
method: .pnpm(
repoPath: "\(NSHomeDirectory())/Projects/bird",
script: "binary",
binary: "bird"),
kind: .tool),
ToolEntry(
id: "agent-tools",
name: "🧰 agent-tools",
url: URL(string: "https://github.com/badlogic/agent-tools")!,
description: "Collection of utilities and scripts tuned for autonomous agents and MCP clients.",
method: .gitClone(
url: "https://github.com/badlogic/agent-tools.git",
destination: "\(NSHomeDirectory())/agent-tools"),
kind: .tool),
]
}
@AppStorage("tools.packageManager") private var packageManagerRaw = NodePackageManager.npm.rawValue
@State private var installStates: [String: InstallState] = [:]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.packageManagerPicker
ScrollView {
LazyVStack(spacing: 12) {
self.section(for: .tool, title: "CLI Tools")
self.section(for: .mcp, title: "MCP Servers")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, 12)
.onChange(of: self.packageManagerRaw) { _, _ in
self.refreshAll()
}
.task { self.refreshAll() }
}
private var packageManager: NodePackageManager {
NodePackageManager(rawValue: self.packageManagerRaw) ?? .npm
}
private var packageManagerPicker: some View {
Picker("Preferred package manager", selection: self.$packageManagerRaw) {
ForEach(NodePackageManager.allCases) { manager in
Text(manager.label).tag(manager.rawValue)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 340)
.padding(.top, 2)
}
@ViewBuilder
private func section(for kind: ToolEntry.Kind, title: String) -> some View {
let filtered = self.tools.filter { $0.kind == kind }
if filtered.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.callout.weight(.semibold))
.padding(.top, 6)
VStack(spacing: 8) {
ForEach(filtered) { tool in
ToolRow(
tool: tool,
state: self.binding(for: tool),
packageManager: self.packageManager,
refreshState: { await self.refresh(tool: tool) })
.padding(10)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
}
}
}
}
}
private func binding(for tool: ToolEntry) -> Binding<InstallState> {
let current = self.installStates[tool.id] ?? .checking
return Binding(
get: { self.installStates[tool.id] ?? current },
set: { self.installStates[tool.id] = $0 })
}
private func refreshAll() {
Task {
for tool in self.tools {
await self.refresh(tool: tool)
}
}
}
@MainActor
private func refresh(tool: ToolEntry) async {
let installed = await ToolInstaller.isInstalled(tool.method, packageManager: self.packageManager)
self.installStates[tool.id] = installed ? .installed : .notInstalled
}
}
// MARK: - Row
private struct ToolRow: View {
let tool: ToolEntry
@Binding var state: InstallState
@State private var statusMessage: String?
@State private var linkHovering = false
let packageManager: NodePackageManager
let refreshState: () async -> Void
private enum Layout {
// Ensure progress indicators and buttons occupy the same space so the row doesn't shift.
static let actionWidth: CGFloat = 96
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Link(destination: self.tool.url) {
Text(self.tool.name)
.font(.headline)
.underline(self.linkHovering, color: .accentColor)
}
.foregroundColor(.accentColor)
.onHover { self.linkHovering = $0 }
.pointingHandCursor()
Text(self.tool.description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
self.actionButton
}
if let statusMessage, !statusMessage.isEmpty {
Text(statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onAppear { self.refresh() }
}
private var actionButton: some View {
VStack {
switch self.state {
case .installed:
Label("Installed", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline)
case .installing:
ProgressView().controlSize(.small)
case .failed:
Button("Retry") { self.install() }
.buttonStyle(.borderedProminent)
case .checking:
ProgressView().controlSize(.small)
case .notInstalled:
Button("Install") { self.install() }
.buttonStyle(.borderedProminent)
}
}
.frame(width: Layout.actionWidth, alignment: .trailing)
}
private func refresh() {
Task {
self.state = .checking
let installed = await ToolInstaller.isInstalled(self.tool.method, packageManager: self.packageManager)
await MainActor.run {
self.state = installed ? .installed : .notInstalled
}
}
}
private func install() {
Task {
self.state = .installing
let result = await ToolInstaller.install(self.tool.method, packageManager: self.packageManager)
await MainActor.run {
self.statusMessage = result.message
self.state = result.installed ? .installed : .failed(result.message)
if result.installed { Task { await self.refreshState() } }
}
}
}
}
// MARK: - Installer
private enum ToolInstaller {
struct InstallResult {
let installed: Bool
let message: String
}
static func isInstalled(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> Bool {
switch method {
case let .brew(formula, _):
return await self.shellSucceeds("brew list --versions \(formula)")
case let .node(_, binary),
let .go(_, binary),
let .pnpm(_, _, binary):
return await self.commandExists(binary)
case let .gitClone(_, destination):
return FileManager.default.fileExists(atPath: destination)
case let .mcporter(name, _, _):
guard await self.commandExists("mcporter") else { return false }
return await self.shellSucceeds("mcporter config get \(name) --json")
}
}
static func install(_ method: InstallMethod, packageManager: NodePackageManager = .npm) async -> InstallResult {
switch method {
case let .brew(formula, _):
return await self.runInstall("brew install \(formula)")
case let .node(package, _):
return await self.runInstall("\(packageManager.installCommandPrefix) \(package)")
case let .go(module, _):
return await self.runInstall("GO111MODULE=on go install \(module)")
case let .pnpm(repoPath, script, _):
let cmd = "cd \(escape(repoPath)) && pnpm install && pnpm run \(script)"
return await self.runInstall(cmd)
case let .gitClone(url, destination):
let cmd = """
if [ -d \(escape(destination)) ]; then
echo "Already cloned"
else
git clone \(url) \(escape(destination))
fi
"""
return await self.runInstall(cmd)
case let .mcporter(name, command, summary):
let cmd = """
mcporter config add \(name) --command "\(command)" --transport stdio --scope home --description "\(summary)"
"""
return await self.runInstall(cmd)
}
}
// MARK: - Helpers
private static func commandExists(_ binary: String) async -> Bool {
await self.shellSucceeds("command -v \(binary)")
}
private static func shellSucceeds(_ command: String) async -> Bool {
let status = await run(command).status
return status == 0
}
private static func runInstall(_ command: String) async -> InstallResult {
let result = await run(command)
let success = result.status == 0
let message = result.output.isEmpty ? (success ? "Installed" : "Install failed") : result.output
return InstallResult(installed: success, message: message.trimmingCharacters(in: .whitespacesAndNewlines))
}
private static func escape(_ path: String) -> String {
"\"\(path.replacingOccurrences(of: "\"", with: "\\\""))\""
}
private static func run(_ command: String) async -> (status: Int32, output: String) {
await withCheckedContinuation { continuation in
let process = Process()
process.launchPath = "/bin/zsh"
process.arguments = ["-lc", command]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (proc.terminationStatus, output))
}
do {
try process.run()
} catch {
continuation.resume(returning: (1, error.localizedDescription))
}
}
}
}
#if DEBUG
struct ToolsSettings_Previews: PreviewProvider {
static var previews: some View {
ToolsSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -621,6 +621,55 @@ public struct ConfigSetParams: Codable {
} }
} }
public struct SkillsStatusParams: Codable {
}
public struct SkillsInstallParams: Codable {
public let name: String
public let installid: String
public let timeoutms: Int?
public init(
name: String,
installid: String,
timeoutms: Int?
) {
self.name = name
self.installid = installid
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case name
case installid = "installId"
case timeoutms = "timeoutMs"
}
}
public struct SkillsUpdateParams: Codable {
public let skillkey: String
public let enabled: Bool?
public let apikey: String?
public let env: [String: AnyCodable]?
public init(
skillkey: String,
enabled: Bool?,
apikey: String?,
env: [String: AnyCodable]?
) {
self.skillkey = skillkey
self.enabled = enabled
self.apikey = apikey
self.env = env
}
private enum CodingKeys: String, CodingKey {
case skillkey = "skillKey"
case enabled
case apikey = "apiKey"
case env
}
}
public struct CronJob: Codable { public struct CronJob: Codable {
public let id: String public let id: String
public let name: String? public let name: String?

View File

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

View File

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

View File

@@ -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
View 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).

View File

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

View 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
View 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)),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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