This commit is contained in:
roomote-v0[bot] 2026-04-09 01:43:29 +00:00 committed by GitHub
commit f8bf6236e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1143 additions and 0 deletions

View file

@ -15,6 +15,7 @@ export * from "./history.js"
export * from "./image-generation.js"
export * from "./ipc.js"
export * from "./marketplace.js"
export * from "./plugin.js"
export * from "./mcp.js"
export * from "./message.js"
export * from "./mode.js"

View file

@ -0,0 +1,104 @@
import { z } from "zod"
/**
* Plugin manifest command entry - references a markdown command file in the plugin repo.
*/
export const pluginCommandSchema = z.object({
name: z.string().min(1).describe("Command name (used as /command-name)"),
file: z.string().min(1).describe("Relative path to the command markdown file"),
description: z.string().optional().describe("Human-readable description of the command"),
})
export type PluginCommand = z.infer<typeof pluginCommandSchema>
/**
* Plugin manifest mode entry - references a YAML mode definition file.
*/
export const pluginModeSchema = z.object({
file: z.string().min(1).describe("Relative path to the mode YAML file"),
})
export type PluginMode = z.infer<typeof pluginModeSchema>
/**
* Plugin manifest skill entry - references a skill directory.
*/
export const pluginSkillSchema = z.object({
name: z.string().min(1).describe("Skill name"),
directory: z.string().min(1).describe("Relative path to the skill directory"),
})
export type PluginSkill = z.infer<typeof pluginSkillSchema>
/**
* MCP server configuration within a plugin manifest.
*/
export const pluginMcpServerSchema = z.record(
z.string(),
z.object({
command: z.string().min(1),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
}),
)
export type PluginMcpServers = z.infer<typeof pluginMcpServerSchema>
/**
* The plugin manifest (plugin.json) found at the root of a plugin repository.
*/
export const pluginManifestSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, "Plugin name must contain only letters, numbers, hyphens, and underscores"),
version: z
.string()
.regex(/^\d+\.\d+\.\d+$/, "Version must be in semver format (e.g. 1.0.0)")
.default("1.0.0"),
description: z.string().optional(),
author: z.string().optional(),
commands: z.array(pluginCommandSchema).optional().default([]),
modes: z.array(pluginModeSchema).optional().default([]),
mcpServers: pluginMcpServerSchema.optional(),
skills: z.array(pluginSkillSchema).optional().default([]),
})
export type PluginManifest = z.infer<typeof pluginManifestSchema>
/**
* Tracks which extension points were installed by a plugin.
*/
export const installedExtensionsSchema = z.object({
commands: z.array(z.string()).default([]),
modes: z.array(z.string()).default([]),
mcpServers: z.array(z.string()).default([]),
skills: z.array(z.string()).default([]),
})
export type InstalledExtensions = z.infer<typeof installedExtensionsSchema>
/**
* Record of an installed plugin, stored in plugins.json.
*/
export const installedPluginSchema = z.object({
name: z.string(),
version: z.string(),
source: z.string().describe("GitHub owner/repo format"),
ref: z.string().default("main").describe("Git ref (branch, tag, or commit) used during install"),
installedAt: z.string().describe("ISO 8601 timestamp"),
target: z.enum(["project", "global"]),
installedExtensions: installedExtensionsSchema,
})
export type InstalledPlugin = z.infer<typeof installedPluginSchema>
/**
* The plugins tracking file schema (plugins.json).
*/
export const pluginsFileSchema = z.object({
installedPlugins: z.array(installedPluginSchema).default([]),
})
export type PluginsFile = z.infer<typeof pluginsFileSchema>

View file

