# plugin-helper Design `plugin-helper` is a Python CLI for managing Beat Saber plugins in BSManager installs. It installs from pinned release artifacts, keeps per-game-version plugin selections locked, records exact filesystem changes, and leaves compatibility judgment visible enough for an agent to help when upstream packaging is inconsistent. The current targets are the local Linux BSManager install and the mounted Windows BSManager install: ```text /home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/.local/share/BSManager/BSInstances ``` ## Goals - Manage plugins for one BSManager Beat Saber instance at a time, such as `1.44.1`, while supporting the same instance name under multiple roots. - Pull plugin releases directly from configured GitHub repositories. - Determine candidate updates while respecting the pinned Beat Saber version. - Support selective updates and explicit pins. - Resolve dependencies before install, including plugin libraries. - Install through a dry-run plan first, then apply exactly that plan. - Record every installed file so uninstall and rollback are deterministic. - Back up `UserData` separately from plugin installation. - Package the CLI with a Nix flake so it can be consumed by `brenise.dev/flake.nix` for `incineroar`. ## Non-Goals - Replacing BSManager as a GUI or Beat Saber instance manager. - Downloading or downgrading Beat Saber versions. - Running `nixos-rebuild switch`. - Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command targeting that root. - Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage mutable game trees. ## Core Model The project should keep four concepts separate: - **Registry**: checked-in knowledge about known plugins and where releases come from. - **Lockfile**: the selected plugin versions for a specific Beat Saber version. - **Install state**: exact files currently installed by `plugin-helper`. - **Plan**: a generated dry-run artifact describing the next filesystem changes. This separation lets the script stay deterministic while an agent helps with ambiguous release metadata, compatibility notes, or per-plugin install rules. ## Files and Directories Recommended repository layout: ```text plugin-helper/ flake.nix pyproject.toml DESIGN.md README.md registry/ plugins.toml locks/ 1.40.8.lock.toml src/plugin_helper/ __init__.py cli.py config.py github.py instances.py metadata.py planner.py installer.py state.py userdata.py tests/ ``` Runtime state should not need to live inside the repository. By default, keep mutable state under an XDG data directory, while allowing explicit paths for easy inspection: ```text ~/.local/state/plugin-helper/ instances/ 1.40.8/ installed.json plans/ downloads/ / backups/ ``` For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles. When managing both local Linux and mounted Windows installs, install state must be separated by target root as well as by instance name. The current state layout is keyed by instance name, so two `1.44.1` installs should not share one state directory. A practical repo-local convention is: ```text .state/ local Linux BSManager state .state-windows/ mounted Windows BSManager state ``` The registry and lockfile remain shared for a Beat Saber version. Downloads may be copied or re-fetched into each target-specific state directory, but generated plans, bootstrap records, backups, and `installed.json` belong to one target game tree. ## Registry The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences. Artifact source preference: 1. Prefer upstream GitHub release artifacts for normal plugins. 2. Use BeatMods as compatibility, dependency, and verification metadata even when the artifact comes from GitHub. 3. Use BeatMods CDN artifacts only when the upstream artifact is inaccessible, the package is effectively BeatMods-only, or the package is a framework or library dependency that does not have a normal plugin release source. 4. Record both the artifact source and any BeatMods `modVersion`/version-id/ `zipHash` metadata used to justify compatibility. Example: ```toml [[plugins]] id = "songcore" name = "SongCore" repo = "Kylemc1413/SongCore" asset_patterns = ["*SongCore*.zip"] install_strategy = "bsipa-zip" category = "library" [plugins.compatibility] source = "metadata-or-release-notes" [[plugins.dependencies]] id = "bs-utils" constraint = ">=1.0" required = true ``` Important fields: - `id`: stable local identifier. Prefer BSIPA metadata ID when known. - `repo`: GitHub `owner/name`. - `asset_patterns`: release asset glob patterns, evaluated in order. - `install_strategy`: known extraction/copy strategy. - `dependencies`: registry-level fallback when release metadata is missing or incomplete. - `pin`: optional repository-wide pin, usually better placed in a per-version lockfile. - `compatibility`: hints for interpreting release metadata. ## Lockfile Each Beat Saber version should have its own lockfile. This is where selective update and "wait for the ecosystem" behavior lives. Example: ```toml beat_saber_version = "1.40.8" instance = "1.40.8" [[plugins]] id = "songcore" repo = "Kylemc1413/SongCore" tag = "v4.3.0" asset = "SongCore-4.3.0.zip" sha256 = "..." reason = "Pinned until dependent mods support newer SongCore" [[plugins]] id = "some-ui-mod" repo = "example/some-ui-mod" tag = "v1.2.1" asset = "SomeUIMod.dll" sha256 = "..." ``` The lockfile should be the source of truth for reproducible installs. `check` may propose newer versions, but `apply` should install what is present in a plan generated from the lockfile or from an explicit update command. ## Install State The install state records what happened on disk. It should not be hand-edited during normal use. Example: ```json { "instance": "1.40.8", "beatSaberVersion": "1.40.8", "plugins": { "songcore": { "repo": "Kylemc1413/SongCore", "tag": "v4.3.0", "asset": "SongCore-4.3.0.zip", "assetSha256": "...", "installedAt": "2026-06-14T16:00:00Z", "files": [ { "path": "Plugins/SongCore.dll", "sha256": "...", "size": 123456 } ] } } } ``` Uninstall should delete only files recorded for that plugin, and should warn before deleting a file whose current hash no longer matches the recorded hash. ## Plans All mutating operations should have a dry-run plan. The plan is both user-facing and machine-readable. Plan generation should: 1. Read the target instance and Beat Saber version. 2. Read the registry and lockfile. 3. Query GitHub releases as needed. 4. Select compatible candidate releases. 5. Resolve dependencies. 6. Download release assets into a staging directory. 7. Inspect archives and DLL metadata where possible. 8. Compute target file changes. 9. Write a plan file. Plan application should: 1. Re-verify downloaded asset hashes. 2. Re-verify the mounted instance path. 3. Back up overwritten files. 4. Copy or extract files exactly as listed in the plan. 5. Write install state atomically. 6. Leave a concise summary of changed files. ## Compatibility Compatibility should be conservative. A release should be considered compatible only if one of these is true: - Embedded BSIPA metadata declares the target game version. - Release notes or asset names explicitly mention the target game version. - The registry contains a maintained compatibility override. - The user or agent has reviewed and pinned the release for that Beat Saber version. When compatibility is ambiguous, `check` should report the candidate as "needs review" instead of proposing an automatic update. Useful data sources: - GitHub release tag, title, body, publish date, and assets. - BSIPA metadata embedded in downloaded DLLs. - `manifest.json` or similar files inside release archives. - Existing installed DLL metadata. - Registry overrides for known edge cases. bs-manager has a `ModMetadata` shape worth mirroring: ```text name, id, version, gameVersion, dependsOn, conflictsWith, loadAfter, loadBefore ``` For `plugin-helper`, this metadata should be part of release inspection and dependency solving, not just display. ## Dependency Resolution Dependency resolution should start simple and explicit: - Build an available-version set from registry entries and inspected candidate releases. - Add selected plugins from the lockfile or command line. - Add transitive dependencies from embedded metadata and registry fallback rules. - Reject plans with unresolved required dependencies. - Warn about conflicts. - Prefer already locked dependency versions unless an update is explicitly requested. This is closer to a small package solver than bs-manager's current dependency closure. It does not need to be sophisticated at first, but it should keep enough structure to grow into constraint handling. Initial dependency policy: - If a dependency is locked, keep it. - If a dependency is installed but not locked, treat it as unmanaged and warn. - If a dependency is missing and the registry has it, select the newest compatible release. - If multiple compatible releases exist, choose the newest stable semver tag. - If no compatible release is clear, stop and require review. ## Install Strategies Start with a few explicit strategies instead of generic archive magic: - `dll-to-plugins`: copy one DLL to `Plugins/`. - `zip-to-pending`: extract archive under `IPA/Pending/`. - `bsipa-zip`: install BSIPA-style archive at the Beat Saber root or pending path as appropriate. - `root-zip`: extract archive relative to the Beat Saber instance root. - `manual`: plan only; requires a plugin-specific rule before apply. Every strategy should produce a concrete file list before apply. If an archive cannot be mapped safely, the plan should fail. ## CLI Proposed commands: ```text plugin-helper instances plugin-helper scan --instance 1.40.8 plugin-helper check --instance 1.40.8 plugin-helper plan --instance 1.40.8 plugin-helper plan --instance 1.40.8 --update songcore plugin-helper apply path/to/plan.json plugin-helper uninstall --instance 1.40.8 songcore plugin-helper pin --instance 1.40.8 songcore v4.3.0 plugin-helper backup-userdata --instance 1.40.8 ``` Command behavior: - `instances`: list BSManager instances found under the configured root. - `scan`: inspect installed plugins and compare against recorded state. - `check`: query GitHub and report updates, pins, ambiguity, and missing dependencies. - `plan`: produce a dry-run plan without mutating the game tree. - `apply`: execute a previously generated plan. - `uninstall`: remove files recorded in install state. - `pin`: update the lockfile for a plugin. - `backup-userdata`: snapshot `UserData` separately. ## UserData Backups `UserData` backups should be independent from plugin install state because those files change when the game runs, not when plugins are installed. Initial backup behavior: - Create timestamped archives under the state directory. - Include a small manifest with source instance, file count, total size, and hashes. - Avoid pruning automatically until a retention policy is configured. - Later, consider incremental backups or `rsync`-style deduplication if archives get large. ## Python Implementation Python is the recommended language for the first version because this project needs fast iteration, readable plugin-specific rules, and good filesystem/archive ergonomics more than a static binary. Suggested dependencies: - `typer` or `click` for CLI. - `pydantic` for typed config, lockfile, state, and plan schemas. - `httpx` for GitHub API calls. - `packaging` for version parsing. - `tomli-w` for writing TOML if needed. - Standard library `tomllib`, `zipfile`, `hashlib`, `pathlib`, and `json`. Keep domain logic in plain Python modules, not inside CLI command functions. The CLI should parse arguments, call services, and print summaries. ## Nix Flake The flake should package the CLI and expose it as both a package and app: ```nix { outputs = { self, nixpkgs }: { packages.x86_64-linux.default = ...; apps.x86_64-linux.default = { type = "app"; program = "${self.packages.x86_64-linux.default}/bin/plugin-helper"; }; }; } ``` Use `buildPythonApplication` once `pyproject.toml` exists. The Nix package should install the tool; it should not encode the mutable Windows mount path or plugin selection. `incineroar` can then consume it as a flake input and install the CLI into the user or system environment. ## Agent Skill Boundary An agent skill would be useful after the CLI skeleton exists. The skill should instruct the agent to: - Read the registry, lockfile, and install state. - Run `plugin-helper check` and inspect ambiguous releases. - Use GitHub release notes and downloaded metadata to classify compatibility. - Edit registry rules when a plugin has unusual packaging. - Generate a plan, review it, then run `apply` only after the plan is concrete. - Summarize changed files from the install state. The skill should not encourage arbitrary manual copying. Manual copying should become a registry rule or an install strategy so future uninstalls remain reliable. ## Safety Rules - Default all mutating commands to dry-run or require an existing plan. - Refuse to apply if the BSManager instance path does not exist. - Refuse to apply if the target path is outside the selected Beat Saber instance. - Warn before overwriting files not recorded in current state. - Back up overwritten files before replacement. - Use atomic writes for lockfiles and state files. - Never delete files during uninstall unless they are recorded in install state. - Warn when current file hashes differ from recorded hashes. ## Open Questions - Should lockfiles live in the repo, XDG state, or both? - Should registry compatibility overrides be committed, or kept in a local override file? - Should GitHub API calls require a token to avoid rate limits? - How should prereleases be handled for mods that publish beta compatibility first? - Does BSIPA need to be managed by `plugin-helper`, or assumed to be installed by BSManager? - Should `plugin-helper` optionally consume BeatMods metadata as an advisory source while still downloading from GitHub? ## Initial Milestones 1. Create the Python package, CLI skeleton, and flake. 2. Implement instance discovery and `instances`. 3. Define registry, lockfile, state, and plan schemas. 4. Implement GitHub release discovery and `check`. 5. Implement archive/DLL inspection enough to produce file plans. 6. Implement `plan` and `apply` for `dll-to-plugins` and `zip-to-pending`. 7. Implement uninstall from install state. 8. Add `backup-userdata`. 9. Add an agent skill once the commands and formats are stable.