fix: reduce brew noise in onboarding
This commit is contained in:
42
src/agents/skills-status.test.ts
Normal file
42
src/agents/skills-status.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
177
src/commands/onboard-skills.test.ts
Normal file
177
src/commands/onboard-skills.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user