feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent.parent
PLUGIN_JSON_PATH = ROOT / "novelmaster" / ".claude-plugin" / "plugin.json"
MARKETPLACE_JSON_PATH = ROOT / ".claude-plugin" / "marketplace.json"
README_PATH = ROOT / "README.md"
PLUGIN_NAME = "novelmaster"
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
README_ROW_PATTERN = re.compile(
r"^\| \*\*v(?P<version>[^\s*]+)(?P<current> \(当前\))?\*\* \| (?P<notes>.*) \|$"
)
README_HEADER = "| 版本 | 说明 |"
README_SEPARATOR = "|------|------|"
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as file:
return json.load(file)
def save_json(path: Path, payload: dict[str, Any]) -> None:
with path.open("w", encoding="utf-8", newline="\n") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
file.write("\n")
def load_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def save_text(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8", newline="\n")
def get_marketplace_plugin(payload: dict[str, Any]) -> dict[str, Any]:
plugins = payload.get("plugins", [])
for plugin in plugins:
if plugin.get("name") == PLUGIN_NAME:
return plugin
raise ValueError(f"Plugin {PLUGIN_NAME} not found in marketplace.json")
def parse_readme_rows(lines: list[str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for index, line in enumerate(lines):
match = README_ROW_PATTERN.match(line.strip())
if not match:
continue
rows.append(
{
"index": index,
"version": match.group("version"),
"notes": match.group("notes"),
"is_current": bool(match.group("current")),
}
)
return rows
def format_readme_row(version: str, notes: str, is_current: bool) -> str:
marker = " (当前)" if is_current else ""
return f"| **v{version}{marker}** | {notes.strip()} |"
def get_readme_current_version(content: str) -> str:
rows = parse_readme_rows(content.splitlines())
current_rows = [row for row in rows if row["is_current"]]
if len(current_rows) != 1:
raise ValueError("README.md must contain exactly one current release row")
return str(current_rows[0]["version"])
def update_readme_release(content: str, version: str, release_notes: str | None) -> str:
lines = content.splitlines()
try:
header_index = next(index for index, line in enumerate(lines) if line.strip() == README_HEADER)
except StopIteration as error:
raise ValueError("README.md release table header not found") from error
separator_index = header_index + 1
if separator_index >= len(lines) or lines[separator_index].strip() != README_SEPARATOR:
raise ValueError("README.md release table separator not found")
rows = parse_readme_rows(lines)
target_row = next((row for row in rows if row["version"] == version), None)
for row in rows:
is_target = row["version"] == version
notes = release_notes if is_target and release_notes is not None else row["notes"]
lines[row["index"]] = format_readme_row(row["version"], notes, is_target)
if target_row is None:
if not release_notes:
raise ValueError(
"Release notes are required when the target version does not exist in README.md"
)
lines.insert(separator_index + 1, format_readme_row(version, release_notes, True))
return "\n".join(lines) + "\n"
def sync_versions(version: str | None = None, release_notes: str | None = None) -> tuple[str, str, bool]:
plugin_payload = load_json(PLUGIN_JSON_PATH)
marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
readme_content = load_text(README_PATH)
marketplace_plugin = get_marketplace_plugin(marketplace_payload)
previous_version = str(plugin_payload.get("version", ""))
target_version = version or previous_version
changed = False
if plugin_payload.get("version") != target_version:
plugin_payload["version"] = target_version
changed = True
if marketplace_plugin.get("version") != target_version:
marketplace_plugin["version"] = target_version
changed = True
updated_readme = update_readme_release(readme_content, target_version, release_notes)
if updated_readme != readme_content:
save_text(README_PATH, updated_readme)
changed = True
if changed:
save_json(PLUGIN_JSON_PATH, plugin_payload)
save_json(MARKETPLACE_JSON_PATH, marketplace_payload)
return previous_version, target_version, changed
def check_versions(expected_version: str | None = None) -> int:
plugin_payload = load_json(PLUGIN_JSON_PATH)
marketplace_payload = load_json(MARKETPLACE_JSON_PATH)
readme_content = load_text(README_PATH)
marketplace_plugin = get_marketplace_plugin(marketplace_payload)
plugin_version = str(plugin_payload.get("version", ""))
marketplace_version = str(marketplace_plugin.get("version", ""))
readme_version = get_readme_current_version(readme_content)
mismatches: list[str] = []
if plugin_version != marketplace_version:
mismatches.append(
f"plugin.json={plugin_version}, marketplace.json={marketplace_version}"
)
if plugin_version != readme_version:
mismatches.append(f"plugin.json={plugin_version}, README.md={readme_version}")
if expected_version and plugin_version != expected_version:
mismatches.append(
f"expected={expected_version}, current release metadata={plugin_version}"
)
if mismatches:
print("Version mismatch detected:")
for mismatch in mismatches:
print(f"- {mismatch}")
return 1
print(f"Versions are in sync: {plugin_version}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Sync Claude plugin release metadata")
parser.add_argument(
"--check",
action="store_true",
help="Check whether plugin metadata and README release info are in sync",
)
parser.add_argument(
"--version",
help="Update release metadata to the given semantic version",
)
parser.add_argument(
"--expected-version",
help="When used with --check, require the current release metadata to match this version",
)
parser.add_argument(
"--release-notes",
help="Release notes used for the README current release row",
)
args = parser.parse_args()
if args.version and not VERSION_PATTERN.fullmatch(args.version):
parser.error("--version must look like X.Y.Z")
if args.expected_version and not VERSION_PATTERN.fullmatch(args.expected_version):
parser.error("--expected-version must look like X.Y.Z")
if args.expected_version and not args.check:
parser.error("--expected-version can only be used together with --check")
try:
if args.check:
return check_versions(expected_version=args.expected_version)
previous_version, target_version, changed = sync_versions(
version=args.version,
release_notes=args.release_notes,
)
except ValueError as error:
print(f"Error: {error}")
return 1
if changed:
print(f"Updated release metadata: {previous_version} -> {target_version}")
else:
print(f"No changes needed. Current version: {target_version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())