@ -284,6 +284,49 @@ Please analyze this codebase and create an AGENTS.md file containing:
Remember: The goal is to create documentation that enables AI assistants to be immediately productive in this codebase, focusing on project-specific knowledge that isn't obvious from the code structure alone.`,
},
plugin: {
name: "plugin",
description: "Install, remove, or list plugins from GitHub repositories",
argumentHint: "<install|remove|list> [owner/repo or plugin-name] [--global]",
content: `<task>
Manage Roo Code plugins. Plugins are installable packages from GitHub repositories that bundle
slash commands, custom modes, MCP server configurations, and skills.
Parse the user's arguments to determine the subcommand:
1. **install owner/repo** - Install a plugin from a GitHub repository
- Fetches the plugin.json manifest from the repo root
- Installs all bundled extension points (commands, modes, MCP servers, skills)
- Tracks the installation in .roo/plugins.json
- Optional: Add @ref to specify a branch/tag (e.g., owner/repo@v1.0.0)
- Optional: Add --global to install globally (~/.roo/) instead of project-level
2. **remove plugin-name** - Remove an installed plugin
- Cleans up all extension points that were installed by the plugin
- Removes the tracking record from plugins.json
- Optional: Add --global to remove from global installation
3. **list** - List all installed plugins
- Shows both project-level and global plugins
- Displays name, version, source, and installed extension points
A plugin repository must contain a plugin.json manifest at its root with this structure:
{
"name": "plugin-name",
"version": "1.0.0",
"description": "What this plugin does",
"author": "author-name",
"commands": [{ "name": "command-name", "file": "commands/command-name.md" }],
"modes": [{ "file": "modes/mode-name.yaml" }],
"mcpServers": { "server-name": { "command": "npx", "args": ["-y", "package-name"] } },
"skills": [{ "name": "skill-name", "directory": "skills/skill-name" }]
}
IMPORTANT: Use the PluginManager service to perform these operations. Do not attempt to
manually create or modify plugin files. The PluginManager handles all validation,
file operations, and tracking automatically.
</task>`,
},
}
/**

View file

@ -0,0 +1,91 @@
import type { PluginManifest } from "@roo-code/types"
import { pluginManifestSchema } from "@roo-code/types"
/**
* Parsed plugin source reference.
*/
export interface PluginSourceRef {
owner: string
repo: string
ref: string // branch, tag, or commit - defaults to "main"
}
/**
* Parse a plugin source string in the format "owner/repo" or "owner/repo@ref".
*/
export function parsePluginSource(source: string): PluginSourceRef {
const atIndex = source.indexOf("@")
let repoPath: string
let ref: string
if (atIndex !== -1) {
repoPath = source.slice(0, atIndex)
ref = source.slice(atIndex + 1)
if (!ref) {
ref = "main"
}
} else {
repoPath = source
ref = "main"
}
const parts = repoPath.split("/")
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid plugin source format: "${source}". Expected "owner/repo" or "owner/repo@ref".`)
}
return { owner: parts[0], repo: parts[1], ref }
}
/**
* Build a raw GitHub content URL for a specific file in a repository.
*/
export function buildRawUrl(sourceRef: PluginSourceRef, filePath: string): string {
return `https://raw.githubusercontent.com/${sourceRef.owner}/${sourceRef.repo}/${sourceRef.ref}/${filePath}`
}
/**
* Fetch a file's text content from a GitHub repository.
*/
export async function fetchFileFromGitHub(sourceRef: PluginSourceRef, filePath: string): Promise<string> {
const url = buildRawUrl(sourceRef, filePath)
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404) {
throw new Error(`File not found: ${filePath} in ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}`)
}
throw new Error(`Failed to fetch ${filePath}: HTTP ${response.status} ${response.statusText}`)
}
return response.text()
}
/**
* Fetch and validate the plugin manifest (plugin.json) from a GitHub repository.
*/
export async function fetchPluginManifest(sourceRef: PluginSourceRef): Promise<PluginManifest> {
const content = await fetchFileFromGitHub(sourceRef, "plugin.json")
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
throw new Error(`Invalid JSON in plugin.json from ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}`)
}
const result = pluginManifestSchema.safeParse(parsed)
if (!result.success) {
const errors = result.error.issues
.map(
(issue: { path: (string | number)[]; message: string }) =>
` - ${issue.path.join(".")}: ${issue.message}`,
)
.join("\n")
throw new Error(
`Invalid plugin manifest from ${sourceRef.owner}/${sourceRef.repo}@${sourceRef.ref}:\n${errors}`,
)
}
return result.data
}

