Use this skill to build or revise MoviePilot plugins that can be developed from a local plugin source and installed into the running MoviePilot instance.
Ground Truth
- Host plugin contract:
app/plugins/__init__.py, especially_PluginBase. - Host plugin discovery, local source sync, install, reload:
app/runtime/extensions/plugin_manager.pyandapp/adapters/external/market.py. - Host plugin endpoints, API auth, static files, remotes, and sidebar nav:
app/api/endpoints/plugin.py. - Local development note:
docs/development-setup.md. - First identify the actual host generation from its source/version and the
target plugin repository. MoviePilot V3 uses
plugins.v3/withpackage.v3.json; V2 usesplugins.v2/withpackage.v2.json; legacy V1 usesplugins/withpackage.json. Do not generate V2 paths for a V3 host. - When working in or from
MoviePilot-Plugins, read itsREADME.md,docs/Repository_Guide.md, and the guide for the target generation:docs/Plugin_Development.mdfor V3,docs/V2_Plugin_Development.mdfor V2. For scenario-specific extensions, read the matchingdocs/faq/*.md. - For V3, prefer the public
app.sdk.*entry points documented by the current host and guide; do not copy old internal imports from a V2 example. Verify extension signatures against the installed host before using a template.
Task Scope
A request to create or fix a plugin authorizes relevant local source edits and validation. Reuse that authorization; it does not by itself authorize changing host configuration, installing/reloading the plugin, restarting, or publishing. Perform those steps when the user requested them or already approved their scope, honoring host confirmation requirements. Inspect auto-reload settings before editing a live watched plugin source; such edits can affect the runtime immediately and require that runtime effect to be within the authorized scope.
Code Tool Workflow
- Use
execute_command(action="run")withrgand narrow globs or paths to locate plugin classes, extension points, tests, package entries, and directory contents. - Read the relevant implementation and adjacent example before editing.
- If
read_filereports truncation, continue with smallerstart_lineandend_lineranges until all relevant sections have been inspected. - Before using a Python or Node.js dependency API, determine the exact installed
or locked version from requirements, package manifests, lockfiles, local
package source, and
.pyi/.d.tsdeclarations. If those are insufficient, usesearch_webwith the official documentation domain andbrowse_webpageto read the matching version. Do not guess API signatures from memory or mix examples from different major versions. Search the relevant package directory,.venv, ornode_modulesdirectly withrginstead of scanning the entire project without bounds. - Pick the editing tool by scope. Use
apply_patchwhen one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in*** Begin Patch/*** End Patchwith*** Add File:,*** Update File:, and*** Delete File:sections; every context and removed line must match the current content exactly. - Use
edit_filefor a single localized change in one file. Itsold_textmust identify one exact location by default; add surrounding context instead of enablingreplace_allunless every match intentionally changes. - Use
write_filefor one standalone new file. Existing files requireoverwrite=truefor a full rewrite; first callread_file(include_metadata=true)and pass itssha256asexpected_sha256when replacing previously read content. - Use
execute_command(action="run")for short validation, Git, and diagnostic commands. Useaction="start"only for interactive or long-running commands, then continue through the returned session ID. - Read the structured command result: only
execution_outcome="succeeded"withexit_code=0means a completed command passed. Inspectoutputon failure andoutput_filefor a truncated log. A timeout or unknown result does not undo side effects and must not trigger a blind retry of a write. - For background commands, resume using both
output_until_seqandoutput_until_offsetassince_seqandsince_offset. Start with offset 0 for partial-chunk paging;last_seqis not a consumed position. Wait for new output in bounded intervals, and drain the remaining pages untiloutput_complete=truefor a complete log. Report anyoutput_lostgap. Anoutput_errorafter start/write/kill requires another read with a suitable byte limit, not another execution of the action. - Do not use shell redirection or inline scripts to perform source edits or to bypass a file-tool permission error.
- When the plugin uses Vue federation, also read
MoviePilot-Frontend/docs/module-federation-guide.md,MoviePilot-Frontend/docs/federation-troubleshooting.md,MoviePilot-Frontend/src/utils/federationLoader.ts, andMoviePilot-Frontend/src/pages/plugin-app.vue. - Repository boundaries:
MoviePilotowns runtime loading, API registration, events, services, data, and permissions;MoviePilot-Frontendowns plugin UI rendering, federation loading, and sidebar pages;MoviePilot-Pluginsowns plugin source, icons, package indexes, and release metadata.
Pre-Flight
- Understand the user request: plugin purpose, trigger mode, configuration, output UI, whether it needs a scheduler, API, command, workflow action, or agent tool.
- Run the UI Mode Selection Gate before writing any UI code.
- If the user already explicitly chose JSON config/Vuetify JSON or Vue federation, follow that choice.
- If the plugin has any UI surface and the user has not chosen a mode, ask them to choose between the two modes below and wait for the answer before implementing UI files or schemas.
- Do not silently default to either mode just because one seems easier.
- Inspect existing plugins before creating a new one:
- Local runtime examples:
app/plugins/<plugin>/__init__.py - Market/local source candidates: call
moviepilot_apiwithoperation_id=plugin.marketwhen the running instance is available. - Installed plugin candidates: use
operation_id=plugin.installed; its summaries includerepo_urlwhen the source can be matched from a local plugin repository or plugin market metadata. - For Vue federation examples, use a current plugin from the matching
generation and
MoviePilot-Frontend/examples/plugin-component/.
- Local runtime examples:
- Determine the target source path:
- Query
PLUGIN_LOCAL_REPO_PATHSwithoperation_id=config.system.getwhen possible. - If exactly one local plugin repository is configured, prefer that path.
- If several are configured, choose the one the user named; otherwise ask which repository to use.
- If none is configured, prepare the source in an appropriate local directory.
When local installation/configuration is authorized, register it with
operation_id=config.system.updatewithsetting_key="PLUGIN_LOCAL_REPO_PATHS", the chosen source path asvalue, andoperation="replace". A relative path such aslocal-pluginsresolves against the MoviePilot root. Write the plugin under the chosen source; do not write directly intoapp/plugins/unless the user explicitly asks for a runtime-only experiment.
- Query
- Choose the plugin ID:
- Class name is the plugin ID, for example
MyNotifier. - Directory name is the class name lowercased, for example
mynotifier. - Avoid collisions with installed or market plugins unless the user is explicitly modifying that plugin.
- Do not hardcode the original plugin ID for data/config namespaces when the
plugin may support clones; use
self.__class__.__name__.
- Class name is the plugin ID, for example
UI Mode Selection Gate
MoviePilot plugin UI has exactly two implementation modes. Make the user choose one whenever the request includes configuration, detail pages, dashboards, sidebar pages, or any other plugin UI and the mode is not already explicit.
Ask a concise question like:
这个插件 UI 用哪种方式实现?
1. JSON 配置:后端返回 Vuetify JSON,适合普通配置表单、简单详情页和轻量仪表板。
2. 联邦 UI:独立 Vue 远程组件,适合复杂交互、自定义布局、侧栏全页或多页面。
Selection rules:
- JSON config / Vuetify JSON: implement
get_form(),get_page(), andget_dashboard()with JSON component schemas. No frontend build ordist/assets/remoteEntry.jsis needed. - Federation UI / Vue remote component: implement
get_render_mode(), expose Vue components through Vite federation, build frontend assets into the plugin directory, and useget_sidebar_nav()only when a sidebar page is requested. - If the plugin truly has no user-facing UI, state that no UI mode is needed and implement only the backend extension points the request requires.
- Backend-only work may proceed while waiting only if it cannot constrain or preclude either UI mode.
Local Source Layout
For a verified V3 host, use this layout for new local plugins. For V2, use
package.v2.json and plugins.v2/ instead and follow its dependency contract.
<local-plugin-repo>/
├── package.v3.json
└── plugins.v3/
└── <plugin_id_lower>/
├── __init__.py
├── pyproject.toml # only when extra runtime dependencies are necessary
└── ... # helper modules, schemas, static assets
For a Vue federation plugin, the runtime requirement is the built remote assets under the plugin directory:
plugins.v3/<plugin_id_lower>/
├── __init__.py
├── dist/
│ └── assets/
│ ├── remoteEntry.js
│ └── ... # JS/CSS/assets referenced by remoteEntry
├── package.json # optional frontend build project metadata
├── vite.config.js # optional frontend build config
└── src/ # optional source, not required at runtime
Do not rely on frontend source files at runtime. If the source is kept in the
plugin repository for maintainability, still build and ship the dist/assets
files required by remoteEntry.js.
Only use the legacy layout when the user explicitly needs it:
<local-plugin-repo>/
├── package.json
└── plugins/
└── <plugin_id_lower>/
└── __init__.py
For legacy package.json entries that should work on V2, include "v2": true.
For current V3 work, use package.v3.json and plugins.v3/.
Package Metadata
Add or update the package entry for the plugin ID. Keep the package version and
the class plugin_version synchronized.
{
"MyNotifier": {
"name": "通知示例",
"description": "根据用户配置发送示例通知。",
"labels": "消息通知",
"version": "1.0.0",
"icon": "mynotifier.png",
"author": "local",
"level": 1,
"system_version": ">=3.0.0",
"history": {
"v1.0.0": "初始版本"
}
}
}
Rules:
- The package object key must match the plugin class name.
versionmust matchplugin_version.name,description,icon,author,labels, andlevelshould match the plugin class attributes when those attributes exist (plugin_name,plugin_desc,plugin_icon,plugin_author,plugin_label,auth_level).historyshould record user-readable changes for each published version.- Use
system_versionwhen the plugin depends on a host capability introduced in a specific MoviePilot version, including new backend APIs, helpers, events, Vue federation behavior, sidebar nav, dashboard behavior, or agent tools. - Use
"release": trueonly when the plugin is intentionally distributed by a GitHub Release archive. - New plugin entries should usually be appended to the package index so they appear as newer marketplace items.
- Set
system_versionto the actual minimum version providing the APIs used; the example is not proof that every host capability exists in 3.0.0. - Do not add dependencies unless required. Use the host generation's manifest
contract (
pyproject.tomlfor current V3). Dependency changes require the host installation flow; hot reload alone does not install them. - Plugin dependencies are installed into the shared MoviePilot Python environment. Do not pin or downgrade packages already provided by MoviePilot unless the user has explicitly accepted the compatibility risk.
Implementation Skeleton
Implement all abstract methods from _PluginBase. All new functions and
methods need Chinese docstrings; public classes, public methods, and public
functions are a hard review gate.
from typing import Any, Dict, List, Optional, Tuple
from app.plugins import _PluginBase
class MyNotifier(_PluginBase):
"""通知示例插件。"""
plugin_name = "通知示例"
plugin_desc = "根据用户配置发送示例通知。"
plugin_icon = "mynotifier.png"
plugin_version = "1.0.0"
plugin_label = "消息通知"
plugin_author = "local"
plugin_config_prefix = "mynotifier_"
plugin_order = 100
auth_level = 1
_enabled = False
_message = ""
def init_plugin(self, config: dict = None) -> None:
"""根据插件配置初始化运行状态。"""
self.stop_service()
self._enabled = False
self._message = ""
if not config:
return
self._enabled = bool(config.get("enabled"))
self._message = str(config.get("message") or "")
def get_state(self) -> bool:
"""获取插件启用状态。"""
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""返回插件远程命令列表。"""
return []
def get_api(self) -> List[Dict[str, Any]]:
"""返回插件 API 列表。"""
return []
def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]:
"""返回插件配置表单与默认配置。"""
return [
{
"component": "VForm",
"content": [
{
"component": "VSwitch",
"props": {
"model": "enabled",
"label": "启用插件"
}
},
{
"component": "VTextField",
"props": {
"model": "message",
"label": "通知内容"
}
}
]
}
], {
"enabled": False,
"message": ""
}
def get_page(self) -> Optional[List[dict]]:
"""返回插件详情页面。"""
if not self._enabled:
return None
return [
{
"component": "VAlert",
"props": {
"type": "info",
"text": self._message or "插件已启用"
}
}
]
def stop_service(self) -> None:
"""停止插件后台服务并释放资源。"""
return None
Extension Points
Use only the extension points the requested plugin actually needs:
- Configuration:
get_form()returns Vuetify form schema and default data;init_plugin()reads config;update_config()persists internal changes. - Data: use
save_data(),get_data(),del_data(), andget_data_path(). - Notification: use
post_message()instead of directly calling message modules. - APIs: return route definitions from
get_api(); default auth isapikeywhenauthis omitted. Vue component APIs should normally useauth: "bear"and be called through theapiprop passed by the frontend. - Commands: return slash-command definitions from
get_command()and dispatch through MoviePilot events. - Services: return scheduler services from
get_service()and always clean them up instop_service(). - One-shot or delayed jobs (for example "run once now" or "run N seconds after
an event"): call
app.sdk.scheduler.add_plugin_once_job(plugin_id, job_id, self.method, name, delay_seconds=..., func_kwargs=...)instead of creating a privateBackgroundScheduler. It does not reset the plugin's periodic services, replaces a pending job with the same ID, and is cleared when the plugin is reloaded or uninstalled. Pass a bound method of the plugin instance, and cancel pending jobs withremove_plugin_once_job()instop_service()when they must not run after the plugin stops. - Dashboards: use
get_dashboard_meta()andget_dashboard()for homepage widgets. - Workflow actions: use
get_actions(); action functions receiveActionContentfirst and return(success, action_content). - Agent tools: use
get_agent_tools(); each tool class must inheritapp.agent.tools.base.MoviePilotTool. - Custom Vue UI: implement
get_render_mode()when Vue is the selected UI mode. Return("vue", "<compiled-assets-path>")and include built frontend assets in the plugin directory. - Agent assistant avatar. Implement
get_agent_pets()on a Vue plugin to replace the web assistant's on-screen character. See "Agent Assistant Avatar" below.
Vue Federation UI
Use Vue federation when selected in the Pre-Flight UI decision. A Vue plugin must align backend methods, built files, and federation exposes.
Backend requirements:
from typing import Any, Dict, List, Tuple
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""声明插件使用 Vue 联邦组件渲染。"""
return "vue", "dist/assets"
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""Vue 模式下返回默认配置模型。"""
return [], self._current_config()
def get_page(self) -> List[dict]:
"""Vue 模式下详情页由远程 Page 组件渲染。"""
return []
When the plugin needs a main-layout sidebar page, also implement:
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""声明插件在主界面左侧导航栏中的全页入口。"""
if not self.get_state():
return []
return [
{
"nav_key": "main",
"title": "我的插件",
"icon": "mdi-puzzle",
"section": "system",
"permission": "manage",
"order": 10,
}
]
Sidebar rules:
- Sidebar entries are only aggregated for enabled plugins whose
get_render_mode()returns"vue". sectionmust be one ofstart,discovery,subscribe,organize,system; invalid values fall back tosystem.permissionmay besubscribe,discovery,search,manage, oradmin; invalid values are ignored.nav_keydefaults tomainand must not contain/,?,#, or spaces.- Multiple sidebar entries are allowed; each entry needs a stable
nav_key.
When the plugin provides an Agent assistant avatar (助手形象), also implement
get_agent_pets(). See "Agent Assistant Avatar" below.
Frontend federation requirements:
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: { requiredVersion: false, generate: false },
vuetify: { requiredVersion: false, generate: false, singleton: true },
'vuetify/styles': { requiredVersion: false, generate: false, singleton: true },
},
format: 'esm',
})
Build requirements:
- Set Vite
build.targettoesnextbecause federation uses top-level await. - Use
cssCodeSplit: trueand scoped/component-local styles where possible. - Build with the frontend project's documented command, then keep
remoteEntry.jsand every JS/CSS/asset file it references underdist/assets. - Do not add frontend runtime dependencies to the plugin Python
requirements.txt; keep frontend dependencies in the frontend build project.
Component contracts:
Pagerenders the plugin detail dialog and may emitaction,switch, andclose.Configrenders plugin settings, receivesinitialConfigandapi, and emitssave,close, andswitch.DashboardreceivesconfigandallowRefresh.AppPagerenders the main-layout sidebar page and receivesapi,pluginId, andnavKey.- For sidebar
nav_key=main, the frontend loads./AppPagethen./Page. - For any other
nav_key, the frontend loads./AppPage{PascalCase(nav_key)}, then./AppPage, then./Page. Examples:settings -> AppPageSettings,my_tool -> AppPageMyTool. - A single
AppPagemay branch onnavKey, or separateAppPage{PascalCase}files may be exposed for specific entries.
Agent Assistant Avatar:
def get_agent_pets(self) -> List[Dict[str, Any]]:
"""声明插件提供的 Agent 助手形象。"""
return [
{
"key": "girl",
"name": "看板娘",
"mode": "stage",
"component": "AgentPet",
"preview": "girl-preview.png",
"avatar": "girl-avatar.png",
}
]
- Declarations are aggregated by
GET /api/v1/plugin/agent_petsonly for enabled plugins whoseget_render_mode()returns"vue". - Required fields are
key([a-z0-9_-]{1,32}, unique within the plugin) andname. Optional fields aredescription,mode(rendererby default, orstage),component(federation expose name, defaultAgentPet),api_version(default1),preview,avatar,bubbles(stage only,hostorself), andrandom_actions(renderer only). previewandavatarare paths relative to the directory that holdsremoteEntry.js(the directory returned byget_render_mode()), orhttp(s):///data:URLs. With("vue", "dist/assets"), putgirl-preview.pngnext todist/assets/remoteEntry.jsand declare"girl-preview.png". Writing"assets/girl-preview.png"would resolve todist/assets/assets/girl-preview.png. Relative paths must not contain..,\, or:.- An item with any invalid field is dropped with a warning, and a duplicate
keykeeps the first item. One plugin may declare several avatars, each with its ownmodeandcomponent, and must expose every referenced component. - The avatar only renders the character. It must not take over the Agent panel
or send messages, and
agent.open({ draft })only fills the input box. - The user picks an avatar from the "更换形象" button in the Agent panel
header, and the admin default is
AI_AGENT_PET(<plugin_id>:<key>). Load failures, timeouts over 8 seconds, and runtime errors fall back to the built-in robot. - Read the "Agent 助手形象(AgentPet)" section (5.11) of
MoviePilot-Frontend/docs/module-federation-guide.mdfor component props, themoviepilot:agenthost API,agent.*events, the renderer action/intent mapping, the 16KBpet.storagelimit, and a stage example.
Vue API calls:
- Define frontend-facing plugin APIs with
auth: "bear". - Call them with the injected API object, for example
props.api.get(\plugin/${props.pluginId}/history`)`. - Do not pass
settings.API_TOKENinto Vue components for browser-side calls.
Local Install And Reload
Run this phase only within the authorized installation/runtime scope.
- After writing files in a configured local plugin repository, call
moviepilot_apiwithoperation_id=plugin.marketand query fieldsquery="<PluginID>",force_refresh=trueto confirm the local source is visible. - Install or reinstall with
operation_id=plugin.install, path parameterplugin_id="<PluginID>", andquery.force=true. The install flow copies the source intoapp/plugins/<plugin_id_lower>/. - If
PLUGIN_AUTO_RELOADor development mode is enabled, Python source changes in an installed local plugin can auto-sync and reload. If it is not enabled, calloperation_id=plugin.reloadwith path parameterplugin_idafter editing runtime files. - When the dependency manifest changes, reinstall with
force=True; reloading alone does not install new dependencies.
Validation
- Re-read the changed files and confirm class name, directory name, package ID, and package version are consistent.
- Confirm every public class, public method, and public function has a Chinese docstring.
- Confirm every newly written function or method has a Chinese docstring, even when it is private helper code.
- For Vue federation plugins, confirm
get_render_mode()returns("vue", "dist/assets")or the actual built asset path, and thatdist/assets/remoteEntry.jsexists. - For sidebar plugins, confirm the plugin is enabled,
get_state()returnsTrue,get_sidebar_nav()returns valid items, and matchingAppPageexposes exist for all non-mainnav_keyvalues or a genericAppPagehandles them. - Confirm frontend-facing API routes use
auth: "bear"and browser code calls them through the providedapiprop. - Keep external HTTP calls behind MoviePilot utilities and avoid real network calls in tests.
- If the plugin has non-trivial logic, add or update pytest-native tests using the repository's bootstrap for the target generation. Inspect its current test configuration instead of copying a V2 backend or namespace into V3.
- Run the narrowest allowed validation for the touched area. In this repository,
follow
docs/rules/03-commands.md; for plugin-only repositories, follow their own documented validation commands. - For plugin repository Python changes, use the host Python environment when possible and run at least syntax compilation for touched plugin files.
- For Vue federation changes, run the frontend project's documented typecheck and build commands when available, then verify the built assets were copied to the plugin directory.
Vue Federation Troubleshooting
GET /api/v1/plugin/remotes?token=moviepilotshould include the plugin with a URL ending in/plugin/file/<plugin_id_lower>/<dist_path>/remoteEntry.js.GET /api/v1/plugin/sidebar_navshould include sidebar entries for enabled Vue plugins with validnav_key,section, andpermission.- If the console says
Module name 'vue' does not resolve to a valid URL, check the federationsharedconfig and userequiredVersion: false. - If the console says top-level await is unavailable, set
build.targettoesnext. - If dynamic import fails, check the remote file request status, the computed
remoteEntry.jspath, and whether the installed runtime plugin directory actually contains the built assets. - If a sidebar page is blank, check the expose name resolution for the current
nav_keyand fallbacks (AppPage{PascalCase}->AppPage->Page).
Final Report
Report:
- Plugin ID, source path, and runtime path if installed.
- Host/plugin generation and package file changed (
package.v3.json,package.v2.json, or legacypackage.json). - UI mode used (
vuetifyJSON orvuefederation), and for Vue plugins the exposed components and built asset path. - Whether the plugin was installed or reloaded.
- Validation commands run, or why validation was not run.