Add plugin helper with agent skill for updating plugins

This commit is contained in:
pleb
2026-06-14 10:26:22 -07:00
parent a9881ebec4
commit caaa4a6558
23 changed files with 1604 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from .config import instances_root, repo_root, state_root
from .checker import check_lock
from .installer import apply_plan, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .planner import create_plan
from .scanner import scan_instance
from .state import load_installed_state
from .userdata import backup_userdata
def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="plugin-helper")
_add_common(parser)
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser(
"instances",
help="List discovered BSManager Beat Saber instances",
parents=[_common_parent()],
)
scan = subcommands.add_parser(
"scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files",
parents=[_common_parent()],
)
scan.add_argument("--instance", required=True)
scan.add_argument("--hashes", action="store_true", help="Include sha256 hashes")
scan.add_argument("--json", action="store_true", help="Print full JSON scan output")
state = subcommands.add_parser(
"state",
help="Show recorded plugin-helper install state",
parents=[_common_parent()],
)
state.add_argument("--instance", required=True)
check = subcommands.add_parser(
"check",
help="Validate local registry, lockfile, and release asset readiness",
parents=[_common_parent()],
)
check.add_argument("--instance", required=True)
check.add_argument("--registry", default="registry/plugins.toml")
check.add_argument("--lockfile")
check.add_argument("--json", action="store_true", help="Print full JSON check output")
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from registry and lockfile",
parents=[_common_parent()],
)
plan.add_argument("--instance", required=True)
plan.add_argument("--registry", default="registry/plugins.toml")
plan.add_argument("--lockfile")
plan.add_argument("--plugin", "--update", action="append", help="Plan only this locked plugin id; repeatable")
apply = subcommands.add_parser(
"apply",
help="Apply a previously generated plan",
parents=[_common_parent()],
)
apply.add_argument("plan")
uninstall = subcommands.add_parser(
"uninstall",
help="Remove files recorded for a managed plugin",
parents=[_common_parent()],
)
uninstall.add_argument("--instance", required=True)
uninstall.add_argument("plugin")
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
backup = subcommands.add_parser(
"backup-userdata",
help="Create a timestamped UserData backup archive",
parents=[_common_parent()],
)
backup.add_argument("--instance", required=True)
return parser
def _common_parent() -> argparse.ArgumentParser:
parent = argparse.ArgumentParser(add_help=False)
_add_common(parent, suppress_default=True)
return parent
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_root = instances_root(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
if args.command == "instances":
found = list_instances(inst_root)
if not found:
print(f"No Beat Saber instances found under {inst_root}")
return 1
for item in found:
flags = []
if item.has_plugins:
flags.append("Plugins")
if item.has_libs:
flags.append("Libs")
if item.has_userdata:
flags.append("UserData")
suffix = f" ({', '.join(flags)})" if flags else ""
print(f"{item.name}\t{item.path}{suffix}")
return 0
if args.command == "scan":
instance = get_instance(inst_root, args.instance)
result = scan_instance(instance.path, include_hashes=args.hashes)
if args.json:
_json(result)
else:
counts = result["counts"]
print(f"{instance.name}: {counts['files']} files")
print(f" Plugins: {counts['plugins']}")
print(f" Libs: {counts['libs']}")
print(f" IPA/Pending: {counts['pending']}")
return 0
if args.command == "state":
_json(load_installed_state(st_root, args.instance))
return 0
if args.command == "check":
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
result = check_lock(
instance=args.instance,
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
state_root=st_root,
repo_root=root,
)
if args.json:
_json(result)
else:
summary = result["summary"]
print(
f"{args.instance}: {summary['ok']} ok, "
f"{summary['warnings']} warnings, {summary['errors']} errors"
)
for plugin in result["plugins"]:
if plugin["status"] == "ok":
continue
print(f" {plugin['id']}: {plugin['status']}")
for message in plugin["messages"]:
print(f" {message['level']}: {message['message']}")
return 2 if result["summary"]["errors"] else 0
if args.command == "plan":
instance = get_instance(inst_root, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
registry = load_registry(registry_path)
lockfile = load_lockfile(lock_path)
selected = set(args.plugin) if args.plugin else None
plan, path = create_plan(
instance=args.instance,
instance_path=instance.path,
beat_saber_version=lockfile.beat_saber_version,
registry=registry,
lockfile=lockfile,
state_root=st_root,
repo_root=root,
selected=selected,
)
print(f"Wrote plan: {path}")
print(f"Changes: {len(plan['changes'])}")
for warning in plan["warnings"]:
print(f"Warning: {warning}", file=sys.stderr)
return 0
if args.command == "apply":
with Path(args.plan).open("r", encoding="utf-8") as handle:
plan = json.load(handle)
result = apply_plan(plan, st_root)
print(f"Applied {len(result['applied'])} file changes")
print(f"State: {result['statePath']}")
return 0
if args.command == "uninstall":
instance = get_instance(inst_root, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
for item in result["skipped"]:
print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2
if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance)
result = backup_userdata(args.instance, instance.path, st_root)
manifest = result["manifest"]
print(f"Archive: {result['archive']}")
print(f"Files: {manifest['fileCount']}")
print(f"Bytes: {manifest['totalSize']}")
return 0
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
parser.error(f"unknown command: {args.command}")
return 2
def main() -> None:
raise SystemExit(run())
if __name__ == "__main__":
main()