View file

@ -0,0 +1,310 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as yaml from "yaml"
import type { PluginManifest, InstalledExtensions } from "@roo-code/types"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
import { fileExistsAtPath } from "../../utils/fs"
import type { PluginSourceRef } from "./GitHubSource"
import { fetchFileFromGitHub } from "./GitHubSource"
export interface PluginInstallContext {
sourceRef: PluginSourceRef
manifest: PluginManifest
target: "project" | "global"
cwd: string
extensionContext: import("vscode").ExtensionContext
}
/**
* Install all extension points defined in a plugin manifest.
* Returns the record of what was installed for tracking purposes.
*/
export async function installPluginExtensions(ctx: PluginInstallContext): Promise<InstalledExtensions> {
const installed: InstalledExtensions = {
commands: [],
modes: [],
mcpServers: [],
skills: [],
}
// Install commands
if (ctx.manifest.commands && ctx.manifest.commands.length > 0) {
const commandNames = await installCommands(ctx)
installed.commands = commandNames
}
// Install modes
if (ctx.manifest.modes && ctx.manifest.modes.length > 0) {
const modeNames = await installModes(ctx)
installed.modes = modeNames
}
// Install MCP servers
if (ctx.manifest.mcpServers && Object.keys(ctx.manifest.mcpServers).length > 0) {
const serverNames = await installMcpServers(ctx)
installed.mcpServers = serverNames
}
// Install skills
if (ctx.manifest.skills && ctx.manifest.skills.length > 0) {
const skillNames = await installSkills(ctx)
installed.skills = skillNames
}
return installed
}
/**
* Remove all extension points installed by a plugin.
*/
export async function removePluginExtensions(
installed: InstalledExtensions,
target: "project" | "global",
cwd: string,
extensionContext: import("vscode").ExtensionContext,
): Promise<void> {
// Remove commands
for (const commandName of installed.commands) {
await removeCommand(commandName, target, cwd)
}
// Remove modes
for (const modeName of installed.modes) {
await removeMode(modeName, target, cwd, extensionContext)
}
// Remove MCP servers
for (const serverName of installed.mcpServers) {
await removeMcpServer(serverName, target, cwd, extensionContext)
}
// Remove skills
for (const skillName of installed.skills) {
await removeSkill(skillName, target, cwd)
}
}
// --- Commands ---
async function getCommandsDir(target: "project" | "global", cwd: string): Promise<string> {
if (target === "project") {
const projectDir = getProjectRooDirectoryForCwd(cwd)
return path.join(projectDir, "commands")
}
const globalDir = getGlobalRooDirectory()
return path.join(globalDir, "commands")
}
async function installCommands(ctx: PluginInstallContext): Promise<string[]> {
const commandsDir = await getCommandsDir(ctx.target, ctx.cwd)
await fs.mkdir(commandsDir, { recursive: true })
const installed: string[] = []
for (const cmd of ctx.manifest.commands!) {
const content = await fetchFileFromGitHub(ctx.sourceRef, cmd.file)
const targetPath = path.join(commandsDir, `${cmd.name}.md`)
await fs.writeFile(targetPath, content, "utf-8")
installed.push(cmd.name)
}
return installed
}
async function removeCommand(name: string, target: "project" | "global", cwd: string): Promise<void> {
const commandsDir = await getCommandsDir(target, cwd)
const filePath = path.join(commandsDir, `${name}.md`)
try {
await fs.unlink(filePath)
} catch {
// File may not exist - that's okay
}
}
// --- Modes ---
async function getModeFilePath(
target: "project" | "global",
cwd: string,
extensionContext: import("vscode").ExtensionContext,
): Promise<string> {
if (target === "project") {
return path.join(cwd, ".roomodes")
}
const settingsDir = await ensureSettingsDirectoryExists(extensionContext)
return path.join(settingsDir, GlobalFileNames.customModes)
}
async function installModes(ctx: PluginInstallContext): Promise<string[]> {
const installed: string[] = []
for (const modeEntry of ctx.manifest.modes!) {
const content = await fetchFileFromGitHub(ctx.sourceRef, modeEntry.file)
// Parse the mode YAML to get the slug
let modeData: Record<string, unknown>
try {
modeData = yaml.parse(content)
} catch {
throw new Error(`Invalid YAML in mode file: ${modeEntry.file}`)
}
const slug = modeData.slug as string
if (!slug) {
throw new Error(`Mode file ${modeEntry.file} is missing a "slug" field`)
}
// Merge into the target modes file
const modesFilePath = await getModeFilePath(ctx.target, ctx.cwd, ctx.extensionContext)
let existingData: { customModes: Record<string, unknown>[] } = { customModes: [] }
try {
const existingContent = await fs.readFile(modesFilePath, "utf-8")
existingData = yaml.parse(existingContent) || { customModes: [] }
if (!existingData.customModes) {
existingData.customModes = []
}
} catch {
// File doesn't exist yet - use defaults
}
// Remove existing mode with same slug
existingData.customModes = existingData.customModes.filter((m) => m.slug !== slug)
existingData.customModes.push(modeData)
await fs.writeFile(modesFilePath, yaml.stringify(existingData, { lineWidth: 0 }), "utf-8")
installed.push(slug)
}
return installed
}
async function removeMode(
slug: string,
target: "project" | "global",
cwd: string,
extensionContext: import("vscode").ExtensionContext,
): Promise<void> {
const modesFilePath = await getModeFilePath(target, cwd, extensionContext)
try {
const content = await fs.readFile(modesFilePath, "utf-8")
const data = yaml.parse(content) || { customModes: [] }
if (data.customModes) {
data.customModes = data.customModes.filter((m: Record<string, unknown>) => m.slug !== slug)
await fs.writeFile(modesFilePath, yaml.stringify(data, { lineWidth: 0 }), "utf-8")
}
} catch {
// File doesn't exist - nothing to remove
}
}
// --- MCP Servers ---
async function getMcpSettingsPath(
target: "project" | "global",
cwd: string,
extensionContext: import("vscode").ExtensionContext,
): Promise<string> {
if (target === "project") {
const rooDir = path.join(cwd, ".roo")
await fs.mkdir(rooDir, { recursive: true })
return path.join(rooDir, "mcp.json")
}
const settingsDir = await ensureSettingsDirectoryExists(extensionContext)
return path.join(settingsDir, GlobalFileNames.mcpSettings)
}
async function installMcpServers(ctx: PluginInstallContext): Promise<string[]> {
const mcpPath = await getMcpSettingsPath(ctx.target, ctx.cwd, ctx.extensionContext)
let existingData: { mcpServers: Record<string, unknown> } = { mcpServers: {} }
try {
const content = await fs.readFile(mcpPath, "utf-8")
existingData = JSON.parse(content)
if (!existingData.mcpServers) {
existingData.mcpServers = {}
}
} catch {
// File doesn't exist yet
}
const installed: string[] = []
for (const [serverName, serverConfig] of Object.entries(ctx.manifest.mcpServers!)) {
existingData.mcpServers[serverName] = serverConfig
installed.push(serverName)
}
await safeWriteJson(mcpPath, existingData)
return installed
}
async function removeMcpServer(
serverName: string,
target: "project" | "global",
cwd: string,
extensionContext: import("vscode").ExtensionContext,
): Promise<void> {
const mcpPath = await getMcpSettingsPath(target, cwd, extensionContext)
try {
const content = await fs.readFile(mcpPath, "utf-8")
const data = JSON.parse(content)
if (data.mcpServers && data.mcpServers[serverName]) {
delete data.mcpServers[serverName]
await safeWriteJson(mcpPath, data)
}
} catch {
// File doesn't exist - nothing to remove
}
}
// --- Skills ---
async function getSkillsDir(target: "project" | "global", cwd: string): Promise<string> {
if (target === "project") {
const projectDir = getProjectRooDirectoryForCwd(cwd)
return path.join(projectDir, "skills")
}
const globalDir = getGlobalRooDirectory()
return path.join(globalDir, "skills")
}
async function installSkills(ctx: PluginInstallContext): Promise<string[]> {
const skillsDir = await getSkillsDir(ctx.target, ctx.cwd)
const installed: string[] = []
for (const skill of ctx.manifest.skills!) {
const skillDir = path.join(skillsDir, skill.name)
await fs.mkdir(skillDir, { recursive: true })
// Fetch the SKILL.md file from the skill directory in the plugin repo
const skillMdPath = path.posix.join(skill.directory, "SKILL.md")
try {
const content = await fetchFileFromGitHub(ctx.sourceRef, skillMdPath)
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf-8")
installed.push(skill.name)
} catch (error) {
throw new Error(
`Failed to install skill "${skill.name}": ${error instanceof Error ? error.message : String(error)}`,
)
}
}
return installed
}
async function removeSkill(name: string, target: "project" | "global", cwd: string): Promise<void> {
const skillsDir = await getSkillsDir(target, cwd)
const skillDir = path.join(skillsDir, name)
try {
if (await fileExistsAtPath(skillDir)) {
await fs.rm(skillDir, { recursive: true, force: true })
}
} catch {
// Directory may not exist - that's okay
}
}

