fix: reduce brew noise in onboarding

This commit is contained in:
Peter Steinberger
2026-02-09 13:27:13 -06:00
parent 9a765c9fb4
commit 268094938b
4 changed files with 287 additions and 45 deletions

View File

@@ -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([]);
});
});

View File

@@ -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 [];

View File

@@ -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();
});
});

View File

@@ -55,18 +55,19 @@ export async function setupSkills(
): Promise<OpenClawConfig> {
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) {