fix: gate skills by OS

This commit is contained in:
Peter Steinberger
2026-01-01 22:23:23 +01:00
parent 47f816696c
commit 73d0e2cb81
11 changed files with 156 additions and 16 deletions

View File

@@ -29,6 +29,7 @@
### Fixes ### Fixes
- Skills: switch imsg installer to brew tap formula. - 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 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 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 - macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b

View File

@@ -59,6 +59,7 @@ 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. - `emoji` — optional emoji used by the macOS Skills UI.
- `homepage` — optional URL shown as “Website” in 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.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.
@@ -78,6 +79,7 @@ metadata: {"clawdis":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":
Notes: Notes:
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). - 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). - 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 Homebrews `bin` when possible.
If no `metadata.clawdis` is present, the skill is always eligible (unless If no `metadata.clawdis` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills). disabled in config or blocked by `skills.allowBundled` for bundled skills).

View File

@@ -2,7 +2,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.
homepage: https://imsg.to 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 # imsg

View File

@@ -2,7 +2,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.
homepage: https://peekaboo.boo 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 # Peekaboo

View File

@@ -2,7 +2,7 @@
name: things-mac 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. 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 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 # Things 3 CLI

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@@ -88,6 +91,29 @@ function buildInstallCommand(
} }
} }
async function resolveBrewBinDir(timeoutMs: number): Promise<string | undefined> {
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( export async function installSkill(
params: SkillInstallRequest, params: SkillInstallRequest,
): Promise<SkillInstallResult> { ): Promise<SkillInstallResult> {
@@ -130,6 +156,15 @@ export async function installSkill(
code: null, 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 (spec.kind === "uv" && !hasBinary("uv")) {
if (hasBinary("brew")) { if (hasBinary("brew")) {
const brewResult = await runCommandWithTimeout( 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 result = await (async () => {
const argv = command.argv; const argv = command.argv;
if (!argv || argv.length === 0) { if (!argv || argv.length === 0) {
return { code: null, stdout: "", stderr: "invalid install command" }; return { code: null, stdout: "", stderr: "invalid install command" };
} }
return runCommandWithTimeout(argv, { try {
timeoutMs, 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; const success = result.code === 0;

View File

@@ -47,11 +47,13 @@ export type SkillStatusEntry = {
bins: string[]; bins: string[];
env: string[]; env: string[];
config: string[]; config: string[];
os: string[];
}; };
missing: { missing: {
bins: string[]; bins: string[];
env: string[]; env: string[];
config: string[]; config: string[];
os: string[];
}; };
configChecks: SkillStatusConfigCheck[]; configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[]; install: SkillInstallOption[];
@@ -149,8 +151,13 @@ function buildSkillStatus(
const requiredBins = entry.clawdis?.requires?.bins ?? []; const requiredBins = entry.clawdis?.requires?.bins ?? [];
const requiredEnv = entry.clawdis?.requires?.env ?? []; const requiredEnv = entry.clawdis?.requires?.env ?? [];
const requiredConfig = entry.clawdis?.requires?.config ?? []; const requiredConfig = entry.clawdis?.requires?.config ?? [];
const requiredOs = entry.clawdis?.os ?? [];
const missingBins = requiredBins.filter((bin) => !hasBinary(bin)); const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
const missingOs =
requiredOs.length > 0 && !requiredOs.includes(process.platform)
? requiredOs
: [];
const missingEnv: string[] = []; const missingEnv: string[] = [];
for (const envName of requiredEnv) { for (const envName of requiredEnv) {
@@ -174,15 +181,21 @@ function buildSkillStatus(
.map((check) => check.path); .map((check) => check.path);
const missing = always const missing = always
? { bins: [], env: [], config: [] } ? { bins: [], env: [], config: [], os: [] }
: { bins: missingBins, env: missingEnv, config: missingConfig }; : {
bins: missingBins,
env: missingEnv,
config: missingConfig,
os: missingOs,
};
const eligible = const eligible =
!disabled && !disabled &&
!blockedByAllowlist && !blockedByAllowlist &&
(always || (always ||
(missing.bins.length === 0 && (missing.bins.length === 0 &&
missing.env.length === 0 && missing.env.length === 0 &&
missing.config.length === 0)); missing.config.length === 0 &&
missing.os.length === 0));
return { return {
name: entry.skill.name, name: entry.skill.name,
@@ -202,6 +215,7 @@ function buildSkillStatus(
bins: requiredBins, bins: requiredBins,
env: requiredEnv, env: requiredEnv,
config: requiredConfig, config: requiredConfig,
os: requiredOs,
}, },
missing, missing,
configChecks, configChecks,

View File

@@ -369,6 +369,32 @@ describe("buildWorkspaceSkillStatus", () => {
expect(skill?.install[0]?.id).toBe("brew"); 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 () => { it("marks bundled skills blocked by allowlist", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const bundledDir = path.join(workspaceDir, ".bundled"); const bundledDir = path.join(workspaceDir, ".bundled");

View File

@@ -27,6 +27,7 @@ export type ClawdisSkillMetadata = {
primaryEnv?: string; primaryEnv?: string;
emoji?: string; emoji?: string;
homepage?: string; homepage?: string;
os?: string[];
requires?: { requires?: {
bins?: string[]; bins?: string[];
env?: string[]; env?: string[];
@@ -188,6 +189,10 @@ export function resolveSkillsInstallPreferences(
return { preferBrew, nodeManager }; return { preferBrew, nodeManager };
} }
export function resolveRuntimePlatform(): string {
return process.platform;
}
export function resolveConfigPath( export function resolveConfigPath(
config: ClawdisConfig | undefined, config: ClawdisConfig | undefined,
pathStr: string, pathStr: string,
@@ -280,6 +285,7 @@ function resolveClawdisMetadata(
const install = installRaw const install = installRaw
.map((entry) => parseInstallSpec(entry)) .map((entry) => parseInstallSpec(entry))
.filter((entry): entry is SkillInstallSpec => Boolean(entry)); .filter((entry): entry is SkillInstallSpec => Boolean(entry));
const osRaw = normalizeStringList(clawdisObj.os);
return { return {
always: always:
typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined, typeof clawdisObj.always === "boolean" ? clawdisObj.always : undefined,
@@ -297,6 +303,7 @@ function resolveClawdisMetadata(
typeof clawdisObj.primaryEnv === "string" typeof clawdisObj.primaryEnv === "string"
? clawdisObj.primaryEnv ? clawdisObj.primaryEnv
: undefined, : undefined,
os: osRaw.length > 0 ? osRaw : undefined,
requires: requiresRaw requires: requiresRaw
? { ? {
bins: normalizeStringList(requiresRaw.bins), bins: normalizeStringList(requiresRaw.bins),
@@ -323,9 +330,13 @@ function shouldIncludeSkill(params: {
const skillKey = resolveSkillKey(entry.skill, entry); const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(config, skillKey); const skillConfig = resolveSkillConfig(config, skillKey);
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
const osList = entry.clawdis?.os ?? [];
if (skillConfig?.enabled === false) return false; if (skillConfig?.enabled === false) return false;
if (!isBundledSkillAllowed(entry, allowBundled)) return false; if (!isBundledSkillAllowed(entry, allowBundled)) return false;
if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) {
return false;
}
if (entry.clawdis?.always === true) { if (entry.clawdis?.always === true) {
return true; return true;
} }

View File

@@ -222,16 +222,19 @@ export type SkillStatusEntry = {
homepage?: string; homepage?: string;
always: boolean; always: boolean;
disabled: boolean; disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean; eligible: boolean;
requirements: { requirements: {
bins: string[]; bins: string[];
env: string[]; env: string[];
config: string[]; config: string[];
os: string[];
}; };
missing: { missing: {
bins: string[]; bins: string[];
env: string[]; env: string[];
config: string[]; config: string[];
os: string[];
}; };
configChecks: SkillsStatusConfigCheck[]; configChecks: SkillsStatusConfigCheck[];
install: SkillInstallOption[]; install: SkillInstallOption[];

View File

@@ -73,6 +73,15 @@ export function renderSkills(props: SkillsProps) {
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name; const busy = props.busyKey === skill.skillKey || props.busyKey === skill.name;
const apiKey = props.edits[skill.skillKey] ?? ""; 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` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
@@ -87,14 +96,17 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
</span> </span>
${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing} ${skill.disabled ? html`<span class="chip chip-warn">disabled</span>` : nothing}
</div> </div>
${skill.missing.bins.length + skill.missing.env.length + skill.missing.config.length > 0 ${missing.length > 0
? html` ? html`
<div class="muted" style="margin-top: 6px;"> <div class="muted" style="margin-top: 6px;">
Missing: ${[ Missing: ${missing.join(", ")}
...skill.missing.bins.map((b) => `bin:${b}`), </div>
...skill.missing.env.map((e) => `env:${e}`), `
...skill.missing.config.map((c) => `config:${c}`), : nothing}
].join(", ")} ${reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
</div> </div>
` `
: nothing} : nothing}
@@ -143,4 +155,3 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
</div> </div>
`; `;
} }