feat: add filesystem plugin loader APIs#38
Conversation
Greptile SummaryAdds filesystem plugin loader APIs ( Key changes:
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| src/plugins/loader.ts | Adds comprehensive filesystem plugin loader with validation, MCP support, and agent definitions - well-structured with proper error handling |
| tests/plugins-loader.test.ts | Comprehensive test coverage for plugin loading, code entrypoints, model resolution, and subagent creation |
| docs/filesystem-plugins.md | Clear documentation with examples, directory layout, and security defaults |
| src/index.ts | Exports new plugin loader APIs and types following existing patterns |
| CHANGELOG.md | Properly documents new APIs under Unreleased/Added section |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
Start[loadPluginsFromDirectories] --> Discover[Discover plugin directories]
Discover --> Sort[Sort discovered directories]
Sort --> Load[loadPluginFromDirectory]
Load --> Manifest[Read plugin.json manifest]
Manifest --> Validate{Validate manifest?}
Validate -->|Yes| ValidateName[Check name format]
Validate -->|No| Code
ValidateName --> Code
Code{allowCodeEntrypoint?}
Code -->|Yes| ImportJS[Import plugin.js entrypoint]
Code -->|No| Skills
ImportJS --> Skills
Skills[Load skills from skills directory]
Skills --> MergeSkills[Merge file skills and code skills]
MergeSkills --> BasePlugin[Create base AgentPlugin]
BasePlugin --> MCP[Collect MCP servers from mcp.json]
MCP --> SyntheticPlugins[Generate synthetic plugins for MCP servers]
SyntheticPlugins --> Agents[Load agents from agents directory]
Agents --> ParseMD[Parse YAML frontmatter]
ParseMD --> ResolveModel{Model specified?}
ResolveModel -->|Yes| CallResolver[Call resolveModel callback]
ResolveModel -->|No| ResolvePlugins
CallResolver --> ResolvePlugins
ResolvePlugins[Resolve plugin refs]
ResolvePlugins --> AgentDef[Create FilesystemAgentDefinition]
AgentDef --> CheckParent{getParentAgent provided?}
CheckParent -->|Yes| CreateSubagents[Create SubagentDefinitions]
CheckParent -->|No| Return
CreateSubagents --> Return[Return PluginLoadResult]
Last reviewed commit: 57dca0a
| } | ||
|
|
||
| function parseMarkdownFrontmatter<TFrontmatter>(content: string): ParsedMarkdownFile<TFrontmatter> { | ||
| const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; |
There was a problem hiding this comment.
Regex requires newlines after both opening and closing markers. Will fail if file ends immediately after closing marker without trailing newline. Consider making body group optional with non-capturing group.
There was a problem hiding this comment.
Pull request overview
Adds a filesystem-based plugin loader to the SDK, enabling discovery and loading of Claude Code-style local plugin packages (manifest, optional MCP config, optional code entrypoint, bundled skills, and markdown-based agent definitions).
Changes:
- Introduces new filesystem plugin loader APIs and typed interfaces (
loadPluginFromDirectory,loadPluginsFromDirectories,createSubagentDefinitionsFromFilesystemAgents). - Exports the new APIs/types from the public entrypoint (
src/index.ts). - Adds documentation and a test suite for plugin discovery, skills/MCP aggregation, and agent parsing.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/plugins/loader.ts |
Implements plugin discovery, manifest parsing, optional entrypoint import, skills loading, MCP server synthesis, and agent markdown parsing. |
src/index.ts |
Re-exports the new loader APIs and associated types as public SDK surface area. |
docs/filesystem-plugins.md |
Documents the filesystem plugin package layout, supported files, and loader API usage. |
tests/plugins-loader.test.ts |
Adds Vitest coverage for loading plugins with skills/MCP/agents and the allowCodeEntrypoint + resolveModel behaviors. |
CHANGELOG.md |
Records the new filesystem plugin loading APIs under Unreleased. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ## `plugin.js` (optional) | ||
|
|
||
| If `allowCodeEntrypoint: true` is passed to the loader, the entrypoint module is imported. | ||
|
|
||
| Supported exports: | ||
| - `default` object and/or named `plugin` object | ||
| - optional fields: `tools`, `skills`, `hooks`, `setup`, `deferred`, `subagent`, `mcpServer`, `mcpServers` | ||
|
|
There was a problem hiding this comment.
The plugin.js section implies ESM-style entrypoints, but in Node a standalone plugin.js outside an ESM package scope (no nearby package.json with "type": "module") will be treated as CommonJS, making import ... syntax fail. Consider documenting the required module format (CJS vs ESM) and/or recommending plugin.mjs or providing a package.json in the plugin folder if authors want ESM syntax.
| ## Security Defaults | ||
|
|
||
| - Code entrypoints are disabled by default (`allowCodeEntrypoint: false`). | ||
| - Manifest/MCP/agent parse errors are non-fatal and reported in `errors`. |
There was a problem hiding this comment.
This doc claims “Manifest/MCP/agent parse errors are non-fatal and reported in errors”, but loadPluginFromDirectory() currently throws on invalid/missing plugin.json because readManifestFile() isn’t caught. Either adjust the implementation to surface manifest errors in PluginLoadResult.errors, or update this bullet to clarify that manifest failures are fatal for the single-directory API (and only non-fatal when using loadPluginsFromDirectories() which catches per-package failures).
| - Manifest/MCP/agent parse errors are non-fatal and reported in `errors`. | |
| - When using `loadPluginsFromDirectories()`, manifest/MCP/agent parse errors are non-fatal and reported in `errors`; `loadPluginFromDirectory()` will throw on manifest failures. |
| function validateManifest(manifest: FilesystemPluginManifest, manifestPath: string): void { | ||
| if (!manifest.name) { | ||
| throw new Error(`Invalid plugin manifest at '${manifestPath}': 'name' is required`); | ||
| } | ||
|
|
||
| if (!/^[a-z0-9-]+$/.test(manifest.name)) { | ||
| throw new Error( | ||
| `Invalid plugin manifest at '${manifestPath}': name must contain lowercase letters, numbers, and hyphens`, |
There was a problem hiding this comment.
validateManifest() only validates name, but entrypoint, skillsDir, and agentsDir are later passed into resolve()/join(). Because join()/resolve() will honor absolute paths and .. segments, a plugin manifest can cause the loader to read/import files outside the plugin directory (even when allowCodeEntrypoint is false via skillsDir/agentsDir). Consider validating these fields when validate is true: require relative paths and ensure the resolved target stays within the plugin package directory (e.g., using path.relative() checks) before using them.
|
|
||
| const pluginDir = resolve(directory); | ||
| const manifestPath = join(pluginDir, "plugin.json"); | ||
| const manifest = await readManifestFile(manifestPath, validate); |
There was a problem hiding this comment.
loadPluginFromDirectory() is documented as returning “non-fatal errors”, but readManifestFile() can throw (missing/invalid plugin.json, validation failures) and this function doesn’t catch it, so callers get a rejected promise rather than a PluginLoadResult.errors entry. Consider wrapping manifest loading/validation in a try/catch and returning { plugins: [], agents: [], subagents: [], errors: [...] } for consistency with the rest of the loader error-handling (and with the docs).
| const manifest = await readManifestFile(manifestPath, validate); | |
| let manifest; | |
| try { | |
| manifest = await readManifestFile(manifestPath, validate); | |
| } catch (error) { | |
| errors.push({ | |
| message: | |
| error instanceof Error | |
| ? error.message | |
| : "Failed to load plugin manifest", | |
| } as PluginLoadError); | |
| return { | |
| plugins: [], | |
| agents: [], | |
| subagents: [], | |
| errors, | |
| }; | |
| } |
| function parseMarkdownFrontmatter<TFrontmatter>(content: string): ParsedMarkdownFile<TFrontmatter> { | ||
| const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; | ||
| const match = content.match(frontmatterRegex); | ||
|
|
||
| if (!match) { | ||
| throw new Error("Missing YAML frontmatter (must start and end with ---)"); | ||
| } |
There was a problem hiding this comment.
parseMarkdownFrontmatter() only matches \n line endings (/^---\n.../). Markdown files on Windows commonly use \r\n, which will make otherwise valid agent files fail to parse. Consider accepting both newline styles (e.g., \r?\n) or normalizing line endings before applying the regex.
Summary
loadPluginFromDirectory,loadPluginsFromDirectories, andcreateSubagentDefinitionsFromFilesystemAgentsplugin.json, optionalmcp.json, optionalplugin.js, bundledskills/, andagents/*.mdsrc/index.tsdocs/filesystem-plugins.mdUnreleasedDetails
plugin.jsloading is opt-in viaallowCodeEntrypoint(secure default is off)mcp.jsonorplugin.jsmcpServers) are converted to synthetic plugin names<plugin>__<server>agents/*.mdfrontmatter supportsdescription,model,allowedTools,plugins, andstreamingself,self:mcp, and explicit plugin namesValidation
bun run lintbun run type-checkbun run test tests/plugins-loader.test.tsbiome check .vitestsuite (2052 passed, 8 skipped)