feat(skill): support creating plugin publish repos

This commit is contained in:
jxxghp
2026-06-19 09:26:16 +08:00
parent bd53598704
commit e8f6e8647b
3 changed files with 323 additions and 15 deletions

View File

@@ -8,6 +8,8 @@ description: >-
repositories, package.json/package.v2.json metadata, plugins/plugins.v2
layouts, safe file exclusion, diff preview before publishing, incremental
GitHub Contents API updates, and syncing local plugin changes back from GitHub.
Includes asking whether to use an existing repository or create a new public
repository when no target repository is available.
Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub,
从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护.
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings
@@ -25,6 +27,8 @@ through GitHub while protecting local secrets and unrelated plugins.
- Merge only that plugin's entry into `package.v2.json` or `package.json`.
- Preview local/remote differences before writing.
- Pull remote plugin files back to the local plugin source.
- Create the target GitHub repository when the user explicitly chooses automatic
creation; repositories are public by default unless the user asks for private.
- Reuse MoviePilot settings `GITHUB_TOKEN`, `REPO_GITHUB_TOKEN`,
and `PLUGIN_LOCAL_REPO_PATHS` when available.
@@ -49,7 +53,12 @@ through GitHub while protecting local secrets and unrelated plugins.
2. Identify the GitHub repository as `owner/repo`.
- Use the user's explicit repository first.
- If omitted, infer only when the local source has an obvious Git remote.
- If neither is available, ask for the target repository.
- If neither is available, ask whether to use an existing repository or
automatically create a new public repository.
- If the user chooses an existing repository, ask for `owner/repo`.
- If the user chooses automatic creation, ask for the target `owner/repo`
and state that the repository will be public by default.
- Do not create a private repository unless the user explicitly asks for it.
3. Select the package version layout.
- Prefer `v2` when `package.v2.json` or `plugins.v2/<plugin_id_lower>/`
exists.
@@ -83,13 +92,20 @@ python skills/publish-moviepilot-plugin/scripts/publish_plugin.py pull \
--plugin-id MyPlugin \
--local-repo /path/to/MoviePilot-Plugins \
--package-version v2
python skills/publish-moviepilot-plugin/scripts/publish_plugin.py create-repo \
--repo owner/repo
```
Options:
- `create-repo`: create the target GitHub repository. Default visibility is
public; use `--private` only when the user explicitly asked for private.
- `preview`: compare local filtered files with remote files and print JSON.
- `push`: upload changed files and merge the plugin package entry.
- `pull`: write remote plugin files and package entry into local source.
- `--create-repo-if-missing`: on push, create the target public repository when
GitHub reports that it does not exist.
- `--delete-remote`: on push, delete remote plugin files that no longer exist
locally after exclusions.
- `--force`: on pull, allow overwriting local files that differ from remote.
@@ -102,6 +118,10 @@ Options:
- Always run `preview` before `push` unless the user explicitly asks for a
direct push and already reviewed the diff.
- When no repository is known, ask the user to choose:
`使用已有 GitHub 仓库` or `自动创建 GitHub 仓库(默认 public`.
- Only run `create-repo` or `push --create-repo-if-missing` after the user has
explicitly chosen automatic creation.
- Never upload these files unless explicitly included:
`.env`, `.env.*`, `config/`, `data/`, `cache/`, `logs/`, `tmp/`,
`__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`,
@@ -122,10 +142,17 @@ Options:
User asks: `把本地 MyPlugin 发布到我的 GitHub 插件仓库`
1. Find `MyPlugin` under configured `PLUGIN_LOCAL_REPO_PATHS`.
2. Ask for `owner/repo` if it cannot be inferred.
2. Ask whether to use an existing repository or create a new public repository
if `owner/repo` cannot be inferred.
3. Run `preview` and summarize the diff.
4. Run `push` only after the user confirms or requested immediate publish.
User asks: `发布插件,没有 GitHub 仓库`
1. Ask for the target `owner/repo` and confirm automatic creation.
2. Run `create-repo` or use `push --create-repo-if-missing`.
3. Continue with `preview` and `push` after repository creation succeeds.
User asks: `同步 GitHub 上 MyPlugin 的最新代码到本地`
1. Run `pull` without `--force`.

View File

@@ -22,6 +22,7 @@ DEFAULT_BRANCH = "main"
DEFAULT_TIMEOUT = 60
GITHUB_API_BASE = "https://api.github.com"
USER_AGENT = "MoviePilot-Plugin-Publisher"
COMMANDS_REQUIRE_LOCAL_PLUGIN = {"preview", "push", "pull"}
PACKAGE_BY_VERSION = {
"legacy": ("package.json", "plugins"),
"v1": ("package.json", "plugins"),
@@ -331,6 +332,39 @@ class GitHubClient:
payload=payload,
)
def repo_exists(self) -> bool:
"""
检查目标仓库是否存在或当前 token 是否可访问。
:return: 仓库存在或可访问时返回 True
"""
try:
self.request("GET", f"/repos/{self.repo}")
return True
except GitHubError as err:
if err.status == 404:
return False
raise
def create_repo(self, private: bool = False) -> Any:
"""
创建目标 GitHub 仓库。
:param private: 是否创建私有仓库,默认创建公开仓库
:return: GitHub API 响应
"""
owner, repo_name = self.repo.split("/", 1)
payload = {
"name": repo_name,
"private": private,
"auto_init": True,
}
user_data = self.request("GET", "/user")
login = user_data.get("login") if isinstance(user_data, dict) else ""
if login and str(login).lower() == owner.lower():
return self.request("POST", "/user/repos", payload=payload)
return self.request("POST", f"/orgs/{quote(owner)}/repos", payload=payload)
def normalize_repo(repo: str) -> str:
"""
@@ -872,6 +906,33 @@ def compare_package(
return remote_package, merged_content, change_type
def load_remote_files_for_push(
context: dict[str, Any],
args: argparse.Namespace,
) -> tuple[dict[str, FileState], Optional[FileState], bool]:
"""
读取推送所需的远端文件,允许显式自动建仓时把缺失仓库视为空仓库。
:param context: 运行上下文
:param args: 命令行参数
:return: 远端插件文件、远端 package 文件、仓库是否已存在
"""
client: GitHubClient = context["client"]
repo_exists = client.repo_exists()
if not repo_exists and not args.create_repo_if_missing:
raise GitHubError(
404,
"目标仓库不存在。请先创建仓库,或在用户明确同意后使用 --create-repo-if-missing。",
)
if not repo_exists:
return {}, None, False
return (
client.list_files(context["remote_prefix"]),
client.get_file(context["layout"].package_file),
True,
)
def preview(args: argparse.Namespace) -> dict[str, Any]:
"""
预览本地插件与远端仓库的差异。
@@ -916,20 +977,28 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
context["excludes"],
context["includes"],
)
remote_files = client.list_files(context["remote_prefix"])
entry = read_package_entry(context["local_repo"], context["layout"], context["plugin_id"])
remote_package, package_content, package_change = compare_package(
client,
context["layout"],
remote_files, remote_package, repo_existed = load_remote_files_for_push(context, args)
package_content = merge_package_content(
remote_package.content if remote_package else None,
context["plugin_id"],
entry,
)
if remote_package is None:
package_change = "create"
elif hashlib.sha256(package_content).hexdigest() != remote_package.sha256:
package_change = "update"
else:
package_change = "same"
changes = build_push_changes(local_files, remote_files, rejected, args.delete_remote)
payload = result_payload(context, changes, package_change=package_change)
payload["repo_existed"] = repo_existed
if args.dry_run:
payload["dry_run"] = True
return payload
if not repo_existed:
client.create_repo(private=args.private)
message = args.message or f"Publish {context['plugin_id']}"
applied: dict[str, list[str]] = {"create": [], "update": [], "delete": []}
if package_change in {"create", "update"}:
@@ -956,6 +1025,43 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
return payload
def create_repo(args: argparse.Namespace) -> dict[str, Any]:
"""
创建 GitHub 仓库,默认创建公开仓库。
:param args: 命令行参数
:return: 创建结果
"""
context = build_context(args, require_token=not args.dry_run)
client: GitHubClient = context["client"]
if args.dry_run:
return {
"success": True,
"repo": context["repo"],
"created": False,
"private": args.private,
"dry_run": True,
"message": "dry-run 未访问 GitHub未创建仓库。",
}
if client.repo_exists():
return {
"success": True,
"repo": context["repo"],
"created": False,
"private": args.private,
"message": "目标 GitHub 仓库已存在。",
}
data = client.create_repo(private=args.private)
return {
"success": True,
"repo": context["repo"],
"created": True,
"private": bool(data.get("private")) if isinstance(data, dict) else args.private,
"url": data.get("html_url") if isinstance(data, dict) else None,
"message": "目标 GitHub 仓库已创建。",
}
def pull(args: argparse.Namespace) -> dict[str, Any]:
"""
从 GitHub 拉取插件文件到本地仓库。
@@ -1080,8 +1186,14 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
:return: 运行上下文
"""
repo = normalize_repo(args.repo)
local_repo = resolve_local_repo(args.local_repo, args.plugin_id, args.package_version)
layout = resolve_layout(args.package_version, local_repo, args.plugin_id)
plugin_id = getattr(args, "plugin_id", "") or ""
package_version = getattr(args, "package_version", "auto") or "auto"
if args.command in COMMANDS_REQUIRE_LOCAL_PLUGIN:
local_repo = resolve_local_repo(args.local_repo, plugin_id, package_version)
layout = resolve_layout(package_version, local_repo, plugin_id)
else:
local_repo = Path.cwd()
layout = Layout(package_file="package.v2.json", plugin_root="plugins.v2")
token = resolve_token(repo, args.token)
if require_token and not token:
raise ValueError("未配置 GitHub token无法写入仓库")
@@ -1101,8 +1213,8 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
"branch": branch,
"local_repo": local_repo,
"layout": layout,
"plugin_id": args.plugin_id,
"remote_prefix": remote_plugin_prefix(layout, args.plugin_id),
"plugin_id": plugin_id,
"remote_prefix": remote_plugin_prefix(layout, plugin_id) if plugin_id else "",
"client": client,
"excludes": excludes,
"includes": includes,
@@ -1127,9 +1239,9 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="发布和同步 MoviePilot 本地插件到 GitHub 仓库",
)
parser.add_argument("command", choices=("preview", "push", "pull"))
parser.add_argument("command", choices=("create-repo", "preview", "push", "pull"))
parser.add_argument("--repo", required=True, help="GitHub 仓库,格式 owner/repo")
parser.add_argument("--plugin-id", required=True, help="插件类名 ID")
parser.add_argument("--plugin-id", default="", help="插件类名 ID")
parser.add_argument("--local-repo", default="", help="本地插件仓库目录")
parser.add_argument("--package-version", default="auto", help="auto、v2 或 legacy")
parser.add_argument("--branch", default=DEFAULT_BRANCH, help="目标分支")
@@ -1141,6 +1253,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--include", action="append", default=[], help="强制包含 glob")
parser.add_argument("--exclude", action="append", default=[], help="额外排除 glob")
parser.add_argument("--delete-remote", action="store_true", help="推送时删除远端多余文件")
parser.add_argument(
"--create-repo-if-missing",
action="store_true",
help="推送时在目标仓库不存在的情况下自动创建公开仓库",
)
parser.add_argument("--private", action="store_true", help="创建仓库时使用私有可见性")
parser.add_argument("--force", action="store_true", help="拉取时允许覆盖本地冲突")
parser.add_argument("--dry-run", action="store_true", help="只输出计划,不写入")
return parser
@@ -1154,8 +1272,13 @@ def main() -> int:
"""
parser = build_parser()
args = parser.parse_args()
if args.command in COMMANDS_REQUIRE_LOCAL_PLUGIN and not args.plugin_id:
print_json({"success": False, "message": "preview、push、pull 必须提供 --plugin-id"})
return 1
try:
if args.command == "preview":
if args.command == "create-repo":
payload = create_repo(args)
elif args.command == "preview":
payload = preview(args)
elif args.command == "push":
payload = push(args)
@@ -1164,7 +1287,8 @@ def main() -> int:
except (GitHubError, OSError, ValueError, json.JSONDecodeError) as err:
print_json({"success": False, "message": str(err)})
return 1
payload["success"] = not payload.get("changes", {}).get("conflicts")
if "success" not in payload:
payload["success"] = not payload.get("changes", {}).get("conflicts")
print_json(payload)
return 0 if payload["success"] else 1

View File

@@ -1,8 +1,9 @@
import importlib.util
import json
import sys
from argparse import Namespace
from pathlib import Path
from typing import Any
from typing import Any, Optional
def load_publish_plugin_module() -> Any:
@@ -68,3 +69,159 @@ def test_merge_package_content_preserves_other_plugins() -> None:
assert package_data["OtherPlugin"] == {"name": "其他插件", "version": "1.0.0"}
assert package_data["MyPlugin"] == {"name": "新插件", "version": "1.0.0"}
def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> None:
"""显式允许自动建仓时,推送会默认创建公开仓库。"""
module = load_publish_plugin_module()
plugin_dir = tmp_path / "plugins.v2" / "myplugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text("class MyPlugin:\n pass\n", encoding="utf-8")
(tmp_path / "package.v2.json").write_text(
json.dumps({"MyPlugin": {"name": "测试插件", "version": "1.0.0"}}, ensure_ascii=False),
encoding="utf-8",
)
class FakeClient:
"""模拟缺失仓库的 GitHub 客户端。"""
def __init__(self) -> None:
"""初始化调用记录。"""
self.created_private = None
self.put_paths: list[str] = []
def repo_exists(self) -> bool:
"""返回仓库不存在。"""
return False
def create_repo(self, private: bool = False) -> dict[str, Any]:
"""记录建仓可见性。"""
self.created_private = private
return {"private": private, "html_url": "https://github.com/example/repo"}
def put_file(
self,
path: str,
content: bytes,
message: str,
sha: Optional[str] = None,
) -> dict[str, Any]:
"""记录上传路径。"""
self.put_paths.append(path)
return {"content": {"path": path}}
def delete_file(self, path: str, message: str, sha: str) -> dict[str, Any]:
"""记录删除路径。"""
return {"content": {"path": path}}
fake_client = FakeClient()
def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]:
"""构造无需访问网络的推送上下文。"""
layout = module.Layout(package_file="package.v2.json", plugin_root="plugins.v2")
return {
"repo": args.repo,
"branch": args.branch,
"local_repo": tmp_path,
"layout": layout,
"plugin_id": args.plugin_id,
"remote_prefix": module.remote_plugin_prefix(layout, args.plugin_id),
"client": fake_client,
"excludes": list(module.DEFAULT_EXCLUDES),
"includes": [],
}
original_build_context = module.build_context
module.build_context = fake_build_context
try:
payload = module.push(
Namespace(
command="push",
repo="example/repo",
plugin_id="MyPlugin",
local_repo=str(tmp_path),
package_version="v2",
branch="main",
token="token",
message="Publish MyPlugin",
api_base=module.GITHUB_API_BASE,
proxy="",
timeout=module.DEFAULT_TIMEOUT,
include=[],
exclude=[],
delete_remote=False,
create_repo_if_missing=True,
private=False,
force=False,
dry_run=False,
)
)
finally:
module.build_context = original_build_context
assert payload["repo_existed"] is False
assert fake_client.created_private is False
assert "package.v2.json" in fake_client.put_paths
assert "plugins.v2/myplugin/__init__.py" in fake_client.put_paths
def test_create_repo_dry_run_does_not_touch_github() -> None:
"""建仓 dry-run 不访问 GitHub只返回计划。"""
module = load_publish_plugin_module()
class FakeClient:
"""模拟不应被调用的 GitHub 客户端。"""
def repo_exists(self) -> bool:
"""如果被调用则说明 dry-run 行为错误。"""
raise AssertionError("dry-run should not query GitHub")
def create_repo(self, private: bool = False) -> dict[str, Any]:
"""如果被调用则说明 dry-run 行为错误。"""
raise AssertionError("dry-run should not create GitHub repo")
def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]:
"""构造无需访问网络的建仓上下文。"""
return {
"repo": args.repo,
"branch": args.branch,
"local_repo": Path.cwd(),
"layout": module.Layout(package_file="package.v2.json", plugin_root="plugins.v2"),
"plugin_id": "",
"remote_prefix": "",
"client": FakeClient(),
"excludes": list(module.DEFAULT_EXCLUDES),
"includes": [],
}
original_build_context = module.build_context
module.build_context = fake_build_context
try:
payload = module.create_repo(
Namespace(
command="create-repo",
repo="example/repo",
plugin_id="",
local_repo="",
package_version="auto",
branch="main",
token="",
message="",
api_base=module.GITHUB_API_BASE,
proxy="",
timeout=module.DEFAULT_TIMEOUT,
include=[],
exclude=[],
delete_remote=False,
create_repo_if_missing=False,
private=False,
force=False,
dry_run=True,
)
)
finally:
module.build_context = original_build_context
assert payload["success"] is True
assert payload["dry_run"] is True
assert payload["private"] is False