Add plugin helper with agent skill for updating plugins
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user