Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
104 changes: 104 additions & 0 deletions packages/types/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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>
43 changes: 43 additions & 0 deletions src/services/command/built-in-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>`,
},
}

/**
Expand Down
91 changes: 91 additions & 0 deletions src/services/plugin/GitHubSource.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading