fix: gate skills by OS
This commit is contained in:
@@ -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<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(
|
||||
params: SkillInstallRequest,
|
||||
): Promise<SkillInstallResult> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user