mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Merge 82171e2a21 into 7adbfec2a4
This commit is contained in:
commit
f8bf6236e9
9 changed files with 1143 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
104
packages/types/src/plugin.ts
Normal file
104
packages/types/src/plugin.ts
Normal 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>
|
||||
|
|
@ -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>`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
91
src/services/plugin/GitHubSource.ts
Normal file
91
src/services/plugin/GitHubSource.ts
Normal 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
|
||||
}
|
||||
310
src/services/plugin/PluginInstaller.ts
Normal file
310
src/services/plugin/PluginInstaller.ts
Normal 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
|
||||
}
|
||||
}
|
||||
165
src/services/plugin/PluginManager.ts
Normal file
165
src/services/plugin/PluginManager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
195
src/services/plugin/__tests__/GitHubSource.spec.ts
Normal file
195
src/services/plugin/__tests__/GitHubSource.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
233
src/services/plugin/__tests__/PluginManager.spec.ts
Normal file
233
src/services/plugin/__tests__/PluginManager.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,4 +6,5 @@ export const GlobalFileNames = {
|
|||
taskMetadata: "task_metadata.json",
|
||||
historyItem: "history_item.json",
|
||||
historyIndex: "_index.json",
|
||||
plugins: "plugins.json",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue