Merge e8bf1b1722 into 09be5d45d5
This commit is contained in:
commit
69578d891e
@ -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"], {
|
||||
|
||||
@ -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;
|
||||
|
||||
162
src/agents/skills/dependency-manager.test.ts
Normal file
162
src/agents/skills/dependency-manager.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
141
src/agents/skills/dependency-manager.ts
Normal file
141
src/agents/skills/dependency-manager.ts
Normal 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];
|
||||
}
|
||||
@ -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[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user