View file

@ -0,0 +1,165 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import type { InstalledPlugin, PluginsFile, PluginManifest } from "@roo-code/types"
import { pluginsFileSchema } from "@roo-code/types"
import { GlobalFileNames } from "../../shared/globalFileNames"
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
import { parsePluginSource, fetchPluginManifest, type PluginSourceRef } from "./GitHubSource"
import { installPluginExtensions, removePluginExtensions, type PluginInstallContext } from "./PluginInstaller"
export class PluginManager {
constructor(
private readonly extensionContext: vscode.ExtensionContext,
private readonly cwd: string,
) {}
/**
* Install a plugin from a GitHub repository source.
*
* @param source - GitHub repo in "owner/repo" or "owner/repo@ref" format
* @param target - Install to "project" (.roo/) or "global" (~/.roo/)
* @returns The installed plugin record
*/
async install(source: string, target: "project" | "global" = "project"): Promise<InstalledPlugin> {
const sourceRef = parsePluginSource(source)
// Fetch and validate manifest
const manifest = await fetchPluginManifest(sourceRef)
// Check if already installed
const existing = await this.getInstalledPlugin(manifest.name, target)
if (existing) {
throw new Error(
`Plugin "${manifest.name}" is already installed (${target}). Remove it first with /plugin remove ${manifest.name}`,
)
}
// Install all extension points
const ctx: PluginInstallContext = {
sourceRef,
manifest,
target,
cwd: this.cwd,
extensionContext: this.extensionContext,
}
const installedExtensions = await installPluginExtensions(ctx)
// Create install record
const record: InstalledPlugin = {
name: manifest.name,
version: manifest.version,
source: `${sourceRef.owner}/${sourceRef.repo}`,
ref: sourceRef.ref,
installedAt: new Date().toISOString(),
target,
installedExtensions,
}
// Save to tracking file
await this.addPluginRecord(record, target)
return record
}
/**
* Remove an installed plugin and clean up all its extension points.
*/
async remove(pluginName: string, target?: "project" | "global"): Promise<void> {
// Find the plugin - check both project and global if target not specified
let record: InstalledPlugin | undefined
let recordTarget: "project" | "global"
if (target) {
record = await this.getInstalledPlugin(pluginName, target)
recordTarget = target
} else {
// Check project first, then global
record = await this.getInstalledPlugin(pluginName, "project")
recordTarget = "project"
if (!record) {
record = await this.getInstalledPlugin(pluginName, "global")
recordTarget = "global"
}
}
if (!record) {
throw new Error(`Plugin "${pluginName}" is not installed.`)
}
// Remove all installed extension points
await removePluginExtensions(record.installedExtensions, recordTarget!, this.cwd, this.extensionContext)
// Remove from tracking file
await this.removePluginRecord(pluginName, recordTarget!)
}
/**
* List all installed plugins (both project and global).
*/
async list(): Promise<InstalledPlugin[]> {
const projectPlugins = await this.readPluginsFile("project")
const globalPlugins = await this.readPluginsFile("global")
return [...projectPlugins.installedPlugins, ...globalPlugins.installedPlugins]
}
/**
* Get a specific installed plugin by name and target.
*/
async getInstalledPlugin(name: string, target: "project" | "global"): Promise<InstalledPlugin | undefined> {
const pluginsFile = await this.readPluginsFile(target)
return pluginsFile.installedPlugins.find((p) => p.name === name)
}
// --- Private helpers ---
private async getPluginsFilePath(target: "project" | "global"): Promise<string> {
if (target === "project") {
const projectDir = getProjectRooDirectoryForCwd(this.cwd)
await fs.mkdir(projectDir, { recursive: true })
return path.join(projectDir, GlobalFileNames.plugins)
}
const settingsDir = await ensureSettingsDirectoryExists(this.extensionContext)
return path.join(settingsDir, GlobalFileNames.plugins)
}
private async readPluginsFile(target: "project" | "global"): Promise<PluginsFile> {
const filePath = await this.getPluginsFilePath(target)
try {
const content = await fs.readFile(filePath, "utf-8")
const parsed = JSON.parse(content)
const result = pluginsFileSchema.safeParse(parsed)
if (result.success) {
return result.data
}
} catch {
// File doesn't exist or is invalid
}
return { installedPlugins: [] }
}
private async addPluginRecord(record: InstalledPlugin, target: "project" | "global"): Promise<void> {
const filePath = await this.getPluginsFilePath(target)
const pluginsFile = await this.readPluginsFile(target)
// Remove existing record with same name (shouldn't happen but be safe)
pluginsFile.installedPlugins = pluginsFile.installedPlugins.filter((p) => p.name !== record.name)
pluginsFile.installedPlugins.push(record)
await safeWriteJson(filePath, pluginsFile)
}
private async removePluginRecord(pluginName: string, target: "project" | "global"): Promise<void> {
const filePath = await this.getPluginsFilePath(target)
const pluginsFile = await this.readPluginsFile(target)
pluginsFile.installedPlugins = pluginsFile.installedPlugins.filter((p) => p.name !== pluginName)
await safeWriteJson(filePath, pluginsFile)
}
}

