From 73d0e2cb81d88203b11c8c2915d03121a66a5a78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 22:23:23 +0100 Subject: [PATCH] fix: gate skills by OS --- CHANGELOG.md | 1 + docs/skills.md | 2 + skills/imsg/SKILL.md | 2 +- skills/peekaboo/SKILL.md | 2 +- skills/things-mac/SKILL.md | 2 +- src/agents/skills-install.ts | 78 ++++++++++++++++++++++++++++++++++-- src/agents/skills-status.ts | 20 +++++++-- src/agents/skills.test.ts | 26 ++++++++++++ src/agents/skills.ts | 11 +++++ ui/src/ui/types.ts | 3 ++ ui/src/ui/views/skills.ts | 25 ++++++++---- 11 files changed, 156 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c324ec3..9d4bb9ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Fixes - Skills: switch imsg installer to brew tap formula. +- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b diff --git a/docs/skills.md b/docs/skills.md index 515e0f56c..c55ffeeaf 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -59,6 +59,7 @@ Fields under `metadata.clawdis`: - `always: true` — always include the skill (skip other gates). - `emoji` — optional emoji used by the macOS Skills UI. - `homepage` — optional URL shown as “Website” in the macOS Skills UI. +- `os` — optional list of platforms (`darwin`, `linux`, `win32`). If set, the skill is only eligible on those OSes. - `requires.bins` — list; each must exist on `PATH`. - `requires.env` — list; env var must exist **or** be provided in config. - `requires.config` — list of `clawdis.json` paths that must be truthy. @@ -78,6 +79,7 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install": Notes: - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - Node installs honor `skills.install.nodeManager` in `clawdis.json` (default: npm; options: npm/pnpm/yarn/bun). +- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. If no `metadata.clawdis` is present, the skill is always eligible (unless disabled in config or blocked by `skills.allowBundled` for bundled skills). diff --git a/skills/imsg/SKILL.md b/skills/imsg/SKILL.md index 37d0ff4ab..a985b03cb 100644 --- a/skills/imsg/SKILL.md +++ b/skills/imsg/SKILL.md @@ -2,7 +2,7 @@ name: imsg description: iMessage/SMS CLI for listing chats, history, watch, and sending. homepage: https://imsg.to -metadata: {"clawdis":{"emoji":"📨","requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}} +metadata: {"clawdis":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}} --- # imsg diff --git a/skills/peekaboo/SKILL.md b/skills/peekaboo/SKILL.md index 650dbd76f..285f4985a 100644 --- a/skills/peekaboo/SKILL.md +++ b/skills/peekaboo/SKILL.md @@ -2,7 +2,7 @@ name: peekaboo description: Capture and automate macOS UI with the Peekaboo CLI. homepage: https://peekaboo.boo -metadata: {"clawdis":{"emoji":"👀","requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}} +metadata: {"clawdis":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}} --- # Peekaboo diff --git a/skills/things-mac/SKILL.md b/skills/things-mac/SKILL.md index 3b956688e..ce556ca1b 100644 --- a/skills/things-mac/SKILL.md +++ b/skills/things-mac/SKILL.md @@ -2,7 +2,7 @@ name: things-mac description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdis to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags. homepage: https://github.com/ossianhempel/things3-cli -metadata: {"clawdis":{"emoji":"✅","requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}} +metadata: {"clawdis":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}} --- # Things 3 CLI diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index df241b36a..bce676389 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; + import type { ClawdisConfig } from "../config/config.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; @@ -88,6 +91,29 @@ function buildInstallCommand( } } +async function resolveBrewBinDir(timeoutMs: number): Promise { + if (!hasBinary("brew")) return undefined; + const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], { + timeoutMs: Math.min(timeoutMs, 30_000), + }); + if (prefixResult.code === 0) { + const prefix = prefixResult.stdout.trim(); + if (prefix) return path.join(prefix, "bin"); + } + + const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); + if (envPrefix) return path.join(envPrefix, "bin"); + + for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + // ignore + } + } + return undefined; +} + export async function installSkill( params: SkillInstallRequest, ): Promise { @@ -130,6 +156,15 @@ export async function installSkill( code: null, }; } + if (spec.kind === "brew" && !hasBinary("brew")) { + return { + ok: false, + message: "brew not installed", + stdout: "", + stderr: "", + code: null, + }; + } if (spec.kind === "uv" && !hasBinary("uv")) { if (hasBinary("brew")) { const brewResult = await runCommandWithTimeout( @@ -167,14 +202,51 @@ export async function installSkill( }; } + if (spec.kind === "go" && !hasBinary("go")) { + if (hasBinary("brew")) { + const brewResult = await runCommandWithTimeout(["brew", "install", "go"], { + timeoutMs, + }); + if (brewResult.code !== 0) { + return { + ok: false, + message: "Failed to install go (brew)", + stdout: brewResult.stdout.trim(), + stderr: brewResult.stderr.trim(), + code: brewResult.code, + }; + } + } else { + return { + ok: false, + message: "go not installed (install via brew)", + stdout: "", + stderr: "", + code: null, + }; + } + } + + let env: NodeJS.ProcessEnv | undefined; + if (spec.kind === "go" && hasBinary("brew")) { + const brewBin = await resolveBrewBinDir(timeoutMs); + if (brewBin) env = { GOBIN: brewBin }; + } + const result = await (async () => { const argv = command.argv; if (!argv || argv.length === 0) { return { code: null, stdout: "", stderr: "invalid install command" }; } - return runCommandWithTimeout(argv, { - timeoutMs, - }); + try { + return await runCommandWithTimeout(argv, { + timeoutMs, + env, + }); + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err); + return { code: null, stdout: "", stderr }; + } })(); const success = result.code === 0; diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index bec69f5a6..6b478d052 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -47,11 +47,13 @@ export type SkillStatusEntry = { bins: string[]; env: string[]; config: string[]; + os: string[]; }; missing: { bins: string[]; env: string[]; config: string[]; + os: string[]; }; configChecks: SkillStatusConfigCheck[]; install: SkillInstallOption[]; @@ -149,8 +151,13 @@ function buildSkillStatus( const requiredBins = entry.clawdis?.requires?.bins ?? []; const requiredEnv = entry.clawdis?.requires?.env ?? []; const requiredConfig = entry.clawdis?.requires?.config ?? []; + const requiredOs = entry.clawdis?.os ?? []; const missingBins = requiredBins.filter((bin) => !hasBinary(bin)); + const missingOs = + requiredOs.length > 0 && !requiredOs.includes(process.platform) + ? requiredOs + : []; const missingEnv: string[] = []; for (const envName of requiredEnv) { @@ -174,15 +181,21 @@ function buildSkillStatus( .map((check) => check.path); const missing = always - ? { bins: [], env: [], config: [] } - : { bins: missingBins, env: missingEnv, config: missingConfig }; + ? { bins: [], env: [], config: [], os: [] } + : { + bins: missingBins, + env: missingEnv, + config: missingConfig, + os: missingOs, + }; const eligible = !disabled && !blockedByAllowlist && (always || (missing.bins.length === 0 && missing.env.length === 0 && - missing.config.length === 0)); + missing.config.length === 0 && + missing.os.length === 0)); return { name: entry.skill.name, @@ -202,6 +215,7 @@ function buildSkillStatus( bins: requiredBins, env: requiredEnv, config: requiredConfig, + os: requiredOs, }, missing, configChecks, diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 13b4f9feb..3ecde188f 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -369,6 +369,32 @@ describe("buildWorkspaceSkillStatus", () => { expect(skill?.install[0]?.id).toBe("brew"); }); + it("respects OS-gated skills", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); + const skillDir = path.join(workspaceDir, "skills", "os-skill"); + + await writeSkill({ + dir: skillDir, + name: "os-skill", + description: "Darwin only", + metadata: '{"clawdis":{"os":["darwin"]}}', + }); + + const report = buildWorkspaceSkillStatus(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + const skill = report.skills.find((entry) => entry.name === "os-skill"); + + expect(skill).toBeDefined(); + if (process.platform === "darwin") { + expect(skill?.eligible).toBe(true); + expect(skill?.missing.os).toEqual([]); + } else { + expect(skill?.eligible).toBe(false); + expect(skill?.missing.os).toEqual(["darwin"]); + } + }); + it("marks bundled skills blocked by allowlist", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const bundledDir = path.join(workspaceDir, ".bundled"); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 16d1f60b4..d9e2bb7a5 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -27,6 +27,7 @@ export type ClawdisSkillMetadata = { primaryEnv?: string; emoji?: string; homepage?: string; + os?: string[]; requires?: { bins?: string[]; env?: string[]; @@ -188,6 +189,10 @@ export function resolveSkillsInstallPreferences( return { preferBrew, nodeManager }; } +export function resolveRuntimePlatform(): string { + return process.platform; +} + export function resolveConfigPath( config: ClawdisConfig | undefined, pathStr: string, @@ -280,6 +285,7 @@ function resolveClawdisMetadata( const install = installRaw .map((entry) => parseInstallSpec(entry)) .filter((entry): entry is SkillInstallSpec => Boolean(entry)); + const osRaw = normalizeStringList(clawdisObj.os); return { always: typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined, @@ -297,6 +303,7 @@ function resolveClawdisMetadata( typeof clawdisObj.primaryEnv === "string" ? clawdisObj.primaryEnv : undefined, + os: osRaw.length > 0 ? osRaw : undefined, requires: requiresRaw ? { bins: normalizeStringList(requiresRaw.bins), @@ -323,9 +330,13 @@ function shouldIncludeSkill(params: { const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); + const osList = entry.clawdis?.os ?? []; if (skillConfig?.enabled === false) return false; if (!isBundledSkillAllowed(entry, allowBundled)) return false; + if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) { + return false; + } if (entry.clawdis?.always === true) { return true; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 5e45bef62..f804a2564 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -222,16 +222,19 @@ export type SkillStatusEntry = { homepage?: string; always: boolean; disabled: boolean; + blockedByAllowlist: boolean; eligible: boolean; requirements: { bins: string[]; env: string[]; config: string[]; + os: string[]; }; missing: { bins: string[]; env: string[]; config: string[]; + os: string[]; }; configChecks: SkillsStatusConfigCheck[]; install: SkillInstallOption[]; diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 05cf7ffc5..50861fee1 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -73,6 +73,15 @@ export function renderSkills(props: SkillsProps) { function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; const apiKey = props.edits[skill.skillKey] ?? ""; + const missing = [ + ...skill.missing.bins.map((b) => `bin:${b}`), + ...skill.missing.env.map((e) => `env:${e}`), + ...skill.missing.config.map((c) => `config:${c}`), + ...skill.missing.os.map((o) => `os:${o}`), + ]; + const reasons: string[] = []; + if (skill.disabled) reasons.push("disabled"); + if (skill.blockedByAllowlist) reasons.push("blocked by allowlist"); return html`
@@ -87,14 +96,17 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { ${skill.disabled ? html`disabled` : nothing}
- ${skill.missing.bins.length + skill.missing.env.length + skill.missing.config.length > 0 + ${missing.length > 0 ? html`
- Missing: ${[ - ...skill.missing.bins.map((b) => `bin:${b}`), - ...skill.missing.env.map((e) => `env:${e}`), - ...skill.missing.config.map((c) => `config:${c}`), - ].join(", ")} + Missing: ${missing.join(", ")} +
+ ` + : nothing} + ${reasons.length > 0 + ? html` +
+ Reason: ${reasons.join(", ")}
` : nothing} @@ -143,4 +155,3 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
`; } -