Add Beat Saber plugin update audit skill

This commit is contained in:
pleb
2026-07-09 15:35:02 -07:00
parent a94a1ed000
commit 1dbe80a36f
7 changed files with 425 additions and 3 deletions
@@ -0,0 +1,78 @@
---
name: beatsaber-plugin-update-auditor
description: Audit Beat Saber plugin-helper locks for available plugin updates and follow-up work. Use when the user asks to check all Beat Saber plugins for updates, compare locked mods against GitHub or BeatMods, review plugin/version compatibility tracking notes, identify PR/local/manual builds that need follow-up, or skip paid/private Patreon and Discord plugin sources during update checks.
---
# Beat Saber Plugin Update Auditor
Use this skill from the `plugin-helper` repo to produce an update audit, not to
blindly update plugins. Keep public, private, and experimental sources distinct.
## Workflow
1. Confirm repo context:
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d locks && test -d registry/plugins
git status --short
```
2. Choose the instance from the user request. If omitted, prefer `1.44.1` only
when that is clearly the active migration context; otherwise list instances:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper instances
```
3. Run the update audit with repo-local state unless the user explicitly targets
another configured profile:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance <instance> --json
```
The command checks ordinary GitHub locks, `beatmods-*` locks via BeatMods
verified metadata, skips `patreon/` and `discord/` sources, and marks local,
PR, manual, failed-trial, or pending-smoke entries as review items.
4. Read the compatibility tracker before proposing updates for review items:
```bash
sed -n '1,380p' docs/notes/install-and-verify-plugins-1.44.1.md
sed -n '1,220p' docs/notes/naluluna-mod-assistant.md
```
Follow up on rows marked failed, omitted, currently failed, smoke blocked,
manual resmoke pending, local build, PR build, or local/private. Do not
reinstall failed or omitted plugins unless the notes identify a newer
compatible source.
5. For each public update candidate, inspect source notes before changing the
lock. Prefer upstream GitHub release artifacts when available; use BeatMods
CDN only for verified fallback cases, BeatMods-only packages, inaccessible
upstream assets, or framework/library dependencies.
6. Do not attempt automated public update checks for:
- `patreon/NalulunaModAssistant` or Naluluna bundles
- `discord/BeatSaberPlus` or BeatSaberPlus/ChatPlexSDK bundles
- other paid or closed-source plugin zips
Report them as skipped/manual follow-up only.
7. If the user asks to apply an update, switch to the `beatsaber-plugin-manager`
workflow for download, lock update, planning, apply, and smoketest.
## Output
Summarize in four groups:
- Public updates found: plugin, current version, candidate version, source.
- Current public locks: count only unless the user asks for every row.
- Review follow-ups: local builds, PR builds, failed trials, blocked smokes,
or notes saying compatibility work is pending.
- Skipped private sources: Patreon/Discord paid or closed-source packages.
Mention BeatMods/GitHub API errors separately from "no update found"; those are
unknowns, not proof that the plugin is current.
@@ -0,0 +1,4 @@
interface:
display_name: "Beat Saber Plugin Update Auditor"
short_description: "Audit plugin locks for GitHub and BeatMods updates."
default_prompt: "Check the locked Beat Saber plugins for updates and review items."
+12
View File
@@ -152,6 +152,18 @@ Install assets are currently expected to already exist locally, usually under:
<state-dir>/instances/<instance>/downloads/<plugin-id>/
```
Use `updates` to audit the version lock against obvious public update sources:
```sh
PYTHONPATH=src python -m plugin_helper --state-dir .state updates --instance 1.44.1
```
The command checks GitHub release assets for ordinary repository-backed locks
and BeatMods verified metadata for `beatmods-*` locks. Patreon and Discord
paid/private sources are skipped, and local builds, PR builds, failed
compatibility trials, and manual installs are marked for review instead of being
treated as routine updates.
## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
+21
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass(frozen=True)
@@ -49,6 +52,24 @@ def normalize_mods(payload: Any) -> list[BeatModsEntry]:
return [normalize_entry(entry) for entry in extract_mods(payload)]
def fetch_verified_mods(game_version: str) -> list[dict[str, Any]]:
query = urlencode(
{
"status": "verified",
"gameVersion": game_version,
"gameName": "BeatSaber",
"platform": "steampc",
}
)
request = Request(
f"https://beatmods.com/api/mods?{query}",
headers={"User-Agent": "Mozilla/5.0 plugin-helper"},
)
with urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8"))
return extract_mods(data)
def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]:
return {entry.version_id: entry for entry in entries if entry.version_id is not None}
+6 -2
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any
from .config import repo_root, resolve_runtime_config
from .beatmods import fetch_verified_mods
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock
@@ -33,12 +34,13 @@ def print_updates(report: dict[str, Any]) -> None:
print(
f"{report['instance']} updates: "
f"{summary['updates']} available, {summary['current']} current, "
f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, "
f"{summary['warnings']} warnings, {summary['errors']} errors"
)
if not plugins:
return
headers = ("Plugin", "Current", "Latest", "Asset", "Status")
headers = ("Plugin", "Current", "Latest", "Asset", "Status", "Notes")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
@@ -46,6 +48,7 @@ def print_updates(report: dict[str, Any]) -> None:
plugin.get("latestTag") or "(unknown)",
plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)",
plugin["status"],
", ".join(plugin.get("reviewReasons") or plugin.get("messages") or ()),
)
for plugin in plugins
]
@@ -142,7 +145,7 @@ def build_parser() -> argparse.ArgumentParser:
updates = subcommands.add_parser(
"updates",
help="Check GitHub for newer matching releases for locked plugins",
help="Check GitHub and BeatMods for newer matching releases for locked plugins",
parents=[_common_parent()],
)
updates.add_argument("--instance", required=True)
@@ -406,6 +409,7 @@ def run(argv: list[str] | None = None) -> int:
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
fetch_releases=fetch_releases,
fetch_beatmods=fetch_verified_mods,
selected=set(args.plugin) if args.plugin else None,
include_prerelease=args.include_prerelease,
)
+181 -1
View File
@@ -4,10 +4,28 @@ import fnmatch
import re
from typing import Any, Callable
from .beatmods import BeatModsEntry, normalize_mods
from .models import Lockfile, Registry
FetchReleases = Callable[[str], list[dict[str, Any]]]
FetchBeatMods = Callable[[str], list[dict[str, Any]]]
PRIVATE_SOURCE_PREFIXES = ("discord/", "patreon/")
REVIEW_PATTERNS = (
"local build",
"localbuild",
"github pr",
"/pull/",
" pr ",
"manual",
"compatibility trial",
"failed compatibility",
"currently failed",
"smoke blocked",
"resmoke pending",
"do not reinstall",
)
def _release_tag(release: dict[str, Any]) -> str:
@@ -23,6 +41,71 @@ def _asset_name(asset: dict[str, Any]) -> str:
return str(asset.get("name") or "")
def _source_kind(repo: str | None) -> str:
if not repo:
return "missing"
lowered = repo.lower()
if lowered.startswith(PRIVATE_SOURCE_PREFIXES):
return "private"
if re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
return "github"
return "unknown"
def _is_beatmods_locked(tag: str | None, reason: str | None) -> bool:
return str(tag or "").startswith("beatmods-") or "beatmods" in str(reason or "").lower()
def _current_beatmods_version(tag: str | None, reason: str | None, asset: str | None) -> str | None:
if tag and tag.startswith("beatmods-"):
return tag.removeprefix("beatmods-")
for text in (reason, asset):
if not text:
continue
match = re.search(r"\bBeatMods(?:\s+verified)?\s+[^0-9]*(\d+(?:\.\d+){1,4})\b", text, re.I)
if match:
return match.group(1)
match = re.search(r"-(\d+(?:\.\d+){1,4})(?:\.zip|\+|-|$)", text)
if match:
return match.group(1)
return None
def _normalized_name(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "", text.lower())
def _beatmods_match(
entries: list[BeatModsEntry],
*,
plugin_id: str,
plugin_name: str,
) -> BeatModsEntry | None:
wanted = {_normalized_name(plugin_id), _normalized_name(plugin_name)}
for entry in entries:
if _normalized_name(entry.name) in wanted:
return entry
return None
def _review_reasons(tag: str | None, reason: str | None) -> list[str]:
text = f"{tag or ''} {reason or ''}".lower()
reasons: list[str] = []
if any(pattern in text for pattern in ("local build", "localbuild")):
reasons.append("local build")
if any(pattern in text for pattern in ("github pr", "/pull/", " pr ")) or str(tag or "").startswith("pr-"):
reasons.append("pull request build")
if "failed" in text or "compatibility trial" in text:
reasons.append("compatibility failure history")
if "smoke blocked" in text or "resmoke pending" in text or "currently failed" in text:
reasons.append("verification follow-up pending")
if "manual" in text:
reasons.append("manual install notes")
if not reasons and any(pattern in text for pattern in REVIEW_PATTERNS):
reasons.append("special handling noted")
return reasons
def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]:
match = re.search(r"(\d+(?:\.\d+){0,3})", tag)
if not match:
@@ -99,18 +182,22 @@ def check_updates(
registry: Registry,
lockfile: Lockfile,
fetch_releases: FetchReleases,
fetch_beatmods: FetchBeatMods | None = None,
selected: set[str] | None = None,
include_prerelease: bool = False,
) -> dict[str, Any]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0}
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0, "skipped": 0, "reviews": 0}
beatmods_entries: list[BeatModsEntry] | None = None
beatmods_error: Exception | None = None
for locked in lockfile.plugins:
if locked.id not in selected_ids:
continue
registry_plugin = registry.get(locked.id)
repo = locked.repo or (registry_plugin.repo if registry_plugin else None)
review_reasons = _review_reasons(locked.tag, locked.reason)
entry: dict[str, Any] = {
"id": locked.id,
"name": registry_plugin.name if registry_plugin else locked.id,
@@ -123,13 +210,106 @@ def check_updates(
"latestAssetSha256": None,
"status": "unknown",
"messages": [],
"review": bool(review_reasons),
"reviewReasons": review_reasons,
}
if review_reasons:
summary["reviews"] += 1
source_kind = _source_kind(repo)
if source_kind == "private":
entry["status"] = "skipped"
entry["messages"].append("paid/private source; check manually outside public update APIs")
summary["skipped"] += 1
plugins.append(entry)
continue
needs_manual_review = any(
reason in review_reasons
for reason in (
"local build",
"pull request build",
"verification follow-up pending",
"manual install notes",
)
)
if review_reasons and (needs_manual_review or not _is_beatmods_locked(locked.tag, locked.reason)):
entry["status"] = "review"
entry["messages"].append("special install or compatibility history; review notes before updating")
plugins.append(entry)
continue
if _is_beatmods_locked(locked.tag, locked.reason):
if fetch_beatmods is None:
entry["status"] = "warning"
entry["messages"].append("BeatMods check unavailable")
summary["warnings"] += 1
plugins.append(entry)
continue
if beatmods_entries is None and beatmods_error is None:
try:
beatmods_entries = normalize_mods(fetch_beatmods(lockfile.beat_saber_version))
except Exception as exc:
beatmods_error = exc
if beatmods_error is not None:
entry["status"] = "error"
entry["messages"].append(f"BeatMods: {beatmods_error}")
summary["errors"] += 1
plugins.append(entry)
continue
beatmods_match = _beatmods_match(
beatmods_entries or [],
plugin_id=locked.id,
plugin_name=entry["name"],
)
if beatmods_match is not None and beatmods_match.mod_version:
current_version = _current_beatmods_version(locked.tag, locked.reason, locked.asset)
entry["latestTag"] = f"beatmods-{beatmods_match.mod_version}"
entry["latestAsset"] = (
f"{beatmods_match.name}-{beatmods_match.mod_version}.zip"
if beatmods_match.name
else None
)
entry["latestBeatModsVersionId"] = beatmods_match.version_id
entry["latestBeatModsZipHash"] = beatmods_match.zip_hash
entry["latestAssetUrl"] = (
f"https://beatmods.com/cdn/mod/{beatmods_match.zip_hash}.zip"
if beatmods_match.zip_hash
else None
)
if current_version == beatmods_match.mod_version:
entry["status"] = "current"
summary["current"] += 1
else:
entry["status"] = "update"
entry["messages"].append(
f"BeatMods verified {beatmods_match.mod_version} for Beat Saber {lockfile.beat_saber_version}"
)
summary["updates"] += 1
plugins.append(entry)
continue
if source_kind != "github":
entry["status"] = "warning"
entry["messages"].append("no matching BeatMods verified entry found")
summary["warnings"] += 1
plugins.append(entry)
continue
entry["messages"].append("no matching BeatMods verified entry found; falling back to GitHub")
if not repo:
entry["status"] = "warning"
entry["messages"].append("missing repository")
summary["warnings"] += 1
plugins.append(entry)
continue
if source_kind != "github":
entry["status"] = "warning"
entry["messages"].append("unsupported repository source")
summary["warnings"] += 1
plugins.append(entry)
continue
try:
releases = fetch_releases(repo)
+123
View File
@@ -1703,6 +1703,129 @@ instance = "1.40.8"
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def test_update_check_reports_beatmods_update(self) -> None:
registry = Registry(
{
"songcore": RegistryPlugin(
id="songcore",
name="SongCore",
repo="Kylemc1413/SongCore",
asset_patterns=("SongCore-*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="songcore",
repo="Kylemc1413/SongCore",
tag="beatmods-3.16.0",
asset="SongCore-3.16.0.zip",
sha256="old",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [],
fetch_beatmods=lambda game_version: [
{
"mod": {"id": 1, "name": "SongCore", "gitUrl": "https://github.com/Kylemc1413/SongCore"},
"latest": {"id": 2600, "modVersion": "3.17.0", "zipHash": "abc"},
}
],
)
self.assertEqual(result["summary"]["updates"], 1)
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestTag"], "beatmods-3.17.0")
self.assertEqual(result["plugins"][0]["latestBeatModsZipHash"], "abc")
def test_update_check_skips_private_sources(self) -> None:
registry = Registry(
{
"naluluna": RegistryPlugin(
id="naluluna",
name="Naluluna",
repo="patreon/NalulunaModAssistant",
install_strategy="root-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="naluluna",
repo="patreon/NalulunaModAssistant",
tag="installed-1.44.1",
asset="Naluluna-1.44.1-installed-bundle.zip",
sha256="hash",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: self.fail("private sources should not query GitHub"),
fetch_beatmods=lambda game_version: self.fail("private sources should not query BeatMods"),
)
self.assertEqual(result["summary"]["skipped"], 1)
self.assertEqual(result["plugins"][0]["status"], "skipped")
self.assertIn("paid/private", result["plugins"][0]["messages"][0])
def test_update_check_marks_local_pr_build_for_review(self) -> None:
registry = Registry(
{
"jdfixer": RegistryPlugin(
id="jdfixer",
name="JDFixer",
repo="zeph-yr/JDFixer",
asset_patterns=("JDFixer.dll",),
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="jdfixer",
repo="zeph-yr/JDFixer",
tag="pr-26-3fce6ce",
asset="JDFixer.dll",
sha256="hash",
reason=(
"Local build from GitHub PR https://github.com/zeph-yr/JDFixer/pull/26. "
"Use this PR build instead of the failed upstream release asset."
),
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: self.fail("review builds should not be treated as routine GitHub checks"),
)
self.assertEqual(result["summary"]["reviews"], 1)
self.assertEqual(result["plugins"][0]["status"], "review")
self.assertEqual(result["plugins"][0]["reviewReasons"], [
"local build",
"pull request build",
"compatibility failure history",
])
def _make_tui_fixture(
root: Path,