View file

@ -0,0 +1,195 @@
import { parsePluginSource, buildRawUrl, fetchFileFromGitHub, fetchPluginManifest } from "../GitHubSource"
// Mock global fetch
const mockFetch = vi.fn()
global.fetch = mockFetch
describe("GitHubSource", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("parsePluginSource", () => {
it("should parse owner/repo format", () => {
const result = parsePluginSource("myowner/myrepo")
expect(result).toEqual({
owner: "myowner",
repo: "myrepo",
ref: "main",
})
})
it("should parse owner/repo@ref format", () => {
const result = parsePluginSource("myowner/myrepo@v1.0.0")
expect(result).toEqual({
owner: "myowner",
repo: "myrepo",
ref: "v1.0.0",
})
})
it("should parse owner/repo@branch format", () => {
const result = parsePluginSource("org/repo@develop")
expect(result).toEqual({
owner: "org",
repo: "repo",
ref: "develop",
})
})
it("should default ref to main when @ is present but ref is empty", () => {
const result = parsePluginSource("owner/repo@")
expect(result).toEqual({
owner: "owner",
repo: "repo",
ref: "main",
})
})
it("should throw for invalid format - no slash", () => {
expect(() => parsePluginSource("invalidformat")).toThrow("Invalid plugin source format")
})
it("should throw for invalid format - empty owner", () => {
expect(() => parsePluginSource("/repo")).toThrow("Invalid plugin source format")
})
it("should throw for invalid format - empty repo", () => {
expect(() => parsePluginSource("owner/")).toThrow("Invalid plugin source format")
})
it("should throw for invalid format - too many parts", () => {
expect(() => parsePluginSource("a/b/c")).toThrow("Invalid plugin source format")
})
})
describe("buildRawUrl", () => {
it("should build correct raw URL", () => {
const url = buildRawUrl({ owner: "owner", repo: "repo", ref: "main" }, "plugin.json")
expect(url).toBe("https://raw.githubusercontent.com/owner/repo/main/plugin.json")
})
it("should handle nested paths", () => {
const url = buildRawUrl({ owner: "o", repo: "r", ref: "v1" }, "commands/review.md")
expect(url).toBe("https://raw.githubusercontent.com/o/r/v1/commands/review.md")
})
})
describe("fetchFileFromGitHub", () => {
it("should fetch file content", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("# Hello"),
})
const content = await fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "README.md")
expect(content).toBe("# Hello")
expect(mockFetch).toHaveBeenCalledWith("https://raw.githubusercontent.com/o/r/main/README.md")
})
it("should throw on 404", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
})
await expect(fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "missing.txt")).rejects.toThrow(
"File not found: missing.txt",
)
})
it("should throw on other HTTP errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
})
await expect(fetchFileFromGitHub({ owner: "o", repo: "r", ref: "main" }, "file.txt")).rejects.toThrow(
"Failed to fetch file.txt: HTTP 500 Internal Server Error",
)
})
})
describe("fetchPluginManifest", () => {
it("should fetch and validate a valid manifest", async () => {
const manifest = {
name: "test-plugin",
version: "1.0.0",
description: "A test plugin",
commands: [{ name: "review", file: "commands/review.md" }],
}
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(manifest)),
})
const result = await fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })
expect(result.name).toBe("test-plugin")
expect(result.version).toBe("1.0.0")
expect(result.commands).toHaveLength(1)
expect(result.commands![0].name).toBe("review")
})
it("should throw on invalid JSON", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json {{{"),
})
await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow("Invalid JSON")
})
it("should throw on invalid manifest schema", async () => {
const invalidManifest = {
// Missing required "name" field
version: "1.0.0",
}
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(invalidManifest)),
})
await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow(
"Invalid plugin manifest",
)
})
it("should throw on invalid plugin name format", async () => {
const manifest = {
name: "invalid name with spaces!",
version: "1.0.0",
}
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(manifest)),
})
await expect(fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })).rejects.toThrow(
"Invalid plugin manifest",
)
})
it("should apply defaults for optional fields", async () => {
const manifest = {
name: "minimal-plugin",
}
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(manifest)),
})
const result = await fetchPluginManifest({ owner: "o", repo: "r", ref: "main" })
expect(result.name).toBe("minimal-plugin")
expect(result.version).toBe("1.0.0")
expect(result.commands).toEqual([])
expect(result.modes).toEqual([])
expect(result.skills).toEqual([])
})
})
})

