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" };
|
if (!spec.formula) return { argv: null, error: "missing brew formula" };
|
||||||
return { argv: ["brew", "install", spec.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": {
|
case "node": {
|
||||||
if (!spec.package) return { argv: null, error: "missing node package" };
|
if (!spec.package) return { argv: null, error: "missing node package" };
|
||||||
return {
|
return {
|
||||||
@ -352,6 +379,42 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
|
|||||||
code: null,
|
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 (spec.kind === "uv" && !hasBinary("uv")) {
|
||||||
if (brewExe) {
|
if (brewExe) {
|
||||||
const brewResult = await runCommandWithTimeout([brewExe, "install", "uv"], {
|
const brewResult = await runCommandWithTimeout([brewExe, "install", "uv"], {
|
||||||
|
|||||||
@ -31,6 +31,13 @@ export {
|
|||||||
resolveSkillsPromptForRun,
|
resolveSkillsPromptForRun,
|
||||||
syncSkillsToWorkspace,
|
syncSkillsToWorkspace,
|
||||||
} from "./skills/workspace.js";
|
} from "./skills/workspace.js";
|
||||||
|
export {
|
||||||
|
detectPlatform,
|
||||||
|
hasPackageManager,
|
||||||
|
selectInstallSpec,
|
||||||
|
type PackageManagerKind,
|
||||||
|
type PlatformInfo,
|
||||||
|
} from "./skills/dependency-manager.js";
|
||||||
|
|
||||||
export function resolveSkillsInstallPreferences(config?: OpenClawConfig) {
|
export function resolveSkillsInstallPreferences(config?: OpenClawConfig) {
|
||||||
const raw = config?.skills?.install;
|
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 = {
|
export type SkillInstallSpec = {
|
||||||
id?: string;
|
id?: string;
|
||||||
kind: "brew" | "node" | "go" | "uv" | "download";
|
kind: "brew" | "apt" | "winget" | "choco" | "scoop" | "node" | "go" | "uv" | "download";
|
||||||
label?: string;
|
label?: string;
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
os?: string[];
|
os?: string[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user