This commit is contained in:
Anmol Patel 2026-01-30 21:11:44 +05:30 committed by GitHub
commit 69578d891e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 374 additions and 1 deletions

View File

@ -106,6 +106,33 @@ function buildInstallCommand(
if (!spec.formula) return { argv: null, error: "missing brew formula" };
return { argv: ["brew", "install", spec.formula] };
}
case "apt": {
if (!spec.package) return { argv: null, error: "missing apt package" };
// Note: sudo handling may need to be configurable or elevated separately
return { argv: ["sudo", "apt-get", "install", "-y", spec.package] };
}
case "winget": {
if (!spec.package) return { argv: null, error: "missing winget package" };
return {
argv: [
"winget",
"install",
"--accept-package-agreements",
"--accept-source-agreements",
"-e",
"--id",
spec.package,
],
};
}
case "choco": {
if (!spec.package) return { argv: null, error: "missing choco package" };
return { argv: ["choco", "install", "-y", spec.package] };
}
case "scoop": {
if (!spec.package) return { argv: null, error: "missing scoop package" };
return { argv: ["scoop", "install", spec.package] };
}
case "node": {
if (!spec.package) return { argv: null, error: "missing node package" };
return {
@ -352,6 +379,42 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
code: null,
};
}
if (spec.kind === "apt" && !hasBinary("apt-get") && !hasBinary("apt")) {
return {
ok: false,
message: "apt not available (requires Debian/Ubuntu)",
stdout: "",
stderr: "",
code: null,
};
}
if (spec.kind === "winget" && !hasBinary("winget")) {
return {
ok: false,
message: "winget not installed (requires Windows 10/11)",
stdout: "",
stderr: "",
code: null,
};
}
if (spec.kind === "choco" && !hasBinary("choco")) {
return {
ok: false,
message: "chocolatey not installed (visit chocolatey.org)",
stdout: "",
stderr: "",
code: null,
};
}
if (spec.kind === "scoop" && !hasBinary("scoop")) {
return {
ok: false,
message: "scoop not installed (visit scoop.sh)",
stdout: "",
stderr: "",
code: null,
};
}
if (spec.kind === "uv" && !hasBinary("uv")) {
if (brewExe) {
const brewResult = await runCommandWithTimeout([brewExe, "install", "uv"], {

View File

@ -31,6 +31,13 @@ export {
resolveSkillsPromptForRun,
syncSkillsToWorkspace,
} from "./skills/workspace.js";
export {
detectPlatform,
hasPackageManager,
selectInstallSpec,
type PackageManagerKind,
type PlatformInfo,
} from "./skills/dependency-manager.js";
export function resolveSkillsInstallPreferences(config?: OpenClawConfig) {
const raw = config?.skills?.install;

View File

@ -0,0 +1,162 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
// Mock hasBinary before importing
vi.mock("./config.js", () => ({
hasBinary: vi.fn(),
}));
import { hasBinary } from "./config.js";
import {
detectPlatform,
hasPackageManager,
selectInstallSpec,
getPackageManagerLabel,
} from "./dependency-manager.js";
import type { SkillEntry, SkillInstallSpec } from "./types.js";
const mockedHasBinary = vi.mocked(hasBinary);
describe("dependency-manager", () => {
beforeEach(() => {
mockedHasBinary.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("detectPlatform", () => {
it("detects available package managers", () => {
mockedHasBinary.mockImplementation((bin) => bin === "npm" || bin === "winget");
const platform = detectPlatform();
expect(platform.availableManagers).toContain("npm");
expect(platform.availableManagers).toContain("winget");
expect(platform.availableManagers).not.toContain("brew");
});
it("detects apt when apt-get is available", () => {
mockedHasBinary.mockImplementation((bin) => bin === "apt-get");
const platform = detectPlatform();
expect(platform.availableManagers).toContain("apt");
});
it("returns os from process.platform", () => {
mockedHasBinary.mockReturnValue(false);
const platform = detectPlatform();
expect(["darwin", "linux", "win32"]).toContain(platform.os);
});
});
describe("hasPackageManager", () => {
it("returns true when brew is available", () => {
mockedHasBinary.mockImplementation((bin) => bin === "brew");
expect(hasPackageManager("brew")).toBe(true);
expect(hasPackageManager("apt")).toBe(false);
});
it("returns true when apt-get is available for apt", () => {
mockedHasBinary.mockImplementation((bin) => bin === "apt-get");
expect(hasPackageManager("apt")).toBe(true);
});
it("checks each package manager correctly", () => {
mockedHasBinary.mockImplementation((bin) => bin === "scoop");
expect(hasPackageManager("scoop")).toBe(true);
expect(hasPackageManager("choco")).toBe(false);
expect(hasPackageManager("winget")).toBe(false);
});
});
describe("selectInstallSpec", () => {
const createEntry = (install: SkillInstallSpec[]): SkillEntry => ({
skill: { name: "test-skill", instructions: "test" },
frontmatter: {},
metadata: { install },
});
it("returns undefined when no specs are available", () => {
const entry = createEntry([]);
const platform = {
os: "win32" as NodeJS.Platform,
preferredManager: "winget" as const,
availableManagers: ["winget" as const],
};
expect(selectInstallSpec(entry, platform)).toBeUndefined();
});
it("selects spec matching preferred manager", () => {
const brewSpec: SkillInstallSpec = { kind: "brew", formula: "test" };
const wingetSpec: SkillInstallSpec = { kind: "winget", package: "test" };
const entry = createEntry([brewSpec, wingetSpec]);
const platform = {
os: "win32" as NodeJS.Platform,
preferredManager: "winget" as const,
availableManagers: ["winget" as const],
};
expect(selectInstallSpec(entry, platform)).toBe(wingetSpec);
});
it("filters by OS when specified", () => {
const brewSpec: SkillInstallSpec = { kind: "brew", formula: "test", os: ["darwin"] };
const wingetSpec: SkillInstallSpec = { kind: "winget", package: "test", os: ["win32"] };
const entry = createEntry([brewSpec, wingetSpec]);
const platform = {
os: "win32" as NodeJS.Platform,
preferredManager: "winget" as const,
availableManagers: ["winget" as const],
};
expect(selectInstallSpec(entry, platform)).toBe(wingetSpec);
});
it("falls back to download spec when no manager matches", () => {
const downloadSpec: SkillInstallSpec = {
kind: "download",
url: "https://example.com/file.zip",
};
const brewSpec: SkillInstallSpec = { kind: "brew", formula: "test" };
const entry = createEntry([brewSpec, downloadSpec]);
const platform = {
os: "win32" as NodeJS.Platform,
preferredManager: "winget" as const,
availableManagers: ["winget" as const],
};
expect(selectInstallSpec(entry, platform)).toBe(downloadSpec);
});
it("includes specs without OS restriction", () => {
const universalSpec: SkillInstallSpec = { kind: "node", package: "test-pkg" };
const entry = createEntry([universalSpec]);
const platform = {
os: "linux" as NodeJS.Platform,
preferredManager: "apt" as const,
availableManagers: ["apt" as const, "npm" as const],
};
expect(selectInstallSpec(entry, platform)).toBe(universalSpec);
});
});
describe("getPackageManagerLabel", () => {
it("returns human-readable labels", () => {
expect(getPackageManagerLabel("brew")).toBe("Homebrew");
expect(getPackageManagerLabel("apt")).toBe("APT (Debian/Ubuntu)");
expect(getPackageManagerLabel("winget")).toBe("Windows Package Manager (winget)");
expect(getPackageManagerLabel("choco")).toBe("Chocolatey");
expect(getPackageManagerLabel("scoop")).toBe("Scoop");
expect(getPackageManagerLabel("npm")).toBe("npm");
});
});
});

View File

@ -0,0 +1,141 @@
/**
* Cross-platform dependency resolution and platform detection.
* Helps select the appropriate install spec for the current OS.
*/
import { hasBinary } from "./config.js";
import type { SkillEntry, SkillInstallSpec } from "./types.js";
export type PackageManagerKind = "brew" | "apt" | "winget" | "choco" | "scoop" | "npm";
export type PlatformInfo = {
/** Node.js process.platform value */
os: NodeJS.Platform;
/** Best package manager for this platform */
preferredManager: PackageManagerKind | undefined;
/** All detected package managers available on PATH */
availableManagers: PackageManagerKind[];
};
/**
* Detect the current platform and available package managers.
*/
export function detectPlatform(): PlatformInfo {
const os = process.platform;
const availableManagers: PackageManagerKind[] = [];
// Check each package manager
if (hasBinary("brew")) availableManagers.push("brew");
if (hasBinary("apt-get") || hasBinary("apt")) availableManagers.push("apt");
if (hasBinary("winget")) availableManagers.push("winget");
if (hasBinary("choco")) availableManagers.push("choco");
if (hasBinary("scoop")) availableManagers.push("scoop");
if (hasBinary("npm")) availableManagers.push("npm");
const preferredManager = resolvePreferredManager(os, availableManagers);
return { os, preferredManager, availableManagers };
}
/**
* Select the best install spec for the current platform from a skill's install array.
* Prioritizes specs matching the preferred package manager, then falls back to
* any compatible spec (e.g., download).
*/
export function selectInstallSpec(
entry: SkillEntry,
platform: PlatformInfo,
): SkillInstallSpec | undefined {
const specs = entry.metadata?.install ?? [];
if (specs.length === 0) return undefined;
// Filter specs compatible with current OS
const compatible = specs.filter((spec) => {
if (!spec.os || spec.os.length === 0) return true;
return spec.os.includes(platform.os);
});
if (compatible.length === 0) return undefined;
// Try preferred manager first, then other available managers
const managerPriority = platform.preferredManager
? [
platform.preferredManager,
...platform.availableManagers.filter((m) => m !== platform.preferredManager),
]
: platform.availableManagers;
for (const manager of managerPriority) {
const match = compatible.find((spec) => spec.kind === manager);
if (match) return match;
}
// Fall back to universal install methods (don't require a platform package manager)
const universalKinds = ["download", "node", "go", "uv"];
const universalSpec = compatible.find((spec) => universalKinds.includes(spec.kind));
if (universalSpec) return universalSpec;
// Last resort: first compatible spec
return compatible[0];
}
/**
* Check if a specific package manager is available on the system.
*/
export function hasPackageManager(kind: PackageManagerKind): boolean {
switch (kind) {
case "brew":
return hasBinary("brew");
case "apt":
return hasBinary("apt-get") || hasBinary("apt");
case "winget":
return hasBinary("winget");
case "choco":
return hasBinary("choco");
case "scoop":
return hasBinary("scoop");
case "npm":
return hasBinary("npm");
default:
return false;
}
}
/**
* Get a human-readable name for a package manager.
*/
export function getPackageManagerLabel(kind: PackageManagerKind): string {
switch (kind) {
case "brew":
return "Homebrew";
case "apt":
return "APT (Debian/Ubuntu)";
case "winget":
return "Windows Package Manager (winget)";
case "choco":
return "Chocolatey";
case "scoop":
return "Scoop";
case "npm":
return "npm";
default:
return kind;
}
}
function resolvePreferredManager(
os: string,
available: PackageManagerKind[],
): PackageManagerKind | undefined {
// Platform-specific preferences
if (os === "darwin" && available.includes("brew")) return "brew";
if (os === "linux" && available.includes("apt")) return "apt";
if (os === "win32") {
// Windows: prefer winget > scoop > choco
if (available.includes("winget")) return "winget";
if (available.includes("scoop")) return "scoop";
if (available.includes("choco")) return "choco";
}
// Fallback to first available
return available[0];
}

View File

@ -2,7 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
export type SkillInstallSpec = {
id?: string;
kind: "brew" | "node" | "go" | "uv" | "download";
kind: "brew" | "apt" | "winget" | "choco" | "scoop" | "node" | "go" | "uv" | "download";
label?: string;
bins?: string[];
os?: string[];