From cc0075e9887f96ceb5e6a2dd81ecbd11a53b7c47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 13:33:06 +0100 Subject: [PATCH] feat: add skills settings and gateway skills management --- .../Sources/Clawdis/GatewayConnection.swift | 39 ++ apps/macos/Sources/Clawdis/Onboarding.swift | 8 +- .../Sources/Clawdis/SettingsRootView.swift | 10 +- apps/macos/Sources/Clawdis/SkillsModels.swift | 68 +++ .../Sources/Clawdis/SkillsSettings.swift | 395 ++++++++++++++ .../macos/Sources/Clawdis/ToolsSettings.swift | 508 ------------------ .../ClawdisProtocol/GatewayModels.swift | 49 ++ .../SettingsViewSmokeTests.swift | 8 +- docs/AGENTS.default.md | 23 +- docs/browser.md | 2 +- docs/mac/skills.md | 24 + docs/skills.md | 11 + src/agents/skills-install.ts | 147 +++++ src/agents/skills-status.ts | 174 ++++++ src/agents/skills.test.ts | 29 + src/agents/skills.ts | 18 +- src/gateway/protocol/index.ts | 21 + src/gateway/protocol/schema.ts | 30 ++ src/gateway/server.ts | 124 ++++- 19 files changed, 1142 insertions(+), 546 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/SkillsModels.swift create mode 100644 apps/macos/Sources/Clawdis/SkillsSettings.swift delete mode 100644 apps/macos/Sources/Clawdis/ToolsSettings.swift create mode 100644 docs/mac/skills.md create mode 100644 src/agents/skills-install.ts create mode 100644 src/agents/skills-status.ts diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index 4a4d86282..c0458b8c9 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -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 { diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index b0b27ae8c..b7daea87d 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -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) } diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 2ce2d44b9..7e799bb8a 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -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" diff --git a/apps/macos/Sources/Clawdis/SkillsModels.swift b/apps/macos/Sources/Clawdis/SkillsModels.swift new file mode 100644 index 000000000..1749f10de --- /dev/null +++ b/apps/macos/Sources/Clawdis/SkillsModels.swift @@ -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]? +} diff --git a/apps/macos/Sources/Clawdis/SkillsSettings.swift b/apps/macos/Sources/Clawdis/SkillsSettings.swift new file mode 100644 index 000000000..6dc51a4f9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/SkillsSettings.swift @@ -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 = [] + + 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 diff --git a/apps/macos/Sources/Clawdis/ToolsSettings.swift b/apps/macos/Sources/Clawdis/ToolsSettings.swift deleted file mode 100644 index eb04fb5ea..000000000 --- a/apps/macos/Sources/Clawdis/ToolsSettings.swift +++ /dev/null @@ -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 { - 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 diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 04973d66b..d1b83c718 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -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? diff --git a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift index 5a1079dde..2947d4fcf 100644 --- a/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/SettingsViewSmokeTests.swift @@ -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")) - } } diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index 00a0edf1b..79c5feeba 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -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:`; 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). diff --git a/docs/browser.md b/docs/browser.md index 738e3bf7e..7a9879333 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -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". diff --git a/docs/mac/skills.md b/docs/mac/skills.md new file mode 100644 index 000000000..3e5b55d74 --- /dev/null +++ b/docs/mac/skills.md @@ -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.`. +- `skills.update` patches `enabled`, `apiKey`, and `env`. + +## Remote mode +- Install + config updates happen on the gateway host (not the local Mac). diff --git a/docs/skills.md b/docs/skills.md index 3b9e8c19e..e270d35bb 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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..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). diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts new file mode 100644 index 000000000..c4a86699a --- /dev/null +++ b/src/agents/skills-install.ts @@ -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 { + 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, + }; +} diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts new file mode 100644 index 000000000..3c71b4701 --- /dev/null +++ b/src/agents/skills-status.ts @@ -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)), + }; +} diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index df858de9b..2ded23307 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -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-")); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index f097273fd..327f4e585 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -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 = { "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(); // Precedence: extra < bundled < managed < workspace diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 33c8f3ee2..e251b7384 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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( export const validateConfigSetParams = ajv.compile( ConfigSetParamsSchema, ); +export const validateSkillsStatusParams = ajv.compile( + SkillsStatusParamsSchema, +); +export const validateSkillsInstallParams = ajv.compile( + SkillsInstallParamsSchema, +); +export const validateSkillsUpdateParams = ajv.compile( + SkillsUpdateParamsSchema, +); export const validateCronListParams = ajv.compile(CronListParamsSchema); export const validateCronStatusParams = ajv.compile( @@ -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, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 5380f0745..d57f9d0c7 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 = { 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; export type SessionsPatchParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; +export type SkillsStatusParams = Static; +export type SkillsInstallParams = Static; +export type SkillsUpdateParams = Static; export type CronJob = Static; export type CronListParams = Static; export type CronStatusParams = Static; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5fdfaef87..36c8a1a69 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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; + 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; + 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; + 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; + }; + 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; if (!validateSessionsListParams(params)) {