diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts new file mode 100644 index 000000000..9f1ec4158 --- /dev/null +++ b/src/agents/skills-status.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { SkillEntry } from "./skills/types.js"; +import { buildWorkspaceSkillStatus } from "./skills-status.js"; + +describe("buildWorkspaceSkillStatus", () => { + it("does not surface install options for OS-scoped skills on unsupported platforms", () => { + if (process.platform === "win32") { + // Keep this simple; win32 platform naming is already explicitly handled elsewhere. + return; + } + + const mismatchedOs = process.platform === "darwin" ? "linux" : "darwin"; + + const entry: SkillEntry = { + skill: { + name: "os-scoped", + description: "test", + source: "test", + filePath: "/tmp/os-scoped", + baseDir: "/tmp", + }, + frontmatter: {}, + metadata: { + os: [mismatchedOs], + requires: { bins: ["fakebin"] }, + install: [ + { + id: "brew", + kind: "brew", + formula: "fake", + bins: ["fakebin"], + label: "Install fake (brew)", + }, + ], + }, + }; + + const report = buildWorkspaceSkillStatus("/tmp/ws", { entries: [entry] }); + expect(report.skills).toHaveLength(1); + expect(report.skills[0]?.install).toEqual([]); + }); +}); diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index abec175b8..4bb666636 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -111,6 +111,13 @@ function normalizeInstallOptions( entry: SkillEntry, prefs: SkillsInstallPreferences, ): SkillInstallOption[] { + // If the skill is explicitly OS-scoped, don't surface install actions on unsupported platforms. + // (Installers run locally; remote OS eligibility is handled separately.) + const requiredOs = entry.metadata?.os ?? []; + if (requiredOs.length > 0 && !requiredOs.includes(process.platform)) { + return []; + } + const install = entry.metadata?.install ?? []; if (install.length === 0) { return []; diff --git a/src/commands/onboard-skills.test.ts b/src/commands/onboard-skills.test.ts new file mode 100644 index 000000000..c61ce2c5a --- /dev/null +++ b/src/commands/onboard-skills.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +// Module under test imports these at module scope. +vi.mock("../agents/skills-status.js", () => ({ + buildWorkspaceSkillStatus: vi.fn(), +})); +vi.mock("../agents/skills-install.js", () => ({ + installSkill: vi.fn(), +})); +vi.mock("./onboard-helpers.js", () => ({ + detectBinary: vi.fn(), + resolveNodeManagerOptions: vi.fn(() => [ + { value: "npm", label: "npm" }, + { value: "pnpm", label: "pnpm" }, + { value: "bun", label: "bun" }, + ]), +})); + +import { installSkill } from "../agents/skills-install.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { detectBinary } from "./onboard-helpers.js"; +import { setupSkills } from "./onboard-skills.js"; + +function createPrompter(params: { + configure?: boolean; + showBrewInstall?: boolean; + multiselect?: string[]; +}): { prompter: WizardPrompter; notes: Array<{ title?: string; message: string }> } { + const notes: Array<{ title?: string; message: string }> = []; + + const confirmAnswers: boolean[] = []; + confirmAnswers.push(params.configure ?? true); + + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async (message: string, title?: string) => { + notes.push({ title, message }); + }), + select: vi.fn(async () => "npm"), + multiselect: vi.fn(async () => params.multiselect ?? ["__skip__"]), + text: vi.fn(async () => ""), + confirm: vi.fn(async ({ message }) => { + if (message === "Show Homebrew install command?") { + return params.showBrewInstall ?? false; + } + return confirmAnswers.shift() ?? false; + }), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + return { prompter, notes }; +} + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: ((code: number) => { + throw new Error(`unexpected exit ${code}`); + }) as RuntimeEnv["exit"], +}; + +describe("setupSkills", () => { + it("does not recommend Homebrew when user skips installing brew-backed deps", async () => { + if (process.platform === "win32") { + return; + } + + vi.mocked(detectBinary).mockResolvedValue(false); + vi.mocked(installSkill).mockResolvedValue({ + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + }); + vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({ + workspaceDir: "/tmp/ws", + managedSkillsDir: "/tmp/managed", + skills: [ + { + name: "apple-reminders", + description: "macOS-only", + source: "openclaw-bundled", + bundled: true, + filePath: "/tmp/skills/apple-reminders", + baseDir: "/tmp/skills/apple-reminders", + skillKey: "apple-reminders", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] }, + missing: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] }, + configChecks: [], + install: [ + { id: "brew", kind: "brew", label: "Install remindctl (brew)", bins: ["remindctl"] }, + ], + }, + { + name: "video-frames", + description: "ffmpeg", + source: "openclaw-bundled", + bundled: true, + filePath: "/tmp/skills/video-frames", + baseDir: "/tmp/skills/video-frames", + skillKey: "video-frames", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }], + }, + ], + }); + + const { prompter, notes } = createPrompter({ multiselect: ["__skip__"] }); + await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); + + // OS-mismatched skill should be counted as unsupported, not installable/missing. + const status = notes.find((n) => n.title === "Skills status")?.message ?? ""; + expect(status).toContain("Unsupported on this OS: 1"); + + const brewNote = notes.find((n) => n.title === "Homebrew recommended"); + expect(brewNote).toBeUndefined(); + }); + + it("recommends Homebrew when user selects a brew-backed install and brew is missing", async () => { + if (process.platform === "win32") { + return; + } + + vi.mocked(detectBinary).mockResolvedValue(false); + vi.mocked(installSkill).mockResolvedValue({ + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + }); + vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({ + workspaceDir: "/tmp/ws", + managedSkillsDir: "/tmp/managed", + skills: [ + { + name: "video-frames", + description: "ffmpeg", + source: "openclaw-bundled", + bundled: true, + filePath: "/tmp/skills/video-frames", + baseDir: "/tmp/skills/video-frames", + skillKey: "video-frames", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }], + }, + ], + }); + + const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] }); + await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter); + + const brewNote = notes.find((n) => n.title === "Homebrew recommended"); + expect(brewNote).toBeDefined(); + }); +}); diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 09f895bf3..c471729bb 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -55,18 +55,19 @@ export async function setupSkills( ): Promise { const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); const eligible = report.skills.filter((s) => s.eligible); - const missing = report.skills.filter((s) => !s.eligible && !s.disabled && !s.blockedByAllowlist); + const unsupportedOs = report.skills.filter( + (s) => !s.disabled && !s.blockedByAllowlist && s.missing.os.length > 0, + ); + const missing = report.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist && s.missing.os.length === 0, + ); const blocked = report.skills.filter((s) => s.blockedByAllowlist); - const needsBrewPrompt = - process.platform !== "win32" && - report.skills.some((skill) => skill.install.some((option) => option.kind === "brew")) && - !(await detectBinary("brew")); - await prompter.note( [ `Eligible: ${eligible.length}`, `Missing requirements: ${missing.length}`, + `Unsupported on this OS: ${unsupportedOs.length}`, `Blocked by allowlist: ${blocked.length}`, ].join("\n"), "Skills status", @@ -80,48 +81,10 @@ export async function setupSkills( return cfg; } - if (needsBrewPrompt) { - await prompter.note( - [ - "Many skill dependencies are shipped via Homebrew.", - "Without brew, you'll need to build from source or download releases manually.", - ].join("\n"), - "Homebrew recommended", - ); - const showBrewInstall = await prompter.confirm({ - message: "Show Homebrew install command?", - initialValue: true, - }); - if (showBrewInstall) { - await prompter.note( - [ - "Run:", - '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', - ].join("\n"), - "Homebrew install", - ); - } - } - - const nodeManager = (await prompter.select({ - message: "Preferred node manager for skill installs", - options: resolveNodeManagerOptions(), - })) as "npm" | "pnpm" | "bun"; - - let next: OpenClawConfig = { - ...cfg, - skills: { - ...cfg.skills, - install: { - ...cfg.skills?.install, - nodeManager, - }, - }, - }; - const installable = missing.filter( (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, ); + let next: OpenClawConfig = cfg; if (installable.length > 0) { const toInstall = await prompter.multiselect({ message: "Install missing skill dependencies", @@ -140,6 +103,59 @@ export async function setupSkills( }); const selected = toInstall.filter((name) => name !== "__skip__"); + + const selectedSkills = selected + .map((name) => installable.find((s) => s.name === name)) + .filter((item): item is (typeof installable)[number] => Boolean(item)); + + const needsBrewPrompt = + process.platform !== "win32" && + selectedSkills.some((skill) => skill.install.some((option) => option.kind === "brew")) && + !(await detectBinary("brew")); + + if (needsBrewPrompt) { + await prompter.note( + [ + "Many skill dependencies are shipped via Homebrew.", + "Without brew, you'll need to build from source or download releases manually.", + ].join("\n"), + "Homebrew recommended", + ); + const showBrewInstall = await prompter.confirm({ + message: "Show Homebrew install command?", + initialValue: true, + }); + if (showBrewInstall) { + await prompter.note( + [ + "Run:", + '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', + ].join("\n"), + "Homebrew install", + ); + } + } + + const needsNodeManagerPrompt = selectedSkills.some((skill) => + skill.install.some((option) => option.kind === "node"), + ); + if (needsNodeManagerPrompt) { + const nodeManager = (await prompter.select({ + message: "Preferred node manager for skill installs", + options: resolveNodeManagerOptions(), + })) as "npm" | "pnpm" | "bun"; + next = { + ...next, + skills: { + ...next.skills, + install: { + ...next.skills?.install, + nodeManager, + }, + }, + }; + } + for (const name of selected) { const target = installable.find((s) => s.name === name); if (!target || target.install.length === 0) {