View file

@ -0,0 +1,233 @@
import * as fs from "fs/promises"
import * as path from "path"
import { PluginManager } from "../PluginManager"
import * as GitHubSource from "../GitHubSource"
import * as PluginInstaller from "../PluginInstaller"
// Mock dependencies
vi.mock("fs/promises")
vi.mock("../GitHubSource")
vi.mock("../PluginInstaller")
vi.mock("../../../utils/globalContext", () => ({
ensureSettingsDirectoryExists: vi.fn().mockResolvedValue("/mock/settings"),
}))
vi.mock("../../roo-config", () => ({
getGlobalRooDirectory: vi.fn().mockReturnValue("/mock/home/.roo"),
getProjectRooDirectoryForCwd: vi.fn().mockReturnValue("/mock/project/.roo"),
}))
vi.mock("../../../utils/safeWriteJson", () => ({
safeWriteJson: vi.fn().mockResolvedValue(undefined),
}))
describe("PluginManager", () => {
let manager: PluginManager
const mockExtensionContext = {} as any
beforeEach(() => {
vi.clearAllMocks()
manager = new PluginManager(mockExtensionContext, "/mock/project")
})
describe("install", () => {
it("should install a plugin from GitHub", async () => {
const mockManifest = {
name: "test-plugin",
version: "1.0.0",
description: "Test",
commands: [{ name: "review", file: "commands/review.md" }],
modes: [],
skills: [],
}
vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({
owner: "owner",
repo: "repo",
ref: "main",
})
vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue(mockManifest as any)
// Mock empty plugins file (not installed yet)
vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT"))
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(PluginInstaller.installPluginExtensions).mockResolvedValue({
commands: ["review"],
modes: [],
mcpServers: [],
skills: [],
})
const result = await manager.install("owner/repo")
expect(result.name).toBe("test-plugin")
expect(result.version).toBe("1.0.0")
expect(result.source).toBe("owner/repo")
expect(result.ref).toBe("main")
expect(result.target).toBe("project")
expect(result.installedExtensions.commands).toEqual(["review"])
expect(GitHubSource.fetchPluginManifest).toHaveBeenCalled()
expect(PluginInstaller.installPluginExtensions).toHaveBeenCalled()
})
it("should throw if plugin is already installed", async () => {
const existingPlugins = {
installedPlugins: [
{
name: "test-plugin",
version: "1.0.0",
source: "owner/repo",
ref: "main",
installedAt: "2024-01-01T00:00:00Z",
target: "project",
installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] },
},
],
}
vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({
owner: "owner",
repo: "repo",
ref: "main",
})
vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue({
name: "test-plugin",
version: "1.0.0",
commands: [],
modes: [],
skills: [],
} as any)
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingPlugins) as any)
await expect(manager.install("owner/repo")).rejects.toThrow("already installed")
})
it("should install to global scope when specified", async () => {
vi.mocked(GitHubSource.parsePluginSource).mockReturnValue({
owner: "owner",
repo: "repo",
ref: "main",
})
vi.mocked(GitHubSource.fetchPluginManifest).mockResolvedValue({
name: "global-plugin",
version: "1.0.0",
commands: [],
modes: [],
skills: [],
} as any)
vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT"))
vi.mocked(PluginInstaller.installPluginExtensions).mockResolvedValue({
commands: [],
modes: [],
mcpServers: [],
skills: [],
})
const result = await manager.install("owner/repo", "global")
expect(result.target).toBe("global")
})
})
describe("remove", () => {
it("should remove an installed plugin", async () => {
const existingPlugins = {
installedPlugins: [
{
name: "test-plugin",
version: "1.0.0",
source: "owner/repo",
ref: "main",
installedAt: "2024-01-01T00:00:00Z",
target: "project",
installedExtensions: {
commands: ["review"],
modes: [],
mcpServers: [],
skills: [],
},
},
],
}
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingPlugins) as any)
vi.mocked(PluginInstaller.removePluginExtensions).mockResolvedValue(undefined)
await manager.remove("test-plugin")
expect(PluginInstaller.removePluginExtensions).toHaveBeenCalledWith(
existingPlugins.installedPlugins[0].installedExtensions,
"project",
"/mock/project",
mockExtensionContext,
)
})
it("should throw if plugin is not installed", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT"))
await expect(manager.remove("nonexistent")).rejects.toThrow("not installed")
})
})
describe("list", () => {
it("should list plugins from both project and global", async () => {
const projectPlugins = {
installedPlugins: [
{
name: "project-plugin",
version: "1.0.0",
source: "o/r",
ref: "main",
installedAt: "2024-01-01T00:00:00Z",
target: "project",
installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] },
},
],
}
const globalPlugins = {
installedPlugins: [
{
name: "global-plugin",
version: "2.0.0",
source: "g/r",
ref: "main",
installedAt: "2024-01-01T00:00:00Z",
target: "global",
installedExtensions: { commands: [], modes: [], mcpServers: [], skills: [] },
},
],
}
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
// First call is for project, second for global
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(projectPlugins) as any)
.mockResolvedValueOnce(JSON.stringify(globalPlugins) as any)
const result = await manager.list()
expect(result).toHaveLength(2)
expect(result[0].name).toBe("project-plugin")
expect(result[1].name).toBe("global-plugin")
})
it("should return empty array when no plugins installed", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT"))
const result = await manager.list()
expect(result).toEqual([])
})
})
})

View file

@ -6,4 +6,5 @@ export const GlobalFileNames = {
taskMetadata: "task_metadata.json",
historyItem: "history_item.json",
historyIndex: "_index.json",
plugins: "plugins.json",
}