From fec4c82a5d1dc4cb3b4dadb882125a300b679ad7 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 10:42:09 -0700 Subject: [PATCH 1/8] docs: update plugin helper agent workflow --- .../skills/beatsaber-plugin-manager/SKILL.md | 13 +------ AGENTS.md | 34 ++++++++++++++++--- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.agents/skills/beatsaber-plugin-manager/SKILL.md b/.agents/skills/beatsaber-plugin-manager/SKILL.md index e0e9d77..51ed61c 100644 --- a/.agents/skills/beatsaber-plugin-manager/SKILL.md +++ b/.agents/skills/beatsaber-plugin-manager/SKILL.md @@ -5,10 +5,7 @@ description: Install or update a Beat Saber plugin in the plugin-helper repo by # Install Beat Saber Plugin -Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except: - -- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command -- to undo your own mistaken install before rerunning the helper +Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. ## Hard Guardrail @@ -37,14 +34,6 @@ such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`, version id, `zipHash`, dependencies, and supported game version in the repo notes/lock data. -Accepted URL shapes include: - -```text -https://github.com///releases -https://github.com///releases/tag/ -https://github.com///releases/download// -``` - ## Workflow 1. Confirm the workspace is the `plugin-helper` repo. diff --git a/AGENTS.md b/AGENTS.md index 148499d..66fd2dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,18 @@ Guidance for coding agents working in this repo. - This repo manages Beat Saber plugins for BSManager instances. - Default instance roots are: - - `/home/pleb/Windows/Users/pleb/BSManager/BSInstances` - - `/home/pleb/.local/share/BSManager/BSInstances` + - `~/Windows/Users/pleb/BSManager/BSInstances` + - `~/.local/share/BSManager/BSInstances` - A local BSManager source checkout may be available at - `/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when + `~/src/Zagrios/bs-manager`. Use it as a read-only reference when investigating launch behavior, inherited Steam arguments, instance layout, or Proton environment details unless the user explicitly asks for BSManager code changes. +- Keep plugin source checkouts under `~/src//` when a + locked or registry plugin has a GitHub source repo. Prefer checking out the + upstream author/repo first, with `origin` pointing at upstream. If the user has + an existing personal fork checkout, preserve it as a remote named `github` + and set/add `origin` to the upstream repo instead of replacing local work. - Prefer repo-local state for planned installs unless the task explicitly targets the user's live default state. Use `--state-dir .state` for the local Linux install and `--state-dir .state-windows` for the mounted Windows install @@ -24,13 +29,17 @@ Guidance for coding agents working in this repo. - For human-style inspection, prefer the menu with repo-local state: `PYTHONPATH=src python -m plugin_helper --state-dir .state menu`. - When targeting the local Linux BSManager install, pass - `--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally + `--instances-root ~/.local/share/BSManager/BSInstances` and normally `--state-dir .state`. - When targeting the mounted Windows BSManager install, pass - `--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and + `--instances-root ~/Windows/Users/pleb/BSManager/BSInstances` and normally `--state-dir .state-windows`. - Use the helper commands instead of manually copying plugin files into an instance. +- When adding, updating, building, or investigating a GitHub-hosted plugin, + check for `~/src//` and clone the upstream repo there if + it is missing. Do not substitute forks or similar repos without explicit user + direction. - Treat BSIPA as a bootstrap phase: - `bootstrap` installs the locked BSIPA archive and records generated files. - ordinary plugin plans should depend on healthy bootstrap state. @@ -46,6 +55,21 @@ Guidance for coding agents working in this repo. - For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber processes afterward. +## Pull Requests + +- When the user asks for a PR against an upstream plugin, compose and submit a + polite pull request using the user's existing `gh` session. +- Keep upstream PRs small, focused, and easy for the maintainer to verify. Favor + narrow compatibility fixes, clear commit messages, and minimal formatting or + project-file churn. +- Work from the upstream checkout under `~/src//` when available. + If the upstream remote is not writable, create or reuse the user's fork with + `gh repo fork`, push a topic branch there, and open the PR against upstream. +- Include concise verification notes in the PR body, especially the exact build + or smoke-test command and any generated artifact name. Mention local-only + build configuration separately if it was needed, and do not commit machine + paths or helper state unless the user explicitly asks for it. + ## Launch Notes - BSManager may inherit Beat Saber launch arguments configured in Steam. From de0b20fa6124a32863fa5b54ecd526e61edd3727 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 10:42:15 -0700 Subject: [PATCH 2/8] chore: update 1.44.1 plugin lock state --- .../install-and-verify-plugins-1.44.1.md | 10 +++--- docs/notes/mods-used-in-1.40.8.md | 2 +- locks/1.44.1.lock.toml | 36 +++++++++---------- registry/plugins.toml | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/notes/install-and-verify-plugins-1.44.1.md b/docs/notes/install-and-verify-plugins-1.44.1.md index dc1543f..93533cd 100644 --- a/docs/notes/install-and-verify-plugins-1.44.1.md +++ b/docs/notes/install-and-verify-plugins-1.44.1.md @@ -151,11 +151,11 @@ Purpose: restore in-game song discovery and playlist management. | Plugin | Upstream | Status | Source/version | Verification notes | | --- | --- | --- | --- | --- | | BeatSaverDownloader | [beatmods zip](https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip) | verified with warning | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. | -| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | verified | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; replaces failed BeatMods 1.7.3 compatibility trial | IPA loaded PlaylistManager 1.7.4, installed `PlaylistManagerAppInstaller`, and reached `MainSystemInit` during smoketest. The old `IPlatformUserModel` / `PlatformUserModel` failure did not recur. | +| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | failed compatibility trial | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; removed from the 1.44.1 lock after DiTails smoketest failure | IPA loaded PlaylistManager 1.7.4, but later smoketest coverage showed it was still incompatible with this stack. Removed from both live BS installs and do not reinstall until a compatible build is available. | | BeatSaverUpdater | [github](https://github.com/ibillingsley/BeatSaverUpdater) | verified | GitHub `ibillingsley/BeatSaverUpdater` tag `1.2.11`, asset `BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip`; BeatMods version id 2352, zipHash `d9ea8dd0cbaac66cbb02fa59a548e42b` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded BeatSaverUpdater 1.2.11. | | BeatSaverVoting | [beatmods zip](https://beatmods.com/cdn/mod/bc002ed1a43e2c6d3a10d0750e5d94b4.zip) | failed compatibility trial | BeatMods 2.4.6, version id 2159, zipHash `bc002ed1a43e2c6d3a10d0750e5d94b4`; most recent blessed entry found was for Beat Saber 1.40.8, with no BeatMods verified entry for 1.44.1, 1.44.0, 1.43.0, 1.42.0, or 1.41.1 | IPA discovered and loaded BeatSaverVoting 2.4.6, but BS Utils caught a menu event failure from `BeatSaverVoting` caused by `TypeLoadException` resolving `IPlatformUserModel` from `PlatformUserModel`. Removed from the live instance after the failed smoketest. | | BeatSaberPlaylistsLib | [beatmods zip](https://beatmods.com/cdn/mod/a3418b75ed7294a3856f3eca12bbd672.zip) | verified | BeatMods 1.7.2, version id 2175, zipHash `a3418b75ed7294a3856f3eca12bbd672`; BeatMods preferred repo `Meivyn/BeatSaberPlaylistsLib` exposes no release assets through the GitHub releases API | IPA loaded BeatSaberPlaylistsLib 1.7.2. | -| BeatSaverSharp | [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | verified | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. | +| BeatSaverSharp | [github](https://github.com/Auros/BeatSaverSharper), [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | verified | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; source repo updated to `Auros/BeatSaverSharper`; locked package remains the BeatMods CDN artifact | IPA loaded BeatSaverSharp 3.4.5. | | ScoreSaberSharp | [beatmods zip](https://beatmods.com/cdn/mod/8713168c598577ee7c73fa3cf0e26f5c.zip) | verified with warning | BeatMods 0.1.0, version id 445, zipHash `8713168c598577ee7c73fa3cf0e26f5c`; BeatMods lists `scoresaber.com` rather than a GitHub release source | IPA loaded ScoreSaberSharp 0.1.0. Warning: bare manifest does not declare files. | | BS Utils | [beatmods zip](https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip) | verified | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9`; BeatMods preferred repo `Kylemc1413/Beat-Saber-Utils` exposes no matching 1.14.3 GitHub release asset | IPA loaded BS Utils 1.14.3. | | Ini Parser | [beatmods zip](https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip) | verified | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. | @@ -188,8 +188,8 @@ Purpose: add small gameplay helpers two or three at a time. | HitsoundTweaks | [github](https://github.com/GalaxyMaster2/HitsoundTweaks) | failed compatibility trial | GitHub `GalaxyMaster2/HitsoundTweaks` tag `v1.1.9`, asset `HitsoundTweaks-1.1.9-bs1.40.3-4ad8461.zip`; GitHub release digest matched downloaded asset | IPA loaded HitsoundTweaks 1.1.9, but SiraUtil failed to apply the `AudioTimeSyncController_dspTimeOffset_Patch` affinity patch with `InvalidProgramException`. Removed from the live instance after the failed smoke. BeatMods verified search on 2026-06-29 found no `HitsoundTweaks` entry for Beat Saber 1.42.0, 1.42.1, 1.43.0, 1.44.0, or 1.44.1. | | KeepMyOverridesPls | [github](https://github.com/qqrz997/KeepMyOverridesPls) | verified with warning | GitHub `qqrz997/KeepMyOverridesPls` tag `v1.1.3-b`, asset `KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip`; GitHub release digest matched downloaded asset | IPA loaded KeepMyOverridesPls 1.1.3, installed its app installer, wrote config, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.40.6 and plugin has no start/exit methods. | | SoundReplacer | [github](https://github.com/Meivyn/SoundReplacer), [beatmods zip](https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip) | verified with warning | BeatMods 2.0.1, version id 2213, zipHash `7d7a869996e10249d1f85f95e060319b`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SoundReplacer 2.0.1, installed its app and menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.29.4. | -| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper) | verified with warning | GitHub `lyyQwQ/KeyRemapper` tag `0.3.0`, asset `KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip`; GitHub release digest matched downloaded asset | IPA loaded KeyRemapper 0.3.0, initialized config, installed menu bindings, registered the Key Remapper button, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.39.1 and FPFC smoke logged dummy input manager because runtime was null. | -| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | verified with warning | GitHub `kinsi55/BeatSaber_SquatToBegin` tag `v0.0.7`, asset `SquatToBegin.dll`; GitHub release digest matched downloaded asset | IPA loaded SquatToBegin 0.0.7 and the game reached `MainSystemInit`. Warnings: manifest reports Beat Saber 1.20.0 despite the GitHub release being labeled for 1.39.1+, and plugin has no start/exit methods. Verify squat gate behavior outside FPFC when practical. | +| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper), [pr](https://github.com/lyyQwQ/KeyRemapper/pull/6) | currently failed | Local build from `/home/pleb/src/lyyQwQ/KeyRemapper` commit `23bb523`, asset `KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip`, SHA-256 `b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c`; PR 6 retargets the pause/menu Affinity patch from removed `UnityXRHelper` to `DevicelessVRHelper` and sets manifest gameVersion to 1.44.0. PR 6 was converted to draft after the failed manual smoke. | Replaces the prior GitHub `0.3.0` Beat Saber 1.39.1 asset. Helper installed `Plugins/KeyRemapper.dll` SHA-256 `07839dd28fa4bfbd88a3b72a8f77ebd22e70ac5acdc1239c47b9deabf775d8cd` and `Plugins/KeyRemapper.pdb` SHA-256 `e118c71519b632027f958d902d5ebcd9efb4851530091639cdbfaa96d43d4cb8`. User manual smoke on 2026-07-01 found the configured `UserData/KeyRemapper.json` pause bindings (`L_X`, `L_Y`, `R_A`, `R_B`) were not remapped. Windows logs show KeyRemapper loaded and read config without a KeyRemapper exception; `2026.07.01.09.53.35.log.gz` found both controllers and installed `KeyRemapper.Installers.GameplayInstaller`, but gameplay then flooded `MissingMethodException: AudioTimeSyncController/InitData .AudioTimeSyncController.get_state()`. TODO: add explicit logging around `GameplayInstaller`'s `IVRPlatformHelper.vrPlatformSDK == VRPlatformSDK.OpenXR` gate and verify whether that gate prevents `RemapBaseGameMenuButton`/`RestartHandler` from binding despite `UnityXRInputManager` being selected from `XRSettings.loadedDeviceName`. | +| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | installed; manual resmoke pending | Local build from `/home/pleb/src/kinsi55/BeatSaber_SquatToBegin` at commit `8703b84` plus local 1.44 compatibility fixes; asset `SquatToBegin.dll`, SHA-256 `0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd`. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0. | Replaces the prior GitHub `v0.0.7` DLL and the first local build. IPA loaded SquatToBegin 0.0.7, but the automated smoke did not reach the menu because Steam platform initialization failed. User manual smoke on 2026-07-01 found the squat gate did not arm and songs started immediately. Follow-up build keeps the 1.44 `IAudioTimeSource.State.Playing` fix and arms the gate from gameplay scene setup instead of relying on the stale level-selection prefix; verify VR behavior manually. | | JDFixer | [github](https://github.com/zeph-yr/JDFixer), [pr](https://github.com/zeph-yr/JDFixer/pull/26) | installed; smoke blocked | Local build from PR 26 commit `3fce6ce465911bdd5e8e00411bc4672c54a317f7`, asset `JDFixer.dll`; SHA-256 `16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d` | IPA loaded JDFixer 7.4.0 and the prior `OnEnable` Harmony failure did not recur; JDFixer logged config/donate activity. The smoke did not reach the main menu because Steam platform initialization failed (`SteamAPI Init failed`, Steam likely not running). Replaces failed GitHub `zeph-yr/JDFixer` tag `v.7.4.0`, asset `JDFixer.dll`, SHA-256 `a83ae3f68921a9698616ecd89d08b7397a550c2464a7871b6c65506ce0c7d360`; that release loaded JDFixer 7.4.0, but `OnEnable` failed with a Harmony patching exception because `StandardLevelScenesTransitionSetupDataSOPatch::TargetMethod()` returned null. | ### Batch 6: UI and Song Browser @@ -200,7 +200,7 @@ Purpose: restore song-list, menu, and visualization conveniences. | --- | --- | --- | --- | --- | | BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | verified with warning | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. | | HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | verified with warning | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. | -| DiTails | [github](https://github.com/Auros/DiTails) | failed compatibility trial | GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip`; GitHub release digest matched downloaded asset; BeatMods also verifies DiTails 1.1.3 for 1.44.1 as version id 2609, zipHash `437904f6db78a2ee928738d7d254a93f` | IPA loaded DiTails 1.1.3, but menu initialization failed in `DiTails.Managers.DetailContextManager.Initialize()` with `NullReferenceException`. Removed from the live instance after the failed smoke. | +| DiTails | [github](https://github.com/Auros/DiTails) | installed; smoke blocked | Local build from `/home/pleb/src/Auros/DiTails` commit `601e3c4` on branch `fix-1.44-artwork-initialization`, asset `DiTails-1.1.3-bs1.44.1-601e3c4.zip`, SHA-256 `b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e`; replaces failed GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip` and BeatMods 1.1.3 trial | Helper installed `Plugins/DiTails.dll` SHA-256 `5c856ffdfeab54982b84cb2e3033c970622668c553396460499d2343363d1d9d` in the mounted Windows instance. IPA loaded DiTails 1.1.3 and the previous `DetailContextManager.Initialize()` `NullReferenceException` did not recur before startup was blocked by `SteamAPI Init failed` because Steam was not running. | | HideTheLogo | [github](https://github.com/TheBlackParrot/HideTheLogo) | verified with warning | GitHub `TheBlackParrot/HideTheLogo` tag `1.0.3`, asset `HideTheLogo-1.0.3-bs1.40.3-c968d91.zip` | IPA loaded HideTheLogo 1.0.3, logged `yeet`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.3. | | SongChartVisualizer | [github](https://github.com/NuggoDEV/SongChartVisualizer), [beatmods zip](https://beatmods.com/cdn/mod/5d3fc025fe098277667fc0846e1b8fe3.zip) | verified with warning | BeatMods 1.1.11, version id 2249, zipHash `5d3fc025fe098277667fc0846e1b8fe3`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SongChartVisualizer 1.1.11, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. | | Setlist | | skipped after local-build trial | Local build from `/home/pleb/ops/beatsaber/setlist` commit `14d21ad` with working tree modifications; first SHA-256 `01ecba3cfa697488faddf6eb8bfcc1aedff5d95f4b5a9f673f70b0ff150f5ab9`, rebuilt SHA-256 `57f07f5d99505ee35d45b3914484434fed98113c84cb94e74c6b362abd2216a1` after manifest dependency fix | First smoke ignored Setlist because `BeatLeader@^0.9.0` did not accept installed BeatLeader 0.10.0; after widening to `>=0.9.0`, IPA loaded Setlist 0.1.0 but only logged `No playlists loaded` and did not produce the expected `platformUserId`/playlist ownership lines. Removed from the live instance per skip instruction. | diff --git a/docs/notes/mods-used-in-1.40.8.md b/docs/notes/mods-used-in-1.40.8.md index 9146898..c87c99c 100755 --- a/docs/notes/mods-used-in-1.40.8.md +++ b/docs/notes/mods-used-in-1.40.8.md @@ -39,7 +39,7 @@ bs-manager installs these mostly as dependency closure. BeatMods records depende - Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader. - BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib - Required by: PlaylistManager. -- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp +- BeatSaverSharp https://github.com/Auros/BeatSaverSharper - Required by: BeatSaverDownloader, BeatSaverUpdater, DiTails, PlaylistManager. - BS Utils https://github.com/Kylemc1413/Beat-Saber-Utils - Required by: BeatSaverDownloader, BeatSaverVoting, BeatLeader. diff --git a/locks/1.44.1.lock.toml b/locks/1.44.1.lock.toml index e075394..9d1ffdb 100644 --- a/locks/1.44.1.lock.toml +++ b/locks/1.44.1.lock.toml @@ -163,12 +163,12 @@ reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as [[plugins]] id = "beatsaversharp" -repo = "lolPants/BeatSaverSharp" +repo = "Auros/BeatSaverSharper" tag = "beatmods-3.4.5" asset = "BeatSaverSharp-3.4.5.zip" sha256 = "f5f37b27438e9b2fa1d9fbdf51a4f015f44ae04979cbdd9b90f6ae18583a6911" install_strategy = "bsipa-zip" -reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback." +reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. Source repository is now Auros/BeatSaverSharper; this remains a BeatMods CDN fallback for the locked 3.4.5 package." [[plugins]] id = "scoresabersharp" @@ -232,15 +232,6 @@ sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1" install_strategy = "bsipa-zip" reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback." -[[plugins]] -id = "playlistmanager" -repo = "rithik-b/PlaylistManager" -tag = "pr-82-localbuild-da1ad17" -asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip" -sha256 = "57b449e614db1d5214cd3a88000d52f5a989c8521390a9f4819b1c62f20f16fa" -install_strategy = "bsipa-zip" -reason = "Local build from .state/build/playlistmanager-pr82-skilltest, artifact PlaylistManager/bin/Release/net48/zip/PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip. Use this PR82 build instead of the failed BeatMods 1.7.3 compatibility trial for Beat Saber 1.44.1." - [[plugins]] id = "beatsaverupdater" repo = "ibillingsley/BeatSaverUpdater" @@ -289,20 +280,20 @@ reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyO [[plugins]] id = "keyremapper" repo = "lyyQwQ/KeyRemapper" -tag = "0.3.0" -asset = "KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip" -sha256 = "962e3f6e18bebdf101e6575fa7b8b7e0d92179bab4f0825d122078f1842f7380" +tag = "pr-6-23bb523-1.44-fix" +asset = "KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip" +sha256 = "b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c" install_strategy = "bsipa-zip" -reason = "User-provided GitHub releases URL https://github.com/lyyQwQ/KeyRemapper/releases. Latest non-draft, non-prerelease release 0.3.0 exposes this asset; GitHub release digest matched the downloaded asset. Release notes label compatibility as Beat Saber 1.39.1 with BSIPA/BSML/SiraUtil dependencies." +reason = "Local build from /home/pleb/src/lyyQwQ/KeyRemapper commit 23bb523, submitted upstream as PR https://github.com/lyyQwQ/KeyRemapper/pull/6. This updates the menu remap Affinity patch target from removed UnityXRHelper to DevicelessVRHelper and sets manifest gameVersion to 1.44.0 for Beat Saber 1.44 compatibility." [[plugins]] id = "squattobegin" repo = "kinsi55/BeatSaber_SquatToBegin" -tag = "v0.0.7" +tag = "localbuild-8703b84-1.44-fix" asset = "SquatToBegin.dll" -sha256 = "8426a64f6a3224b8cd79b9ee86347727a5a43c40a0fda116bf9c613f145fc18e" +sha256 = "0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd" install_strategy = "dll-to-plugins" -reason = "User-provided GitHub releases URL https://github.com/kinsi55/BeatSaber_SquatToBegin/releases. Latest non-draft, non-prerelease release v0.0.7 is labeled for Beat Saber 1.39.1+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset." +reason = "Local build from /home/pleb/src/kinsi55/BeatSaber_SquatToBegin at commit 8703b84 plus local 1.44 compatibility fixes: change AudioTimeSyncController.State.Playing to IAudioTimeSource.State.Playing and arm the squat gate from gameplay scene setup instead of relying on the stale level-selection prefix. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0." [[plugins]] id = "introskip" @@ -340,6 +331,15 @@ sha256 = "b889355cd47cc3b09a352f081110b8130ac6e1d285fa58776466117d585814db" install_strategy = "bsipa-zip" reason = "User-provided GitHub repository URL https://github.com/ErisApps/HitScoreVisualizer. Latest non-draft, non-prerelease release 3.7.3 exposes this asset for Beat Saber 1.42.0; GitHub release digest matched the downloaded asset." +[[plugins]] +id = "ditails" +repo = "Auros/DiTails" +tag = "localbuild-601e3c4-1.44-fix" +asset = "DiTails-1.1.3-bs1.44.1-601e3c4.zip" +sha256 = "b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e" +install_strategy = "bsipa-zip" +reason = "Local build from /home/pleb/src/Auros/DiTails commit 601e3c4 on branch fix-1.44-artwork-initialization. Use this build instead of the failed upstream 1.1.3 / BeatMods DiTails trial, which loaded but failed menu initialization with a NullReferenceException on Beat Saber 1.44.1." + [[plugins]] id = "hidethelogo" repo = "TheBlackParrot/HideTheLogo" diff --git a/registry/plugins.toml b/registry/plugins.toml index 79036c6..3b70cad 100644 --- a/registry/plugins.toml +++ b/registry/plugins.toml @@ -407,7 +407,7 @@ required = true [[plugins]] id = "beatsaversharp" name = "BeatSaverSharp" -repo = "lolPants/BeatSaverSharp" +repo = "Auros/BeatSaverSharper" asset_patterns = ["BeatSaverSharp-*.zip"] install_strategy = "bsipa-zip" category = "library" From 13b1840ba0dffedf613ee67c0334583680c37b61 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 10:42:18 -0700 Subject: [PATCH 3/8] feat: add plugin disable and enable commands --- src/plugin_helper/cli.py | 87 +++++++++++++++++++++-- src/plugin_helper/installer.py | 35 ++++++++++ tests/test_plugin_helper.py | 123 ++++++++++++++++++++++++++++++++- 3 files changed, 240 insertions(+), 5 deletions(-) diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index d711494..ea8bd3a 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -11,7 +11,7 @@ from .bootstrap import run_bootstrap from .bsipa import check_bsipa_health from .checker import check_lock from .github import fetch_releases -from .installer import apply_plan, uninstall_plugin +from .installer import apply_plan, disable_plugin, uninstall_plugin from .instances import get_instance, list_instances from .models import load_lockfile, load_registry from .models import Lockfile, Registry @@ -34,7 +34,15 @@ def installed_plugins_report( ) -> dict[str, Any]: locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} plugins: list[dict[str, Any]] = [] - for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()): + state_plugins = [ + (plugin_id, plugin_state, "enabled") + for plugin_id, plugin_state in installed_state.get("plugins", {}).items() + ] + state_plugins.extend( + (plugin_id, plugin_state, "disabled") + for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items() + ) + for plugin_id, plugin_state, status in sorted(state_plugins): registry_plugin = registry.get(plugin_id) locked = locked_by_id.get(plugin_id) files = plugin_state.get("files", []) @@ -48,6 +56,8 @@ def installed_plugins_report( or (registry_plugin.repo if registry_plugin else None) or "(unknown)", "installedAt": plugin_state.get("installedAt", "(unknown)"), + "disabledAt": plugin_state.get("disabledAt"), + "status": status, "fileCount": len(files), "files": files, } @@ -66,14 +76,15 @@ def print_installed_plugins(report: dict[str, Any]) -> None: print("No plugins have been installed by plugin-helper yet.") return - headers = ("Plugin", "Version", "Asset", "Files", "Installed") + headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed") rows = [ ( f"{plugin['name']} ({plugin['id']})", + plugin["status"], plugin["version"], plugin["asset"], str(plugin["fileCount"]), - plugin["installedAt"], + plugin["disabledAt"] or plugin["installedAt"], ) for plugin in plugins ] @@ -236,6 +247,25 @@ def build_parser() -> argparse.ArgumentParser: uninstall.add_argument("plugin") uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ") + disable = subcommands.add_parser( + "disable", + help="Remove a managed plugin's files from the game but keep state/assets for re-enable", + parents=[_common_parent()], + ) + disable.add_argument("--instance", required=True) + disable.add_argument("plugin") + disable.add_argument("--force", action="store_true", help="Disable even when current file hashes differ") + + enable = subcommands.add_parser( + "enable", + help="Reinstall a disabled locked plugin from local assets", + parents=[_common_parent()], + ) + enable.add_argument("--instance", required=True) + enable.add_argument("--registry", default="registry/plugins.toml") + enable.add_argument("--lockfile") + enable.add_argument("plugin") + backup = subcommands.add_parser( "backup-userdata", help="Copy UserData and Windows AppData into the adjacent backups repo", @@ -310,6 +340,8 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], ("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."), ("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."), ("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."), + ("disable", "Disable managed plugin", "Removes one recorded plugin from the game while keeping helper state and assets."), + ("enable", "Enable managed plugin", "Reinstalls one disabled locked plugin from local assets."), ("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."), ("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."), ("change", "Choose another version", "Returns to the Beat Saber version picker."), @@ -356,6 +388,12 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], print("No plan path entered.") continue command.append(plan_path) + elif selected_action in {"disable", "enable"}: + plugin_id = ask("Plugin id> ").strip() + if not plugin_id: + print("No plugin id entered.") + continue + command.extend(["--instance", selected_instance.name, plugin_id]) else: command.extend(["--instance", selected_instance.name]) @@ -566,6 +604,47 @@ def run(argv: list[str] | None = None) -> int: print(f" {item['path']}: {item['reason']}") return 0 if result["stateUpdated"] else 2 + if args.command == "disable": + instance = get_instance(inst_roots, args.instance) + result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) + print(f"Disabled: {args.plugin}") + 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 == "enable": + instance = get_instance(inst_roots, args.instance) + installed_state = load_installed_state(st_root, args.instance) + if args.plugin not in installed_state.get("disabledPlugins", {}): + raise KeyError(f"plugin is not recorded as disabled: {args.plugin}") + 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() + lockfile = load_lockfile(lock_path) + if not any(plugin.id == args.plugin for plugin in lockfile.plugins): + raise KeyError(f"plugin is disabled but not locked for this instance: {args.plugin}") + plan, path = create_plan( + instance=args.instance, + instance_path=instance.path, + beat_saber_version=lockfile.beat_saber_version, + registry=load_registry(registry_path), + lockfile=lockfile, + state_root=st_root, + repo_root=root, + selected={args.plugin}, + ) + result = apply_plan(plan, st_root) + print(f"Enabled: {args.plugin}") + print(f"Plan: {path}") + print(f"Applied: {len(result['applied'])}") + print(f"State: {result['statePath']}") + return 0 + if args.command == "backup-userdata": instance = get_instance(inst_roots, args.instance) root = repo_root() diff --git a/src/plugin_helper/installer.py b/src/plugin_helper/installer.py index d650194..bb51fec 100644 --- a/src/plugin_helper/installer.py +++ b/src/plugin_helper/installer.py @@ -77,6 +77,7 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]: "size": target.stat().st_size, } ) + installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None) applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup}) save_installed_state(state_root, instance, installed_state) @@ -110,3 +111,37 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi installed_state.get("plugins", {}).pop(plugin_id, None) save_installed_state(state_root, instance, installed_state) return {"removed": removed, "skipped": skipped, "stateUpdated": True} + + +def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]: + installed_state = load_installed_state(state_root, instance) + plugin_state = installed_state.get("plugins", {}).get(plugin_id) + if not plugin_state: + if plugin_id in installed_state.get("disabledPlugins", {}): + raise KeyError(f"plugin is already disabled: {plugin_id}") + raise KeyError(f"plugin is not recorded in install state: {plugin_id}") + + removed: list[str] = [] + skipped: list[dict[str, str]] = [] + for item in plugin_state.get("files", []): + rel_path = ensure_relative(item["path"]).as_posix() + target = ensure_inside(instance_path, instance_path / rel_path) + if not target.exists(): + removed.append(rel_path) + continue + current_sha = sha256_file(target) + if current_sha != item.get("sha256") and not force: + skipped.append({"path": rel_path, "reason": "hash mismatch"}) + continue + target.unlink() + removed.append(rel_path) + + if skipped and not force: + return {"removed": removed, "skipped": skipped, "stateUpdated": False} + + disabled_state = dict(plugin_state) + disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state + installed_state.get("plugins", {}).pop(plugin_id, None) + save_installed_state(state_root, instance, installed_state) + return {"removed": removed, "skipped": skipped, "stateUpdated": True} diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index ede277e..5811590 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -14,7 +14,7 @@ from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run from plugin_helper.fsutil import sha256_file -from plugin_helper.installer import apply_plan, uninstall_plugin +from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin from plugin_helper.instances import get_instance, list_instances from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin from plugin_helper.planner import create_plan @@ -242,6 +242,127 @@ class PluginHelperTests(unittest.TestCase): self.assertEqual(removed["removed"], ["Plugins/Example.dll"]) self.assertFalse((instance / "Plugins" / "Example.dll").exists()) + def test_disable_plugin_removes_files_but_keeps_disabled_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance = work / "instances" / "1.40.8" + state = work / "state" + instance.mkdir(parents=True) + (instance / "Beat Saber_Data").mkdir() + (instance / "Plugins").mkdir() + + target = instance / "Plugins" / "Example.dll" + target.write_bytes(b"managed dll") + installed = { + "instance": "1.40.8", + "plugins": { + "example": { + "installedAt": "2026-06-14T17:18:40Z", + "files": [ + { + "path": "Plugins/Example.dll", + "sha256": sha256_file(target), + "size": target.stat().st_size, + } + ], + } + }, + } + from plugin_helper.state import save_installed_state + + save_installed_state(state, "1.40.8", installed) + + result = disable_plugin("1.40.8", instance, state, "example") + + self.assertEqual(result["removed"], ["Plugins/Example.dll"]) + self.assertFalse(target.exists()) + updated = load_installed_state(state, "1.40.8") + self.assertNotIn("example", updated["plugins"]) + self.assertIn("example", updated["disabledPlugins"]) + self.assertEqual(updated["disabledPlugins"]["example"]["files"][0]["path"], "Plugins/Example.dll") + + def test_enable_command_reinstalls_disabled_plugin_from_asset(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance_root = work / "instances" + instance = instance_root / "1.40.8" + state = work / "state" + registry_dir = work / "registry" + locks_dir = work / "locks" + registry_dir.mkdir() + locks_dir.mkdir() + instance.mkdir(parents=True) + (instance / "Beat Saber_Data").mkdir() + (instance / "Plugins").mkdir() + + asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll" + asset.write_bytes(b"managed dll") + (registry_dir / "plugins.toml").write_text( + """ +[[plugins]] +id = "example" +name = "Example" +repo = "owner/example" +asset_patterns = ["*.dll"] +install_strategy = "dll-to-plugins" +""".lstrip(), + encoding="utf-8", + ) + (locks_dir / "1.40.8.lock.toml").write_text( + f""" +beat_saber_version = "1.40.8" +instance = "1.40.8" + +[[plugins]] +id = "example" +repo = "owner/example" +tag = "v1.0.0" +asset = "Example.dll" +sha256 = "{sha256_file(asset)}" +""".lstrip(), + encoding="utf-8", + ) + disabled = { + "instance": "1.40.8", + "plugins": {}, + "disabledPlugins": { + "example": { + "installedAt": "2026-06-14T17:18:40Z", + "disabledAt": "2026-06-14T17:20:00Z", + "files": [ + { + "path": "Plugins/Example.dll", + "sha256": sha256_file(asset), + "size": asset.stat().st_size, + } + ], + } + }, + } + from plugin_helper.state import save_installed_state + + save_installed_state(state, "1.40.8", disabled) + + with patch("plugin_helper.cli.repo_root", return_value=work): + status = run( + [ + "--instances-root", + str(instance_root), + "--state-dir", + str(state), + "enable", + "--instance", + "1.40.8", + "example", + ] + ) + + self.assertEqual(status, 0) + self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") + updated = load_installed_state(state, "1.40.8") + self.assertIn("example", updated["plugins"]) + self.assertNotIn("example", updated["disabledPlugins"]) + def test_zip_to_pending_targets_ipa_pending(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) From 69f9dbd9b107aa71e337f49882ebaa04ac5c699f Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 11:44:03 -0700 Subject: [PATCH 4/8] Add profile-aware plugin TUI --- .gitignore | 2 + README.md | 46 +++-- plugin-helper.toml.example | 11 ++ pyproject.toml | 4 +- src/plugin_helper/cli.py | 295 +++++++++++--------------------- src/plugin_helper/config.py | 113 +++++++++++- src/plugin_helper/operations.py | 47 +++++ src/plugin_helper/reports.py | 78 +++++++++ src/plugin_helper/tui.py | 275 +++++++++++++++++++++++++++++ tests/test_plugin_helper.py | 270 ++++++++++++++++++++++++----- 10 files changed, 876 insertions(+), 265 deletions(-) create mode 100644 plugin-helper.toml.example create mode 100644 src/plugin_helper/operations.py create mode 100644 src/plugin_helper/reports.py create mode 100644 src/plugin_helper/tui.py diff --git a/.gitignore b/.gitignore index 1c26636..bf74cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.state/ /.state-*/ +/plugin-helper.local.toml /.pytest_cache/ /build/ /dist/ @@ -7,3 +8,4 @@ /src/*.egg-info/ /__pycache__/ *.pyc +.venv diff --git a/README.md b/README.md index 2580b58..aab56ce 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,17 @@ multiple explicit roots, separate them with `:`. The helper is intended to manage both the local Linux BSManager install and the mounted Windows install. Lockfiles and registry entries are shared by Beat Saber version, but install state is target-specific. When the same instance name -exists under both roots, such as `1.44.1`, use an explicit `--instances-root` -and a separate state directory for each target. +exists under both roots, such as `1.44.1`, give each install profile its own +state directory. -Suggested repo-local convention: +Copy the example profile config and adjust paths if needed: + +```sh +cp plugin-helper.toml.example plugin-helper.local.toml +``` + +`plugin-helper.local.toml` is ignored by git. The default example uses this +repo-local convention: ```text .state/ local Linux BSManager state @@ -40,35 +47,38 @@ Examples: ```sh PYTHONPATH=src python -m plugin_helper \ - --instances-root /home/pleb/.local/share/BSManager/BSInstances \ - --state-dir .state \ + --profile linux \ installed --instance 1.44.1 PYTHONPATH=src python -m plugin_helper \ - --instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \ - --state-dir .state-windows \ + --profile windows \ installed --instance 1.44.1 ``` -Do not reuse the same state directory for both targets when their instance names -match. The current state layout is keyed by instance name, so sharing one state -directory would mix bootstrap records, generated plans, backups, and installed -file records for different game trees. +Explicit `--instances-root` and `--state-dir` still work and override profile +values. Do not reuse the same state directory for both targets when their +instance names match. The current state layout is keyed by instance name, so +sharing one state directory would mix bootstrap records, generated plans, +backups, and installed file records for different game trees. ## Commands -For normal use, run the menu from the repo root. Use repo-local state so the -menu sees the same plans, downloads, and install records used by the helper -workflow: +For normal use, run the Textual menu from the repo root: ```sh -PYTHONPATH=src python -m plugin_helper --state-dir .state menu +PYTHONPATH=src python -m plugin_helper menu ``` +The menu reads `plugin-helper.local.toml` when present, shows each discovered +Beat Saber install with its resolved state directory, and lets you toggle +managed plugins with arrow keys and Space. In the plugin table, use `d` to +disable all currently enabled managed plugins and `e` to enable all currently +disabled managed plugins. + The individual subcommands are mostly for automation and debugging. If you use -them, pass `--state-dir .state` unless you intentionally want the default live -state outside this repo or are intentionally targeting the Windows install with -`.state-windows`. +them, prefer `--profile linux` or `--profile windows`. Pass `--state-dir` +directly only when you intentionally want to override profile state or use the +default live state outside this repo. Install assets are currently expected to already exist locally, usually under: diff --git a/plugin-helper.toml.example b/plugin-helper.toml.example new file mode 100644 index 0000000..c150215 --- /dev/null +++ b/plugin-helper.toml.example @@ -0,0 +1,11 @@ +[[profiles]] +id = "linux" +label = "Local Linux BSManager" +instances_root = "~/.local/share/BSManager/BSInstances" +state_dir = ".state" + +[[profiles]] +id = "windows" +label = "Mounted Windows BSManager" +instances_root = "~/Windows/Users/pleb/BSManager/BSInstances" +state_dir = ".state-windows" diff --git a/pyproject.toml b/pyproject.toml index 93708df..f0f8a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,9 @@ readme = "README.md" requires-python = ">=3.11" license = "MIT" authors = [{ name = "plugin-helper contributors" }] -dependencies = [] +dependencies = [ + "textual>=8.2,<9", +] [project.scripts] plugin-helper = "plugin_helper.cli:main" diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index ea8bd3a..fedbfde 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -4,9 +4,9 @@ import argparse import json import sys from pathlib import Path -from typing import Any, Callable +from typing import Any -from .config import instances_roots, repo_root, state_root +from .config import Profile, repo_root, resolve_runtime_config from .bootstrap import run_bootstrap from .bsipa import check_bsipa_health from .checker import check_lock @@ -14,8 +14,9 @@ from .github import fetch_releases from .installer import apply_plan, disable_plugin, uninstall_plugin from .instances import get_instance, list_instances from .models import load_lockfile, load_registry -from .models import Lockfile, Registry +from .operations import enable_disabled_plugin from .planner import create_plan +from .reports import installed_plugins_report, print_installed_plugins from .scanner import scan_instance from .state import load_installed_state from .updates import check_updates @@ -26,79 +27,6 @@ def _json(data: Any) -> None: print(json.dumps(data, indent=2, sort_keys=True)) -def installed_plugins_report( - *, - installed_state: dict[str, Any], - registry: Registry, - lockfile: Lockfile, -) -> dict[str, Any]: - locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} - plugins: list[dict[str, Any]] = [] - state_plugins = [ - (plugin_id, plugin_state, "enabled") - for plugin_id, plugin_state in installed_state.get("plugins", {}).items() - ] - state_plugins.extend( - (plugin_id, plugin_state, "disabled") - for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items() - ) - for plugin_id, plugin_state, status in sorted(state_plugins): - registry_plugin = registry.get(plugin_id) - locked = locked_by_id.get(plugin_id) - files = plugin_state.get("files", []) - plugins.append( - { - "id": plugin_id, - "name": registry_plugin.name if registry_plugin else plugin_id, - "version": locked.tag if locked and locked.tag else "(not locked)", - "asset": locked.asset if locked and locked.asset else "(unknown)", - "repo": (locked.repo if locked and locked.repo else None) - or (registry_plugin.repo if registry_plugin else None) - or "(unknown)", - "installedAt": plugin_state.get("installedAt", "(unknown)"), - "disabledAt": plugin_state.get("disabledAt"), - "status": status, - "fileCount": len(files), - "files": files, - } - ) - return { - "instance": installed_state.get("instance", lockfile.instance), - "beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version), - "plugins": plugins, - } - - -def print_installed_plugins(report: dict[str, Any]) -> None: - plugins = report["plugins"] - print(f"{report['instance']} managed plugins ({len(plugins)})") - if not plugins: - print("No plugins have been installed by plugin-helper yet.") - return - - headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed") - rows = [ - ( - f"{plugin['name']} ({plugin['id']})", - plugin["status"], - plugin["version"], - plugin["asset"], - str(plugin["fileCount"]), - plugin["disabledAt"] or plugin["installedAt"], - ) - for plugin in plugins - ] - widths = [ - max(len(headers[index]), *(len(row[index]) for row in rows)) - for index in range(len(headers)) - ] - header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers)) - print(header) - print(" ".join("-" * width for width in widths)) - for row in rows: - print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row))) - - def print_updates(report: dict[str, Any]) -> None: plugins = report["plugins"] summary = report["summary"] @@ -135,6 +63,8 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal 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") + parser.add_argument("--profile", default=default, help="Configured profile id from plugin-helper.local.toml") + parser.add_argument("--config", default=default, help="Path to plugin-helper TOML config") def build_parser() -> argparse.ArgumentParser: @@ -295,120 +225,101 @@ def _common_parent() -> argparse.ArgumentParser: return parent -def _ask_choice( +def _menu_profiles( *, - title: str, - choices: list[tuple[str, str] | tuple[str, str, str]], - input_func: Callable[[str], str] | None = None, -) -> str | None: - ask = input_func or input - print() - print(title) - for index, choice in enumerate(choices, start=1): - label = choice[1] - print(f" {index}. {label}") - if len(choice) > 2: - print(f" {choice[2]}") - print(" q. Quit") - - while True: - answer = ask("> ").strip().lower() - if answer in {"q", "quit", "exit"}: - return None - if answer.isdigit(): - index = int(answer) - if 1 <= index <= len(choices): - return choices[index - 1][0] - print("Choose a listed number, or q to quit.") - - -def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int: - ask = input_func or input - instances = list_instances(inst_roots) - if not instances: - print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}") - return 1 - - instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)} - instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)] - action_choices = [ - ("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."), - ("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."), - ("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."), - ("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."), - ("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."), - ("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."), - ("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."), - ("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."), - ("disable", "Disable managed plugin", "Removes one recorded plugin from the game while keeping helper state and assets."), - ("enable", "Enable managed plugin", "Reinstalls one disabled locked plugin from local assets."), - ("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."), - ("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."), - ("change", "Choose another version", "Returns to the Beat Saber version picker."), + profiles: tuple[Profile, ...], + selected_profile: Profile | None, + instances_roots: list[Path], + state_root: Path, + use_config_profiles: bool, +) -> list[Profile]: + if selected_profile: + return [ + Profile( + id=selected_profile.id if len(instances_roots) == 1 else f"{selected_profile.id}-{index}", + label=selected_profile.label, + instances_root=root, + state_dir=state_root, + ) + for index, root in enumerate(instances_roots, start=1) + ] + if use_config_profiles and profiles: + return list(profiles) + return [ + Profile( + id=f"root-{index}", + label=str(root), + instances_root=root, + state_dir=state_root, + ) + for index, root in enumerate(instances_roots, start=1) ] - selected_instance_key = _ask_choice( - title="Choose Beat Saber version", - choices=instance_choices, - input_func=ask, + +def _run_menu( + *, + runtime: Any, + explicit_instances_root: bool, + explicit_state_dir: bool, +) -> int: + try: + from .tui import InstallationChoice, PluginHelperTui + except ImportError as exc: + print(f"Textual is required for the menu TUI: {exc}") + print("Install project dependencies, for example: python -m pip install -e .") + return 1 + + profiles = _menu_profiles( + profiles=runtime.profiles, + selected_profile=runtime.selected_profile, + instances_roots=runtime.instances_roots, + state_root=runtime.state_root, + use_config_profiles=runtime.config_loaded and not explicit_instances_root and not explicit_state_dir, ) - if selected_instance_key is None: - return 0 - selected_instance = instances_by_choice[selected_instance_key] - - while True: - selected_action = _ask_choice( - title=f"Choose action for {selected_instance.name}", - choices=action_choices, - input_func=ask, - ) - if selected_action is None: - return 0 - if selected_action == "change": - selected_instance_key = _ask_choice( - title="Choose Beat Saber version", - choices=instance_choices, - input_func=ask, + choices: list[InstallationChoice] = [] + for profile in profiles: + for instance in list_instances(profile.instances_root): + choices.append( + InstallationChoice( + profile_id=profile.id, + profile_label=profile.label, + instance_name=instance.name, + instance_path=instance.path, + state_root=profile.state_dir, + ) ) - if selected_instance_key is None: - return 0 - selected_instance = instances_by_choice[selected_instance_key] - continue + if not choices: + searched = ", ".join(str(profile.instances_root) for profile in profiles) + print(f"No Beat Saber instances found under {searched}") + return 1 - command = [ - "--instances-root", - str(selected_instance.path.parent), - "--state-dir", - str(st_root), - selected_action, - ] - if selected_action == "apply": - plan_path = ask("Plan path> ").strip() - if not plan_path: - print("No plan path entered.") - continue - command.append(plan_path) - elif selected_action in {"disable", "enable"}: - plugin_id = ask("Plugin id> ").strip() - if not plugin_id: - print("No plugin id entered.") - continue - command.extend(["--instance", selected_instance.name, plugin_id]) - else: - command.extend(["--instance", selected_instance.name]) - - print() - status = run(command) - print(f"Command exited with status {status}") + setup_hint = None + if not runtime.config_loaded and not runtime.selected_profile and not explicit_instances_root and not explicit_state_dir: + setup_hint = ( + f"No {runtime.config_path.name} found; using default roots and default state. " + f"Copy plugin-helper.toml.example to {runtime.config_path.name} to map each install to a state dir." + ) + app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint) + result = app.run() + return int(result or 0) def run(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - inst_roots = instances_roots(getattr(args, "instances_root", None)) - st_root = state_root(getattr(args, "state_dir", None)) try: + explicit_instances_root = getattr(args, "instances_root", None) is not None + explicit_state_dir = getattr(args, "state_dir", None) is not None + runtime = resolve_runtime_config( + instances_root_value=getattr(args, "instances_root", None), + state_dir_value=getattr(args, "state_dir", None), + profile_id=getattr(args, "profile", None), + config_path=getattr(args, "config", None), + ) + inst_roots = runtime.instances_roots + st_root = runtime.state_root + if args.command == "instances": found = list_instances(inst_roots) if not found: @@ -427,7 +338,11 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "menu": - return _run_menu(inst_roots, st_root) + return _run_menu( + runtime=runtime, + explicit_instances_root=explicit_instances_root, + explicit_state_dir=explicit_state_dir, + ) if args.command == "scan": instance = get_instance(inst_roots, args.instance) @@ -617,30 +532,16 @@ def run(argv: list[str] | None = None) -> int: if args.command == "enable": instance = get_instance(inst_roots, args.instance) - installed_state = load_installed_state(st_root, args.instance) - if args.plugin not in installed_state.get("disabledPlugins", {}): - raise KeyError(f"plugin is not recorded as disabled: {args.plugin}") - 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() - lockfile = load_lockfile(lock_path) - if not any(plugin.id == args.plugin for plugin in lockfile.plugins): - raise KeyError(f"plugin is disabled but not locked for this instance: {args.plugin}") - plan, path = create_plan( + result = enable_disabled_plugin( instance=args.instance, instance_path=instance.path, - beat_saber_version=lockfile.beat_saber_version, - registry=load_registry(registry_path), - lockfile=lockfile, state_root=st_root, - repo_root=root, - selected={args.plugin}, + plugin_id=args.plugin, + registry=args.registry, + lockfile=args.lockfile, ) - result = apply_plan(plan, st_root) print(f"Enabled: {args.plugin}") - print(f"Plan: {path}") + print(f"Plan: {result['planPath']}") print(f"Applied: {len(result['applied'])}") print(f"State: {result['statePath']}") return 0 diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index 9b6f5e2..0c5cd2a 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -1,13 +1,39 @@ from __future__ import annotations import os +import tomllib +from dataclasses import dataclass from pathlib import Path +from typing import Any WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances") LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances" DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT) DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT +LOCAL_CONFIG_NAME = "plugin-helper.local.toml" + + +@dataclass(frozen=True) +class Profile: + id: str + label: str + instances_root: Path + state_dir: Path + + +@dataclass(frozen=True) +class RuntimeConfig: + instances_roots: list[Path] + state_root: Path + profiles: tuple[Profile, ...] + selected_profile: Profile | None + config_path: Path + config_loaded: bool + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] def instances_root(value: str | None = None) -> Path: @@ -23,11 +49,92 @@ def instances_roots(value: str | None = None) -> list[Path]: def state_root(value: str | None = None) -> Path: if value: - return Path(value).expanduser() + return _resolve_path(value, repo_root()) xdg_state = os.environ.get("XDG_STATE_HOME") base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" return base / "plugin-helper" -def repo_root() -> Path: - return Path(__file__).resolve().parents[2] +def default_config_path(root: Path | None = None) -> Path: + return (root or repo_root()) / LOCAL_CONFIG_NAME + + +def _resolve_path(value: str | Path, base: Path) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + return (base / path).resolve() + + +def load_profiles(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[tuple[Profile, ...], Path, bool]: + repo = root or repo_root() + path = _resolve_path(config_path, repo) if config_path else default_config_path(repo) + if not path.exists(): + if config_path: + raise FileNotFoundError(f"plugin-helper config not found: {path}") + return (), path, False + + with path.open("rb") as handle: + data: dict[str, Any] = tomllib.load(handle) + + profiles: list[Profile] = [] + seen: set[str] = set() + base = path.parent + for item in data.get("profiles", []): + profile_id = item["id"] + if profile_id in seen: + raise ValueError(f"{path}: duplicate profile id: {profile_id}") + seen.add(profile_id) + profiles.append( + Profile( + id=profile_id, + label=item.get("label", profile_id), + instances_root=_resolve_path(item["instances_root"], base), + state_dir=_resolve_path(item["state_dir"], base), + ) + ) + return tuple(profiles), path, True + + +def profile_by_id(profiles: tuple[Profile, ...], profile_id: str) -> Profile: + for profile in profiles: + if profile.id == profile_id: + return profile + available = ", ".join(profile.id for profile in profiles) or "(none)" + raise KeyError(f"unknown profile: {profile_id}; available profiles: {available}") + + +def resolve_runtime_config( + *, + instances_root_value: str | None = None, + state_dir_value: str | None = None, + profile_id: str | None = None, + config_path: str | Path | None = None, + root: Path | None = None, +) -> RuntimeConfig: + repo = root or repo_root() + profiles, loaded_path, loaded = load_profiles(config_path, root=repo) + selected = profile_by_id(profiles, profile_id) if profile_id else None + + if instances_root_value: + resolved_instances = instances_roots(instances_root_value) + elif selected: + resolved_instances = [selected.instances_root] + else: + resolved_instances = instances_roots(None) + + if state_dir_value: + resolved_state = _resolve_path(state_dir_value, repo) + elif selected: + resolved_state = selected.state_dir + else: + resolved_state = state_root(None) + + return RuntimeConfig( + instances_roots=resolved_instances, + state_root=resolved_state, + profiles=profiles, + selected_profile=selected, + config_path=loaded_path, + config_loaded=loaded, + ) diff --git a/src/plugin_helper/operations.py b/src/plugin_helper/operations.py new file mode 100644 index 0000000..501f140 --- /dev/null +++ b/src/plugin_helper/operations.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import repo_root +from .installer import apply_plan +from .models import load_lockfile, load_registry +from .planner import create_plan +from .state import load_installed_state + + +def enable_disabled_plugin( + *, + instance: str, + instance_path: Path, + state_root: Path, + plugin_id: str, + registry: str = "registry/plugins.toml", + lockfile: str | None = None, + repo: Path | None = None, +) -> dict[str, Any]: + installed_state = load_installed_state(state_root, instance) + if plugin_id not in installed_state.get("disabledPlugins", {}): + raise KeyError(f"plugin is not recorded as disabled: {plugin_id}") + + root = repo or repo_root() + registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry) + lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml" + if not lock_path.is_absolute(): + lock_path = (root / lock_path).resolve() + loaded_lockfile = load_lockfile(lock_path) + if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins): + raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}") + + plan, path = create_plan( + instance=instance, + instance_path=instance_path, + beat_saber_version=loaded_lockfile.beat_saber_version, + registry=load_registry(registry_path), + lockfile=loaded_lockfile, + state_root=state_root, + repo_root=root, + selected={plugin_id}, + ) + result = apply_plan(plan, state_root) + return {"planPath": str(path), **result} diff --git a/src/plugin_helper/reports.py b/src/plugin_helper/reports.py new file mode 100644 index 0000000..218e7fb --- /dev/null +++ b/src/plugin_helper/reports.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from .models import Lockfile, Registry + + +def installed_plugins_report( + *, + installed_state: dict[str, Any], + registry: Registry, + lockfile: Lockfile, +) -> dict[str, Any]: + locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} + plugins: list[dict[str, Any]] = [] + state_plugins = [ + (plugin_id, plugin_state, "enabled") + for plugin_id, plugin_state in installed_state.get("plugins", {}).items() + ] + state_plugins.extend( + (plugin_id, plugin_state, "disabled") + for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items() + ) + for plugin_id, plugin_state, status in sorted(state_plugins): + registry_plugin = registry.get(plugin_id) + locked = locked_by_id.get(plugin_id) + files = plugin_state.get("files", []) + plugins.append( + { + "id": plugin_id, + "name": registry_plugin.name if registry_plugin else plugin_id, + "version": locked.tag if locked and locked.tag else "(not locked)", + "asset": locked.asset if locked and locked.asset else "(unknown)", + "repo": (locked.repo if locked and locked.repo else None) + or (registry_plugin.repo if registry_plugin else None) + or "(unknown)", + "installedAt": plugin_state.get("installedAt", "(unknown)"), + "disabledAt": plugin_state.get("disabledAt"), + "status": status, + "fileCount": len(files), + "files": files, + } + ) + return { + "instance": installed_state.get("instance", lockfile.instance), + "beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version), + "plugins": plugins, + } + + +def print_installed_plugins(report: dict[str, Any]) -> None: + plugins = report["plugins"] + print(f"{report['instance']} managed plugins ({len(plugins)})") + if not plugins: + print("No plugins have been installed by plugin-helper yet.") + return + + headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed") + rows = [ + ( + f"{plugin['name']} ({plugin['id']})", + plugin["status"], + plugin["version"], + plugin["asset"], + str(plugin["fileCount"]), + plugin["disabledAt"] or plugin["installedAt"], + ) + for plugin in plugins + ] + widths = [ + max(len(headers[index]), *(len(row[index]) for row in rows)) + for index in range(len(headers)) + ] + header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers)) + print(header) + print(" ".join("-" * width for width in widths)) + for row in rows: + print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row))) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py new file mode 100644 index 0000000..0343dd9 --- /dev/null +++ b/src/plugin_helper/tui.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import DataTable, Footer, Header, Static + +from .installer import disable_plugin +from .models import load_lockfile, load_registry +from .operations import enable_disabled_plugin +from .reports import installed_plugins_report +from .state import load_installed_state + + +@dataclass(frozen=True) +class InstallationChoice: + profile_id: str + profile_label: str + instance_name: str + instance_path: Path + state_root: Path + + +class PluginHelperTui(App[int]): + CSS = """ + #title { + padding: 0 1; + text-style: bold; + } + + #status { + padding: 0 1; + color: $text-muted; + } + """ + BINDINGS = [ + Binding("enter", "select", "Select", priority=True), + Binding("space", "toggle_plugin", "Toggle", priority=True), + Binding("d", "disable_all_plugins", "Disable all", priority=True), + Binding("e", "enable_all_plugins", "Enable all", priority=True), + Binding("r", "refresh", "Refresh"), + Binding("b", "back", "Back"), + Binding("q", "quit", "Quit"), + Binding("ctrl+q", "quit", "Quit", show=False, priority=True), + ] + + def __init__( + self, + *, + choices: list[InstallationChoice], + repo_root: Path, + setup_hint: str | None = None, + ) -> None: + super().__init__() + self.choices = choices + self.repo_root = repo_root + self.setup_hint = setup_hint + self.mode = "installations" + self.selected_installation: InstallationChoice | None = None + self.plugin_rows: list[dict[str, Any]] = [] + self.status_message = "" + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + yield Static("", id="title") + yield DataTable(id="table") + yield Static("", id="status") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.cursor_type = "row" + self._show_installations() + + def action_select(self) -> None: + if self.mode != "installations": + return + index = self._cursor_index(len(self.choices)) + if index is None: + return + self.selected_installation = self.choices[index] + self._show_plugins() + + def action_back(self) -> None: + if self.mode == "plugins": + self._show_installations() + + def action_refresh(self) -> None: + if self.mode == "plugins": + self._show_plugins() + else: + self._show_installations() + + def action_toggle_plugin(self) -> None: + if self.mode != "plugins" or self.selected_installation is None: + return + index = self._cursor_index(len(self.plugin_rows)) + if index is None: + return + plugin = self.plugin_rows[index] + plugin_id = plugin["id"] + target = self.selected_installation + try: + if plugin["status"] == "enabled": + result = disable_plugin( + target.instance_name, + target.instance_path, + target.state_root, + plugin_id, + force=False, + ) + if not result["stateUpdated"]: + self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}") + return + self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.") + elif plugin["status"] == "disabled": + result = enable_disabled_plugin( + instance=target.instance_name, + instance_path=target.instance_path, + state_root=target.state_root, + plugin_id=plugin_id, + repo=self.repo_root, + ) + self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") + else: + self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.") + return + except Exception as exc: + self._set_status(f"Could not toggle {plugin_id}: {exc}") + return + self._show_plugins(preserve_status=True) + + def action_disable_all_plugins(self) -> None: + if self.mode != "plugins" or self.selected_installation is None: + return + target = self.selected_installation + enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"] + changed = 0 + errors: list[str] = [] + for plugin in enabled: + plugin_id = plugin["id"] + try: + result = disable_plugin( + target.instance_name, + target.instance_path, + target.state_root, + plugin_id, + force=False, + ) + if result["stateUpdated"]: + changed += 1 + else: + errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}") + except Exception as exc: + errors.append(f"{plugin_id}: {exc}") + self._set_bulk_status("Disabled", changed, errors) + self._show_plugins(preserve_status=True) + + def action_enable_all_plugins(self) -> None: + if self.mode != "plugins" or self.selected_installation is None: + return + target = self.selected_installation + disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"] + changed = 0 + errors: list[str] = [] + for plugin in disabled: + plugin_id = plugin["id"] + try: + enable_disabled_plugin( + instance=target.instance_name, + instance_path=target.instance_path, + state_root=target.state_root, + plugin_id=plugin_id, + repo=self.repo_root, + ) + changed += 1 + except Exception as exc: + errors.append(f"{plugin_id}: {exc}") + self._set_bulk_status("Enabled", changed, errors) + self._show_plugins(preserve_status=True) + + def _show_installations(self) -> None: + self.mode = "installations" + self.plugin_rows = [] + self._set_title("Choose Beat Saber Installation") + table = self.query_one(DataTable) + table.clear(columns=True) + table.add_columns("Profile", "Version", "Instance Path", "State Dir") + for choice in self.choices: + table.add_row( + choice.profile_label, + choice.instance_name, + str(choice.instance_path), + str(choice.state_root), + ) + if self.setup_hint: + self._set_status(self.setup_hint) + else: + self._set_status("Enter selects an installation. q quits.") + + def _show_plugins(self, *, preserve_status: bool = False) -> None: + if self.selected_installation is None: + self._show_installations() + return + target = self.selected_installation + self.mode = "plugins" + self._set_title(f"{target.profile_label} / {target.instance_name}") + table = self.query_one(DataTable) + table.clear(columns=True) + table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset") + + try: + lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml") + report = installed_plugins_report( + installed_state=load_installed_state(target.state_root, target.instance_name), + registry=load_registry(self.repo_root / "registry" / "plugins.toml"), + lockfile=lockfile, + ) + self.plugin_rows = report["plugins"] + except Exception as exc: + self.plugin_rows = [] + if not preserve_status: + self._set_status(f"Could not load plugins: {exc}") + return + + for plugin in self.plugin_rows: + table.add_row( + self._status_marker(plugin["status"]), + plugin["name"], + plugin["id"], + plugin["version"], + str(plugin["fileCount"]), + plugin["asset"], + ) + if not preserve_status: + if self.plugin_rows: + self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.") + else: + self._set_status("No managed plugins recorded for this installation.") + + def _cursor_index(self, row_count: int) -> int | None: + table = self.query_one(DataTable) + row = table.cursor_coordinate.row + if 0 <= row < row_count: + return row + return None + + def _set_title(self, message: str) -> None: + self.query_one("#title", Static).update(message) + + def _set_status(self, message: str) -> None: + self.status_message = message + self.query_one("#status", Static).update(message) + + def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None: + if errors: + preview = "; ".join(errors[:3]) + suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else "" + self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}") + else: + self._set_status(f"{verb} {changed} plugins.") + + @staticmethod + def _status_marker(status: str) -> Text: + return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True) + + @staticmethod + def _format_skipped(skipped: list[dict[str, str]]) -> str: + if not skipped: + return "no files changed" + return "; ".join(f"{item['path']} {item['reason']}" for item in skipped) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 5811590..612f9cb 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -4,22 +4,27 @@ import json import tempfile import unittest import os -from io import StringIO from pathlib import Path from unittest.mock import patch from zipfile import ZipFile +from rich.text import Text +from textual.coordinate import Coordinate +from textual.widgets import DataTable + from plugin_helper.bootstrap import _run_ipa from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run +from plugin_helper.config import load_profiles, profile_by_id, resolve_runtime_config from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin from plugin_helper.instances import get_instance, list_instances from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin from plugin_helper.planner import create_plan from plugin_helper.scanner import scan_bootstrap_files, scan_instance -from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state +from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state +from plugin_helper.tui import InstallationChoice, PluginHelperTui from plugin_helper.updates import check_updates from plugin_helper.userdata import ( backup_userdata, @@ -119,58 +124,61 @@ class PluginHelperTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "ambiguous"): get_instance([windows, local], "1.44.1") - def test_menu_selects_instance_and_action(self) -> None: + def test_profile_config_loads_and_resolves_paths(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - instance = root / "instances" / "1.40.8" - state = root / "state" - (instance / "Beat Saber_Data").mkdir(parents=True) - (instance / "Plugins").mkdir() + config = root / "plugin-helper.local.toml" + config.write_text( + """ +[[profiles]] +id = "linux" +label = "Linux" +instances_root = "~/BSInstances" +state_dir = ".state" - answers = iter(["1", "3", "q"]) - output = StringIO() - with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output): - status = run( - [ - "--instances-root", - str(root / "instances"), - "--state-dir", - str(state), - "menu", - ] - ) +[[profiles]] +id = "windows" +label = "Windows" +instances_root = "mounted/BSInstances" +state_dir = ".state-windows" +""".lstrip(), + encoding="utf-8", + ) - self.assertEqual(status, 0) - self.assertIn("Counts files currently present", output.getvalue()) + profiles, loaded_path, loaded = load_profiles(config, root=root) - def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None: + self.assertTrue(loaded) + self.assertEqual(loaded_path, config) + self.assertEqual(profile_by_id(profiles, "linux").instances_root, Path("~/BSInstances").expanduser()) + self.assertEqual(profile_by_id(profiles, "windows").instances_root, root / "mounted" / "BSInstances") + self.assertEqual(profile_by_id(profiles, "windows").state_dir, root / ".state-windows") + + def test_profile_runtime_explicit_overrides(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - first_root = root / "a-root" - second_root = root / "z-root" - first = first_root / "1.44.1" - second = second_root / "1.44.1" - state = root / "state" - (first / "Beat Saber_Data").mkdir(parents=True) - (second / "Beat Saber_Data").mkdir(parents=True) - (second / "Plugins").mkdir() - (second / "Plugins" / "Example.dll").write_bytes(b"dll") + config = root / "plugin-helper.local.toml" + config.write_text( + """ +[[profiles]] +id = "linux" +label = "Linux" +instances_root = "profile-root" +state_dir = ".state" +""".lstrip(), + encoding="utf-8", + ) - answers = iter(["2", "3", "q"]) - output = StringIO() - with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output): - status = run( - [ - "--instances-root", - os.pathsep.join([str(first_root), str(second_root)]), - "--state-dir", - str(state), - "menu", - ] - ) + runtime = resolve_runtime_config( + instances_root_value=str(root / "explicit-root"), + state_dir_value="explicit-state", + profile_id="linux", + config_path=config, + root=root, + ) - self.assertEqual(status, 0) - self.assertIn("1.44.1: 1 files", output.getvalue()) + self.assertEqual(runtime.instances_roots, [root / "explicit-root"]) + self.assertEqual(runtime.state_root, root / "explicit-state") + self.assertEqual(runtime.selected_profile.id, "linux") def test_run_ipa_timeout_returns_control(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -343,7 +351,7 @@ sha256 = "{sha256_file(asset)}" save_installed_state(state, "1.40.8", disabled) - with patch("plugin_helper.cli.repo_root", return_value=work): + with patch("plugin_helper.operations.repo_root", return_value=work): status = run( [ "--instances-root", @@ -971,5 +979,175 @@ sha256 = "{sha256_file(asset)}" self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new") +def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]: + repo = root / "repo" + instance_root = root / "instances" + instance = instance_root / "1.40.8" + state = root / "state" + (repo / "registry").mkdir(parents=True) + (repo / "locks").mkdir() + (instance / "Beat Saber_Data").mkdir(parents=True) + (instance / "Plugins").mkdir() + + asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll" + asset.write_bytes(b"managed dll") + (repo / "registry" / "plugins.toml").write_text( + """ +[[plugins]] +id = "example" +name = "Example" +repo = "owner/example" +asset_patterns = ["*.dll"] +install_strategy = "dll-to-plugins" +""".lstrip(), + encoding="utf-8", + ) + (repo / "locks" / "1.40.8.lock.toml").write_text( + f""" +beat_saber_version = "1.40.8" +instance = "1.40.8" + +[[plugins]] +id = "example" +repo = "owner/example" +tag = "v1.0.0" +asset = "Example.dll" +sha256 = "{sha256_file(asset)}" +""".lstrip(), + encoding="utf-8", + ) + + target = instance / "Plugins" / "Example.dll" + if disabled: + plugins = {} + disabled_plugins = { + "example": { + "installedAt": "2026-06-14T17:18:40Z", + "disabledAt": "2026-06-14T17:20:00Z", + "files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}], + } + } + else: + target.write_bytes(b"changed dll" if hash_mismatch else b"managed dll") + plugins = { + "example": { + "installedAt": "2026-06-14T17:18:40Z", + "files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}], + } + } + disabled_plugins = {} + save_installed_state( + state, + "1.40.8", + {"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins}, + ) + choice = InstallationChoice( + profile_id="test", + profile_label="Test Profile", + instance_name="1.40.8", + instance_path=instance, + state_root=state, + ) + return PluginHelperTui(choices=[choice], repo_root=repo), instance, state + + +class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): + async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None: + choices = [ + InstallationChoice( + profile_id="linux", + profile_label="Linux", + instance_name="1.44.1", + instance_path=Path("/tmp/linux/1.44.1"), + state_root=Path("/tmp/state-linux"), + ), + InstallationChoice( + profile_id="windows", + profile_label="Windows", + instance_name="1.44.1", + instance_path=Path("/tmp/windows/1.44.1"), + state_root=Path("/tmp/state-windows"), + ), + ] + app = PluginHelperTui(choices=choices, repo_root=Path("/tmp/repo")) + + async with app.run_test(): + table = app.query_one(DataTable) + self.assertEqual(table.row_count, 2) + self.assertEqual(app.mode, "installations") + + async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + await pilot.press("enter") + self.assertEqual(app.plugin_rows[0]["status"], "enabled") + table = app.query_one(DataTable) + self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True)) + await pilot.press("space") + + self.assertFalse((instance / "Plugins" / "Example.dll").exists()) + updated = load_installed_state(state, "1.40.8") + self.assertNotIn("example", updated["plugins"]) + self.assertIn("example", updated["disabledPlugins"]) + + async def test_space_enables_disabled_plugin_from_asset(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) + + async with app.run_test() as pilot: + await pilot.press("enter") + self.assertEqual(app.plugin_rows[0]["status"], "disabled") + await pilot.press("space") + + self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") + updated = load_installed_state(state, "1.40.8") + self.assertIn("example", updated["plugins"]) + self.assertNotIn("example", updated["disabledPlugins"]) + + async def test_disable_all_disables_enabled_plugins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp)) + + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.press("d") + + self.assertFalse((instance / "Plugins" / "Example.dll").exists()) + updated = load_installed_state(state, "1.40.8") + self.assertNotIn("example", updated["plugins"]) + self.assertIn("example", updated["disabledPlugins"]) + self.assertIn("Disabled 1 plugins", app.status_message) + + async def test_enable_all_enables_disabled_plugins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) + + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.press("e") + + self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") + updated = load_installed_state(state, "1.40.8") + self.assertIn("example", updated["plugins"]) + self.assertNotIn("example", updated["disabledPlugins"]) + self.assertIn("Enabled 1 plugins", app.status_message) + + async def test_space_reports_hash_mismatch_without_state_update(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True) + + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.press("space") + + self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll") + updated = load_installed_state(state, "1.40.8") + self.assertIn("example", updated["plugins"]) + self.assertNotIn("example", updated.get("disabledPlugins", {})) + self.assertIn("hash mismatch", app.status_message) + + if __name__ == "__main__": unittest.main() From eed12376c6d6c759ee52018c268847e7d7a77c22 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 11:59:01 -0700 Subject: [PATCH 5/8] Default interactive CLI to menu --- AGENTS.md | 2 ++ README.md | 5 ++++- src/plugin_helper/cli.py | 9 ++++++++- tests/test_plugin_helper.py | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 66fd2dd..66faca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ Guidance for coding agents working in this repo. - Be careful with duplicate instance names across Windows and local roots. Use the menu or pass `--instances-root` explicitly when targeting one install, and keep install/bootstrap state separate per target root. +- After completing repo changes, suggest a concise commit message in the final + response unless the user already asked you to commit. ## Validation diff --git a/README.md b/README.md index aab56ce..8a67657 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,12 @@ backups, and installed file records for different game trees. For normal use, run the Textual menu from the repo root: ```sh -PYTHONPATH=src python -m plugin_helper menu +PYTHONPATH=src python -m plugin_helper ``` +That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run +from an interactive terminal. + The menu reads `plugin-helper.local.toml` when present, shows each discovered Beat Saber install with its resolved state directory, and lets you toggle managed plugins with arrow keys and Space. In the plugin table, use `d` to diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index fedbfde..3683336 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -70,7 +70,7 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="plugin-helper") _add_common(parser) - subcommands = parser.add_subparsers(dest="command", required=True) + subcommands = parser.add_subparsers(dest="command") subcommands.add_parser( "instances", @@ -309,6 +309,13 @@ def run(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: + if args.command is None: + if sys.stdin.isatty() and sys.stdout.isatty(): + args.command = "menu" + else: + parser.print_help() + return 2 + explicit_instances_root = getattr(args, "instances_root", None) is not None explicit_state_dir = getattr(args, "state_dir", None) is not None runtime = resolve_runtime_config( diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 612f9cb..9b31d5a 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -4,6 +4,7 @@ import json import tempfile import unittest import os +from io import StringIO from pathlib import Path from unittest.mock import patch from zipfile import ZipFile @@ -180,6 +181,26 @@ state_dir = ".state" self.assertEqual(runtime.state_root, root / "explicit-state") self.assertEqual(runtime.selected_profile.id, "linux") + def test_no_args_prints_help_when_not_interactive(self) -> None: + output = StringIO() + with patch("sys.stdin.isatty", return_value=False), patch("sys.stdout", output): + status = run([]) + + self.assertEqual(status, 2) + self.assertIn("usage: plugin-helper", output.getvalue()) + self.assertIn("menu", output.getvalue()) + + def test_no_command_defaults_to_menu_when_interactive(self) -> None: + with ( + patch("sys.stdin.isatty", return_value=True), + patch("sys.stdout.isatty", return_value=True), + patch("plugin_helper.cli._run_menu", return_value=0) as run_menu, + ): + status = run(["--profile", "linux"]) + + self.assertEqual(status, 0) + run_menu.assert_called_once() + def test_run_ipa_timeout_returns_control(self) -> None: with tempfile.TemporaryDirectory() as tmp: result = _run_ipa( From c0a167dc6745916daa6d725d9c9ca3e9b6b8cc50 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 12:45:58 -0700 Subject: [PATCH 6/8] Use plugin-helper profiles in builder skill --- .../skills/beatsaber-plugin-builder/SKILL.md | 70 ++++++++++++++----- .../references/linux-bsipa-build.md | 41 ++++++----- .../skills/beatsaber-plugin-manager/SKILL.md | 2 +- AGENTS.md | 11 +-- 4 files changed, 81 insertions(+), 43 deletions(-) diff --git a/.agents/skills/beatsaber-plugin-builder/SKILL.md b/.agents/skills/beatsaber-plugin-builder/SKILL.md index 633e39e..1daf24d 100644 --- a/.agents/skills/beatsaber-plugin-builder/SKILL.md +++ b/.agents/skills/beatsaber-plugin-builder/SKILL.md @@ -1,9 +1,9 @@ --- name: beatsaber-plugin-builder -description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for net472 BSIPA projects. +description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for .NET Framework BSIPA projects. --- -# Build Beat Saber Plugin +# Beat Saber Plugin Builder Use this skill to compile PC BSIPA plugin projects on this Linux host, especially from the `plugin-helper` repo. The workflow is adapted from the Setlist repo's Linux/Cursor build notes. @@ -16,18 +16,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b ```bash pwd git status --short + sed -n '1,220p' plugin-helper.local.toml ``` - In `plugin-helper`, run commands from repo root and keep temporary source checkouts under `.state/build/` unless the user asks for another location. Do not disturb unrelated dirty files. + In `plugin-helper`, run commands from repo root. Treat + `plugin-helper.local.toml` as the source of truth for profile + `instances_root` and `state_dir` values, and keep temporary source checkouts + under the chosen profile's `state_dir` such as + `/build/`. Do not disturb unrelated dirty files. 2. Resolve source. - For a GitHub PR, clone or reuse a checkout under `.state/build`, add/fetch the upstream remote if needed, and check out the PR head: + For a GitHub PR, clone or reuse a checkout under the selected profile's + `/build`, add/fetch the upstream remote if needed, and check out + the PR head: ```bash - git clone https://github.com//.git .state/build/ - git -C .state/build/ fetch origin pull//head:pr- - git -C .state/build/ checkout pr- + git clone https://github.com//.git /build/ + git -C /build/ fetch origin pull//head:pr- + git -C /build/ checkout pr- ``` If the PR is from a fork and the repo already has a fork remote, preserve it. Never overwrite local source changes without explicit approval. @@ -43,14 +50,19 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b 4. Choose Beat Saber references. - Prefer a BSManager instance matching the plugin or manifest `gameVersion`. Common local roots: + Prefer a BSManager instance matching the plugin or manifest `gameVersion`. + Read `plugin-helper.local.toml` and select the intended profile, then use + that profile's `instances_root` and matching `state_dir` instead of + searching default BSManager paths manually: - ```text - /home/pleb/.local/share/BSManager/BSInstances/ - /home/pleb/Windows/Users/pleb/BSManager/BSInstances/ + ```bash + PYTHONPATH=src .venv/bin/python -m plugin_helper --profile instances ``` - Use a local `.csproj.user` or MSBuild properties rather than committing machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation does not mutate the game install. + Use a local `.csproj.user` or MSBuild properties rather than committing + machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for + BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation + does not mutate the game install. 5. Restore and build. @@ -61,9 +73,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b dotnet build -c Release -p:DisableCopyToPlugins=True -p:DisableCopyToGame=True ``` - If the project lacks .NET Framework reference assemblies on Linux, add or pass `Microsoft.NETFramework.ReferenceAssemblies.net472` as described in the reference file. + If the project lacks .NET Framework reference assemblies on Linux, add or + pass the package matching the project's target framework, usually + `Microsoft.NETFramework.ReferenceAssemblies.net48` for current BSIPA + projects, as described in the reference file. -6. Collect artifacts. +6. Verify Beat Saber API changes before patching gameplay code. + + When a Beat Saber upgrade breaks a type/member reference, first determine + whether the API moved assemblies before substituting another type. Inspect + the target game DLLs and nearby mod sources: + + ```bash + strings "/Beat Saber_Data/Managed/Main.dll" | rg 'TypeOrMemberName' + strings "/Beat Saber_Data/Managed/HMLib.dll" | rg 'TypeOrMemberName' + ilspycmd -t TypeName "/Beat Saber_Data/Managed/Main.dll" | sed -n '1,220p' + rg -n 'TypeOrMemberName|NearbyConcept' ~/src// ~/src/Auros/SiraUtil ~/src/nike4613/BeatSaber-IPA-Reloaded + ``` + +7. Collect artifacts. Find produced DLLs and release zips: @@ -71,23 +99,27 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b find -path '*/bin/*' \( -name '*.dll' -o -name '*.zip' \) -print ``` - Verify the DLL name, version, and manifest. If the result is meant for `plugin-helper`, place a copy under `.state/instances//downloads//` and use the helper plan/apply workflow rather than hand-copying into a BSManager instance. + Verify the DLL name, version, and manifest. If the result is meant for + `plugin-helper`, place a copy under + `/instances//downloads//` for the selected + profile and use the helper plan/apply workflow rather than hand-copying into + a BSManager instance. -7. Validate. +8. Validate. For skill edits inside this repo, run: ```bash python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/beatsaber-plugin-builder - PYTHONPATH=src python -m compileall -q src tests - PYTHONPATH=src python -m unittest discover -s tests + PYTHONPATH=src .venv/bin/python -m compileall -q src tests + PYTHONPATH=src .venv/bin/python -m unittest discover -s tests ``` For a plugin build, at minimum report the exact `dotnet build` result and artifact paths. For live game validation, use `docs/SMOKETEST.md` and tear down Beat Saber processes afterward. ## Failure Triage -- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the net472 reference-assemblies package or pass an equivalent MSBuild/package restore fix. +- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the package matching the target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects, or pass an equivalent MSBuild/package restore fix. - Missing `Main.dll`, `HMUI.dll`, `IPA.Loader.dll`, `BSML.dll`, `SongCore.dll`, or similar: `BeatSaberDir` points at the wrong/unmodded instance, or dependencies are absent from `Plugins/`/`Libs/`. - BSMT copies to `IPA/Pending` or `Plugins` during build: rebuild with `-p:DisableCopyToPlugins=True -p:DisableCopyToGame=True` unless the user explicitly wants deployment. - NuGet package restore fails because a source is missing: inspect `NuGet.config` and installed package sources; use repo-local configuration where possible. diff --git a/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md b/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md index 58d377e..cb94817 100644 --- a/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md +++ b/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md @@ -6,7 +6,8 @@ This workflow comes from `/home/pleb/ops/beatsaber/setlist/docs/pc-modding.md` a ## Toolchain -- PC BSIPA plugins are usually .NET Framework `net472` class libraries. +- PC BSIPA plugins are .NET Framework class libraries; current projects + commonly target `net48`, while older projects may still target `net472`. - Linux `dotnet` SDK 6+ can build them because output DLLs are platform-agnostic CIL loaded by Beat Saber under Proton. - `BeatSaberModdingTools.Tasks` supplies the MSBuild targets normally driven by Visual Studio/Rider BSMT extensions. - On this host, `dotnet --list-sdks` should show a usable SDK. NuGet is available for package inspection. @@ -24,16 +25,13 @@ Plugins/ winhttp.dll ``` -Preferred local managed instance root: +In `plugin-helper`, prefer the profile selected from +`plugin-helper.local.toml` as the source of truth for the managed instance root +and state directory: -```text -/home/pleb/.local/share/BSManager/BSInstances/ -``` - -Windows mirror root: - -```text -/home/pleb/Windows/Users/pleb/BSManager/BSInstances/ +```bash +sed -n '1,220p' plugin-helper.local.toml +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile instances ``` Use `BeatSaberVersion.txt` for the exact game version. The manifest `gameVersion` normally uses the `major.minor.patch` prefix, not the build suffix. @@ -45,7 +43,7 @@ Prefer machine-local configuration in `.csproj.user`: ```xml - /home/pleb/.local/share/BSManager/BSInstances/1.40.8 + /path/from/selected/profile/instances_root/1.44.1 ``` @@ -60,7 +58,10 @@ For those, pass `-p:LocalRefsDir=/path/to/instance` or add a local `.csproj.user When changing a project file for Linux portability, prefer the smallest explicit fix: -- Add `Microsoft.NETFramework.ReferenceAssemblies.net472` when MSBuild reports missing .NET Framework reference assemblies. +- Add the `Microsoft.NETFramework.ReferenceAssemblies.*` package matching the + project target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48` + for current projects, when MSBuild reports missing .NET Framework reference + assemblies. - Set or pass `DisableCopyToPlugins=True` for artifact-only builds. - Keep hint paths rooted at `$(BeatSaberDir)` where possible. - Do not add broad multi-version compatibility logic unless requested. @@ -105,7 +106,9 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st ## Common Reference Failures -- `MSB3644` or missing `.NETFramework,Version=v4.7.2` reference assemblies: add `Microsoft.NETFramework.ReferenceAssemblies.net472`. +- `MSB3644` or missing `.NETFramework,Version=v4.x` reference assemblies: add + the matching `Microsoft.NETFramework.ReferenceAssemblies.*` package, usually + `Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects. - Missing game assemblies such as `Main.dll`, `HMUI.dll`, `UnityEngine.CoreModule.dll`: `BeatSaberDir` is wrong or incomplete. - Missing mod dependencies such as `BSML.dll`, `SongCore.dll`, `SiraUtil.dll`, `BeatSaberPlaylistsLib.dll`: install or point at an instance containing those plugins, or fetch the dependency DLL from its verified release only when appropriate. - `IPA.Loader.dll` missing: BSIPA is not bootstrapped in that instance. @@ -115,17 +118,17 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st For a built plugin DLL intended for a managed instance: ```bash -mkdir -p .state/instances//downloads/ -cp //bin/Release/.dll .state/instances//downloads//.dll -sha256sum .state/instances//downloads//.dll +mkdir -p /instances//downloads/ +cp //bin/Release/.dll /instances//downloads//.dll +sha256sum /instances//downloads//.dll ``` Then update registry/lock data only if the user asked to manage/install the artifact, and use: ```bash -PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance -PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance --plugin -PYTHONPATH=src python -m plugin_helper --state-dir .state apply +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile check --instance +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile plan --instance --plugin +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile apply ``` Inspect the generated plan before applying. diff --git a/.agents/skills/beatsaber-plugin-manager/SKILL.md b/.agents/skills/beatsaber-plugin-manager/SKILL.md index 51ed61c..89bc5b4 100644 --- a/.agents/skills/beatsaber-plugin-manager/SKILL.md +++ b/.agents/skills/beatsaber-plugin-manager/SKILL.md @@ -3,7 +3,7 @@ name: beatsaber-plugin-manager description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, bootstrap BSIPA, or manage a Beat Saber plugin release for a BSManager instance. Prefer upstream GitHub release artifacts for normal plugins; use BeatMods primarily as compatibility/dependency metadata, with CDN artifacts only for inaccessible upstream assets, BeatMods-only packages, or framework/library dependencies. --- -# Install Beat Saber Plugin +# Beat Saber Plugin Installer Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. diff --git a/AGENTS.md b/AGENTS.md index 66faca5..efecf59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,10 @@ Guidance for coding agents working in this repo. ## Workflow Rules - Run commands from the repo root with `PYTHONPATH=src`. +- A repo-local Python virtualenv is normally available at `.venv`; prefer + `.venv/bin/python` for helper commands and tests when it exists. - For human-style inspection, prefer the menu with repo-local state: - `PYTHONPATH=src python -m plugin_helper --state-dir .state menu`. + `PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`. - When targeting the local Linux BSManager install, pass `--instances-root ~/.local/share/BSManager/BSInstances` and normally `--state-dir .state`. @@ -51,9 +53,10 @@ Guidance for coding agents working in this repo. ## Validation -- Run `PYTHONPATH=src python -m unittest discover -s tests` after code changes. -- Run `PYTHONPATH=src python -m compileall -q src tests` for syntax/import - checks. +- Run `PYTHONPATH=src .venv/bin/python -m unittest discover -s tests` after + code changes when `.venv` exists; otherwise use `python`. +- Run `PYTHONPATH=src .venv/bin/python -m compileall -q src tests` for + syntax/import checks when `.venv` exists; otherwise use `python`. - For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber processes afterward. From 1be1353835dade9e8fdb47afca177ff40390c7b9 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 13:24:18 -0700 Subject: [PATCH 7/8] Plan for Windows compat --- .gitignore | 1 + docs/notes/windows-compatibility.md | 77 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 docs/notes/windows-compatibility.md diff --git a/.gitignore b/.gitignore index bf74cf6..a049623 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.state/ /.state-*/ /plugin-helper.local.toml +/plugin-helper.windows.toml /.pytest_cache/ /build/ /dist/ diff --git a/docs/notes/windows-compatibility.md b/docs/notes/windows-compatibility.md new file mode 100644 index 0000000..a655760 --- /dev/null +++ b/docs/notes/windows-compatibility.md @@ -0,0 +1,77 @@ +# Windows Compatibility Tracker + +Started: 2026-07-01 + +This note tracks the changes needed to run `plugin-helper` natively on Windows +11 with Python 3.13, separate from the Linux helper that manages mounted +Windows instances. + +## Target Setup + +- Install Python with: + + ```powershell + winget install --id Python.Python.3.13 --exact + ``` + +- Use a separate checkout or working copy on the Windows partition. +- Use a Windows-specific config file: + + ```powershell + py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances + ``` + +- Keep native Windows state local to that checkout, such as `.state/`, so it + does not mix with the Linux `.state` or mounted-Windows `.state-windows` + directories. + +## Current Compatibility Notes + +- Core scan, plan, apply, uninstall, disable, and enable flows are mostly + platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and + local file hashes. +- Native Windows defaults should not reuse Linux-mounted paths such as + `/home/pleb/Windows/...`. A Windows-specific TOML file handles this for normal + use. +- Multiple `--instances-root` values use `os.pathsep`; that means `;` on + Windows and `:` on Linux. Documentation should make this platform-specific. +- The Textual TUI dependency supports Windows and Python 3.13, but the best + terminal target is Windows Terminal or a modern PowerShell host. + +## Work Items + +- Add native Windows bootstrap support. + - Current bootstrap assumes Proton. + - Native Windows should run `IPA.exe -n` directly from the Beat Saber instance. + - Timeout cleanup needs Windows-compatible process handling instead of + POSIX process groups. +- Decide whether `bootstrap-check` should accept a recorded native Windows + bootstrap state without a Proton launch history. +- Add Windows-aware default paths or keep requiring `--config + plugin-helper.windows.toml` for native use. +- Update README examples for PowerShell: + - editable install + - `--config plugin-helper.windows.toml` + - Windows path-list separator `;` +- Add or adjust tests for Windows behavior. + - Skip or rewrite the POSIX-only `_run_ipa` timeout test on Windows. + - Add tests for native Windows config path resolution. + - Add tests for native bootstrap command construction. +- Review backup and restore helpers on native Windows. + - `sync_windows_data_repo` and `restore_windows_data_repo` should work with + ordinary Windows paths. + - The older tar-based `backup_userdata` helper uses `NamedTemporaryFile` in a + way that may not be Windows-friendly if it becomes part of the CLI later. + +## First Manual Smoke + +From the Windows checkout: + +```powershell +py -3.13 -m pip install -e . +py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances +py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1 +``` + +If instance discovery fails, verify the BSManager instance root in +`plugin-helper.windows.toml`. From 407abfe6ec57be54ad479ae8644b2fbf9102fac0 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 13:43:39 -0700 Subject: [PATCH 8/8] Simplify plugin-helper state configuration --- README.md | 84 ++++++++++++------------ plugin-helper.toml.example | 15 ++--- src/plugin_helper/cli.py | 61 +++-------------- src/plugin_helper/config.py | 115 ++++++++++++++++---------------- src/plugin_helper/tui.py | 10 +-- tests/test_plugin_helper.py | 127 +++++++++++++++++++++++++----------- 6 files changed, 213 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index 8a67657..73e64b5 100644 --- a/README.md +++ b/README.md @@ -11,55 +11,67 @@ The first implementation focuses on safe local workflows: - apply exactly that plan and record install state - uninstall only files recorded in install state -Default BSManager instance roots: +Default BSManager instance root: ```text -/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/.local/share/BSManager/BSInstances ``` -Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search +Default plugin-helper state directory: + +```text +$XDG_STATE_HOME/plugin-helper +``` + +If `XDG_STATE_HOME` is not set, the state directory defaults to: + +```text +~/.local/state/plugin-helper +``` + +Override the instance root with `--instances-root`, +`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search multiple explicit roots, separate them with `:`. -## Managing Multiple Installs +Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or +`plugin-helper.local.toml`. -The helper is intended to manage both the local Linux BSManager install and the -mounted Windows install. Lockfiles and registry entries are shared by Beat Saber -version, but install state is target-specific. When the same instance name -exists under both roots, such as `1.44.1`, give each install profile its own -state directory. +## Local Configuration + +This checkout is intended to manage the local Linux BSManager install. If you +also manage a Windows install, use a separate clone on that partition and point +both clones at the same state directory only when you intentionally want one +shared source of truth. + +Copy the example config and adjust paths if needed: -Copy the example profile config and adjust paths if needed: ```sh cp plugin-helper.toml.example plugin-helper.local.toml ``` -`plugin-helper.local.toml` is ignored by git. The default example uses this -repo-local convention: +`plugin-helper.local.toml` is ignored by git and uses top-level fields: -```text -.state/ local Linux BSManager state -.state-windows/ mounted Windows BSManager state +```toml +instances_root = "~/.local/share/BSManager/BSInstances" +state_dir = "~/.local/state/plugin-helper" ``` -Examples: +For repo-local state, set: -```sh -PYTHONPATH=src python -m plugin_helper \ - --profile linux \ - installed --instance 1.44.1 - -PYTHONPATH=src python -m plugin_helper \ - --profile windows \ - installed --instance 1.44.1 +```toml +state_dir = ".state" ``` -Explicit `--instances-root` and `--state-dir` still work and override profile -values. Do not reuse the same state directory for both targets when their -instance names match. The current state layout is keyed by instance name, so -sharing one state directory would mix bootstrap records, generated plans, -backups, and installed file records for different game trees. +For a shared Windows-partition state directory, set the same `state_dir` in both +clones, for example: + +```toml +state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state" +``` + +CLI flags override environment variables, environment variables override local +config, and local config overrides built-in defaults. ## Commands @@ -79,21 +91,13 @@ disable all currently enabled managed plugins and `e` to enable all currently disabled managed plugins. The individual subcommands are mostly for automation and debugging. If you use -them, prefer `--profile linux` or `--profile windows`. Pass `--state-dir` -directly only when you intentionally want to override profile state or use the -default live state outside this repo. +them, pass `--state-dir` directly only when you intentionally want to override +the configured state directory for one command. Install assets are currently expected to already exist locally, usually under: ```text -.state/instances//downloads// -``` - -For a second target-specific state directory, copy or re-download the same -locked assets under that state root before planning. For example: - -```text -.state-windows/instances//downloads// +/instances//downloads// ``` ## Beat Saber Data Backups diff --git a/plugin-helper.toml.example b/plugin-helper.toml.example index c150215..caab0a8 100644 --- a/plugin-helper.toml.example +++ b/plugin-helper.toml.example @@ -1,11 +1,8 @@ -[[profiles]] -id = "linux" -label = "Local Linux BSManager" instances_root = "~/.local/share/BSManager/BSInstances" -state_dir = ".state" +state_dir = "~/.local/state/plugin-helper" -[[profiles]] -id = "windows" -label = "Mounted Windows BSManager" -instances_root = "~/Windows/Users/pleb/BSManager/BSInstances" -state_dir = ".state-windows" +# To keep state inside this checkout instead: +# state_dir = ".state" +# +# To share one state directory between Linux and a Windows-partition checkout: +# state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state" diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 3683336..0178d55 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -6,7 +6,7 @@ import sys from pathlib import Path from typing import Any -from .config import Profile, repo_root, resolve_runtime_config +from .config import repo_root, resolve_runtime_config from .bootstrap import run_bootstrap from .bsipa import check_bsipa_health from .checker import check_lock @@ -63,8 +63,6 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal 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") - parser.add_argument("--profile", default=default, help="Configured profile id from plugin-helper.local.toml") - parser.add_argument("--config", default=default, help="Path to plugin-helper TOML config") def build_parser() -> argparse.ArgumentParser: @@ -225,37 +223,6 @@ def _common_parent() -> argparse.ArgumentParser: return parent -def _menu_profiles( - *, - profiles: tuple[Profile, ...], - selected_profile: Profile | None, - instances_roots: list[Path], - state_root: Path, - use_config_profiles: bool, -) -> list[Profile]: - if selected_profile: - return [ - Profile( - id=selected_profile.id if len(instances_roots) == 1 else f"{selected_profile.id}-{index}", - label=selected_profile.label, - instances_root=root, - state_dir=state_root, - ) - for index, root in enumerate(instances_roots, start=1) - ] - if use_config_profiles and profiles: - return list(profiles) - return [ - Profile( - id=f"root-{index}", - label=str(root), - instances_root=root, - state_dir=state_root, - ) - for index, root in enumerate(instances_roots, start=1) - ] - - def _run_menu( *, runtime: Any, @@ -269,35 +236,29 @@ def _run_menu( print("Install project dependencies, for example: python -m pip install -e .") return 1 - profiles = _menu_profiles( - profiles=runtime.profiles, - selected_profile=runtime.selected_profile, - instances_roots=runtime.instances_roots, - state_root=runtime.state_root, - use_config_profiles=runtime.config_loaded and not explicit_instances_root and not explicit_state_dir, - ) choices: list[InstallationChoice] = [] - for profile in profiles: - for instance in list_instances(profile.instances_root): + for index, root in enumerate(runtime.instances_roots, start=1): + install_label = str(root) if len(runtime.instances_roots) > 1 else "Default" + for instance in list_instances(root): choices.append( InstallationChoice( - profile_id=profile.id, - profile_label=profile.label, + install_id=f"root-{index}", + install_label=install_label, instance_name=instance.name, instance_path=instance.path, - state_root=profile.state_dir, + state_root=runtime.state_root, ) ) if not choices: - searched = ", ".join(str(profile.instances_root) for profile in profiles) + searched = ", ".join(str(root) for root in runtime.instances_roots) print(f"No Beat Saber instances found under {searched}") return 1 setup_hint = None - if not runtime.config_loaded and not runtime.selected_profile and not explicit_instances_root and not explicit_state_dir: + if not runtime.config_loaded and not explicit_instances_root and not explicit_state_dir: setup_hint = ( f"No {runtime.config_path.name} found; using default roots and default state. " - f"Copy plugin-helper.toml.example to {runtime.config_path.name} to map each install to a state dir." + f"Copy plugin-helper.toml.example to {runtime.config_path.name} to set a custom state dir." ) app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint) result = app.run() @@ -321,8 +282,6 @@ def run(argv: list[str] | None = None) -> int: runtime = resolve_runtime_config( instances_root_value=getattr(args, "instances_root", None), state_dir_value=getattr(args, "state_dir", None), - profile_id=getattr(args, "profile", None), - config_path=getattr(args, "config", None), ) inst_roots = runtime.instances_roots st_root = runtime.state_root diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index 0c5cd2a..6832c89 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -7,27 +7,21 @@ from pathlib import Path from typing import Any -WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances") LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances" -DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT) -DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT +DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT LOCAL_CONFIG_NAME = "plugin-helper.local.toml" @dataclass(frozen=True) -class Profile: - id: str - label: str - instances_root: Path - state_dir: Path +class LocalConfig: + instances_roots: list[Path] | None + state_root: Path | None @dataclass(frozen=True) class RuntimeConfig: instances_roots: list[Path] state_root: Path - profiles: tuple[Profile, ...] - selected_profile: Profile | None config_path: Path config_loaded: bool @@ -40,16 +34,19 @@ def instances_root(value: str | None = None) -> Path: return instances_roots(value)[0] -def instances_roots(value: str | None = None) -> list[Path]: +def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]: raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") if raw: - return [Path(item).expanduser() for item in raw.split(os.pathsep) if item] - return list(DEFAULT_INSTANCES_ROOTS) + return _resolve_path_list(raw, base or repo_root()) + return [DEFAULT_INSTANCES_ROOT] def state_root(value: str | None = None) -> Path: if value: return _resolve_path(value, repo_root()) + env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR") + if env_state: + return _resolve_path(env_state, repo_root()) xdg_state = os.environ.get("XDG_STATE_HOME") base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" return base / "plugin-helper" @@ -66,75 +63,81 @@ def _resolve_path(value: str | Path, base: Path) -> Path: return (base / path).resolve() -def load_profiles(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[tuple[Profile, ...], Path, bool]: +def _resolve_path_list(value: str, base: Path) -> list[Path]: + return [_resolve_path(item, base) for item in value.split(os.pathsep) if item] + + +def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]: repo = root or repo_root() path = _resolve_path(config_path, repo) if config_path else default_config_path(repo) if not path.exists(): if config_path: raise FileNotFoundError(f"plugin-helper config not found: {path}") - return (), path, False + return LocalConfig(instances_roots=None, state_root=None), path, False with path.open("rb") as handle: data: dict[str, Any] = tomllib.load(handle) - profiles: list[Profile] = [] - seen: set[str] = set() base = path.parent - for item in data.get("profiles", []): - profile_id = item["id"] - if profile_id in seen: - raise ValueError(f"{path}: duplicate profile id: {profile_id}") - seen.add(profile_id) - profiles.append( - Profile( - id=profile_id, - label=item.get("label", profile_id), - instances_root=_resolve_path(item["instances_root"], base), - state_dir=_resolve_path(item["state_dir"], base), - ) - ) - return tuple(profiles), path, True + instances_value = data.get("instances_root") + state_value = data.get("state_dir") + return ( + LocalConfig( + instances_roots=_resolve_path_list(instances_value, base) if instances_value else None, + state_root=_resolve_path(state_value, base) if state_value else None, + ), + path, + True, + ) -def profile_by_id(profiles: tuple[Profile, ...], profile_id: str) -> Profile: - for profile in profiles: - if profile.id == profile_id: - return profile - available = ", ".join(profile.id for profile in profiles) or "(none)" - raise KeyError(f"unknown profile: {profile_id}; available profiles: {available}") +def _env_instances_roots(root: Path) -> list[Path] | None: + value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") + return _resolve_path_list(value, root) if value else None + + +def _env_state_root(root: Path) -> Path | None: + value = os.environ.get("PLUGIN_HELPER_STATE_DIR") + return _resolve_path(value, root) if value else None + + +def _default_state_root() -> Path: + xdg_state = os.environ.get("XDG_STATE_HOME") + base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" + return base / "plugin-helper" + + +def _default_instances_roots() -> list[Path]: + return [DEFAULT_INSTANCES_ROOT] def resolve_runtime_config( *, instances_root_value: str | None = None, state_dir_value: str | None = None, - profile_id: str | None = None, - config_path: str | Path | None = None, root: Path | None = None, ) -> RuntimeConfig: repo = root or repo_root() - profiles, loaded_path, loaded = load_profiles(config_path, root=repo) - selected = profile_by_id(profiles, profile_id) if profile_id else None + local_config, loaded_path, loaded = load_local_config(root=repo) - if instances_root_value: - resolved_instances = instances_roots(instances_root_value) - elif selected: - resolved_instances = [selected.instances_root] - else: - resolved_instances = instances_roots(None) - - if state_dir_value: - resolved_state = _resolve_path(state_dir_value, repo) - elif selected: - resolved_state = selected.state_dir - else: - resolved_state = state_root(None) + resolved_instances = ( + _resolve_path_list(instances_root_value, repo) + if instances_root_value + else _env_instances_roots(repo) + or local_config.instances_roots + or _default_instances_roots() + ) + resolved_state = ( + _resolve_path(state_dir_value, repo) + if state_dir_value + else _env_state_root(repo) + or local_config.state_root + or _default_state_root() + ) return RuntimeConfig( instances_roots=resolved_instances, state_root=resolved_state, - profiles=profiles, - selected_profile=selected, config_path=loaded_path, config_loaded=loaded, ) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 0343dd9..0ceed4c 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -18,8 +18,8 @@ from .state import load_installed_state @dataclass(frozen=True) class InstallationChoice: - profile_id: str - profile_label: str + install_id: str + install_label: str instance_name: str instance_path: Path state_root: Path @@ -189,10 +189,10 @@ class PluginHelperTui(App[int]): self._set_title("Choose Beat Saber Installation") table = self.query_one(DataTable) table.clear(columns=True) - table.add_columns("Profile", "Version", "Instance Path", "State Dir") + table.add_columns("Install", "Version", "Instance Path", "State Dir") for choice in self.choices: table.add_row( - choice.profile_label, + choice.install_label, choice.instance_name, str(choice.instance_path), str(choice.state_root), @@ -208,7 +208,7 @@ class PluginHelperTui(App[int]): return target = self.selected_installation self.mode = "plugins" - self._set_title(f"{target.profile_label} / {target.instance_name}") + self._set_title(f"{target.install_label} / {target.instance_name}") table = self.query_one(DataTable) table.clear(columns=True) table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset") diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 9b31d5a..887730d 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -17,7 +17,7 @@ from plugin_helper.bootstrap import _run_ipa from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run -from plugin_helper.config import load_profiles, profile_by_id, resolve_runtime_config +from plugin_helper.config import load_local_config, resolve_runtime_config from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin from plugin_helper.instances import get_instance, list_instances @@ -125,61 +125,112 @@ class PluginHelperTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "ambiguous"): get_instance([windows, local], "1.44.1") - def test_profile_config_loads_and_resolves_paths(self) -> None: + def test_local_config_loads_top_level_paths(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config = root / "plugin-helper.local.toml" config.write_text( """ -[[profiles]] -id = "linux" -label = "Linux" instances_root = "~/BSInstances" state_dir = ".state" - -[[profiles]] -id = "windows" -label = "Windows" -instances_root = "mounted/BSInstances" -state_dir = ".state-windows" """.lstrip(), encoding="utf-8", ) - profiles, loaded_path, loaded = load_profiles(config, root=root) + local_config, loaded_path, loaded = load_local_config(config, root=root) self.assertTrue(loaded) self.assertEqual(loaded_path, config) - self.assertEqual(profile_by_id(profiles, "linux").instances_root, Path("~/BSInstances").expanduser()) - self.assertEqual(profile_by_id(profiles, "windows").instances_root, root / "mounted" / "BSInstances") - self.assertEqual(profile_by_id(profiles, "windows").state_dir, root / ".state-windows") + self.assertEqual(local_config.instances_roots, [Path("~/BSInstances").expanduser()]) + self.assertEqual(local_config.state_root, root / ".state") - def test_profile_runtime_explicit_overrides(self) -> None: + def test_runtime_explicit_overrides_env_and_config(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - config = root / "plugin-helper.local.toml" - config.write_text( + (root / "plugin-helper.local.toml").write_text( """ -[[profiles]] -id = "linux" -label = "Linux" -instances_root = "profile-root" -state_dir = ".state" +instances_root = "config-root" +state_dir = "config-state" """.lstrip(), encoding="utf-8", ) - runtime = resolve_runtime_config( - instances_root_value=str(root / "explicit-root"), - state_dir_value="explicit-state", - profile_id="linux", - config_path=config, - root=root, - ) + with patch.dict( + os.environ, + { + "PLUGIN_HELPER_INSTANCES_ROOT": str(root / "env-root"), + "PLUGIN_HELPER_STATE_DIR": str(root / "env-state"), + }, + clear=True, + ): + runtime = resolve_runtime_config( + instances_root_value=str(root / "explicit-root"), + state_dir_value="explicit-state", + root=root, + ) self.assertEqual(runtime.instances_roots, [root / "explicit-root"]) self.assertEqual(runtime.state_root, root / "explicit-state") - self.assertEqual(runtime.selected_profile.id, "linux") + + def test_runtime_env_overrides_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "plugin-helper.local.toml").write_text( + """ +instances_root = "config-root" +state_dir = "config-state" +""".lstrip(), + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "PLUGIN_HELPER_INSTANCES_ROOT": f"{root / 'env-root-a'}{os.pathsep}{root / 'env-root-b'}", + "PLUGIN_HELPER_STATE_DIR": str(root / "env-state"), + }, + clear=True, + ): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.instances_roots, [root / "env-root-a", root / "env-root-b"]) + self.assertEqual(runtime.state_root, root / "env-state") + + def test_runtime_uses_local_config_before_defaults(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "plugin-helper.local.toml").write_text( + """ +instances_root = "config-root" +state_dir = "config-state" +""".lstrip(), + encoding="utf-8", + ) + + with patch.dict(os.environ, {}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.instances_roots, [root / "config-root"]) + self.assertEqual(runtime.state_root, root / "config-state") + + def test_runtime_default_state_uses_xdg_state_home(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + xdg = root / "xdg-state" + + with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.state_root, xdg / "plugin-helper") + + def test_runtime_default_state_uses_home_local_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + with patch.dict(os.environ, {}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper") def test_no_args_prints_help_when_not_interactive(self) -> None: output = StringIO() @@ -196,7 +247,7 @@ state_dir = ".state" patch("sys.stdout.isatty", return_value=True), patch("plugin_helper.cli._run_menu", return_value=0) as run_menu, ): - status = run(["--profile", "linux"]) + status = run([]) self.assertEqual(status, 0) run_menu.assert_called_once() @@ -1063,8 +1114,8 @@ sha256 = "{sha256_file(asset)}" {"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins}, ) choice = InstallationChoice( - profile_id="test", - profile_label="Test Profile", + install_id="test", + install_label="Test Install", instance_name="1.40.8", instance_path=instance, state_root=state, @@ -1076,15 +1127,15 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None: choices = [ InstallationChoice( - profile_id="linux", - profile_label="Linux", + install_id="linux", + install_label="Linux", instance_name="1.44.1", instance_path=Path("/tmp/linux/1.44.1"), state_root=Path("/tmp/state-linux"), ), InstallationChoice( - profile_id="windows", - profile_label="Windows", + install_id="windows", + install_label="Windows", instance_name="1.44.1", instance_path=Path("/tmp/windows/1.44.1"), state_root=Path("/tmp/state-windows"),