feat: refresh skills metadata and toggles

This commit is contained in:
Peter Steinberger
2025-12-20 17:23:45 +01:00
parent bd572c775d
commit c4a67b7d02
35 changed files with 221 additions and 99 deletions

View File

@@ -15,6 +15,7 @@ struct SkillStatus: Codable, Identifiable {
let baseDir: String let baseDir: String
let skillKey: String let skillKey: String
let primaryEnv: String? let primaryEnv: String?
let emoji: String?
let always: Bool let always: Bool
let disabled: Bool let disabled: Bool
let eligible: Bool let eligible: Bool

View File

@@ -96,32 +96,30 @@ private struct SkillRow: View {
private var missingConfig: [String] { self.skill.missing.config } private var missingConfig: [String] { self.skill.missing.config }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) { Text(self.skill.emoji ?? "")
Text(self.skill.name) .font(.title2)
.font(.headline) VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.skill.name)
.font(.headline)
self.statusBadge
}
Text(self.skill.description) Text(self.skill.description)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(self.sourceLabel) self.metaRow
.font(.caption)
.foregroundStyle(.secondary)
} }
Spacer() Spacer()
self.statusBadge
} }
if self.skill.disabled { if self.skill.disabled {
Text("Disabled in config") Text("Disabled in config")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if self.skill.eligible { } else if !self.skill.eligible {
Text("Enabled")
.font(.caption)
.foregroundStyle(.secondary)
} else {
self.missingSummary self.missingSummary
} }
@@ -140,7 +138,18 @@ private struct SkillRow: View {
} }
private var sourceLabel: String { private var sourceLabel: String {
self.skill.source.replacingOccurrences(of: "clawdis-", with: "") switch self.skill.source {
case "clawdis-bundled":
return "Bundled"
case "clawdis-managed":
return "Managed"
case "clawdis-workspace":
return "Workspace"
case "clawdis-extra":
return "Extra"
default:
return self.skill.source
}
} }
private var statusBadge: some View { private var statusBadge: some View {
@@ -156,7 +165,33 @@ private struct SkillRow: View {
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
} }
.font(.subheadline) .font(.caption)
}
private var metaRow: some View {
HStack(spacing: 10) {
SkillTag(text: self.sourceLabel)
HStack(spacing: 6) {
Text(self.enabledLabel)
.font(.caption)
.foregroundStyle(.secondary)
Toggle("", isOn: self.enabledBinding)
.toggleStyle(.switch)
.labelsHidden()
.disabled(self.isBusy)
}
Spacer(minLength: 0)
}
}
private var enabledLabel: String {
self.skill.disabled ? "Disabled" : "Enabled"
}
private var enabledBinding: Binding<Bool> {
Binding(
get: { !self.skill.disabled },
set: { self.onToggleEnabled($0) })
} }
@ViewBuilder @ViewBuilder
@@ -199,16 +234,6 @@ private struct SkillRow: View {
private var actionRow: some View { private var actionRow: some View {
HStack(spacing: 8) { 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 ForEach(self.installOptions) { option in
Button(option.label) { self.onInstall(option) } Button(option.label) { self.onInstall(option) }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -254,6 +279,20 @@ private struct SkillRow: View {
} }
} }
private struct SkillTag: View {
let text: String
var body: some View {
Text(self.text)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.12))
.clipShape(Capsule())
}
}
private struct EnvEditorState: Identifiable { private struct EnvEditorState: Identifiable {
let skillKey: String let skillKey: String
let skillName: String let skillName: String

View File

@@ -13,8 +13,9 @@ The macOS app surfaces Clawdis skills via the gateway; it does not parse skills
- Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`. - Requirements are derived from `metadata.clawdis.requires` in each `SKILL.md`.
## Install actions ## Install actions
- `metadata.clawdis.install` defines install options (brew/node/go/pnpm/git/shell). - `metadata.clawdis.install` defines install options (brew/node/go/pnpm/shell).
- The app calls `skills.install` to run installers on the gateway host. - The app calls `skills.install` to run installers on the gateway host.
- The gateway surfaces only one preferred installer when multiple are provided (brew when available, otherwise node manager from `skillsInstall`).
## Env/API keys ## Env/API keys
- The app stores keys in `~/.clawdis/clawdis.json` under `skills.<skillKey>`. - The app stores keys in `~/.clawdis/clawdis.json` under `skills.<skillKey>`.

View File

@@ -54,11 +54,12 @@ metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
Fields under `metadata.clawdis`: Fields under `metadata.clawdis`:
- `always: true` — always include the skill (skip other gates). - `always: true` — always include the skill (skip other gates).
- `emoji` — optional emoji used by the macOS Skills UI.
- `requires.bins` — list; each must exist on `PATH`. - `requires.bins` — list; each must exist on `PATH`.
- `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). - `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/pnpm/shell).
Installer example: Installer example:
@@ -66,10 +67,14 @@ Installer example:
--- ---
name: gemini name: gemini
description: Use Gemini CLI for coding assistance and Google search lookups. 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)"}]}} metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
--- ---
``` ```
Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
- Node installs honor `skillsInstall.nodeManager` in `clawdis.json` (default: npm).
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).
## Config overrides (`~/.clawdis/clawdis.json`) ## Config overrides (`~/.clawdis/clawdis.json`)

View File

@@ -1,7 +1,7 @@
--- ---
name: bird name: bird
description: X/Twitter CLI for reading, searching, and posting via cookies or Sweetistics. description: X/Twitter CLI for reading, searching, and posting via cookies or Sweetistics.
metadata: {"clawdis":{"requires":{"bins":["bird"]},"install":[{"id":"pnpm-build","kind":"shell","command":"if [ ! -d ~/Projects/bird ]; then git clone https://github.com/steipete/bird.git ~/Projects/bird; fi && cd ~/Projects/bird && pnpm install && pnpm run binary","bins":["bird"],"label":"Clone + build bird (pnpm)"}]}} metadata: {"clawdis":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"}]}}
--- ---
# bird # bird

View File

@@ -1,7 +1,7 @@
--- ---
name: blucli name: blucli
description: BluOS CLI (blu) for discovery, playback, grouping, and volume. description: BluOS CLI (blu) for discovery, playback, grouping, and volume.
metadata: {"clawdis":{"requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}} metadata: {"clawdis":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
--- ---
# blucli (blu) # blucli (blu)

View File

@@ -1,7 +1,7 @@
--- ---
name: brave-search name: brave-search
description: Web search and content extraction via Brave Search API. description: Web search and content extraction via Brave Search API.
metadata: {"clawdis":{"requires":{"bins":["node"],"env":["BRAVE_API_KEY"]},"primaryEnv":"BRAVE_API_KEY"}} metadata: {"clawdis":{"emoji":"🦁","requires":{"bins":["node"],"env":["BRAVE_API_KEY"]},"primaryEnv":"BRAVE_API_KEY"}}
--- ---
# Brave Search # Brave Search

View File

@@ -1,7 +1,7 @@
--- ---
name: camsnap name: camsnap
description: Capture frames or clips from RTSP/ONVIF cameras. description: Capture frames or clips from RTSP/ONVIF cameras.
metadata: {"clawdis":{"requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}} metadata: {"clawdis":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
--- ---
# camsnap # camsnap

View File

@@ -1,7 +1,7 @@
--- ---
name: clawdis-browser name: clawdis-browser
description: Control clawd's dedicated browser (tabs, snapshots, actions) via the clawdis CLI. description: Control clawd's dedicated browser (tabs, snapshots, actions) via the clawdis CLI.
metadata: {"clawdis":{"requires":{"config":["browser.enabled"]}}} metadata: {"clawdis":{"emoji":"🧭","requires":{"config":["browser.enabled"]}}}
--- ---
# Clawdis Browser # Clawdis Browser

View File

@@ -1,7 +1,7 @@
--- ---
name: clawdis-cron name: clawdis-cron
description: Schedule jobs and wakeups via Clawdis Gateway cron.* RPC. description: Schedule jobs and wakeups via Clawdis Gateway cron.* RPC.
metadata: {"clawdis":{"always":true}} metadata: {"clawdis":{"emoji":"⏰","always":true}}
--- ---
# Clawdis Cron # Clawdis Cron

View File

@@ -1,6 +1,7 @@
--- ---
name: clawdis-nodes name: clawdis-nodes
description: Discover, interpret, and target Clawdis nodes (paired devices) via the Gateway/CLI. Use when an agent must find available nodes, choose the best target machine, or reason about presence vs node availability (Tailnet/Tailscale optional). description: Discover, interpret, and target Clawdis nodes (paired devices) via the Gateway/CLI. Use when an agent must find available nodes, choose the best target machine, or reason about presence vs node availability (Tailnet/Tailscale optional).
metadata: {"clawdis":{"emoji":"🛰️"}}
--- ---
# Clawdis Nodes # Clawdis Nodes

View File

@@ -1,6 +1,7 @@
--- ---
name: clawdis-notify name: clawdis-notify
description: Send system notifications to specific Clawdis nodes (macOS computers) via the Gateway and CLI. Use when you need to alert a person or confirm a remote action on a particular machine, or when an agent must push a notification to another computer. description: Send system notifications to specific Clawdis nodes (macOS computers) via the Gateway and CLI. Use when you need to alert a person or confirm a remote action on a particular machine, or when an agent must push a notification to another computer.
metadata: {"clawdis":{"emoji":"🔔"}}
--- ---
# Clawdis Notify # Clawdis Notify

View File

@@ -1,7 +1,7 @@
--- ---
name: eightctl name: eightctl
description: Control Eight Sleep pods (status, temperature, alarms, schedules). description: Control Eight Sleep pods (status, temperature, alarms, schedules).
metadata: {"clawdis":{"requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}} metadata: {"clawdis":{"emoji":"🎛️","requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}}
--- ---
# eightctl # eightctl

View File

@@ -1,7 +1,7 @@
--- ---
name: gemini name: gemini
description: Gemini CLI for one-shot Q&A, summaries, and generation. description: Gemini CLI for one-shot Q&A, summaries, and generation.
metadata: {"clawdis":{"requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}} metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
--- ---
# Gemini CLI # Gemini CLI

View File

@@ -1,7 +1,7 @@
--- ---
name: gog name: gog
description: Google Workspace CLI for Gmail, Calendar, Drive, and Contacts. description: Google Workspace CLI for Gmail, Calendar, Drive, and Contacts.
metadata: {"clawdis":{"requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}} metadata: {"clawdis":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
--- ---
# gog # gog

View File

@@ -1,7 +1,7 @@
--- ---
name: imsg name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending. description: iMessage/SMS CLI for listing chats, history, watch, and sending.
metadata: {"clawdis":{"requires":{"bins":["imsg"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/imsg/cmd/imsg@latest","bins":["imsg"],"label":"Install imsg (go)"}]}} metadata: {"clawdis":{"emoji":"📨","requires":{"bins":["imsg"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/imsg/cmd/imsg@latest","bins":["imsg"],"label":"Install imsg (go)"}]}}
--- ---
# imsg # imsg

View File

@@ -1,7 +1,7 @@
--- ---
name: mcporter name: mcporter
description: Manage and call MCP servers (list, call, auth, daemon). description: Manage and call MCP servers (list, call, auth, daemon).
metadata: {"clawdis":{"requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}} metadata: {"clawdis":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
--- ---
# mcporter # mcporter

View File

@@ -1,7 +1,7 @@
--- ---
name: nano-banana-pro name: nano-banana-pro
description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro). description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
metadata: {"clawdis":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}} metadata: {"clawdis":{"emoji":"🍌","requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
--- ---
# Nano Banana Pro (Gemini 3 Pro Image) # Nano Banana Pro (Gemini 3 Pro Image)

View File

@@ -1,7 +1,7 @@
--- ---
name: nano-pdf name: nano-pdf
description: Edit PDFs with natural-language instructions using the nano-pdf CLI. description: Edit PDFs with natural-language instructions using the nano-pdf CLI.
metadata: {"clawdis":{"requires":{"bins":["nano-pdf"]},"install":[{"id":"pipx","kind":"shell","command":"python3 -m pip install --user pipx && python3 -m pipx ensurepath && pipx install nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (pipx)"},{"id":"pip","kind":"shell","command":"python3 -m pip install --user nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (pip --user)"}]}} metadata: {"clawdis":{"emoji":"📄","requires":{"bins":["nano-pdf"]},"install":[{"id":"pipx","kind":"shell","command":"python3 -m pip install --user pipx && python3 -m pipx ensurepath && pipx install nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (pipx)"},{"id":"pip","kind":"shell","command":"python3 -m pip install --user nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (pip --user)"}]}}
--- ---
# nano-pdf # nano-pdf

View File

@@ -1,7 +1,7 @@
--- ---
name: openai-image-gen name: openai-image-gen
description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery. description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery.
metadata: {"clawdis":{"requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}} metadata: {"clawdis":{"emoji":"🖼️","requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}}
--- ---
# OpenAI Image Gen # OpenAI Image Gen

View File

@@ -1,7 +1,7 @@
--- ---
name: openai-whisper-api name: openai-whisper-api
description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper). description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
metadata: {"clawdis":{"requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}} metadata: {"clawdis":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
--- ---
# OpenAI Whisper API (curl) # OpenAI Whisper API (curl)

View File

@@ -1,7 +1,7 @@
--- ---
name: openai-whisper name: openai-whisper
description: Local speech-to-text with the Whisper CLI (no API key). description: Local speech-to-text with the Whisper CLI (no API key).
metadata: {"clawdis":{"requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}} metadata: {"clawdis":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
--- ---
# Whisper (CLI) # Whisper (CLI)

View File

@@ -1,7 +1,7 @@
--- ---
name: openhue name: openhue
description: Control Philips Hue lights/scenes via the OpenHue CLI. description: Control Philips Hue lights/scenes via the OpenHue CLI.
metadata: {"clawdis":{"requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}} metadata: {"clawdis":{"emoji":"💡","requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}}
--- ---
# OpenHue CLI # OpenHue CLI

View File

@@ -1,7 +1,7 @@
--- ---
name: oracle name: oracle
description: Run a second-model review or debug session with the oracle CLI. description: Run a second-model review or debug session with the oracle CLI.
metadata: {"clawdis":{"requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}} metadata: {"clawdis":{"emoji":"🧿","requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}}
--- ---
# oracle # oracle

View File

@@ -1,7 +1,7 @@
--- ---
name: peekaboo name: peekaboo
description: Capture and automate macOS UI with the Peekaboo CLI. description: Capture and automate macOS UI with the Peekaboo CLI.
metadata: {"clawdis":{"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}} metadata: {"clawdis":{"emoji":"👀","requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
--- ---
# Peekaboo # Peekaboo

View File

@@ -1,7 +1,7 @@
--- ---
name: qmd name: qmd
description: Local search/indexing CLI (BM25 + vectors + rerank) with MCP mode. description: Local search/indexing CLI (BM25 + vectors + rerank) with MCP mode.
metadata: {"clawdis":{"requires":{"bins":["qmd"]},"install":[{"id":"node","kind":"node","package":"https://github.com/tobi/qmd","bins":["qmd"],"label":"Install qmd (node)"}]}} metadata: {"clawdis":{"emoji":"📝","requires":{"bins":["qmd"]},"install":[{"id":"node","kind":"node","package":"https://github.com/tobi/qmd","bins":["qmd"],"label":"Install qmd (node)"}]}}
--- ---
# qmd # qmd

View File

@@ -1,7 +1,7 @@
--- ---
name: sag name: sag
description: ElevenLabs text-to-speech with mac-style say UX. description: ElevenLabs text-to-speech with mac-style say UX.
metadata: {"clawdis":{"requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}} metadata: {"clawdis":{"emoji":"🗣️","requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}}
--- ---
# sag # sag

View File

@@ -1,7 +1,7 @@
--- ---
name: sonoscli name: sonoscli
description: Control Sonos speakers (discover/status/play/volume/group). description: Control Sonos speakers (discover/status/play/volume/group).
metadata: {"clawdis":{"requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}} metadata: {"clawdis":{"emoji":"🔊","requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}}
--- ---
# Sonos CLI # Sonos CLI

View File

@@ -1,7 +1,7 @@
--- ---
name: spotify-player name: spotify-player
description: Terminal Spotify client (TUI + CLI commands) for playback and search. description: Terminal Spotify client (TUI + CLI commands) for playback and search.
metadata: {"clawdis":{"requires":{"bins":["spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify-player (brew)"}]}} metadata: {"clawdis":{"emoji":"🎵","requires":{"bins":["spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify-player (brew)"}]}}
--- ---
# spotify_player # spotify_player

View File

@@ -1,7 +1,7 @@
--- ---
name: summarize name: summarize
description: Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube). description: Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube).
metadata: {"clawdis":{"requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"},{"id":"node","kind":"node","package":"@steipete/summarize","bins":["summarize"],"label":"Install summarize (node)"}]}} metadata: {"clawdis":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"},{"id":"node","kind":"node","package":"@steipete/summarize","bins":["summarize"],"label":"Install summarize (node)"}]}}
--- ---
# Summarize # Summarize

View File

@@ -1,7 +1,7 @@
--- ---
name: video-frames name: video-frames
description: Extract frames or short clips from videos using ffmpeg. description: Extract frames or short clips from videos using ffmpeg.
metadata: {"clawdis":{"requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}} metadata: {"clawdis":{"emoji":"🎞️","requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}}
--- ---
# Video Frames (ffmpeg) # Video Frames (ffmpeg)

View File

@@ -1,7 +1,7 @@
--- ---
name: wacli name: wacli
description: WhatsApp CLI for sync, search, and sending messages. description: WhatsApp CLI for sync, search, and sending messages.
metadata: {"clawdis":{"requires":{"bins":["wacli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}} metadata: {"clawdis":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
--- ---
# wacli # wacli

View File

@@ -2,15 +2,19 @@ import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { import {
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
resolveSkillsInstallPreferences,
type SkillEntry, type SkillEntry,
type SkillInstallSpec, type SkillInstallSpec,
type SkillsInstallPreferences,
} from "./skills.js"; } from "./skills.js";
import type { ClawdisConfig } from "../config/config.js";
export type SkillInstallRequest = { export type SkillInstallRequest = {
workspaceDir: string; workspaceDir: string;
skillName: string; skillName: string;
installId: string; installId: string;
timeoutMs?: number; timeoutMs?: number;
config?: ClawdisConfig;
}; };
export type SkillInstallResult = { export type SkillInstallResult = {
@@ -40,7 +44,24 @@ function runShell(command: string, timeoutMs: number) {
return runCommandWithTimeout(["/bin/zsh", "-lc", command], { timeoutMs }); return runCommandWithTimeout(["/bin/zsh", "-lc", command], { timeoutMs });
} }
function buildInstallCommand(spec: SkillInstallSpec): { function buildNodeInstallCommand(
packageName: string,
prefs: SkillsInstallPreferences,
): string[] {
switch (prefs.nodeManager) {
case "pnpm":
return ["pnpm", "add", "-g", packageName];
case "bun":
return ["bun", "add", "-g", packageName];
default:
return ["npm", "install", "-g", packageName];
}
}
function buildInstallCommand(
spec: SkillInstallSpec,
prefs: SkillsInstallPreferences,
): {
argv: string[] | null; argv: string[] | null;
shell: string | null; shell: string | null;
cwd?: string; cwd?: string;
@@ -55,7 +76,10 @@ function buildInstallCommand(spec: SkillInstallSpec): {
case "node": { case "node": {
if (!spec.package) if (!spec.package)
return { argv: null, shell: null, error: "missing node package" }; return { argv: null, shell: null, error: "missing node package" };
return { argv: ["npm", "install", "-g", spec.package], shell: null }; return {
argv: buildNodeInstallCommand(spec.package, prefs),
shell: null,
};
} }
case "go": { case "go": {
if (!spec.module) if (!spec.module)
@@ -74,18 +98,6 @@ function buildInstallCommand(spec: SkillInstallSpec): {
const cmd = `cd ${JSON.stringify(repoPath)} && pnpm install && pnpm run ${JSON.stringify(spec.script)}`; const cmd = `cd ${JSON.stringify(repoPath)} && pnpm install && pnpm run ${JSON.stringify(spec.script)}`;
return { argv: null, shell: cmd }; 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": { case "shell": {
if (!spec.command) if (!spec.command)
return { argv: null, shell: null, error: "missing shell command" }; return { argv: null, shell: null, error: "missing shell command" };
@@ -127,7 +139,8 @@ export async function installSkill(
}; };
} }
const command = buildInstallCommand(spec); const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs);
if (command.error) { if (command.error) {
return { return {
ok: false, ok: false,

View File

@@ -8,8 +8,10 @@ import {
loadWorkspaceSkillEntries, loadWorkspaceSkillEntries,
resolveConfigPath, resolveConfigPath,
resolveSkillConfig, resolveSkillConfig,
resolveSkillsInstallPreferences,
type SkillEntry, type SkillEntry,
type SkillInstallSpec, type SkillInstallSpec,
type SkillsInstallPreferences,
} from "./skills.js"; } from "./skills.js";
export type SkillStatusConfigCheck = { export type SkillStatusConfigCheck = {
@@ -33,6 +35,7 @@ export type SkillStatusEntry = {
baseDir: string; baseDir: string;
skillKey: string; skillKey: string;
primaryEnv?: string; primaryEnv?: string;
emoji?: string;
always: boolean; always: boolean;
disabled: boolean; disabled: boolean;
eligible: boolean; eligible: boolean;
@@ -60,45 +63,78 @@ function resolveSkillKey(entry: SkillEntry): string {
return entry.clawdis?.skillKey ?? entry.skill.name; return entry.clawdis?.skillKey ?? entry.skill.name;
} }
function normalizeInstallOptions(entry: SkillEntry): SkillInstallOption[] { function selectPreferredInstallSpec(
install: SkillInstallSpec[],
prefs: SkillsInstallPreferences,
): { spec: SkillInstallSpec; index: number } | undefined {
if (install.length === 0) return undefined;
const indexed = install.map((spec, index) => ({ spec, index }));
const findKind = (kind: SkillInstallSpec["kind"]) =>
indexed.find((item) => item.spec.kind === kind);
const brewSpec = findKind("brew");
const nodeSpec = findKind("node");
const goSpec = findKind("go");
const pnpmSpec = findKind("pnpm");
const shellSpec = findKind("shell");
if (prefs.preferBrew && hasBinary("brew") && brewSpec) return brewSpec;
if (nodeSpec) return nodeSpec;
if (brewSpec) return brewSpec;
if (goSpec) return goSpec;
if (pnpmSpec) return pnpmSpec;
if (shellSpec) return shellSpec;
return indexed[0];
}
function normalizeInstallOptions(
entry: SkillEntry,
prefs: SkillsInstallPreferences,
): SkillInstallOption[] {
const install = entry.clawdis?.install ?? []; const install = entry.clawdis?.install ?? [];
if (install.length === 0) return []; if (install.length === 0) return [];
return install.map((spec, index) => { const preferred = selectPreferredInstallSpec(install, prefs);
const id = (spec.id ?? `${spec.kind}-${index}`).trim(); if (!preferred) return [];
const bins = spec.bins ?? []; const { spec, index } = preferred;
let label = (spec.label ?? "").trim(); const id = (spec.id ?? `${spec.kind}-${index}`).trim();
if (!label) { const bins = spec.bins ?? [];
if (spec.kind === "brew" && spec.formula) { let label = (spec.label ?? "").trim();
label = `Install ${spec.formula} (brew)`; if (spec.kind === "node" && spec.package) {
} else if (spec.kind === "node" && spec.package) { label = `Install ${spec.package} (${prefs.nodeManager})`;
label = `Install ${spec.package} (node)`; }
} else if (spec.kind === "go" && spec.module) { if (!label) {
label = `Install ${spec.module} (go)`; if (spec.kind === "brew" && spec.formula) {
} else if (spec.kind === "pnpm" && spec.repoPath) { label = `Install ${spec.formula} (brew)`;
label = `Install ${spec.repoPath} (pnpm)`; } else if (spec.kind === "node" && spec.package) {
} else if (spec.kind === "git" && spec.url) { label = `Install ${spec.package} (${prefs.nodeManager})`;
label = `Clone ${spec.url}`; } else if (spec.kind === "go" && spec.module) {
} else { label = `Install ${spec.module} (go)`;
label = "Run installer"; } else if (spec.kind === "pnpm" && spec.repoPath) {
} label = `Install ${spec.repoPath} (pnpm)`;
} else {
label = "Run installer";
} }
return { }
return [
{
id, id,
kind: spec.kind, kind: spec.kind,
label, label,
bins, bins,
}; },
}); ];
} }
function buildSkillStatus( function buildSkillStatus(
entry: SkillEntry, entry: SkillEntry,
config?: ClawdisConfig, config?: ClawdisConfig,
prefs?: SkillsInstallPreferences,
): SkillStatusEntry { ): SkillStatusEntry {
const skillKey = resolveSkillKey(entry); const skillKey = resolveSkillKey(entry);
const skillConfig = resolveSkillConfig(config, skillKey); const skillConfig = resolveSkillConfig(config, skillKey);
const disabled = skillConfig?.enabled === false; const disabled = skillConfig?.enabled === false;
const always = entry.clawdis?.always === true; const always = entry.clawdis?.always === true;
const emoji = entry.clawdis?.emoji ?? entry.frontmatter.emoji;
const requiredBins = entry.clawdis?.requires?.bins ?? []; const requiredBins = entry.clawdis?.requires?.bins ?? [];
const requiredEnv = entry.clawdis?.requires?.env ?? []; const requiredEnv = entry.clawdis?.requires?.env ?? [];
@@ -145,6 +181,7 @@ function buildSkillStatus(
baseDir: entry.skill.baseDir, baseDir: entry.skill.baseDir,
skillKey, skillKey,
primaryEnv: entry.clawdis?.primaryEnv, primaryEnv: entry.clawdis?.primaryEnv,
emoji,
always, always,
disabled, disabled,
eligible, eligible,
@@ -155,7 +192,10 @@ function buildSkillStatus(
}, },
missing, missing,
configChecks, configChecks,
install: normalizeInstallOptions(entry), install: normalizeInstallOptions(
entry,
prefs ?? resolveSkillsInstallPreferences(config),
),
}; };
} }
@@ -171,9 +211,12 @@ export function buildWorkspaceSkillStatus(
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const skillEntries = const skillEntries =
opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts); opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts);
const prefs = resolveSkillsInstallPreferences(opts?.config);
return { return {
workspaceDir, workspaceDir,
managedSkillsDir, managedSkillsDir,
skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config)), skills: skillEntries.map((entry) =>
buildSkillStatus(entry, opts?.config, prefs),
),
}; };
} }

View File

@@ -13,7 +13,7 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js";
export type SkillInstallSpec = { export type SkillInstallSpec = {
id?: string; id?: string;
kind: "brew" | "node" | "go" | "pnpm" | "git" | "shell"; kind: "brew" | "node" | "go" | "pnpm" | "shell";
label?: string; label?: string;
bins?: string[]; bins?: string[];
formula?: string; formula?: string;
@@ -21,8 +21,6 @@ export type SkillInstallSpec = {
module?: string; module?: string;
repoPath?: string; repoPath?: string;
script?: string; script?: string;
url?: string;
destination?: string;
command?: string; command?: string;
}; };
@@ -30,6 +28,7 @@ export type ClawdisSkillMetadata = {
always?: boolean; always?: boolean;
skillKey?: string; skillKey?: string;
primaryEnv?: string; primaryEnv?: string;
emoji?: string;
requires?: { requires?: {
bins?: string[]; bins?: string[];
env?: string[]; env?: string[];
@@ -38,6 +37,11 @@ export type ClawdisSkillMetadata = {
install?: SkillInstallSpec[]; install?: SkillInstallSpec[];
}; };
export type SkillsInstallPreferences = {
preferBrew: boolean;
nodeManager: "npm" | "pnpm" | "bun";
};
type ParsedSkillFrontmatter = Record<string, string>; type ParsedSkillFrontmatter = Record<string, string>;
export type SkillEntry = { export type SkillEntry = {
@@ -141,7 +145,6 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
kind !== "node" && kind !== "node" &&
kind !== "go" && kind !== "go" &&
kind !== "pnpm" && kind !== "pnpm" &&
kind !== "git" &&
kind !== "shell" kind !== "shell"
) { ) {
return undefined; return undefined;
@@ -160,8 +163,6 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (typeof raw.module === "string") spec.module = raw.module; if (typeof raw.module === "string") spec.module = raw.module;
if (typeof raw.repoPath === "string") spec.repoPath = raw.repoPath; if (typeof raw.repoPath === "string") spec.repoPath = raw.repoPath;
if (typeof raw.script === "string") spec.script = raw.script; if (typeof raw.script === "string") spec.script = raw.script;
if (typeof raw.url === "string") spec.url = raw.url;
if (typeof raw.destination === "string") spec.destination = raw.destination;
if (typeof raw.command === "string") spec.command = raw.command; if (typeof raw.command === "string") spec.command = raw.command;
return spec; return spec;
@@ -179,6 +180,21 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
"browser.enabled": true, "browser.enabled": true,
}; };
export function resolveSkillsInstallPreferences(
config?: ClawdisConfig,
): SkillsInstallPreferences {
const raw = config?.skillsInstall;
const preferBrew = raw?.preferBrew ?? true;
const managerRaw =
typeof raw?.nodeManager === "string" ? raw.nodeManager.trim() : "";
const manager = managerRaw.toLowerCase();
const nodeManager =
manager === "pnpm" || manager === "bun" || manager === "npm"
? (manager as SkillsInstallPreferences["nodeManager"])
: "npm";
return { preferBrew, nodeManager };
}
export function resolveConfigPath( export function resolveConfigPath(
config: ClawdisConfig | undefined, config: ClawdisConfig | undefined,
pathStr: string, pathStr: string,
@@ -253,6 +269,8 @@ function resolveClawdisMetadata(
return { return {
always: always:
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined, typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
emoji:
typeof clawdisObj.emoji === "string" ? clawdisObj.emoji : undefined,
skillKey: skillKey:
typeof clawdisObj.skillKey === "string" typeof clawdisObj.skillKey === "string"
? clawdisObj.skillKey ? clawdisObj.skillKey