From de33ff9e921e657df22edd93729b896ca0f3553e Mon Sep 17 00:00:00 2001 From: AdamParker19 Date: Thu, 29 Jan 2026 23:10:13 +0530 Subject: [PATCH 1/2] feat(skills): add cross-platform dependency manager Add support for apt, winget, chocolatey, and scoop package managers. AI-assisted --- src/agents/skills-install.ts | 63 +++++++++ src/agents/skills.ts | 7 + src/agents/skills/dependency-manager.test.ts | 139 +++++++++++++++++++ src/agents/skills/dependency-manager.ts | 138 ++++++++++++++++++ src/agents/skills/types.ts | 11 +- 5 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 src/agents/skills/dependency-manager.test.ts create mode 100644 src/agents/skills/dependency-manager.ts diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 4dfdf9ef4..b3a4a953f 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -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 ({ + 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"); + }); + }); +}); diff --git a/src/agents/skills/dependency-manager.ts b/src/agents/skills/dependency-manager.ts new file mode 100644 index 000000000..bf124c1f1 --- /dev/null +++ b/src/agents/skills/dependency-manager.ts @@ -0,0 +1,138 @@ +/** + * 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]; +} diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index 1cb3a2684..ac0515a40 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -2,7 +2,16 @@ 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[]; From 4526fd97861f9f329af8c3cf0680235466ba6309 Mon Sep 17 00:00:00 2001 From: AdamParker19 Date: Thu, 29 Jan 2026 23:10:13 +0530 Subject: [PATCH 2/2] feat(skills): add cross-platform dependency manager Add support for apt, winget, chocolatey, and scoop package managers. AI-assisted --- src/agents/skills-install.ts | 63 ++++++++ src/agents/skills.ts | 7 + src/agents/skills/dependency-manager.test.ts | 162 +++++++++++++++++++ src/agents/skills/dependency-manager.ts | 141 ++++++++++++++++ src/agents/skills/types.ts | 2 +- 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/agents/skills/dependency-manager.test.ts create mode 100644 src/agents/skills/dependency-manager.ts diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 4dfdf9ef4..b3a4a953f 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -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 ({ + 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"); + }); + }); +}); diff --git a/src/agents/skills/dependency-manager.ts b/src/agents/skills/dependency-manager.ts new file mode 100644 index 000000000..f518ea9a9 --- /dev/null +++ b/src/agents/skills/dependency-manager.ts @@ -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]; +} diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index 1cb3a2684..ff429dbdc 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -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[];