modwright 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +212 -0
- package/assets/claude/agents/mw-explore.md +46 -0
- package/assets/claude/agents/mw-fixture.md +33 -0
- package/assets/claude/agents/mw-rule-author.md +51 -0
- package/assets/claude/agents/mw-scaffold-author.md +48 -0
- package/assets/claude/skills/mw-author.md +59 -0
- package/assets/claude/skills/mw-new-item.md +51 -0
- package/assets/claude/skills/mw-new-spell.md +50 -0
- package/assets/claude/skills/mw-scaffold.md +59 -0
- package/assets/claude/skills/mw-ship.md +57 -0
- package/assets/claude/skills/mw-validate.md +51 -0
- package/assets/claude/skills/mw-verify.md +59 -0
- package/assets/nivalisnights/extract_resources.py +177 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Config.json +5 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapClient.lua +55 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/BootstrapServer.lua +81 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Log.lua +96 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Probes.lua +1038 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/ProbesClient.lua +74 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Protocol.lua +985 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/ScriptExtender/Lua/ModWrightBridge/Watch.lua +175 -0
- package/bridges/bg3-se/Mods/ModWrightBridge/meta.lsx +45 -0
- package/bridges/bg3-se/README.md +99 -0
- package/bridges/bg3-se/bridge.json +68 -0
- package/bridges/cp2077-cet/ModWrightBridge/init.lua +375 -0
- package/bridges/cp2077-cet/ModWrightBridge/probes.lua +1104 -0
- package/bridges/cp2077-cet/ModWrightBridge/protocol.lua +1306 -0
- package/bridges/cp2077-cet/README.md +165 -0
- package/bridges/cp2077-cet/bridge.json +53 -0
- package/dist/core/bridge/arm.js +175 -0
- package/dist/core/bridge/cet-console.js +26 -0
- package/dist/core/bridge/cet-log.js +362 -0
- package/dist/core/bridge/index.js +11 -0
- package/dist/core/bridge/locate.js +35 -0
- package/dist/core/bridge/paths.js +34 -0
- package/dist/core/bridge/pending.js +89 -0
- package/dist/core/bridge/poll.js +155 -0
- package/dist/core/bridge/stage.js +100 -0
- package/dist/core/bridge/status.js +97 -0
- package/dist/core/bridge/types.js +145 -0
- package/dist/core/bridge/validate.js +107 -0
- package/dist/core/build/index.js +6 -0
- package/dist/core/build/manifest.js +47 -0
- package/dist/core/build/paths.js +32 -0
- package/dist/core/build/run.js +342 -0
- package/dist/core/build/stage.js +265 -0
- package/dist/core/build/steps/bg3.js +129 -0
- package/dist/core/build/steps/core.js +195 -0
- package/dist/core/build/steps/cyberpunk.js +95 -0
- package/dist/core/build/steps/listing.js +35 -0
- package/dist/core/build/types.js +13 -0
- package/dist/core/build/zip.js +148 -0
- package/dist/core/compat/check.js +113 -0
- package/dist/core/compat/pe.js +220 -0
- package/dist/core/compat/types.js +1 -0
- package/dist/core/compat/version.js +70 -0
- package/dist/core/deploy/guards.js +292 -0
- package/dist/core/deploy/index.js +5 -0
- package/dist/core/deploy/plan.js +696 -0
- package/dist/core/deploy/reload-apply.js +361 -0
- package/dist/core/deploy/reload.js +358 -0
- package/dist/core/deploy/sources.js +169 -0
- package/dist/core/deploy/types.js +1 -0
- package/dist/core/epic.js +54 -0
- package/dist/core/fsutil.js +191 -0
- package/dist/core/home.js +70 -0
- package/dist/core/knowledge/facts.js +229 -0
- package/dist/core/knowledge/index.js +4 -0
- package/dist/core/knowledge/search.js +112 -0
- package/dist/core/knowledge/store.js +123 -0
- package/dist/core/knowledge/types.js +1 -0
- package/dist/core/ledger/check.js +42 -0
- package/dist/core/ledger/hooks.js +80 -0
- package/dist/core/ledger/index.js +7 -0
- package/dist/core/ledger/query.js +17 -0
- package/dist/core/ledger/record.js +84 -0
- package/dist/core/ledger/status.js +53 -0
- package/dist/core/ledger/store.js +98 -0
- package/dist/core/ledger/types.js +108 -0
- package/dist/core/logs/index.js +4 -0
- package/dist/core/logs/registry.js +50 -0
- package/dist/core/logs/scan.js +195 -0
- package/dist/core/logs/triage.js +160 -0
- package/dist/core/logs/types.js +1 -0
- package/dist/core/lsx.js +10 -0
- package/dist/core/lz4.js +66 -0
- package/dist/core/mods.js +229 -0
- package/dist/core/process.js +49 -0
- package/dist/core/project/build-steps.js +83 -0
- package/dist/core/project/describe.js +74 -0
- package/dist/core/project/index.js +5 -0
- package/dist/core/project/load.js +146 -0
- package/dist/core/project/schema.js +264 -0
- package/dist/core/project/types.js +3 -0
- package/dist/core/prompts.js +72 -0
- package/dist/core/registry.js +35 -0
- package/dist/core/safety/backup.js +206 -0
- package/dist/core/safety/execute.js +188 -0
- package/dist/core/safety/index.js +5 -0
- package/dist/core/safety/plan.js +122 -0
- package/dist/core/safety/restore.js +172 -0
- package/dist/core/safety/types.js +1 -0
- package/dist/core/scaffold/bootstrap.js +7 -0
- package/dist/core/scaffold/catalog.js +28 -0
- package/dist/core/scaffold/claude-assets.js +271 -0
- package/dist/core/scaffold/collisions.js +64 -0
- package/dist/core/scaffold/emit.js +356 -0
- package/dist/core/scaffold/engine.js +680 -0
- package/dist/core/scaffold/ids.js +78 -0
- package/dist/core/scaffold/index.js +10 -0
- package/dist/core/scaffold/jsonpath.js +121 -0
- package/dist/core/scaffold/load.js +81 -0
- package/dist/core/scaffold/render.js +85 -0
- package/dist/core/scaffold/sequence.js +396 -0
- package/dist/core/scaffold/types.js +309 -0
- package/dist/core/steam.js +63 -0
- package/dist/core/surfaceutil.js +36 -0
- package/dist/core/testplan/check.js +67 -0
- package/dist/core/testplan/index.js +5 -0
- package/dist/core/testplan/load.js +100 -0
- package/dist/core/testplan/registry.js +317 -0
- package/dist/core/testplan/run.js +389 -0
- package/dist/core/testplan/types.js +82 -0
- package/dist/core/text/converters/bg3.js +50 -0
- package/dist/core/text/converters/cyberpunk.js +36 -0
- package/dist/core/text/converters/index.js +261 -0
- package/dist/core/text/diff/cr2w.js +180 -0
- package/dist/core/text/diff/index.js +16 -0
- package/dist/core/text/diff/loca.js +43 -0
- package/dist/core/text/diff/lsx.js +126 -0
- package/dist/core/text/handles.js +86 -0
- package/dist/core/text/index.js +6 -0
- package/dist/core/text/json.js +46 -0
- package/dist/core/text/templates/cache.js +71 -0
- package/dist/core/text/templates/index.js +3 -0
- package/dist/core/text/templates/library.js +245 -0
- package/dist/core/text/templates/types.js +51 -0
- package/dist/core/text/toolchain.js +16 -0
- package/dist/core/text/types.js +1 -0
- package/dist/core/text/validate.js +149 -0
- package/dist/core/text/xml.js +58 -0
- package/dist/core/toolchain/datafetch.js +151 -0
- package/dist/core/toolchain/env.js +63 -0
- package/dist/core/toolchain/features.js +170 -0
- package/dist/core/toolchain/index.js +8 -0
- package/dist/core/toolchain/install.js +191 -0
- package/dist/core/toolchain/locate.js +275 -0
- package/dist/core/toolchain/remember.js +84 -0
- package/dist/core/toolchain/run.js +318 -0
- package/dist/core/toolchain/specs.js +351 -0
- package/dist/core/toolchain/types.js +1 -0
- package/dist/core/types.js +1 -0
- package/dist/core/userconfig.js +66 -0
- package/dist/core/validate/context.js +139 -0
- package/dist/core/validate/index.js +5 -0
- package/dist/core/validate/registry.js +58 -0
- package/dist/core/validate/run.js +131 -0
- package/dist/core/validate/suppressions.js +91 -0
- package/dist/core/validate/types.js +2 -0
- package/dist/core/yamledit.js +51 -0
- package/dist/index.js +389 -0
- package/dist/server.js +1914 -0
- package/dist/surfaces/baldursgate3/compat.js +164 -0
- package/dist/surfaces/baldursgate3/identity.js +110 -0
- package/dist/surfaces/baldursgate3/index/build.js +331 -0
- package/dist/surfaces/baldursgate3/index/index.js +17 -0
- package/dist/surfaces/baldursgate3/index/info.js +40 -0
- package/dist/surfaces/baldursgate3/index/parsers/loca.js +41 -0
- package/dist/surfaces/baldursgate3/index/parsers/lsx.js +137 -0
- package/dist/surfaces/baldursgate3/index/parsers/stats.js +45 -0
- package/dist/surfaces/baldursgate3/index/parsers/treasuretable.js +57 -0
- package/dist/surfaces/baldursgate3/index/parsers/xmlutil.js +142 -0
- package/dist/surfaces/baldursgate3/index/queries.js +327 -0
- package/dist/surfaces/baldursgate3/index/schema.js +62 -0
- package/dist/surfaces/baldursgate3/index.js +3 -0
- package/dist/surfaces/baldursgate3/logs.js +278 -0
- package/dist/surfaces/baldursgate3/modsettings.js +115 -0
- package/dist/surfaces/baldursgate3/pak.js +153 -0
- package/dist/surfaces/baldursgate3/settings.js +49 -0
- package/dist/surfaces/baldursgate3/surface.js +232 -0
- package/dist/surfaces/baldursgate3/validators/bridge.js +7 -0
- package/dist/surfaces/baldursgate3/validators/files.js +476 -0
- package/dist/surfaces/baldursgate3/validators/items.js +1001 -0
- package/dist/surfaces/baldursgate3/validators/progression.js +734 -0
- package/dist/surfaces/baldursgate3/validators/stats.js +1076 -0
- package/dist/surfaces/baldursgate3/validators/story.js +790 -0
- package/dist/surfaces/baldursgate3/validators/templates.js +7 -0
- package/dist/surfaces/cyberpunk2077/compat.js +223 -0
- package/dist/surfaces/cyberpunk2077/index/build.js +231 -0
- package/dist/surfaces/cyberpunk2077/index/index.js +17 -0
- package/dist/surfaces/cyberpunk2077/index/info.js +51 -0
- package/dist/surfaces/cyberpunk2077/index/model.js +52 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweak.js +483 -0
- package/dist/surfaces/cyberpunk2077/index/parsers/tweakxl-yaml.js +183 -0
- package/dist/surfaces/cyberpunk2077/index/queries.js +504 -0
- package/dist/surfaces/cyberpunk2077/index/resource.js +135 -0
- package/dist/surfaces/cyberpunk2077/index/schema.js +90 -0
- package/dist/surfaces/cyberpunk2077/index.js +3 -0
- package/dist/surfaces/cyberpunk2077/logs.js +742 -0
- package/dist/surfaces/cyberpunk2077/paths.js +12 -0
- package/dist/surfaces/cyberpunk2077/surface.js +218 -0
- package/dist/surfaces/cyberpunk2077/validators/bridge.js +7 -0
- package/dist/surfaces/cyberpunk2077/validators/cr2w.js +349 -0
- package/dist/surfaces/cyberpunk2077/validators/garments.js +1181 -0
- package/dist/surfaces/cyberpunk2077/validators/mesh.js +338 -0
- package/dist/surfaces/cyberpunk2077/validators/modsettings.js +120 -0
- package/dist/surfaces/cyberpunk2077/validators/packaging.js +463 -0
- package/dist/surfaces/cyberpunk2077/validators/red4ext.js +318 -0
- package/dist/surfaces/cyberpunk2077/validators/redscript.js +193 -0
- package/dist/surfaces/cyberpunk2077/validators/templates.js +7 -0
- package/dist/surfaces/cyberpunk2077/validators/tweaks.js +768 -0
- package/dist/surfaces/cyberpunk2077/validators/vehicles.js +531 -0
- package/dist/surfaces/cyberpunk2077/validators/xl.js +308 -0
- package/dist/surfaces/eldenring/binders.js +111 -0
- package/dist/surfaces/eldenring/compat.js +166 -0
- package/dist/surfaces/eldenring/dcx.js +49 -0
- package/dist/surfaces/eldenring/index.js +13 -0
- package/dist/surfaces/eldenring/loaders.js +132 -0
- package/dist/surfaces/eldenring/logs.js +67 -0
- package/dist/surfaces/eldenring/paramdef.js +256 -0
- package/dist/surfaces/eldenring/params.js +129 -0
- package/dist/surfaces/eldenring/surface.js +121 -0
- package/dist/surfaces/eldenring/tools.js +52 -0
- package/dist/surfaces/eldenring/validators/binders.js +193 -0
- package/dist/surfaces/eldenring/validators/packages.js +159 -0
- package/dist/surfaces/eldenring/validators/params.js +334 -0
- package/dist/surfaces/eldenring/validators/profiles.js +287 -0
- package/dist/surfaces/index.js +34 -0
- package/dist/surfaces/nivalisnights/compat.js +229 -0
- package/dist/surfaces/nivalisnights/index/build.js +310 -0
- package/dist/surfaces/nivalisnights/index/index.js +5 -0
- package/dist/surfaces/nivalisnights/index/queries.js +231 -0
- package/dist/surfaces/nivalisnights/index.js +4 -0
- package/dist/surfaces/nivalisnights/logs.js +473 -0
- package/dist/surfaces/nivalisnights/surface.js +195 -0
- package/dist/surfaces/skyrimse/archives.js +229 -0
- package/dist/surfaces/skyrimse/compat.js +163 -0
- package/dist/surfaces/skyrimse/index.js +12 -0
- package/dist/surfaces/skyrimse/loadorder.js +174 -0
- package/dist/surfaces/skyrimse/logs.js +241 -0
- package/dist/surfaces/skyrimse/plugins.js +149 -0
- package/dist/surfaces/skyrimse/records.js +94 -0
- package/dist/surfaces/skyrimse/surface.js +240 -0
- package/dist/surfaces/skyrimse/validators/archive-records.js +165 -0
- package/dist/surfaces/skyrimse/validators/archives.js +110 -0
- package/dist/surfaces/skyrimse/validators/plugins.js +374 -0
- package/dist/surfaces/skyrimse/validators/records.js +117 -0
- package/dist/surfaces/stardewvalley/compat.js +190 -0
- package/dist/surfaces/stardewvalley/config.js +42 -0
- package/dist/surfaces/stardewvalley/contentpatcher.js +203 -0
- package/dist/surfaces/stardewvalley/index/index.js +229 -0
- package/dist/surfaces/stardewvalley/index/wiki.js +190 -0
- package/dist/surfaces/stardewvalley/index.js +12 -0
- package/dist/surfaces/stardewvalley/logs.js +307 -0
- package/dist/surfaces/stardewvalley/mods.js +182 -0
- package/dist/surfaces/stardewvalley/surface.js +244 -0
- package/dist/surfaces/stardewvalley/validators/content.js +321 -0
- package/dist/surfaces/stardewvalley/validators/fields.js +147 -0
- package/dist/surfaces/stardewvalley/validators/loadorder.js +69 -0
- package/dist/surfaces/stardewvalley/validators/manifest.js +221 -0
- package/dist/surfaces/subnautica2/compat.js +138 -0
- package/dist/surfaces/subnautica2/index.js +5 -0
- package/dist/surfaces/subnautica2/logs.js +129 -0
- package/dist/surfaces/subnautica2/paks.js +173 -0
- package/dist/surfaces/subnautica2/surface.js +151 -0
- package/dist/surfaces/subnautica2/validators/packaging.js +298 -0
- package/dist/surfaces/valheim/compat.js +171 -0
- package/dist/surfaces/valheim/index/index.js +301 -0
- package/dist/surfaces/valheim/index/tables.js +103 -0
- package/dist/surfaces/valheim/index.js +11 -0
- package/dist/surfaces/valheim/logs.js +197 -0
- package/dist/surfaces/valheim/packaging.js +131 -0
- package/dist/surfaces/valheim/plugins.js +270 -0
- package/dist/surfaces/valheim/references.js +121 -0
- package/dist/surfaces/valheim/surface.js +223 -0
- package/dist/surfaces/valheim/validators/packaging.js +218 -0
- package/dist/surfaces/valheim/validators/plugins.js +287 -0
- package/dist/surfaces/valheim/validators/references.js +169 -0
- package/knowledge/baldursgate3/animations.gr2.yaml +166 -0
- package/knowledge/baldursgate3/audio.voice.yaml +182 -0
- package/knowledge/baldursgate3/audio.wwise.yaml +104 -0
- package/knowledge/baldursgate3/charactercreation.heads-hair.yaml +193 -0
- package/knowledge/baldursgate3/communitylibrary.shared-content.yaml +129 -0
- package/knowledge/baldursgate3/compatibilityframework.api.yaml +176 -0
- package/knowledge/baldursgate3/crash.causes.yaml +90 -0
- package/knowledge/baldursgate3/dialogue.dialogbank.yaml +120 -0
- package/knowledge/baldursgate3/dialogue.files.yaml +236 -0
- package/knowledge/baldursgate3/dialogue.osiris-goals.yaml +386 -0
- package/knowledge/baldursgate3/dialogue.yaml +122 -0
- package/knowledge/baldursgate3/feats.classes.yaml +164 -0
- package/knowledge/baldursgate3/items.cloth-physics.yaml +167 -0
- package/knowledge/baldursgate3/items.roottemplates.yaml +320 -0
- package/knowledge/baldursgate3/items.visualbank.yaml +628 -0
- package/knowledge/baldursgate3/journal.quests.yaml +214 -0
- package/knowledge/baldursgate3/loadorder.modsettings.yaml +128 -0
- package/knowledge/baldursgate3/localization.handles.yaml +92 -0
- package/knowledge/baldursgate3/misc.encoding-and-practice.yaml +111 -0
- package/knowledge/baldursgate3/origins.companions.yaml +144 -0
- package/knowledge/baldursgate3/osiris.signatures.yaml +211 -0
- package/knowledge/baldursgate3/packaging.meta.yaml +155 -0
- package/knowledge/baldursgate3/packaging.paks.yaml +79 -0
- package/knowledge/baldursgate3/process.launch.yaml +34 -0
- package/knowledge/baldursgate3/progressions.yaml +92 -0
- package/knowledge/baldursgate3/publishing.modio-nexus.yaml +170 -0
- package/knowledge/baldursgate3/publishing.toolkit-modio.yaml +135 -0
- package/knowledge/baldursgate3/races.progression.yaml +228 -0
- package/knowledge/baldursgate3/scriptextender.console.yaml +257 -0
- package/knowledge/baldursgate3/scriptextender.yaml +252 -0
- package/knowledge/baldursgate3/stats.boosts.yaml +293 -0
- package/knowledge/baldursgate3/stats.equipment-kits.yaml +69 -0
- package/knowledge/baldursgate3/stats.interrupts.yaml +45 -0
- package/knowledge/baldursgate3/stats.itemcombos.yaml +65 -0
- package/knowledge/baldursgate3/stats.spells.yaml +293 -0
- package/knowledge/baldursgate3/stats.weapons.yaml +210 -0
- package/knowledge/baldursgate3/toolchain.divine.yaml +179 -0
- package/knowledge/baldursgate3/toolchain.lslib-formats.yaml +242 -0
- package/knowledge/baldursgate3/toolkit.levels.yaml +177 -0
- package/knowledge/baldursgate3/ui.icons-atlas.yaml +78 -0
- package/knowledge/baldursgate3/ui.icons.yaml +412 -0
- package/knowledge/baldursgate3/ui.mcm.yaml +161 -0
- package/knowledge/baldursgate3/vfx.materials.yaml +174 -0
- package/knowledge/baldursgate3/visuals.characterfix.yaml +73 -0
- package/knowledge/cyberpunk2077/animations.anims.yaml +118 -0
- package/knowledge/cyberpunk2077/anims.archivexl.yaml +163 -0
- package/knowledge/cyberpunk2077/anims.authoring.yaml +579 -0
- package/knowledge/cyberpunk2077/anims.encoder.yaml +156 -0
- package/knowledge/cyberpunk2077/anims.graph.yaml +629 -0
- package/knowledge/cyberpunk2077/anims.method.yaml +360 -0
- package/knowledge/cyberpunk2077/anims.pipeline.yaml +352 -0
- package/knowledge/cyberpunk2077/anims.roundtrip.yaml +222 -0
- package/knowledge/cyberpunk2077/anims.sets.yaml +176 -0
- package/knowledge/cyberpunk2077/archivexl.bodytypes.yaml +149 -0
- package/knowledge/cyberpunk2077/archivexl.dynamic-appearances.yaml +218 -0
- package/knowledge/cyberpunk2077/archivexl.manifest.yaml +214 -0
- package/knowledge/cyberpunk2077/audio.events.yaml +168 -0
- package/knowledge/cyberpunk2077/audio.sounds.yaml +164 -0
- package/knowledge/cyberpunk2077/audio.voicesets.yaml +146 -0
- package/knowledge/cyberpunk2077/cet.sandbox.yaml +219 -0
- package/knowledge/cyberpunk2077/clothing.garments.yaml +283 -0
- package/knowledge/cyberpunk2077/clothing.refits.yaml +215 -0
- package/knowledge/cyberpunk2077/codeware.overview.yaml +111 -0
- package/knowledge/cyberpunk2077/codeware.systems.yaml +192 -0
- package/knowledge/cyberpunk2077/drones.combat.yaml +416 -0
- package/knowledge/cyberpunk2077/drones.control.yaml +552 -0
- package/knowledge/cyberpunk2077/entities.appearance.yaml +166 -0
- package/knowledge/cyberpunk2077/equipmentex.slots.yaml +151 -0
- package/knowledge/cyberpunk2077/fx.effects.yaml +246 -0
- package/knowledge/cyberpunk2077/fx.lights.yaml +171 -0
- package/knowledge/cyberpunk2077/hair.modding.yaml +145 -0
- package/knowledge/cyberpunk2077/items.wiring.yaml +246 -0
- package/knowledge/cyberpunk2077/loadorder.archives.yaml +71 -0
- package/knowledge/cyberpunk2077/mesh.blender.yaml +209 -0
- package/knowledge/cyberpunk2077/nativedb.dump.yaml +98 -0
- package/knowledge/cyberpunk2077/npc.appearance.yaml +275 -0
- package/knowledge/cyberpunk2077/npc.behaviour.yaml +128 -0
- package/knowledge/cyberpunk2077/npc.locomotion.yaml +490 -0
- package/knowledge/cyberpunk2077/packaging.deploy.yaml +175 -0
- package/knowledge/cyberpunk2077/packaging.variants.yaml +176 -0
- package/knowledge/cyberpunk2077/player.control.yaml +918 -0
- package/knowledge/cyberpunk2077/process.assetbuild.yaml +154 -0
- package/knowledge/cyberpunk2077/process.lessons.yaml +276 -0
- package/knowledge/cyberpunk2077/quests.minor.yaml +46 -0
- package/knowledge/cyberpunk2077/quests.scenes.yaml +282 -0
- package/knowledge/cyberpunk2077/quickhacks.tiers.yaml +41 -0
- package/knowledge/cyberpunk2077/red4ext.api.yaml +645 -0
- package/knowledge/cyberpunk2077/red4ext.plugins.yaml +124 -0
- package/knowledge/cyberpunk2077/redhottools.hotreload.yaml +245 -0
- package/knowledge/cyberpunk2077/redscript.compile-log.yaml +76 -0
- package/knowledge/cyberpunk2077/redscript.modules.yaml +118 -0
- package/knowledge/cyberpunk2077/toolchain.wolvenkit.yaml +163 -0
- package/knowledge/cyberpunk2077/tweakdb.loot-recipes.yaml +184 -0
- package/knowledge/cyberpunk2077/tweakdb.melee.yaml +352 -0
- package/knowledge/cyberpunk2077/tweakdb.weapons.yaml +325 -0
- package/knowledge/cyberpunk2077/tweakxl.hotreload.yaml +151 -0
- package/knowledge/cyberpunk2077/tweakxl.yaml-syntax.yaml +208 -0
- package/knowledge/cyberpunk2077/ui.icons.yaml +259 -0
- package/knowledge/cyberpunk2077/ui.inkatlas.yaml +81 -0
- package/knowledge/cyberpunk2077/ui.modsettings.yaml +204 -0
- package/knowledge/cyberpunk2077/vehicles.control.yaml +475 -0
- package/knowledge/cyberpunk2077/vehicles.dashboard-ui.yaml +203 -0
- package/knowledge/cyberpunk2077/vehicles.entities.yaml +195 -0
- package/knowledge/cyberpunk2077/vehicles.wiring.yaml +137 -0
- package/knowledge/cyberpunk2077/wolvenkit.cli.yaml +167 -0
- package/knowledge/cyberpunk2077/workspot.system.yaml +55 -0
- package/knowledge/cyberpunk2077/world.interactions.yaml +41 -0
- package/knowledge/cyberpunk2077/world.loot-container.yaml +167 -0
- package/knowledge/cyberpunk2077/world.sectors.yaml +233 -0
- package/knowledge/eldenring/formats.binders.yaml +237 -0
- package/knowledge/eldenring/launcher.me3.yaml +230 -0
- package/knowledge/eldenring/launcher.modengine2.yaml +119 -0
- package/knowledge/eldenring/params.paramdef.yaml +149 -0
- package/knowledge/eldenring/params.regulation.yaml +142 -0
- package/knowledge/eldenring/saves.online.yaml +118 -0
- package/knowledge/eldenring/toolchain.cli.yaml +256 -0
- package/knowledge/nivalisnights/modding.runtime.yaml +157 -0
- package/knowledge/skyrimse/archives.bsa.yaml +212 -0
- package/knowledge/skyrimse/install.detection.yaml +41 -0
- package/knowledge/skyrimse/loadorder.files.yaml +242 -0
- package/knowledge/skyrimse/plugins.esl.yaml +189 -0
- package/knowledge/skyrimse/plugins.format.yaml +224 -0
- package/knowledge/skyrimse/skse.runtime.yaml +283 -0
- package/knowledge/skyrimse/toolchain.xedit-spriggit.yaml +336 -0
- package/knowledge/stardewvalley/contentpatcher.format.yaml +338 -0
- package/knowledge/stardewvalley/distribution.channels.yaml +236 -0
- package/knowledge/stardewvalley/game.versions.yaml +202 -0
- package/knowledge/stardewvalley/install.layout.yaml +277 -0
- package/knowledge/stardewvalley/smapi.loader.yaml +284 -0
- package/knowledge/stardewvalley/smapi.logs.yaml +329 -0
- package/knowledge/stardewvalley/smapi.manifest.yaml +293 -0
- package/knowledge/stardewvalley/toolchain.build.yaml +261 -0
- package/knowledge/stardewvalley/wiki.dataformat.yaml +130 -0
- package/knowledge/subnautica2/game.build.yaml +331 -0
- package/knowledge/subnautica2/modding.state.yaml +210 -0
- package/knowledge/subnautica2/ue5.containers.yaml +72 -0
- package/knowledge/subnautica2/ue5.paks.yaml +203 -0
- package/knowledge/subnautica2/ue5.tooling.yaml +175 -0
- package/knowledge/subnautica2/ue5.ue4ss.yaml +203 -0
- package/knowledge/valheim/bepinex.loader.yaml +247 -0
- package/knowledge/valheim/bepinex.plugins.yaml +251 -0
- package/knowledge/valheim/game.versions.yaml +321 -0
- package/knowledge/valheim/install.layout.yaml +216 -0
- package/knowledge/valheim/jotunn.library.yaml +334 -0
- package/knowledge/valheim/thunderstore.packaging.yaml +217 -0
- package/knowledge/valheim/toolchain.build.yaml +111 -0
- package/package.json +29 -0
- package/scaffolds/baldursgate3/new-class-injection.yaml +148 -0
- package/scaffolds/baldursgate3/new-item.yaml +264 -0
- package/scaffolds/baldursgate3/new-mod.yaml +174 -0
- package/scaffolds/baldursgate3/new-passive.yaml +100 -0
- package/scaffolds/baldursgate3/new-project.yaml +118 -0
- package/scaffolds/baldursgate3/new-quest-stub.yaml +159 -0
- package/scaffolds/baldursgate3/new-spell.yaml +102 -0
- package/scaffolds/baldursgate3/new-status.yaml +119 -0
- package/scaffolds/baldursgate3/sequences/subclass-kit.yaml +50 -0
- package/scaffolds/cyberpunk2077/edit-tweak.yaml +70 -0
- package/scaffolds/cyberpunk2077/mesh-export-preset.yaml +170 -0
- package/scaffolds/cyberpunk2077/new-entity-patch.yaml +255 -0
- package/scaffolds/cyberpunk2077/new-garment-refit.yaml +279 -0
- package/scaffolds/cyberpunk2077/new-item-chain.yaml +548 -0
- package/scaffolds/cyberpunk2077/new-localization.yaml +130 -0
- package/scaffolds/cyberpunk2077/new-mod-settings.yaml +133 -0
- package/scaffolds/cyberpunk2077/new-player-replacer.yaml +1003 -0
- package/scaffolds/cyberpunk2077/new-project.yaml +157 -0
- package/scaffolds/cyberpunk2077/new-sector-node.yaml +156 -0
- package/scaffolds/cyberpunk2077/new-tweak.yaml +121 -0
- package/scaffolds/cyberpunk2077/new-vehicle-livery.yaml +148 -0
- package/scaffolds/cyberpunk2077/new-workspot-entity.yaml +177 -0
- package/scaffolds/cyberpunk2077/sequences/iconic-weapon.yaml +72 -0
- package/scaffolds/cyberpunk2077/sequences/quickhack-takeover.yaml +213 -0
- package/scaffolds/stardewvalley/new-cp-patch.yaml +106 -0
- package/scaffolds/stardewvalley/new-csharp-mod.yaml +195 -0
- package/scaffolds/stardewvalley/new-project.yaml +184 -0
- package/scaffolds/stardewvalley/sequences/content-pack.yaml +31 -0
- package/scripts/mw-tool.mjs +184 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { pathExists, tail } from "./core/fsutil.js";
|
|
6
|
+
import { describeProject, findProjectFile, loadProject } from "./core/project/index.js";
|
|
7
|
+
import { userDataDir } from "./core/home.js";
|
|
8
|
+
import { applyDataFetch, applyManagedInstall, checkToolchain, featureReadiness, locateTool, planDataFetch, planDataMemory, planManagedInstall, planToolMemory, readinessSummary, TOOL_SPECS, toolsToLocate } from "./core/toolchain/index.js";
|
|
9
|
+
import { loadUserConfig } from "./core/userconfig.js";
|
|
10
|
+
import { runCompat } from "./core/compat/check.js";
|
|
11
|
+
import { SignatureRegistry, triageLogs } from "./core/logs/index.js";
|
|
12
|
+
import { buildContext, createValidatorRegistry, runValidators } from "./core/validate/index.js";
|
|
13
|
+
import { runBuild } from "./core/build/index.js";
|
|
14
|
+
import { classifyReload, detectRedHotTools, runDeploy } from "./core/deploy/index.js";
|
|
15
|
+
import { armBridge, bridgeCandidateDirs, bridgeStatus, cetLogCandidates, listPendingArms, PROTOCOL_VERSION as BRIDGE_PROTOCOL_VERSION, pollCyberpunk, pollForResult, probeSchema as bridgeProbeSchema, readPendingArm, stageBridge } from "./core/bridge/index.js";
|
|
16
|
+
import { runTestPlan } from "./core/testplan/index.js";
|
|
17
|
+
import { applyBuildLedgerHook, applyClaimTransition, applyDecisionRecord, applyValidateLedgerHook, checkLedger, claimsFilePath, listClaims, loadClaims, loadDecisions, saveClaims, saveDecisions, showClaim, } from "./core/ledger/index.js";
|
|
18
|
+
import { IndexStore, defaultCacheDir, findLegacyIndex, indexFile, legacyIndexHint, loadKnowledge, resolveIndexFile, searchFacts, } from "./core/knowledge/index.js";
|
|
19
|
+
import { pathExists as fileExists } from "./core/fsutil.js";
|
|
20
|
+
import { executePlan, listBackups, restoreBackup } from "./core/safety/index.js";
|
|
21
|
+
import { readTextIfExists } from "./core/fsutil.js";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import { crossCheckIdentities, findConflicts, listMods, sharedIdentities } from "./core/mods.js";
|
|
25
|
+
import { resolveLogs, resolveRoots } from "./core/surfaceutil.js";
|
|
26
|
+
import { SURFACES, getSurface, installGameVersion, resolveInstall, surfaceIds } from "./surfaces/index.js";
|
|
27
|
+
import { CONVERTERS, convert as runConvert, defaultLocateTool as defaultTextLocateTool, extractTemplate, fieldDiff, getTemplate, invalidateTemplate, listTemplates, } from "./core/text/index.js";
|
|
28
|
+
import { bootstrapProject, describe as describeScaffold, findScaffold, findSequence, generateClaudeAssets, loadAllScaffolds, loadAllSequences, planSequence, runScaffold, summarize, summarizeSequences } from "./core/scaffold/index.js";
|
|
29
|
+
import { loadSkillPromptCatalog, renderSkillPrompt } from "./core/prompts.js";
|
|
30
|
+
const server = new McpServer({ name: "modwright", version: "0.1.0" });
|
|
31
|
+
const registry = new SignatureRegistry();
|
|
32
|
+
for (const surface of SURFACES) {
|
|
33
|
+
if (surface.logSignatures?.length)
|
|
34
|
+
registry.register(surface.logSignatures);
|
|
35
|
+
}
|
|
36
|
+
function json(value) {
|
|
37
|
+
return { content: [{ type: "text", text: JSON.stringify(value, binaryAsSize, 2) }] };
|
|
38
|
+
}
|
|
39
|
+
function binaryAsSize(key, value) {
|
|
40
|
+
const raw = this?.[key];
|
|
41
|
+
if (raw instanceof Uint8Array)
|
|
42
|
+
return { bytes: raw.byteLength };
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function fail(message) {
|
|
46
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
class NoProjectError extends Error {
|
|
49
|
+
}
|
|
50
|
+
async function projectOrFail(start) {
|
|
51
|
+
try {
|
|
52
|
+
return await resolveProject(start);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return fail(error instanceof NoProjectError ? error.message : `Could not load project: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const gameArg = z
|
|
59
|
+
.string()
|
|
60
|
+
.describe(`Game id or alias. Known ids: ${surfaceIds().join(", ")}`);
|
|
61
|
+
const installPathArg = z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Override the game root path. Omit to use auto-detection.");
|
|
65
|
+
server.registerTool("list_games", {
|
|
66
|
+
title: "List supported games",
|
|
67
|
+
description: "List every game ModWright has a surface for, whether an install was detected, " +
|
|
68
|
+
"and the modding model each game uses. Start here.",
|
|
69
|
+
inputSchema: {},
|
|
70
|
+
}, async () => {
|
|
71
|
+
const games = await Promise.all(SURFACES.map(async (surface) => ({
|
|
72
|
+
id: surface.id,
|
|
73
|
+
name: surface.name,
|
|
74
|
+
aliases: surface.aliases ?? [],
|
|
75
|
+
summary: surface.summary,
|
|
76
|
+
installs: await surface.detect(),
|
|
77
|
+
})));
|
|
78
|
+
return json({ games });
|
|
79
|
+
});
|
|
80
|
+
server.registerTool("detect_install", {
|
|
81
|
+
title: "Detect a game install",
|
|
82
|
+
description: "Locate installs of one game and report its mod roots and log sources, marking " +
|
|
83
|
+
"which exist on disk, plus surface-specific guidance.",
|
|
84
|
+
inputSchema: { game: gameArg, installPath: installPathArg },
|
|
85
|
+
}, async ({ game, installPath }) => {
|
|
86
|
+
const surface = getSurface(game);
|
|
87
|
+
if (!surface)
|
|
88
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
89
|
+
const install = await resolveInstall(surface, installPath);
|
|
90
|
+
if (!install) {
|
|
91
|
+
return json({
|
|
92
|
+
game: surface.id,
|
|
93
|
+
detected: false,
|
|
94
|
+
hint: "No install found. Pass installPath explicitly, or set " +
|
|
95
|
+
`MODWRIGHT_${surface.id.toUpperCase()}_ROOT.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return json({
|
|
99
|
+
game: surface.id,
|
|
100
|
+
detected: true,
|
|
101
|
+
install,
|
|
102
|
+
modRoots: await resolveRoots(surface, install),
|
|
103
|
+
logs: await resolveLogs(surface, install),
|
|
104
|
+
guidance: surface.guidance,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
server.registerTool("list_mods", {
|
|
108
|
+
title: "List installed mods",
|
|
109
|
+
description: "Enumerate installed mods across a game's mod roots, with size, modification " +
|
|
110
|
+
"time and parsed manifest data where the game provides one.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
game: gameArg,
|
|
113
|
+
installPath: installPathArg,
|
|
114
|
+
rootIds: z
|
|
115
|
+
.array(z.string())
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Restrict to these mod root ids. Omit for all roots."),
|
|
118
|
+
includeFiles: z
|
|
119
|
+
.boolean()
|
|
120
|
+
.default(false)
|
|
121
|
+
.describe("Include each mod's full file listing. Verbose; off by default."),
|
|
122
|
+
},
|
|
123
|
+
}, async ({ game, installPath, rootIds, includeFiles }) => {
|
|
124
|
+
const surface = getSurface(game);
|
|
125
|
+
if (!surface)
|
|
126
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
127
|
+
const install = await resolveInstall(surface, installPath);
|
|
128
|
+
if (!install)
|
|
129
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
130
|
+
const { mods, roots } = await listMods(surface, install, { rootIds });
|
|
131
|
+
return json({
|
|
132
|
+
game: surface.id,
|
|
133
|
+
install: install.root,
|
|
134
|
+
roots: roots.map(({ id, label, path, exists, notes }) => ({
|
|
135
|
+
id,
|
|
136
|
+
label,
|
|
137
|
+
path,
|
|
138
|
+
exists,
|
|
139
|
+
notes,
|
|
140
|
+
})),
|
|
141
|
+
modCount: mods.length,
|
|
142
|
+
mods: mods.map((mod) => includeFiles ? mod : { ...mod, files: undefined, fileCount: mod.files.length }),
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
server.registerTool("inspect_mod", {
|
|
146
|
+
title: "Inspect one mod",
|
|
147
|
+
description: "Full detail for a single mod: every file it owns, its manifest, and any " +
|
|
148
|
+
"warnings found while reading it.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
game: gameArg,
|
|
151
|
+
installPath: installPathArg,
|
|
152
|
+
modId: z.string().describe("Mod id from list_mods, e.g. archive:MyMod.archive"),
|
|
153
|
+
},
|
|
154
|
+
}, async ({ game, installPath, modId }) => {
|
|
155
|
+
const surface = getSurface(game);
|
|
156
|
+
if (!surface)
|
|
157
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
158
|
+
const install = await resolveInstall(surface, installPath);
|
|
159
|
+
if (!install)
|
|
160
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
161
|
+
const { mods } = await listMods(surface, install);
|
|
162
|
+
const mod = mods.find((candidate) => candidate.id === modId);
|
|
163
|
+
if (!mod)
|
|
164
|
+
return fail(`Mod "${modId}" not found. Run list_mods for valid ids.`);
|
|
165
|
+
return json(mod);
|
|
166
|
+
});
|
|
167
|
+
const IDENTITY_UNREADABLE_LIMIT = 25;
|
|
168
|
+
server.registerTool("find_conflicts", {
|
|
169
|
+
title: "Find file conflicts",
|
|
170
|
+
description: "Report paths claimed by more than one mod within the same mod root. Only " +
|
|
171
|
+
"roots where mods merge into a shared tree are analyzed; roots where each mod " +
|
|
172
|
+
"is isolated, or is a packed archive, are listed in skippedRoots instead. " +
|
|
173
|
+
"Kind 'overwrite' means the game has a defined last-wins rule; 'ambiguous' " +
|
|
174
|
+
"means the outcome is not predictable and needs a human decision. " +
|
|
175
|
+
"sharedIdentities is a separate, surface-level pass (BG3-only for now): the same " +
|
|
176
|
+
"module UUID installed under more than one path (e.g. a .pak and a mod.io " +
|
|
177
|
+
"re-download of it). Which copy the game loads is not predictable from outside; " +
|
|
178
|
+
"load_order's crossCheck reports the Folder the mod manager last bound. It reports a total plus a capped " +
|
|
179
|
+
"identities list (most-duplicated first, each with its own capped copies list); " +
|
|
180
|
+
"both carry truncated: true when capped. total is 0 for surfaces without the pass.",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
game: gameArg,
|
|
183
|
+
installPath: installPathArg,
|
|
184
|
+
rootIds: z.array(z.string()).optional().describe("Restrict to these mod root ids."),
|
|
185
|
+
limit: z.number().int().positive().default(100).describe("Max conflicts to return."),
|
|
186
|
+
},
|
|
187
|
+
}, async ({ game, installPath, rootIds, limit }) => {
|
|
188
|
+
const surface = getSurface(game);
|
|
189
|
+
if (!surface)
|
|
190
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
191
|
+
const install = await resolveInstall(surface, installPath);
|
|
192
|
+
if (!install)
|
|
193
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
194
|
+
const { mods, roots } = await listMods(surface, install, { rootIds });
|
|
195
|
+
const { conflicts, skippedRoots, suppressedNoiseFiles } = findConflicts(mods, roots);
|
|
196
|
+
let identities = { total: 0, identities: [] };
|
|
197
|
+
let identityUnreadable = [];
|
|
198
|
+
let identityUnreadableCount = 0;
|
|
199
|
+
if (surface.scanModIdentities) {
|
|
200
|
+
const scanned = await surface.scanModIdentities(install);
|
|
201
|
+
identities = sharedIdentities(scanned.copies);
|
|
202
|
+
identityUnreadableCount = scanned.unreadable.length;
|
|
203
|
+
identityUnreadable = scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT);
|
|
204
|
+
}
|
|
205
|
+
return json({
|
|
206
|
+
game: surface.id,
|
|
207
|
+
modsAnalyzed: mods.length,
|
|
208
|
+
conflictCount: conflicts.length,
|
|
209
|
+
skippedRoots,
|
|
210
|
+
suppressedNoiseFiles,
|
|
211
|
+
truncated: conflicts.length > limit,
|
|
212
|
+
conflicts: conflicts.slice(0, limit),
|
|
213
|
+
sharedIdentities: identities,
|
|
214
|
+
identityUnreadableCount,
|
|
215
|
+
identityUnreadable,
|
|
216
|
+
identityUnreadableTruncated: identityUnreadableCount > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
|
|
217
|
+
note: "File-level analysis, and only for roots where mods share one deploy tree. " +
|
|
218
|
+
"See skippedRoots for what was excluded and why.",
|
|
219
|
+
guidance: surface.guidance,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
server.registerTool("load_order", {
|
|
223
|
+
title: "Read load order",
|
|
224
|
+
description: "Read the game's explicit load order and cross-check it against what is " +
|
|
225
|
+
"installed, reporting entries with no matching mod and mods absent from the order. " +
|
|
226
|
+
"For BG3, crossCheck resolves each entry by the UUID read from every installed " +
|
|
227
|
+
"pak/loose meta.lsx rather than by filename: each entry carries every installed " +
|
|
228
|
+
"copy sharing its UUID (matches), whether more than one exists (duplicated) or " +
|
|
229
|
+
"none does (missing), and per match whether its module folder is the one " +
|
|
230
|
+
"modsettings.lsx's own Folder attribute names (folderMatchesEntry) — the folder the " +
|
|
231
|
+
"mod manager last bound, a hint rather than proof of what the running game loads " +
|
|
232
|
+
"(Ext.Mod.GetMod(uuid).Info.Directory at the SE console is the live answer). " +
|
|
233
|
+
"inertCopies summarises installed paks/modules matching no " +
|
|
234
|
+
"entry — a total, a capped copies list, and truncated: true when capped, because a " +
|
|
235
|
+
"real profile holds hundreds of installed paks against a handful of ordered entries.",
|
|
236
|
+
inputSchema: { game: gameArg, installPath: installPathArg },
|
|
237
|
+
}, async ({ game, installPath }) => {
|
|
238
|
+
const surface = getSurface(game);
|
|
239
|
+
if (!surface)
|
|
240
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
241
|
+
if (!surface.loadOrder) {
|
|
242
|
+
return json({
|
|
243
|
+
game: surface.id,
|
|
244
|
+
supported: false,
|
|
245
|
+
note: `${surface.name} has no explicit load order file; ` +
|
|
246
|
+
"order is derived from the filesystem.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const install = await resolveInstall(surface, installPath);
|
|
250
|
+
if (!install)
|
|
251
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
252
|
+
const order = await surface.loadOrder(install);
|
|
253
|
+
const { mods } = await listMods(surface, install);
|
|
254
|
+
let crossCheck;
|
|
255
|
+
if (surface.scanModIdentities) {
|
|
256
|
+
const scanned = await surface.scanModIdentities(install);
|
|
257
|
+
crossCheck = {
|
|
258
|
+
...crossCheckIdentities(order, scanned.copies, scanned.unreadable.slice(0, IDENTITY_UNREADABLE_LIMIT)),
|
|
259
|
+
unreadableCount: scanned.unreadable.length,
|
|
260
|
+
unreadableTruncated: scanned.unreadable.length > IDENTITY_UNREADABLE_LIMIT ? true : undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const resolvable = order.some((entry) => entry.modId);
|
|
265
|
+
const installedIds = new Set(mods.map((mod) => mod.id));
|
|
266
|
+
const orderedIds = new Set(order.map((entry) => entry.modId).filter(Boolean));
|
|
267
|
+
crossCheck = resolvable
|
|
268
|
+
? {
|
|
269
|
+
available: true,
|
|
270
|
+
inOrderButNotInstalled: order
|
|
271
|
+
.filter((entry) => entry.modId && !installedIds.has(entry.modId))
|
|
272
|
+
.map((entry) => entry.name),
|
|
273
|
+
installedButNotInOrder: mods
|
|
274
|
+
.filter((mod) => !orderedIds.has(mod.id))
|
|
275
|
+
.map((mod) => mod.id),
|
|
276
|
+
}
|
|
277
|
+
: {
|
|
278
|
+
available: false,
|
|
279
|
+
reason: `${surface.name} identifies mods in its load order by something the ` +
|
|
280
|
+
"filesystem does not expose, so entries cannot be matched to installed " +
|
|
281
|
+
"files by name alone.",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return json({
|
|
285
|
+
game: surface.id,
|
|
286
|
+
supported: true,
|
|
287
|
+
entryCount: order.length,
|
|
288
|
+
installedModCount: mods.length,
|
|
289
|
+
order,
|
|
290
|
+
crossCheck,
|
|
291
|
+
guidance: surface.guidance,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
server.registerTool("read_log", {
|
|
295
|
+
title: "Read a mod log",
|
|
296
|
+
description: "Tail one of the game's mod-related logs. Call detect_install first to see " +
|
|
297
|
+
"which log ids exist for this install.",
|
|
298
|
+
inputSchema: {
|
|
299
|
+
game: gameArg,
|
|
300
|
+
installPath: installPathArg,
|
|
301
|
+
logId: z.string().describe("Log id from detect_install."),
|
|
302
|
+
lines: z.number().int().positive().max(2000).default(200),
|
|
303
|
+
},
|
|
304
|
+
}, async ({ game, installPath, logId, lines }) => {
|
|
305
|
+
const surface = getSurface(game);
|
|
306
|
+
if (!surface)
|
|
307
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
308
|
+
const install = await resolveInstall(surface, installPath);
|
|
309
|
+
if (!install)
|
|
310
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
311
|
+
const sources = await resolveLogs(surface, install);
|
|
312
|
+
const source = sources.find((candidate) => candidate.id === logId);
|
|
313
|
+
if (!source) {
|
|
314
|
+
const available = sources.map((entry) => entry.id).join(", ") || "none";
|
|
315
|
+
return fail(`Unknown log "${logId}". Available: ${available}`);
|
|
316
|
+
}
|
|
317
|
+
if (!source.exists || !(await pathExists(source.path))) {
|
|
318
|
+
return json({ logId, path: source.path, exists: false, content: null });
|
|
319
|
+
}
|
|
320
|
+
return json({
|
|
321
|
+
logId,
|
|
322
|
+
path: source.path,
|
|
323
|
+
exists: true,
|
|
324
|
+
lines,
|
|
325
|
+
content: await tail(source.path, lines),
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
server.registerTool("project_info", {
|
|
329
|
+
title: "Describe a mod project",
|
|
330
|
+
description: "Load a modwright.json (from a file path, a directory containing one, or by walking " +
|
|
331
|
+
"up from a directory) and report its sources, build and deploy configuration, " +
|
|
332
|
+
"dependencies and publish targets with paths resolved and existence checked, plus " +
|
|
333
|
+
"structural warnings such as staging the repo root or a deploy target no root knows.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
path: z
|
|
336
|
+
.string()
|
|
337
|
+
.optional()
|
|
338
|
+
.describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
339
|
+
},
|
|
340
|
+
}, async ({ path: start }) => {
|
|
341
|
+
const from = start ?? process.cwd();
|
|
342
|
+
try {
|
|
343
|
+
const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json")
|
|
344
|
+
? from
|
|
345
|
+
: await findProjectFile(from);
|
|
346
|
+
if (!file) {
|
|
347
|
+
return json({
|
|
348
|
+
found: false,
|
|
349
|
+
searchedFrom: from,
|
|
350
|
+
hint: "No modwright.json found here or in any parent directory.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const resolved = await loadProject(file);
|
|
354
|
+
return json({ found: true, ...describeProject(resolved) });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
return fail(`Could not load project: ${error.message}`);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
server.registerTool("check_toolchain", {
|
|
361
|
+
title: "Locate external tools",
|
|
362
|
+
description: "Find the external tools ModWright drives (LSLib divine, WolvenKit CLI, Cpp2IL, Blender, " +
|
|
363
|
+
"...) in this order: environment override, a project's toolchain override, the per-user " +
|
|
364
|
+
"config, ModWright's managed tools folder, PATH, common install locations. Reports path, " +
|
|
365
|
+
"where it was found, version where the tool can print one ('unknown' otherwise, never " +
|
|
366
|
+
"guessed) and a hint when missing. action 'install' fetches a pinned release of a tool " +
|
|
367
|
+
"ModWright can install (Cpp2IL, the UnityPy Python) into its own tools folder, dry-run first, and " +
|
|
368
|
+
"with data=<id> fetches a data folder ModWright can fetch (Stardew Valley's wiki pages) and remembers it; " +
|
|
369
|
+
"action 'remember' records a tool's path in the " +
|
|
370
|
+
"per-user config so every session finds it (no environment variable, no restart); " +
|
|
371
|
+
"'forget' drops it. Both are dry-run unless mode is 'apply'.",
|
|
372
|
+
inputSchema: {
|
|
373
|
+
action: z.enum(["check", "remember", "forget", "install"]).default("check").describe("check (default) reports; remember/forget change the per-user config; install fetches a pinned tool into ModWright's tools folder (dry-run shows the file, source, size and sha256 first), or with data a fetchable data folder (dry-run lists every URL first)."),
|
|
374
|
+
game: gameArg.optional().describe("Restrict to tools used by this game (check only)."),
|
|
375
|
+
projectPath: z
|
|
376
|
+
.string()
|
|
377
|
+
.optional()
|
|
378
|
+
.describe("modwright.json (or a directory) whose toolchain overrides should apply."),
|
|
379
|
+
tool: z.string().optional().describe("remember/forget/install: the tool id, e.g. 'cpp2il', 'divine'."),
|
|
380
|
+
toolPath: z.string().optional().describe("remember: absolute path to the executable, or the folder that directly holds it."),
|
|
381
|
+
data: z.string().optional().describe("remember/forget/install: a data folder id instead of a tool, e.g. 'valheim.jotunn' (a Jotunn checkout) or 'stardewvalley.wiki' (saved wiki pages); check_toolchain game=<id> lists the ones a game reads."),
|
|
382
|
+
dataPath: z.string().optional().describe("remember with data: absolute path to that folder. install with data: where to save it (default: ModWright's per-user data folder)."),
|
|
383
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("remember/forget: dry-run shows the config change; apply writes it (backing up the old file)."),
|
|
384
|
+
},
|
|
385
|
+
}, async ({ action, game, projectPath, tool, toolPath, data, dataPath, mode }) => {
|
|
386
|
+
if ((action === "remember" || action === "forget") && data) {
|
|
387
|
+
if (tool)
|
|
388
|
+
return fail("Give either tool or data, not both.");
|
|
389
|
+
const known = SURFACES.flatMap((s) => (s.toolNeeds ?? []).flatMap((n) => n.data ?? []));
|
|
390
|
+
const planned = await planDataMemory(action, data, dataPath, known);
|
|
391
|
+
if (!planned.ok)
|
|
392
|
+
return fail(planned.error);
|
|
393
|
+
const report = await executePlan(planned.plan, {
|
|
394
|
+
mode,
|
|
395
|
+
confidence: { level: "high", assumptions: ["The per-user config is ModWright's own file; nothing in a game or a project is touched."] },
|
|
396
|
+
});
|
|
397
|
+
const envVar = known.find((k) => k.id === planned.data)?.envVar;
|
|
398
|
+
return json({
|
|
399
|
+
action,
|
|
400
|
+
data: planned.data,
|
|
401
|
+
mode,
|
|
402
|
+
applied: report.applied,
|
|
403
|
+
file: planned.file,
|
|
404
|
+
...(planned.entry ? { entry: planned.entry } : {}),
|
|
405
|
+
...(planned.previous ? { previous: planned.previous } : {}),
|
|
406
|
+
operations: report.operations,
|
|
407
|
+
...(report.backupDir ? { backupDir: report.backupDir } : {}),
|
|
408
|
+
note: report.applied
|
|
409
|
+
? `Saved. Every session now reads ${planned.data} from the per-user config${envVar ? `, unless ${envVar} is set` : ""}.`
|
|
410
|
+
: "Dry run: nothing written. Repeat with mode 'apply' to save it.",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (dataPath && !data)
|
|
414
|
+
return fail("dataPath goes with data (a data folder id).");
|
|
415
|
+
if (action === "install" && data) {
|
|
416
|
+
if (tool)
|
|
417
|
+
return fail("Give either tool or data, not both.");
|
|
418
|
+
const known = SURFACES.flatMap((s) => (s.toolNeeds ?? []).flatMap((n) => n.data ?? []));
|
|
419
|
+
const planned = planDataFetch(data, known, dataPath);
|
|
420
|
+
if (!planned.ok)
|
|
421
|
+
return fail(planned.error);
|
|
422
|
+
const plan = planned.plan;
|
|
423
|
+
if (mode !== "apply") {
|
|
424
|
+
return json({
|
|
425
|
+
action,
|
|
426
|
+
data: plan.data,
|
|
427
|
+
mode,
|
|
428
|
+
applied: false,
|
|
429
|
+
plan: { ...plan, files: plan.files.length, urls: plan.files.map((f) => f.url) },
|
|
430
|
+
note: `Dry run: nothing was requested or written. Repeat with mode 'apply' to fetch these ${plan.files.length} pages (about ${plan.estimatedSeconds} s, one request every ${plan.delayMs / 1000} s after robots.txt), save them under ${plan.destination}, and remember that folder.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const result = await applyDataFetch(plan, { known });
|
|
434
|
+
if (!result.ok)
|
|
435
|
+
return fail(result.error);
|
|
436
|
+
return json({
|
|
437
|
+
action,
|
|
438
|
+
data: plan.data,
|
|
439
|
+
mode,
|
|
440
|
+
applied: result.saved.length > 0,
|
|
441
|
+
destination: result.destination,
|
|
442
|
+
saved: result.saved.length,
|
|
443
|
+
...(result.failed.length > 0 ? { failed: result.failed } : {}),
|
|
444
|
+
...(result.refused.length > 0 ? { refused: result.refused } : {}),
|
|
445
|
+
remembered: result.remembered,
|
|
446
|
+
note: result.saved.length === 0
|
|
447
|
+
? "Nothing was saved; see failed and refused."
|
|
448
|
+
: `Saved ${result.saved.length} of ${plan.files.length} pages${result.remembered ? " and remembered the folder" : ""}. Next: build_index for the game that reads ${plan.data}.`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (action === "install") {
|
|
452
|
+
if (!tool)
|
|
453
|
+
return fail("install needs tool (a tool id; check_toolchain game=<id> marks which are installable).");
|
|
454
|
+
const planned = await planManagedInstall(tool);
|
|
455
|
+
if (!planned.ok)
|
|
456
|
+
return fail(planned.error);
|
|
457
|
+
if (mode !== "apply") {
|
|
458
|
+
return json({ action, tool: planned.plan.tool, mode, applied: false, plan: planned.plan, note: "Dry run: nothing was downloaded, run or written. Repeat with mode 'apply' to install exactly this." });
|
|
459
|
+
}
|
|
460
|
+
const result = await applyManagedInstall(planned.plan);
|
|
461
|
+
if (!result.ok)
|
|
462
|
+
return fail(result.error);
|
|
463
|
+
const after = await checkToolchain({ probe: "trusted" }).then((all) => all.find((t) => t.id === planned.plan.tool));
|
|
464
|
+
return json({ action, tool: planned.plan.tool, mode, applied: true, executable: result.executable, verified: result.verified, ...(after ? { nowFound: after } : {}) });
|
|
465
|
+
}
|
|
466
|
+
if (action === "remember" || action === "forget") {
|
|
467
|
+
if (!tool)
|
|
468
|
+
return fail(`${action} needs tool (a tool id from check_toolchain's list).`);
|
|
469
|
+
const planned = await planToolMemory(action, tool, toolPath);
|
|
470
|
+
if (!planned.ok)
|
|
471
|
+
return fail(planned.error);
|
|
472
|
+
const report = await executePlan(planned.plan, {
|
|
473
|
+
mode,
|
|
474
|
+
confidence: { level: "high", assumptions: ["The per-user config is ModWright's own file; nothing in a game or a project is touched."] },
|
|
475
|
+
});
|
|
476
|
+
const after = report.applied ? await checkToolchain({ probe: "trusted" }).then((all) => all.find((t) => t.id === planned.tool)) : undefined;
|
|
477
|
+
return json({
|
|
478
|
+
action,
|
|
479
|
+
tool: planned.tool,
|
|
480
|
+
mode,
|
|
481
|
+
applied: report.applied,
|
|
482
|
+
file: planned.file,
|
|
483
|
+
...(planned.entry ? { entry: planned.entry } : {}),
|
|
484
|
+
...(planned.previous ? { previous: planned.previous } : {}),
|
|
485
|
+
operations: report.operations,
|
|
486
|
+
...(report.backupDir ? { backupDir: report.backupDir } : {}),
|
|
487
|
+
...(after ? { nowFound: after } : {}),
|
|
488
|
+
note: report.applied
|
|
489
|
+
? `Saved. Every session now finds ${planned.tool} through the per-user config, unless ${TOOL_SPECS.find((s) => s.id === planned.tool)?.envVar} or a project override says otherwise.`
|
|
490
|
+
: "Dry run: nothing written. Repeat with mode 'apply' to save it.",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (tool || toolPath)
|
|
494
|
+
return fail("tool and toolPath belong to action 'remember' or 'forget'; action 'check' takes game and projectPath.");
|
|
495
|
+
let surfaceId;
|
|
496
|
+
if (game) {
|
|
497
|
+
const surface = getSurface(game);
|
|
498
|
+
if (!surface)
|
|
499
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
500
|
+
surfaceId = surface.id;
|
|
501
|
+
}
|
|
502
|
+
let projectOverrides;
|
|
503
|
+
let project;
|
|
504
|
+
if (projectPath) {
|
|
505
|
+
try {
|
|
506
|
+
project = (await loadProject(projectPath)).project;
|
|
507
|
+
projectOverrides = project.toolchain;
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return fail(`Could not load project: ${error.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const userConfig = await loadUserConfig();
|
|
514
|
+
const tools = await checkToolchain({ surfaceId, projectOverrides, userTools: userConfig.config.tools });
|
|
515
|
+
let features;
|
|
516
|
+
if (surfaceId) {
|
|
517
|
+
const surface = getSurface(surfaceId);
|
|
518
|
+
const located = new Map(tools.map((t) => [t.id, t]));
|
|
519
|
+
for (const id of toolsToLocate(surface, project ? { project } : {})) {
|
|
520
|
+
const spec = TOOL_SPECS.find((s) => s.id === id);
|
|
521
|
+
if (spec && !located.has(id))
|
|
522
|
+
located.set(id, await locateTool(spec, { projectOverrides, probe: "trusted", userTools: userConfig.config.tools }));
|
|
523
|
+
}
|
|
524
|
+
const indexBuilt = surface.index ? await fileExists(resolveIndexFile(surface.id).file) : undefined;
|
|
525
|
+
features = featureReadiness(surface, [...located.values()], { ...(project ? { project } : {}), ...(indexBuilt !== undefined ? { indexBuilt } : {}), userData: userConfig.config.data });
|
|
526
|
+
}
|
|
527
|
+
return json({
|
|
528
|
+
game: surfaceId,
|
|
529
|
+
...(features ? { summary: readinessSummary(features), features } : {}),
|
|
530
|
+
found: tools.filter((t) => t.found).length,
|
|
531
|
+
missing: tools.filter((t) => !t.found).map((t) => t.id),
|
|
532
|
+
userConfig: { file: userConfig.file, exists: userConfig.exists, remembered: Object.keys(userConfig.config.tools), rememberedData: Object.keys(userConfig.config.data), ...(userConfig.problems.length > 0 ? { problems: userConfig.problems } : {}) },
|
|
533
|
+
tools,
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
server.registerTool("check_compat", {
|
|
537
|
+
title: "Check the version matrix",
|
|
538
|
+
description: "Detect the installed game build, loaders, frameworks and tools for a game and " +
|
|
539
|
+
"check the combination against known compatibility floors (each rule cites its " +
|
|
540
|
+
"source). Reports 'block' for a combination known not to work, 'warn' when a " +
|
|
541
|
+
"version could not be read, and lists what is undetermined rather than guessing.",
|
|
542
|
+
inputSchema: { game: gameArg, installPath: installPathArg },
|
|
543
|
+
}, async ({ game, installPath }) => {
|
|
544
|
+
const surface = getSurface(game);
|
|
545
|
+
if (!surface)
|
|
546
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
547
|
+
const install = await resolveInstall(surface, installPath);
|
|
548
|
+
if (!install)
|
|
549
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
550
|
+
return json(await runCompat(surface, install));
|
|
551
|
+
});
|
|
552
|
+
server.registerTool("triage_logs", {
|
|
553
|
+
title: "Triage the game's logs",
|
|
554
|
+
description: "Read every mod-related log for a game, scoped to the current run, classify lines " +
|
|
555
|
+
"against known failure signatures, and return one verdict: which mod systems loaded, " +
|
|
556
|
+
"which failed and why, with the first error's explanation and usual fix. Pass the " +
|
|
557
|
+
"returned offsets and offsetKeys back as offsets/offsetKeys for incremental triage after " +
|
|
558
|
+
"the next launch (the keys let a re-created log of identical size be read again). " +
|
|
559
|
+
"Stale and zero-byte logs are reported as such rather than trusted.",
|
|
560
|
+
inputSchema: {
|
|
561
|
+
game: gameArg,
|
|
562
|
+
installPath: installPathArg,
|
|
563
|
+
since: z
|
|
564
|
+
.string()
|
|
565
|
+
.optional()
|
|
566
|
+
.describe("ISO timestamp; ignore log lines older than this where the log has timestamps."),
|
|
567
|
+
offsets: z
|
|
568
|
+
.record(z.number())
|
|
569
|
+
.optional()
|
|
570
|
+
.describe("Per log id byte offsets from a previous triage_logs result."),
|
|
571
|
+
offsetKeys: z
|
|
572
|
+
.record(z.string())
|
|
573
|
+
.optional()
|
|
574
|
+
.describe("Per log id file identities (offsetKeys) from the same previous result."),
|
|
575
|
+
maxLines: z.number().int().positive().max(20000).optional(),
|
|
576
|
+
includeFindings: z.boolean().default(true),
|
|
577
|
+
},
|
|
578
|
+
}, async ({ game, installPath, since, offsets, offsetKeys, maxLines, includeFindings }) => {
|
|
579
|
+
const surface = getSurface(game);
|
|
580
|
+
if (!surface)
|
|
581
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
582
|
+
const install = await resolveInstall(surface, installPath);
|
|
583
|
+
if (!install)
|
|
584
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
585
|
+
const verdict = await triageLogs(surface, install, registry, { since, offsets, offsetKeys, maxLines });
|
|
586
|
+
return json({
|
|
587
|
+
...verdict,
|
|
588
|
+
signatureCount: registry.forGame(surface.id).length,
|
|
589
|
+
findings: includeFindings ? verdict.findings : undefined,
|
|
590
|
+
sources: verdict.sources.map(({ source, ...rest }) => ({
|
|
591
|
+
id: source.id,
|
|
592
|
+
label: source.label,
|
|
593
|
+
path: source.path,
|
|
594
|
+
exists: source.exists,
|
|
595
|
+
resolvedFrom: source.resolvedFrom,
|
|
596
|
+
...rest,
|
|
597
|
+
})),
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
server.registerTool("load_order_diff", {
|
|
601
|
+
title: "Snapshot or diff the load order file",
|
|
602
|
+
description: "For games that rewrite their load order on launch (BG3's modsettings.lsx), compare a " +
|
|
603
|
+
"saved snapshot with the current file and report entries the game dropped, added or " +
|
|
604
|
+
"reordered. With no beforePath, saves a snapshot of the current file and returns its " +
|
|
605
|
+
"path for use after the next launch: under the project's .modwright/snapshots when " +
|
|
606
|
+
"`path` names a project, else under ModWright's per-user data directory.",
|
|
607
|
+
inputSchema: {
|
|
608
|
+
game: gameArg,
|
|
609
|
+
installPath: installPathArg,
|
|
610
|
+
beforePath: z.string().optional().describe("A snapshot saved earlier by this tool."),
|
|
611
|
+
afterPath: z
|
|
612
|
+
.string()
|
|
613
|
+
.optional()
|
|
614
|
+
.describe("File to compare against. Default: the game's live load order file."),
|
|
615
|
+
path: z.string().optional().describe("A mod project root or modwright.json; when given, the snapshot is kept in that project's .modwright/snapshots."),
|
|
616
|
+
},
|
|
617
|
+
}, async ({ game, installPath, beforePath, afterPath, path: projectPath }) => {
|
|
618
|
+
const surface = getSurface(game);
|
|
619
|
+
if (!surface)
|
|
620
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
621
|
+
if (!surface.loadOrderFile || !surface.diffLoadOrder) {
|
|
622
|
+
return json({ game: surface.id, supported: false, note: `${surface.name} has no explicit load order file to diff.` });
|
|
623
|
+
}
|
|
624
|
+
const install = await resolveInstall(surface, installPath);
|
|
625
|
+
if (!install)
|
|
626
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
627
|
+
const live = surface.loadOrderFile(install);
|
|
628
|
+
if (!beforePath) {
|
|
629
|
+
const content = await readTextIfExists(live);
|
|
630
|
+
if (content === undefined)
|
|
631
|
+
return fail(`Load order file not found at ${live}.`);
|
|
632
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
633
|
+
let snapshotDir = path.join(userDataDir(), "snapshots");
|
|
634
|
+
if (projectPath) {
|
|
635
|
+
const projectFile = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
|
|
636
|
+
if (!projectFile)
|
|
637
|
+
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
638
|
+
snapshotDir = path.join(path.dirname(path.resolve(projectFile)), ".modwright", "snapshots");
|
|
639
|
+
}
|
|
640
|
+
const snapshot = path.join(snapshotDir, `${surface.id}-${stamp}${path.extname(live)}`);
|
|
641
|
+
const report = await executePlan({
|
|
642
|
+
purpose: `snapshot ${surface.name} load order`,
|
|
643
|
+
operations: [{ kind: "mkdir", path: path.dirname(snapshot) }, { kind: "create", path: snapshot, content }],
|
|
644
|
+
}, { mode: "apply", confidence: { level: "high", assumptions: ["Snapshot is written under the project's .modwright or ModWright's per-user data directory, never the game."] } });
|
|
645
|
+
return json({ game: surface.id, snapshot, live, applied: report.applied, next: "Launch the game, then call load_order_diff with beforePath set to the snapshot." });
|
|
646
|
+
}
|
|
647
|
+
const before = await readTextIfExists(beforePath);
|
|
648
|
+
if (before === undefined)
|
|
649
|
+
return fail(`Snapshot not found at ${beforePath}.`);
|
|
650
|
+
const afterFile = afterPath ?? live;
|
|
651
|
+
const after = await readTextIfExists(afterFile);
|
|
652
|
+
if (after === undefined)
|
|
653
|
+
return fail(`Load order file not found at ${afterFile}.`);
|
|
654
|
+
return json({ game: surface.id, before: beforePath, after: afterFile, ...surface.diffLoadOrder(before, after) });
|
|
655
|
+
});
|
|
656
|
+
const validators = createValidatorRegistry();
|
|
657
|
+
for (const surface of SURFACES) {
|
|
658
|
+
if (surface.validators?.length)
|
|
659
|
+
validators.register(surface.id, surface.validators);
|
|
660
|
+
}
|
|
661
|
+
let knowledge;
|
|
662
|
+
function getKnowledge() {
|
|
663
|
+
knowledge ??= loadKnowledge().then(({ facts, problems }) => ({ facts, problems }));
|
|
664
|
+
return knowledge;
|
|
665
|
+
}
|
|
666
|
+
const stores = new Map();
|
|
667
|
+
const STORE_RETIRE_GRACE_MS = 60_000;
|
|
668
|
+
function retireStore(gameId) {
|
|
669
|
+
const old = stores.get(gameId);
|
|
670
|
+
stores.delete(gameId);
|
|
671
|
+
void old
|
|
672
|
+
?.then((store) => {
|
|
673
|
+
if (store)
|
|
674
|
+
setTimeout(() => store.close(), STORE_RETIRE_GRACE_MS).unref();
|
|
675
|
+
})
|
|
676
|
+
.catch(() => { });
|
|
677
|
+
}
|
|
678
|
+
const legacyStores = new Map();
|
|
679
|
+
function getStore(gameId, reopen = false) {
|
|
680
|
+
if (reopen)
|
|
681
|
+
retireStore(gameId);
|
|
682
|
+
let pending = stores.get(gameId);
|
|
683
|
+
if (!pending) {
|
|
684
|
+
const { file, legacy } = resolveIndexFile(gameId);
|
|
685
|
+
if (legacy)
|
|
686
|
+
legacyStores.set(gameId, legacy);
|
|
687
|
+
else
|
|
688
|
+
legacyStores.delete(gameId);
|
|
689
|
+
pending = fileExists(file).then((exists) => (exists ? IndexStore.open(file) : undefined));
|
|
690
|
+
stores.set(gameId, pending);
|
|
691
|
+
}
|
|
692
|
+
return pending;
|
|
693
|
+
}
|
|
694
|
+
const FACT_STATUSES = ["verified", "unverified", "contradicted", "community", "inferred"];
|
|
695
|
+
server.registerTool("lookup", {
|
|
696
|
+
title: "Look up facts and vanilla data",
|
|
697
|
+
description: "Ground truth before authoring. kind 'facts' searches ModWright's knowledge base of " +
|
|
698
|
+
"game and toolchain behaviour, each fact tagged verified / unverified / contradicted / " +
|
|
699
|
+
"community / inferred with its source. Other kinds query the game's locally built " +
|
|
700
|
+
"vanilla index (records, GUIDs, handles, inheritance chains, the precedent census, archive resource paths); " +
|
|
701
|
+
"call kind 'kinds' to list what this game supports, and build_index first if the index " +
|
|
702
|
+
"is missing. Results are capped; refine the query rather than raising the limit. For " +
|
|
703
|
+
"facts' 'version' param: pass the game's marketing version, not a framework's or the " +
|
|
704
|
+
"PE file version — for Cyberpunk 2077 that's check_compat's game.marketingVersion " +
|
|
705
|
+
"('2.31'), not its 'version' (the PE FileVersion, e.g. '3.0.80.51928'); passing the " +
|
|
706
|
+
"wrong axis never reports false staleness, but does surface as staleUnknownAxis instead " +
|
|
707
|
+
"of a real answer.",
|
|
708
|
+
inputSchema: {
|
|
709
|
+
game: gameArg,
|
|
710
|
+
kind: z.string().describe("'facts', 'rule', 'kinds', 'info', or an index query kind from 'kinds'."),
|
|
711
|
+
query: z
|
|
712
|
+
.record(z.union([z.string(), z.number()]))
|
|
713
|
+
.default({})
|
|
714
|
+
.describe("For facts: text, topic, tags (comma separated), status (comma separated), version " +
|
|
715
|
+
"(installed game version, for staleness), limit, full (1 to include full detail). " +
|
|
716
|
+
"For rule (the validator catalogue): id (exact, or a prefix ending in '.'), text, group, modType, cheap (1 for the on-every-edit set), limit. " +
|
|
717
|
+
"For index kinds: the params listed by 'kinds'."),
|
|
718
|
+
installPath: installPathArg,
|
|
719
|
+
},
|
|
720
|
+
}, async ({ game, kind, query, installPath }) => {
|
|
721
|
+
const surface = getSurface(game);
|
|
722
|
+
if (!surface)
|
|
723
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
724
|
+
const str = (key) => (query[key] === undefined ? undefined : String(query[key]));
|
|
725
|
+
const num = (key) => (query[key] === undefined ? undefined : Number(query[key]));
|
|
726
|
+
if (kind === "kinds") {
|
|
727
|
+
return json({
|
|
728
|
+
game: surface.id,
|
|
729
|
+
kinds: [
|
|
730
|
+
{ kind: "facts", description: "Search the knowledge base.", params: ["text", "topic", "tags", "status", "version", "limit", "full"] },
|
|
731
|
+
{ kind: "rule", description: "The validator catalogue: every rule id with its trigger, needs, severity and evidence.", params: ["id", "text", "group", "modType", "cheap", "limit"] },
|
|
732
|
+
{ kind: "info", description: "Describe the vanilla index on disk.", params: [] },
|
|
733
|
+
...(surface.index?.queryKinds() ?? []),
|
|
734
|
+
],
|
|
735
|
+
indexAvailable: Boolean(surface.index),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (kind === "rule") {
|
|
739
|
+
const id = str("id");
|
|
740
|
+
const text = str("text")?.toLowerCase();
|
|
741
|
+
const group = str("group");
|
|
742
|
+
const modType = str("modType");
|
|
743
|
+
const cheap = str("cheap") === "1";
|
|
744
|
+
const limit = num("limit") ?? 50;
|
|
745
|
+
const all = validators.forGame(surface.id);
|
|
746
|
+
const hits = all.filter((v) => (!id || v.id === id || (id.endsWith(".") && v.id.startsWith(id))) &&
|
|
747
|
+
(!text || v.id.toLowerCase().includes(text) || v.summary.toLowerCase().includes(text)) &&
|
|
748
|
+
(!group || v.groups.includes(group)) &&
|
|
749
|
+
(!modType || v.modTypes.includes(modType)) &&
|
|
750
|
+
(!cheap || v.cost === "edit"));
|
|
751
|
+
return json({
|
|
752
|
+
game: surface.id,
|
|
753
|
+
totalRules: all.length,
|
|
754
|
+
count: hits.length,
|
|
755
|
+
truncated: hits.length > limit ? true : undefined,
|
|
756
|
+
rules: hits.slice(0, limit).map((v) => ({
|
|
757
|
+
id: v.id,
|
|
758
|
+
groups: v.groups,
|
|
759
|
+
modTypes: v.modTypes,
|
|
760
|
+
summary: v.summary,
|
|
761
|
+
appliesTo: v.appliesToDescription,
|
|
762
|
+
needs: v.needs,
|
|
763
|
+
cost: v.cost,
|
|
764
|
+
severity: v.severity,
|
|
765
|
+
evidence: v.evidence,
|
|
766
|
+
})),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (kind === "facts") {
|
|
770
|
+
const { facts, problems } = await getKnowledge();
|
|
771
|
+
const statuses = str("status")?.split(",").map((value) => value.trim());
|
|
772
|
+
const bad = statuses?.filter((value) => !FACT_STATUSES.includes(value));
|
|
773
|
+
if (bad?.length)
|
|
774
|
+
return fail(`Unknown status ${bad.join(", ")}. Known: ${FACT_STATUSES.join(", ")}`);
|
|
775
|
+
const version = str("version");
|
|
776
|
+
const hits = searchFacts(facts, {
|
|
777
|
+
game: surface.id,
|
|
778
|
+
text: str("text"),
|
|
779
|
+
topic: str("topic"),
|
|
780
|
+
tags: str("tags")?.split(",").map((value) => value.trim().toLowerCase()),
|
|
781
|
+
status: statuses,
|
|
782
|
+
installedVersion: version,
|
|
783
|
+
limit: num("limit") ?? 20,
|
|
784
|
+
});
|
|
785
|
+
const full = str("full") === "1";
|
|
786
|
+
const staleUnknownAxis = version === undefined ? undefined : hits.filter((hit) => hit.staleUnknownAxis).length;
|
|
787
|
+
return json({
|
|
788
|
+
game: surface.id,
|
|
789
|
+
totalFacts: facts.filter((fact) => fact.game === surface.id).length,
|
|
790
|
+
count: hits.length,
|
|
791
|
+
staleUnknownAxis,
|
|
792
|
+
knowledgeProblems: problems.length ? problems : undefined,
|
|
793
|
+
facts: hits.map((hit) => ({
|
|
794
|
+
id: hit.id,
|
|
795
|
+
status: hit.status,
|
|
796
|
+
stale: hit.stale,
|
|
797
|
+
staleUnknownAxis: hit.staleUnknownAxis,
|
|
798
|
+
claim: hit.claim,
|
|
799
|
+
detail: full || !hit.detail || hit.detail.length <= 400 ? hit.detail : `${hit.detail.slice(0, 400)}…`,
|
|
800
|
+
source: hit.source,
|
|
801
|
+
verified_on: hit.verified_on,
|
|
802
|
+
verified_by: hit.verified_by,
|
|
803
|
+
supersedes: hit.supersedes,
|
|
804
|
+
tags: hit.tags,
|
|
805
|
+
topic: hit.topic,
|
|
806
|
+
score: hit.score,
|
|
807
|
+
})),
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
if (!surface.index) {
|
|
811
|
+
return json({ game: surface.id, kind, available: false, note: `${surface.name} has no vanilla index implementation yet.` });
|
|
812
|
+
}
|
|
813
|
+
if (kind === "info") {
|
|
814
|
+
const install = await resolveInstall(surface, installPath);
|
|
815
|
+
if (!install)
|
|
816
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
817
|
+
const indexInfo = await surface.index.info(install);
|
|
818
|
+
const legacy = indexInfo.exists ? undefined : findLegacyIndex(surface.id);
|
|
819
|
+
return json(legacy ? { ...indexInfo, legacyIndex: legacy.file, cacheNote: legacyIndexHint(surface.id, legacy) } : indexInfo);
|
|
820
|
+
}
|
|
821
|
+
const store = await getStore(surface.id);
|
|
822
|
+
if (!store) {
|
|
823
|
+
return json({
|
|
824
|
+
game: surface.id,
|
|
825
|
+
kind,
|
|
826
|
+
available: false,
|
|
827
|
+
file: indexFile(defaultCacheDir(), surface.id),
|
|
828
|
+
hint: "No index built yet. Run build_index for this game first.",
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
const result = await surface.index.query(store, kind, query);
|
|
833
|
+
const legacy = legacyStores.get(surface.id);
|
|
834
|
+
return json(legacy ? { ...result, legacyIndex: legacy.file, cacheNote: legacyIndexHint(surface.id, legacy) } : result);
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
return fail(`lookup ${kind} failed: ${error.message}`);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
server.registerTool("build_index", {
|
|
841
|
+
title: "Build the vanilla data index",
|
|
842
|
+
description: "Extract and index the game's own data (BG3: stats, lists, progressions, templates, " +
|
|
843
|
+
"localization from the vanilla paks via LSLib; Cyberpunk: TweakDB records from the " +
|
|
844
|
+
"REDmod tweak sources) into a local SQLite file under the ModWright cache. Takes " +
|
|
845
|
+
"minutes on first run and drives external tools; nothing in the game directory is " +
|
|
846
|
+
"modified. Never redistribute the resulting index.",
|
|
847
|
+
inputSchema: {
|
|
848
|
+
game: gameArg,
|
|
849
|
+
installPath: installPathArg,
|
|
850
|
+
force: z.boolean().default(false).describe("Rebuild even if an index exists."),
|
|
851
|
+
only: z.array(z.string()).optional().describe("Restrict to these sources (surface-specific names)."),
|
|
852
|
+
path: z
|
|
853
|
+
.string()
|
|
854
|
+
.optional()
|
|
855
|
+
.describe("A mod project root or modwright.json. When given, the project's toolchain overrides " +
|
|
856
|
+
"(e.g. the divine path) are used to locate tools, as check_toolchain, build and deploy do."),
|
|
857
|
+
},
|
|
858
|
+
}, async ({ game, installPath, force, only, path: projectPath }) => {
|
|
859
|
+
const surface = getSurface(game);
|
|
860
|
+
if (!surface)
|
|
861
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
862
|
+
if (!surface.index)
|
|
863
|
+
return json({ game: surface.id, supported: false, note: `${surface.name} has no index implementation yet.` });
|
|
864
|
+
const install = await resolveInstall(surface, installPath);
|
|
865
|
+
if (!install)
|
|
866
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
867
|
+
let toolOverrides;
|
|
868
|
+
if (projectPath) {
|
|
869
|
+
try {
|
|
870
|
+
const file = (await pathExists(projectPath)) && projectPath.toLowerCase().endsWith(".json") ? projectPath : await findProjectFile(projectPath);
|
|
871
|
+
if (!file)
|
|
872
|
+
return fail(`No modwright.json found at or above ${projectPath}.`);
|
|
873
|
+
toolOverrides = (await loadProject(file)).project.toolchain;
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
return fail(`Could not load project for tool overrides: ${error.message}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const file = indexFile(defaultCacheDir(), surface.id);
|
|
880
|
+
const legacy = force ? undefined : findLegacyIndex(surface.id);
|
|
881
|
+
if (legacy) {
|
|
882
|
+
return json({ game: surface.id, rebuilt: false, file: legacy.file, legacyIndex: legacy.file, note: `${legacyIndexHint(surface.id, legacy)} lookup already uses it. Pass force to build a fresh index in the new location instead.` });
|
|
883
|
+
}
|
|
884
|
+
if (!force && (await fileExists(file))) {
|
|
885
|
+
const existing = await surface.index.info(install);
|
|
886
|
+
if (!existing.stale)
|
|
887
|
+
return json({ ...existing, note: "Index already exists; pass force to rebuild." });
|
|
888
|
+
}
|
|
889
|
+
const progress = [];
|
|
890
|
+
const store = await IndexStore.open(force ? undefined : file);
|
|
891
|
+
try {
|
|
892
|
+
const info = await surface.index.build(install, store, {
|
|
893
|
+
force,
|
|
894
|
+
only,
|
|
895
|
+
toolOverrides,
|
|
896
|
+
progress: (event) => {
|
|
897
|
+
progress.push(`${event.step}${event.done !== undefined ? ` ${event.done}${event.total ? `/${event.total}` : ""}` : ""}${event.note ? `: ${event.note}` : ""}`);
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
await store.save(file);
|
|
901
|
+
retireStore(surface.id);
|
|
902
|
+
const freshInfo = await surface.index.info(install);
|
|
903
|
+
return json({ ...info, ...freshInfo, buildNotes: info.sources ?? [], rebuilt: true, file, progressTail: progress.slice(-20) });
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
return fail(`build_index failed: ${error.message}
|
|
907
|
+
Progress: ${progress.slice(-10).join(" | ")}`);
|
|
908
|
+
}
|
|
909
|
+
finally {
|
|
910
|
+
store.close();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
server.registerTool("validate", {
|
|
914
|
+
title: "Validate a mod project before building",
|
|
915
|
+
description: "Run the static validators for a mod project (from its modwright.json): schema and " +
|
|
916
|
+
"syntax checks, reference integrity against the vanilla index, known crash causes and " +
|
|
917
|
+
"silent failures, packaging hygiene. Each finding names the file, what is wrong, the " +
|
|
918
|
+
"fix, and the knowledge facts behind the rule. Rules whose needs are unmet (no index " +
|
|
919
|
+
"built, tool not located, no install) are reported as skipped with the reason, never " +
|
|
920
|
+
"guessed. Findings can be suppressed with a reason in .modwright/suppressions.json.",
|
|
921
|
+
inputSchema: {
|
|
922
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
923
|
+
groups: z.array(z.string()).optional().describe("Restrict to these rule groups (see the rules document)."),
|
|
924
|
+
cheapOnly: z.boolean().default(false).describe("Only the on-every-edit rules."),
|
|
925
|
+
rule: z
|
|
926
|
+
.string()
|
|
927
|
+
.optional()
|
|
928
|
+
.describe("Run only this rule id (comma-separated for several), e.g. after a fix. lookup kind 'rule' lists the ids; an unknown id is refused, not ignored."),
|
|
929
|
+
changedFiles: z.array(z.string()).optional().describe("Absolute paths; when given, file rules run only on these."),
|
|
930
|
+
installPath: installPathArg,
|
|
931
|
+
includeInfo: z.boolean().default(false).describe("Include info-severity findings in the output."),
|
|
932
|
+
},
|
|
933
|
+
}, async ({ path: start, groups, cheapOnly, rule, changedFiles, installPath, includeInfo }) => {
|
|
934
|
+
const resolved = await projectOrFail(start);
|
|
935
|
+
if ("isError" in resolved)
|
|
936
|
+
return resolved;
|
|
937
|
+
const surface = getSurface(resolved.project.game);
|
|
938
|
+
if (!surface)
|
|
939
|
+
return fail(`Unknown game "${resolved.project.game}".`);
|
|
940
|
+
const ids = rule
|
|
941
|
+
?.split(",")
|
|
942
|
+
.map((id) => id.trim())
|
|
943
|
+
.filter(Boolean);
|
|
944
|
+
if (ids?.length) {
|
|
945
|
+
const unknown = ids.filter((id) => !validators.get(id));
|
|
946
|
+
if (unknown.length)
|
|
947
|
+
return fail(`Unknown rule id(s): ${unknown.join(", ")}. Call lookup with kind "rule" and game "${surface.id}" to list them.`);
|
|
948
|
+
const foreign = ids.filter((id) => validators.get(id).game !== surface.id);
|
|
949
|
+
if (foreign.length)
|
|
950
|
+
return fail(`Rule(s) ${foreign.join(", ")} belong to another game, not ${surface.id}.`);
|
|
951
|
+
}
|
|
952
|
+
const install = await resolveInstall(surface, installPath).catch(() => undefined);
|
|
953
|
+
const store = await getStore(surface.id);
|
|
954
|
+
const ctx = buildContext({ project: resolved, surface, install, store, changedFiles });
|
|
955
|
+
const report = await runValidators(validators, ctx, {
|
|
956
|
+
groups: groups,
|
|
957
|
+
cheapOnly,
|
|
958
|
+
ids: ids?.length ? ids : undefined,
|
|
959
|
+
changedOnly: Boolean(changedFiles?.length),
|
|
960
|
+
});
|
|
961
|
+
const findings = includeInfo ? report.findings : report.findings.filter((finding) => finding.severity !== "info");
|
|
962
|
+
const blockingFiles = report.findings.filter((f) => f.severity === "block" && !f.suppressedBy).map((f) => f.file);
|
|
963
|
+
const ledger = await applyValidateLedgerHook(resolved.root, blockingFiles, changedFiles, "validate");
|
|
964
|
+
return json({
|
|
965
|
+
...report,
|
|
966
|
+
findings: findings.slice(0, 200),
|
|
967
|
+
truncated: findings.length > 200 ? true : undefined,
|
|
968
|
+
infoHidden: includeInfo ? undefined : report.findings.length - findings.length,
|
|
969
|
+
indexAvailable: Boolean(store),
|
|
970
|
+
installFound: Boolean(install),
|
|
971
|
+
ledger,
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
server.registerTool("build", {
|
|
975
|
+
title: "Build a mod project",
|
|
976
|
+
description: "Run a mod project's build.steps (modwright.json): stage sources into a clean tree under " +
|
|
977
|
+
".modwright/cache/build/, run the typed pack/convert/verify steps for the project's surface " +
|
|
978
|
+
"(WolvenKit CLI, Blender, LSLib divine, an in-house zip writer), and write a manifest of every " +
|
|
979
|
+
"step's inputs/outputs with content hashes. Dry-run by default: resolves and describes every " +
|
|
980
|
+
"step without running a tool or writing a byte. Before the first pack step, the cheap " +
|
|
981
|
+
"(on-every-edit) validators run against the staged tree and refuse the build on any block, " +
|
|
982
|
+
"unless noValidate. The stage tree is deleted on success and always kept on any failure, for " +
|
|
983
|
+
"inspection.",
|
|
984
|
+
inputSchema: {
|
|
985
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
986
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run resolves and describes every step without touching disk or running a tool."),
|
|
987
|
+
variant: z.string().optional().describe("Name of a build.variants entry to layer onto the base build (addSources/addSteps/dropExclude). It also selects the steps, stage source entries and deploy targets that carry the same `variant` gate; without it, those gated steps are left out."),
|
|
988
|
+
only: z.array(z.string()).optional().describe("Run only the steps whose id is in this list (a full restriction, not an addition) — e.g. a mesh-export chain's id, to reproduce an --Export-only run."),
|
|
989
|
+
version: z.string().optional().describe("Overrides ${version} substitution. Default: the project file's own version, else \"0.0.0\"."),
|
|
990
|
+
keepStage: z.boolean().default(false).describe("Keep the stage tree even when the build succeeds."),
|
|
991
|
+
noValidate: z.boolean().default(false).describe("Skip the cheap-validate gate before the first pack step; the manifest records validated:false."),
|
|
992
|
+
installPath: installPathArg,
|
|
993
|
+
},
|
|
994
|
+
}, async ({ path: start, mode, variant, only, version, keepStage, noValidate, installPath }) => {
|
|
995
|
+
const resolved = await projectOrFail(start);
|
|
996
|
+
if ("isError" in resolved)
|
|
997
|
+
return resolved;
|
|
998
|
+
const surface = getSurface(resolved.project.game);
|
|
999
|
+
if (!surface)
|
|
1000
|
+
return fail(`Unknown game "${resolved.project.game}".`);
|
|
1001
|
+
const install = await resolveInstall(surface, installPath).catch(() => undefined);
|
|
1002
|
+
const store = await getStore(surface.id);
|
|
1003
|
+
let result;
|
|
1004
|
+
try {
|
|
1005
|
+
result = await runBuild(resolved, surface, {
|
|
1006
|
+
mode,
|
|
1007
|
+
variant,
|
|
1008
|
+
only,
|
|
1009
|
+
version,
|
|
1010
|
+
keepStage,
|
|
1011
|
+
noValidate,
|
|
1012
|
+
validatorRegistry: validators,
|
|
1013
|
+
install,
|
|
1014
|
+
store,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
return fail(`build failed: ${error.message}`);
|
|
1019
|
+
}
|
|
1020
|
+
const summary = result.failed
|
|
1021
|
+
? `FAILED: ${result.failureReason}`
|
|
1022
|
+
: mode === "dry-run"
|
|
1023
|
+
? `dry-run: ${result.plan?.length ?? 0} step(s) would run${result.skippedOptional.length ? `; skipped optional [${result.skippedOptional.join(", ")}]` : ""}${result.skippedVariant?.length ? `; left out by their variant gate [${result.skippedVariant.join(", ")}]` : ""}.`
|
|
1024
|
+
: `built ${result.manifest?.steps.length ?? 0} step(s), validated=${result.manifest?.validated}, manifest at ${result.manifestPath}.`;
|
|
1025
|
+
const ledger = mode === "apply" && !result.failed && result.manifestPath
|
|
1026
|
+
? await applyBuildLedgerHook(resolved.root, result.manifestPath, "build")
|
|
1027
|
+
: { skipped: true, applied: [], refused: [] };
|
|
1028
|
+
return json({ ...result, summary, ledger });
|
|
1029
|
+
});
|
|
1030
|
+
const RELOAD_PATH_LIMIT = 200;
|
|
1031
|
+
function trimReload(reload) {
|
|
1032
|
+
const total = reload.classifications.length;
|
|
1033
|
+
return {
|
|
1034
|
+
...reload,
|
|
1035
|
+
classifications: reload.classifications.slice(0, RELOAD_PATH_LIMIT),
|
|
1036
|
+
groups: reload.groups.map((g) => ({ ...g, paths: g.paths.slice(0, RELOAD_PATH_LIMIT), pathCount: g.paths.length })),
|
|
1037
|
+
classifiedPathCount: total,
|
|
1038
|
+
truncated: total > RELOAD_PATH_LIMIT ? true : undefined,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
server.registerTool("deploy", {
|
|
1042
|
+
title: "Deploy a built mod into the game",
|
|
1043
|
+
description: "Copy a mod project's built artifacts into the game's own mod roots as one write plan: " +
|
|
1044
|
+
"resolve every deploy target through the surface's roots, refuse an artifact this build did not " +
|
|
1045
|
+
"produce, refuse a packed artifact older than its own sources, refuse (or, with unlink, remove) a " +
|
|
1046
|
+
"junction that would coexist with a pak, wait a bounded time for the game to release locked files, " +
|
|
1047
|
+
"prune leftovers a previous build left behind, and copy locked artifacts last. A file matching the " +
|
|
1048
|
+
"project's build.exclude or the target's own exclude never reaches the install. If the game still holds a " +
|
|
1049
|
+
"locked file when the wait expires, the unlocked targets are deployed anyway and the locked ones come back " +
|
|
1050
|
+
"as deferred, naming the process and what to re-run. Dry-run by default: nothing is touched and nothing is " +
|
|
1051
|
+
"waited on. Every overwrite and delete is backed up first (restore one with rollback), and the result " +
|
|
1052
|
+
"carries a reload recommendation for the paths actually written. With reload: apply (Cyberpunk only) it " +
|
|
1053
|
+
"then fires Red Hot Tools' file triggers under a session grant, each with a read-back.",
|
|
1054
|
+
inputSchema: {
|
|
1055
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1056
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run resolves, checks and plans without writing a byte or waiting on a process."),
|
|
1057
|
+
variant: z.string().optional().describe("Build variant this deploy belongs to; targets naming a different variant are skipped (and their prune rules still remove the old copy)."),
|
|
1058
|
+
deployMode: z.enum(["pak", "loose"]).default("pak").describe("pak: deploy the packed artifact, refusing while a junction exists. loose: junction the install at the project's own trees and report the modsettings.lsx entry to add."),
|
|
1059
|
+
unlink: z.boolean().default(false).describe("Allow pak mode to remove existing junctions first. The link is removed, never what it points at."),
|
|
1060
|
+
waitMinutes: z.number().int().min(0).max(30).optional().describe("How long to wait for the game to exit before a locked copy. Default: the project's deploy.waitMinutes, else 0 (fail fast, naming the process)."),
|
|
1061
|
+
outDir: z.string().optional().describe("Override where built artifacts are read from. Default: the project's build.outDir."),
|
|
1062
|
+
redHotTools: z.boolean().default(false).describe("Detect Red Hot Tools presence and classify the RHT-dependent Cyberpunk reload rows accordingly."),
|
|
1063
|
+
reload: z
|
|
1064
|
+
.enum(["classify", "apply"])
|
|
1065
|
+
.default("classify")
|
|
1066
|
+
.describe("classify (default): only recommend what a reload needs. apply (Cyberpunk only): after writing, fire Red Hot Tools' file triggers (.hot-scripts, .hot-tweaks, archive/pc/hot) so a running game picks the change up — gated on a session grant, with a read-back per trigger."),
|
|
1067
|
+
grant: z
|
|
1068
|
+
.array(z.enum(["read", "observe", "session", "persist", "process"]))
|
|
1069
|
+
.optional()
|
|
1070
|
+
.describe("Capability tiers for reload apply; the session tier is required to write the triggers into the running game."),
|
|
1071
|
+
allowMutate: z.boolean().default(false).describe("DEPRECATED alias for grant:[\"session\"], for reload apply."),
|
|
1072
|
+
meshArchives: z.array(z.string()).optional().describe("Deployed archive paths/basenames known to carry a mounted mesh: kept cold (the hot folder is banned for whole-mesh archives)."),
|
|
1073
|
+
installPath: installPathArg,
|
|
1074
|
+
},
|
|
1075
|
+
}, async ({ path: start, mode, variant, deployMode, unlink, waitMinutes, outDir, redHotTools, reload, grant, allowMutate, meshArchives, installPath }) => {
|
|
1076
|
+
const resolved = await projectOrFail(start);
|
|
1077
|
+
if ("isError" in resolved)
|
|
1078
|
+
return resolved;
|
|
1079
|
+
const surface = getSurface(resolved.project.game);
|
|
1080
|
+
if (!surface)
|
|
1081
|
+
return fail(`Unknown game "${resolved.project.game}".`);
|
|
1082
|
+
const install = await resolveInstall(surface, installPath).catch(() => undefined);
|
|
1083
|
+
const wantRht = redHotTools || reload === "apply";
|
|
1084
|
+
const compat = wantRht && install ? await runCompat(surface, install).catch(() => undefined) : undefined;
|
|
1085
|
+
const rht = wantRht ? await detectRedHotTools(install, compat).catch(() => undefined) : undefined;
|
|
1086
|
+
try {
|
|
1087
|
+
const result = await runDeploy({
|
|
1088
|
+
project: resolved,
|
|
1089
|
+
surface,
|
|
1090
|
+
install,
|
|
1091
|
+
mode,
|
|
1092
|
+
variant,
|
|
1093
|
+
deployMode,
|
|
1094
|
+
unlink,
|
|
1095
|
+
waitMinutes,
|
|
1096
|
+
outDir: outDir ? path.resolve(outDir) : undefined,
|
|
1097
|
+
compat,
|
|
1098
|
+
redHotTools: rht,
|
|
1099
|
+
reloadApply: reload === "apply"
|
|
1100
|
+
? { grant, allowMutate, meshArchives, devModule: resolved.project.identity?.devModule }
|
|
1101
|
+
: undefined,
|
|
1102
|
+
});
|
|
1103
|
+
return json({ ...result, summary: { ...result.summary, reload: trimReload(result.summary.reload) } });
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
return fail(`deploy failed: ${error.message}`);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
server.registerTool("rollback", {
|
|
1110
|
+
title: "List or restore a deploy backup",
|
|
1111
|
+
description: "Every deploy backs up what it overwrites or deletes under the project's " +
|
|
1112
|
+
".modwright/backups/<stamp>/ before touching it. With no backupId this lists what is there — stamp, " +
|
|
1113
|
+
"purpose, entry count, how many of those entries are junction pointers rather than content, and total " +
|
|
1114
|
+
"size. With a backupId it restores that backup: dry-run by default, and every file's sha256 is re-checked " +
|
|
1115
|
+
"against the manifest immediately before it is written back. A junction is recreated as a junction, never " +
|
|
1116
|
+
"as a copy of what it pointed at.",
|
|
1117
|
+
inputSchema: {
|
|
1118
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1119
|
+
backupId: z.string().optional().describe("The stamp naming one backup directory under .modwright/backups (from this tool's own listing). Omit to list."),
|
|
1120
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run reports what would be restored without writing a byte."),
|
|
1121
|
+
},
|
|
1122
|
+
}, async ({ path: start, backupId, mode }) => {
|
|
1123
|
+
const resolved = await projectOrFail(start);
|
|
1124
|
+
if ("isError" in resolved)
|
|
1125
|
+
return resolved;
|
|
1126
|
+
const backupRoot = path.join(resolved.root, ".modwright", "backups");
|
|
1127
|
+
if (!backupId) {
|
|
1128
|
+
const backups = await listBackups(backupRoot);
|
|
1129
|
+
return json({
|
|
1130
|
+
backupRoot,
|
|
1131
|
+
backups,
|
|
1132
|
+
next: backups.length === 0
|
|
1133
|
+
? `No backups under ${backupRoot} — nothing has been overwritten or deleted in this project yet.`
|
|
1134
|
+
: `Re-run with { backupId: "${backups[0].id}" } to see what restoring it would do, then { mode: "apply" } to restore.`,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
if (backupId.includes("/") || backupId.includes("\\") || backupId === "." || backupId === "..") {
|
|
1138
|
+
return fail(`backupId must be one directory name under ${backupRoot}, not a path ("${backupId}").`);
|
|
1139
|
+
}
|
|
1140
|
+
const backupDir = path.join(backupRoot, backupId);
|
|
1141
|
+
if (!(await pathExists(backupDir))) {
|
|
1142
|
+
return fail(`No backup "${backupId}" under ${backupRoot}. Call rollback with no backupId to list what is there.`);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const report = await restoreBackup(backupDir, { mode });
|
|
1146
|
+
return json({ ...report, backupId, backupDir });
|
|
1147
|
+
}
|
|
1148
|
+
catch (error) {
|
|
1149
|
+
return fail(`rollback failed: ${error.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
server.registerTool("reload_plan", {
|
|
1153
|
+
title: "Classify what a set of deployed paths needs to take effect",
|
|
1154
|
+
description: "Map deployed paths to a reload recommendation — hot, hot-degraded, save-reload, restart or unknown — " +
|
|
1155
|
+
"using the project's own reload.rules first and then the per-surface table, and reduce them to the single " +
|
|
1156
|
+
"most conservative answer with its caveats. An unmatched path is reported as unknown ('restart to be sure'), " +
|
|
1157
|
+
"never promoted. ModWright never launches the game and never sends a reload command; this is a " +
|
|
1158
|
+
"recommendation for the user to confirm. `deploy` returns the same block for what it just wrote.",
|
|
1159
|
+
inputSchema: {
|
|
1160
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1161
|
+
paths: z.array(z.string()).min(1).describe("Deployed paths to classify — normally the destination paths from a deploy summary."),
|
|
1162
|
+
redHotTools: z.boolean().default(false).describe("Detect Red Hot Tools presence (DLL + this launch's ArchiveWatcher line) and classify the RHT-dependent Cyberpunk rows accordingly."),
|
|
1163
|
+
installPath: installPathArg,
|
|
1164
|
+
},
|
|
1165
|
+
}, async ({ path: start, paths, redHotTools, installPath }) => {
|
|
1166
|
+
const resolved = await projectOrFail(start);
|
|
1167
|
+
if ("isError" in resolved)
|
|
1168
|
+
return resolved;
|
|
1169
|
+
const surface = getSurface(resolved.project.game);
|
|
1170
|
+
if (!surface)
|
|
1171
|
+
return fail(`Unknown game "${resolved.project.game}".`);
|
|
1172
|
+
const install = redHotTools ? await resolveInstall(surface, installPath).catch(() => undefined) : undefined;
|
|
1173
|
+
const compat = install ? await runCompat(surface, install).catch(() => undefined) : undefined;
|
|
1174
|
+
const rht = redHotTools ? await detectRedHotTools(install, compat).catch(() => undefined) : undefined;
|
|
1175
|
+
return json(trimReload(classifyReload(surface.id, resolved.project, paths, { compat, redHotTools: rht })));
|
|
1176
|
+
});
|
|
1177
|
+
const BRIDGE_GAMES = ["baldursgate3", "cyberpunk2077"];
|
|
1178
|
+
function asBridgeGame(id) {
|
|
1179
|
+
return BRIDGE_GAMES.includes(id) ? id : undefined;
|
|
1180
|
+
}
|
|
1181
|
+
server.registerTool("bridge", {
|
|
1182
|
+
title: "In-game verification bridge",
|
|
1183
|
+
description: "The in-game bridge: a small in-game mod that answers probes under a " +
|
|
1184
|
+
"capability policy for BG3 (Script Extender) and Cyberpunk " +
|
|
1185
|
+
"2077 (CET) only. The old 'ModWright never launches the game and never sends a reload command' rule is retired " +
|
|
1186
|
+
"in favour of that policy: a request carries the tiers it was granted (read | observe | session | persist | " +
|
|
1187
|
+
"process), and a mutating probe runs only under a session grant. The retired allowMutate: true is still " +
|
|
1188
|
+
"accepted for one release as an alias for grant:[\"session\"]. This tool arms a request, polls for its result " +
|
|
1189
|
+
"and reports the bridge's heartbeat. The two games use different transports: BG3 " +
|
|
1190
|
+
"writes and reads files in the Script Extender folder, while Cyberpunk — whose CET console sandbox has no 'io' " +
|
|
1191
|
+
"at all — is armed by pasting one console line and answers in the CET log. 'status': " +
|
|
1192
|
+
"heartbeat freshness and protocolVersion match, from the heartbeat file (BG3) or the newest " +
|
|
1193
|
+
"[MODWRIGHT-HEARTBEAT] line in cyber_engine_tweaks.log / gamelog.log (Cyberpunk). 'stage': copy this repo's " +
|
|
1194
|
+
"bridges/<id>/ into <project>/.modwright/bridge/<id>/ so 'deploy --variant withBridge' can target it, and " +
|
|
1195
|
+
"register that withBridge target in the project's own modwright.json unless a target already deploys to the " +
|
|
1196
|
+
"same place under the same variant — reported as deployTarget 'added' | 'present' | 'no-project', with the " +
|
|
1197
|
+
"modwright.json overwrite backed up like any other; dry-run " +
|
|
1198
|
+
"by default. 'arm': for BG3, write a probe request through the two-file commit; for Cyberpunk, return the exact " +
|
|
1199
|
+
"GetMod(\"ModWrightBridge\").arm('...') console line to paste and record the pending nonce under the project " +
|
|
1200
|
+
"(dry-run by default for both; refuses on a protocolVersion mismatch rather than guessing). 'poll': wait for " +
|
|
1201
|
+
"that request's result up to a budget of max(15s, 3s x probe count) + window, capped at 300s, " +
|
|
1202
|
+
"reporting one of four named timeout outcomes rather than a bare 'timed out' — plus, on the CET log transport, " +
|
|
1203
|
+
"'truncated' when the result line was cut mid-write. Test-plan execution is run_tests; claims are the " +
|
|
1204
|
+
"ledger tool.",
|
|
1205
|
+
inputSchema: {
|
|
1206
|
+
action: z.enum(["status", "arm", "poll", "stage"]),
|
|
1207
|
+
game: z
|
|
1208
|
+
.enum(BRIDGE_GAMES)
|
|
1209
|
+
.optional()
|
|
1210
|
+
.describe("Required for status/arm/poll unless path is given (then defaulted from the project). Only baldursgate3 and cyberpunk2077 have a bridge."),
|
|
1211
|
+
installPath: installPathArg,
|
|
1212
|
+
path: z
|
|
1213
|
+
.string()
|
|
1214
|
+
.optional()
|
|
1215
|
+
.describe("modwright.json path or a directory to search from. Required for 'stage', and for 'arm'/'poll' on cyberpunk2077 " +
|
|
1216
|
+
"(whose pending arms are recorded under <project>/.modwright/bridge/pending/). For BG3 it is optional but symmetric: " +
|
|
1217
|
+
"an 'arm' given it records the pending arm so a later 'poll' with the same path fills id/nonce/probeCount " +
|
|
1218
|
+
"from it. For the other actions, only used to default 'game'."),
|
|
1219
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("'stage'/'arm': dry-run describes without writing a byte."),
|
|
1220
|
+
probes: z.array(bridgeProbeSchema).min(1).max(64).optional().describe("Required for 'arm'. Each probe names a kind from ModWright's probe catalogue."),
|
|
1221
|
+
armForSeconds: z.number().int().positive().max(3600).default(60).describe("'arm': how long the request stays armed for, from issuedAt."),
|
|
1222
|
+
grant: z
|
|
1223
|
+
.array(z.enum(["read", "observe", "session", "persist", "process"]))
|
|
1224
|
+
.optional()
|
|
1225
|
+
.describe("'arm': the capability tiers to grant this request. A mutating probe needs \"session\". Overrides the allowMutate alias."),
|
|
1226
|
+
allowMutate: z.boolean().default(false).describe("'arm': DEPRECATED alias for grant:[\"session\"]; allows effect:\"mutate\" probes to run."),
|
|
1227
|
+
windowSeconds: z.number().nonnegative().optional().describe("'arm'/'poll': an observe window in seconds; 0 or absent means a read-only request."),
|
|
1228
|
+
planId: z.string().optional().describe("'arm': ties this request to a test-plan run, once one exists."),
|
|
1229
|
+
planPath: z.string().optional(),
|
|
1230
|
+
runId: z.string().optional(),
|
|
1231
|
+
id: z.string().optional().describe("'poll': the request id from 'arm'. On cyberpunk2077, defaults to the newest pending arm recorded under 'path'."),
|
|
1232
|
+
nonce: z.string().optional().describe("'poll': the nonce from 'arm'. On cyberpunk2077, read from the pending record when 'path' is given."),
|
|
1233
|
+
probeCount: z.number().int().positive().max(64).optional().describe("'poll': probes.length from the 'arm' call, for the timeout budget. On cyberpunk2077, read from the pending record when 'path' is given."),
|
|
1234
|
+
timeoutSeconds: z.number().positive().max(300).optional().describe("'poll': override the computed budget; still capped at 300s."),
|
|
1235
|
+
},
|
|
1236
|
+
}, async ({ action, game, installPath, path: start, mode, probes, armForSeconds, grant, allowMutate, windowSeconds, planId, planPath, runId, id, nonce, probeCount, timeoutSeconds }) => {
|
|
1237
|
+
let projectGame;
|
|
1238
|
+
let projectRoot;
|
|
1239
|
+
if (start) {
|
|
1240
|
+
try {
|
|
1241
|
+
const from = start;
|
|
1242
|
+
const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
|
|
1243
|
+
if (!file)
|
|
1244
|
+
return fail(`No modwright.json found at or above ${from}.`);
|
|
1245
|
+
const loaded = await loadProject(file);
|
|
1246
|
+
projectGame = loaded.project.game;
|
|
1247
|
+
projectRoot = loaded.root;
|
|
1248
|
+
}
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
return fail(`Could not load project: ${error.message}`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (action === "stage") {
|
|
1254
|
+
if (!start)
|
|
1255
|
+
return fail('bridge stage requires "path" (the project to stage the bridge into).');
|
|
1256
|
+
const gameId = game ?? projectGame;
|
|
1257
|
+
if (!gameId)
|
|
1258
|
+
return fail("Could not determine which game's bridge to stage — pass game explicitly.");
|
|
1259
|
+
const bridgeGame = asBridgeGame(gameId);
|
|
1260
|
+
if (!bridgeGame)
|
|
1261
|
+
return fail(`"${gameId}" has no bridge. Known: ${BRIDGE_GAMES.join(", ")}`);
|
|
1262
|
+
const resolved = await loadProject(start);
|
|
1263
|
+
try {
|
|
1264
|
+
const report = await stageBridge(resolved.root, bridgeGame, { mode });
|
|
1265
|
+
return json({ action, game: bridgeGame, project: resolved.project.name, ...report });
|
|
1266
|
+
}
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
return fail(`bridge stage failed: ${error.message}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const gameId = game ?? projectGame;
|
|
1272
|
+
if (!gameId)
|
|
1273
|
+
return fail('bridge status/arm/poll requires "game" (or a "path" to a project that names one).');
|
|
1274
|
+
const bridgeGame = asBridgeGame(gameId);
|
|
1275
|
+
if (!bridgeGame)
|
|
1276
|
+
return fail(`"${gameId}" has no bridge. Known: ${BRIDGE_GAMES.join(", ")}`);
|
|
1277
|
+
const surface = getSurface(bridgeGame);
|
|
1278
|
+
if (!surface)
|
|
1279
|
+
return fail(`Unknown game "${bridgeGame}".`);
|
|
1280
|
+
if (action === "poll") {
|
|
1281
|
+
const install = await resolveInstall(surface, installPath);
|
|
1282
|
+
if (!install)
|
|
1283
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
1284
|
+
if (bridgeGame === "cyberpunk2077") {
|
|
1285
|
+
const pending = projectRoot ? (id ? await readPendingArm(projectRoot, id) : (await listPendingArms(projectRoot, "cyberpunk2077", { activeAt: new Date() }))[0]) : undefined;
|
|
1286
|
+
const pollId = id ?? pending?.id;
|
|
1287
|
+
const pollNonce = nonce ?? pending?.nonce;
|
|
1288
|
+
const pollProbeCount = probeCount ?? pending?.probeCount;
|
|
1289
|
+
if (!pollId || !pollNonce || !pollProbeCount) {
|
|
1290
|
+
return fail('bridge poll for cyberpunk2077 needs either "path" (the project whose .modwright/bridge/pending/ holds the arm) ' +
|
|
1291
|
+
'or all of "id", "nonce" and "probeCount" from the prior "arm" call.');
|
|
1292
|
+
}
|
|
1293
|
+
const files = await cetLogCandidates(install, surface);
|
|
1294
|
+
const dirs = bridgeCandidateDirs(bridgeGame, install, surface);
|
|
1295
|
+
const outcome = await pollCyberpunk({
|
|
1296
|
+
files,
|
|
1297
|
+
dirs,
|
|
1298
|
+
id: pollId,
|
|
1299
|
+
nonce: pollNonce,
|
|
1300
|
+
probeCount: pollProbeCount,
|
|
1301
|
+
armedAt: pending ? new Date(pending.armedAt) : undefined,
|
|
1302
|
+
windowSeconds: windowSeconds ?? pending?.windowSeconds,
|
|
1303
|
+
timeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
|
|
1304
|
+
});
|
|
1305
|
+
return json({
|
|
1306
|
+
action,
|
|
1307
|
+
game: bridgeGame,
|
|
1308
|
+
expectedProtocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
1309
|
+
candidates: [...dirs, ...files],
|
|
1310
|
+
armedAt: pending?.armedAt,
|
|
1311
|
+
consoleLine: pending?.consoleLine,
|
|
1312
|
+
...outcome,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
const pending = projectRoot ? (id ? await readPendingArm(projectRoot, id) : (await listPendingArms(projectRoot, "baldursgate3", { activeAt: new Date() }))[0]) : undefined;
|
|
1316
|
+
const pollId = id ?? pending?.id;
|
|
1317
|
+
const pollNonce = nonce ?? pending?.nonce;
|
|
1318
|
+
const pollProbeCount = probeCount ?? pending?.probeCount;
|
|
1319
|
+
if (!pollId || !pollNonce || !pollProbeCount) {
|
|
1320
|
+
return fail('bridge poll needs either "path" (the project whose .modwright/bridge/pending/ holds the arm) or all of "id", "nonce" and "probeCount" from the prior "arm" call.');
|
|
1321
|
+
}
|
|
1322
|
+
const dirs = bridgeCandidateDirs(bridgeGame, install, surface);
|
|
1323
|
+
const outcome = await pollForResult({
|
|
1324
|
+
game: bridgeGame,
|
|
1325
|
+
dirs,
|
|
1326
|
+
id: pollId,
|
|
1327
|
+
nonce: pollNonce,
|
|
1328
|
+
probeCount: pollProbeCount,
|
|
1329
|
+
windowSeconds: windowSeconds ?? pending?.windowSeconds,
|
|
1330
|
+
timeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
|
|
1331
|
+
});
|
|
1332
|
+
return json({ action, game: bridgeGame, transport: "file", expectedProtocolVersion: BRIDGE_PROTOCOL_VERSION, armedAt: pending?.armedAt, ...outcome });
|
|
1333
|
+
}
|
|
1334
|
+
const install = await resolveInstall(surface, installPath);
|
|
1335
|
+
if (!install)
|
|
1336
|
+
return fail(`No install detected for ${surface.name}.`);
|
|
1337
|
+
if (action === "status") {
|
|
1338
|
+
return json({ action, ...(await bridgeStatus(bridgeGame, install, surface)) });
|
|
1339
|
+
}
|
|
1340
|
+
if (!probes || probes.length === 0)
|
|
1341
|
+
return fail('bridge arm requires "probes" (1-64 entries).');
|
|
1342
|
+
if (bridgeGame === "cyberpunk2077" && !projectRoot) {
|
|
1343
|
+
return fail('bridge arm for cyberpunk2077 requires "path" (the mod project): its request may have to be armed by pasting a ' +
|
|
1344
|
+
"console line, and the pending nonce is recorded under <project>/.modwright/bridge/pending/ so poll can match " +
|
|
1345
|
+
"the CET log lines.");
|
|
1346
|
+
}
|
|
1347
|
+
const result = await armBridge({
|
|
1348
|
+
game: bridgeGame,
|
|
1349
|
+
install,
|
|
1350
|
+
surface,
|
|
1351
|
+
probes,
|
|
1352
|
+
armForSeconds,
|
|
1353
|
+
grant,
|
|
1354
|
+
allowMutate,
|
|
1355
|
+
window: windowSeconds !== undefined ? { seconds: windowSeconds } : undefined,
|
|
1356
|
+
plan: planId && planPath && runId ? { planId, planPath, runId } : undefined,
|
|
1357
|
+
mode,
|
|
1358
|
+
projectRoot,
|
|
1359
|
+
console: true,
|
|
1360
|
+
});
|
|
1361
|
+
return json({ action, expectedProtocolVersion: BRIDGE_PROTOCOL_VERSION, ...result });
|
|
1362
|
+
});
|
|
1363
|
+
server.registerTool("run_tests", {
|
|
1364
|
+
title: "Run a mod's in-game test plan",
|
|
1365
|
+
description: "Execute a test-plan YAML against the in-game bridge: rows carrying a " +
|
|
1366
|
+
"probe are combined into one bridge request, armed and polled through the bridge protocol; rows without one " +
|
|
1367
|
+
"(or explicitly manual) are checklist-only. A row naming an unknown or contradicted probe kind is refused; an " +
|
|
1368
|
+
"unverified probe kind is still run, but a passing result is capped below 'verified' since a pass from an " +
|
|
1369
|
+
"unattested read proves nothing about whether it read the right thing. Runs under the verify policy " +
|
|
1370
|
+
"unless policyMode: workbench (or the retired allowMutate) grants the session tier; a row may declare " +
|
|
1371
|
+
"setup steps that run before its reads, recorded on the claim as verifiedWithSetup. Dry-run by default: shows " +
|
|
1372
|
+
"the combined request and the full checklist without arming the bridge. Writes observed/status/evidence back into the " +
|
|
1373
|
+
"plan's own YAML on apply, and — when a row names a ledger 'claim' id and the project has a ledger — records " +
|
|
1374
|
+
"verified/contradicted against that claim with inline evidence.",
|
|
1375
|
+
inputSchema: {
|
|
1376
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1377
|
+
plan: z.string().describe("A planId (from the test plan's own header) or a path to its YAML file."),
|
|
1378
|
+
rows: z.array(z.string()).optional().describe("Restrict to these row ids. Omit for every row in the plan."),
|
|
1379
|
+
gates: z.array(z.string()).optional().describe("Restrict to rows carrying one of these gate ids."),
|
|
1380
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run shows the combined request and the full checklist without arming the bridge or writing anything."),
|
|
1381
|
+
policyMode: z
|
|
1382
|
+
.enum(["verify", "workbench"])
|
|
1383
|
+
.optional()
|
|
1384
|
+
.describe("Capability policy: verify (default, regardless of the project's own policy) grants read+observe; workbench adds session, letting effect:\"mutate\" probes and row setup steps run. Overrides the project's bridge.policy.mode."),
|
|
1385
|
+
windowSeconds: z.number().nonnegative().optional().describe("An observe window in seconds, on top of any a row's own probe declares."),
|
|
1386
|
+
allowMutate: z.boolean().default(false).describe("DEPRECATED: retired alias for policyMode: workbench / grant:[\"session\"]. Prefer bridge.policy or policyMode."),
|
|
1387
|
+
timeoutSeconds: z.number().positive().max(300).optional().describe("Override the computed poll budget; still capped at 300s."),
|
|
1388
|
+
installPath: installPathArg,
|
|
1389
|
+
},
|
|
1390
|
+
}, async ({ path: start, plan, rows, gates, mode, policyMode, windowSeconds, allowMutate, timeoutSeconds, installPath }) => {
|
|
1391
|
+
const resolved = await projectOrFail(start);
|
|
1392
|
+
if ("isError" in resolved)
|
|
1393
|
+
return resolved;
|
|
1394
|
+
const gameId = asBridgeGame(resolved.project.game);
|
|
1395
|
+
if (!gameId)
|
|
1396
|
+
return fail(`"${resolved.project.game}" has no bridge, so it has no test plan to run. Known: ${BRIDGE_GAMES.join(", ")}`);
|
|
1397
|
+
const surface = getSurface(gameId);
|
|
1398
|
+
if (!surface)
|
|
1399
|
+
return fail(`Unknown game "${gameId}".`);
|
|
1400
|
+
const install = await resolveInstall(surface, installPath).catch(() => undefined);
|
|
1401
|
+
try {
|
|
1402
|
+
const result = await runTestPlan({
|
|
1403
|
+
project: resolved,
|
|
1404
|
+
surface,
|
|
1405
|
+
install,
|
|
1406
|
+
plan,
|
|
1407
|
+
rows,
|
|
1408
|
+
gates,
|
|
1409
|
+
mode,
|
|
1410
|
+
policyMode,
|
|
1411
|
+
window: windowSeconds !== undefined ? { seconds: windowSeconds } : undefined,
|
|
1412
|
+
allowMutate,
|
|
1413
|
+
timeoutSeconds,
|
|
1414
|
+
});
|
|
1415
|
+
return json(result);
|
|
1416
|
+
}
|
|
1417
|
+
catch (error) {
|
|
1418
|
+
return fail(`run_tests failed: ${error.message}`);
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
function asLedgerTool(value) {
|
|
1422
|
+
return value === "validate" || value === "build" || value === "run_tests" ? value : "ledger";
|
|
1423
|
+
}
|
|
1424
|
+
server.registerTool("ledger", {
|
|
1425
|
+
title: "Track claims and blind decisions across a fix",
|
|
1426
|
+
description: "`<mod repo>/ledger/claims.yaml` and `ledger/decisions.yaml`. Statuses " +
|
|
1427
|
+
"authored -> checker-clean -> built -> verified | contradicted, each written by exactly one tool " +
|
|
1428
|
+
"(validate/build/run_tests already wire the first three transitions automatically when a project has a " +
|
|
1429
|
+
"ledger); 'ledger record' authors a new claim/decision or makes a manual correction, always dry-run by " +
|
|
1430
|
+
"default. Nothing reaches verified/contradicted without an evidence block. 'list'/'show' read only; 'check' " +
|
|
1431
|
+
"verifies every verified/contradicted claim has evidence, every evidence path exists on disk, every id is " +
|
|
1432
|
+
"unique, and that a claim whose evidence names an unverified probe kind also carries a manual-row " +
|
|
1433
|
+
"corroboration entry.",
|
|
1434
|
+
inputSchema: {
|
|
1435
|
+
action: z.enum(["list", "show", "record", "check"]),
|
|
1436
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1437
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("'record': dry-run previews the write without touching disk."),
|
|
1438
|
+
status: z.enum(["authored", "checker-clean", "built", "verified", "contradicted"]).optional().describe("'list': restrict to this claim status."),
|
|
1439
|
+
file: z.string().optional().describe("'list': restrict to claims whose scope.files names this path (exact or suffix match)."),
|
|
1440
|
+
id: z.string().optional().describe("'show'/'record': the claim or decision id."),
|
|
1441
|
+
kind: z.enum(["claim", "decision"]).default("claim").describe("'record': which ledger this write targets."),
|
|
1442
|
+
claimText: z.string().optional().describe("'record' kind:\"claim\": the claim text — required when authoring a new claim id."),
|
|
1443
|
+
scopeFiles: z.array(z.string()).optional().describe("'record' kind:\"claim\": files this claim is about, for a new claim."),
|
|
1444
|
+
scopeEntries: z.array(z.string()).optional().describe("'record' kind:\"claim\": named entries (stat/tweak ids) this claim is about, for a new claim."),
|
|
1445
|
+
toStatus: z.enum(["authored", "checker-clean", "built", "verified", "contradicted"]).optional().describe("'record' kind:\"claim\": the status to move this claim to."),
|
|
1446
|
+
tool: z.enum(["validate", "build", "run_tests", "ledger"]).optional().describe("'record' kind:\"claim\": which tool this write claims to be — defaults to \"ledger\" (a manual record/correction). Passing another tool's name does not skip that tool's own gate; it only labels the write."),
|
|
1447
|
+
by: z.string().optional().describe("'record': who or what made this write. Default \"ledger\"."),
|
|
1448
|
+
note: z.string().optional().describe("'record': why, for the history entry."),
|
|
1449
|
+
evidence: z
|
|
1450
|
+
.array(z.object({
|
|
1451
|
+
runId: z.string(),
|
|
1452
|
+
planId: z.string(),
|
|
1453
|
+
rowId: z.string(),
|
|
1454
|
+
probeId: z.string(),
|
|
1455
|
+
probeKind: z.string(),
|
|
1456
|
+
resultPath: z.string(),
|
|
1457
|
+
resultSha256: z.string(),
|
|
1458
|
+
observedAt: z.string(),
|
|
1459
|
+
observed: z.unknown(),
|
|
1460
|
+
manual: z.boolean().optional(),
|
|
1461
|
+
}))
|
|
1462
|
+
.optional()
|
|
1463
|
+
.describe("'record' kind:\"claim\": evidence entries to append — required to reach verified/contradicted unless the claim already carries some."),
|
|
1464
|
+
supersedes: z.string().optional().describe("'record' kind:\"claim\": id of a contradicted claim this one replaces."),
|
|
1465
|
+
question: z.string().optional().describe("'record' kind:\"decision\": required when authoring a new decision id."),
|
|
1466
|
+
appliedDefault: z.string().optional().describe("'record' kind:\"decision\": required when authoring a new decision id."),
|
|
1467
|
+
where: z.string().optional().describe("'record' kind:\"decision\": required when authoring a new decision id."),
|
|
1468
|
+
madeBlind: z.boolean().optional().describe("'record' kind:\"decision\": required when authoring a new decision id."),
|
|
1469
|
+
resolutionStatus: z.enum(["open", "resolved"]).optional().describe("'record' kind:\"decision\": sets resolution.status."),
|
|
1470
|
+
outcome: z.string().optional().describe("'record' kind:\"decision\": sets resolution.outcome."),
|
|
1471
|
+
source: z.string().optional().describe("'record' kind:\"decision\": sets resolution.source."),
|
|
1472
|
+
},
|
|
1473
|
+
}, async ({ action, path: start, mode, status, file, id, kind, claimText, scopeFiles, scopeEntries, toStatus, tool, by, note, evidence, supersedes, question, appliedDefault, where, madeBlind, resolutionStatus, outcome, source, }) => {
|
|
1474
|
+
const resolved = await projectOrFail(start);
|
|
1475
|
+
if ("isError" in resolved)
|
|
1476
|
+
return resolved;
|
|
1477
|
+
if (action === "list") {
|
|
1478
|
+
const loaded = await loadClaims(resolved.root);
|
|
1479
|
+
return json({
|
|
1480
|
+
action,
|
|
1481
|
+
path: claimsFilePath(resolved.root),
|
|
1482
|
+
exists: loaded.exists,
|
|
1483
|
+
problems: loaded.problems,
|
|
1484
|
+
count: loaded.claims.length,
|
|
1485
|
+
claims: listClaims(loaded.claims, { status, claim: undefined, file }),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
if (action === "show") {
|
|
1489
|
+
if (!id)
|
|
1490
|
+
return fail('ledger show requires "id".');
|
|
1491
|
+
const loaded = await loadClaims(resolved.root);
|
|
1492
|
+
const claim = showClaim(loaded.claims, id);
|
|
1493
|
+
if (!claim)
|
|
1494
|
+
return fail(`No claim "${id}" in ${claimsFilePath(resolved.root)}.`);
|
|
1495
|
+
return json({ action, claim });
|
|
1496
|
+
}
|
|
1497
|
+
if (action === "check") {
|
|
1498
|
+
const loaded = await loadClaims(resolved.root);
|
|
1499
|
+
if (!loaded.exists)
|
|
1500
|
+
return json({ action, exists: false, ok: true, issues: [], note: "No ledger/claims.yaml — nothing to check." });
|
|
1501
|
+
const report = await checkLedger(resolved.root, loaded.claims);
|
|
1502
|
+
return json({ action, path: claimsFilePath(resolved.root), ...report });
|
|
1503
|
+
}
|
|
1504
|
+
if (!id)
|
|
1505
|
+
return fail('ledger record requires "id".');
|
|
1506
|
+
const writeTool = asLedgerTool(tool);
|
|
1507
|
+
if (kind === "decision") {
|
|
1508
|
+
const loaded = await loadDecisions(resolved.root);
|
|
1509
|
+
const resolution = resolutionStatus || outcome || source ? { status: resolutionStatus ?? "open", outcome, source, at: new Date().toISOString() } : undefined;
|
|
1510
|
+
const result = applyDecisionRecord({ decisions: loaded.decisions, id, question, appliedDefault, where, madeBlind, resolution });
|
|
1511
|
+
if (!result.ok)
|
|
1512
|
+
return fail(`ledger record (decision) refused: ${result.refusal}`);
|
|
1513
|
+
const write = await saveDecisions(resolved.root, result.decisions, { mode });
|
|
1514
|
+
return json({ action, kind, decision: result.decision, write });
|
|
1515
|
+
}
|
|
1516
|
+
if (!toStatus)
|
|
1517
|
+
return fail('ledger record kind:"claim" requires "toStatus".');
|
|
1518
|
+
const loaded = await loadClaims(resolved.root);
|
|
1519
|
+
const scope = scopeFiles || scopeEntries ? { files: scopeFiles, entries: scopeEntries } : undefined;
|
|
1520
|
+
const result = applyClaimTransition({
|
|
1521
|
+
claims: loaded.claims,
|
|
1522
|
+
id,
|
|
1523
|
+
to: toStatus,
|
|
1524
|
+
tool: writeTool,
|
|
1525
|
+
by: by ?? "ledger",
|
|
1526
|
+
note,
|
|
1527
|
+
evidence: evidence,
|
|
1528
|
+
scope,
|
|
1529
|
+
claimText,
|
|
1530
|
+
supersedes,
|
|
1531
|
+
});
|
|
1532
|
+
if (!result.ok)
|
|
1533
|
+
return fail(`ledger record refused: ${result.refusal}`);
|
|
1534
|
+
const write = await saveClaims(resolved.root, result.claims, { mode });
|
|
1535
|
+
return json({ action, kind, claim: result.claim, write });
|
|
1536
|
+
});
|
|
1537
|
+
const CONVERSION_IDS = CONVERTERS.map((c) => c.id);
|
|
1538
|
+
const DIFF_KINDS = ["cr2w-json", "lsx", "loca-xml"];
|
|
1539
|
+
const TEMPLATE_SURFACES = ["baldursgate3", "cyberpunk2077"];
|
|
1540
|
+
const TEMPLATE_PERMISSIONS = ["never-redistribute", "maintainer-own", "declared-dependency"];
|
|
1541
|
+
async function resolveProject(start) {
|
|
1542
|
+
const from = start ?? process.cwd();
|
|
1543
|
+
const file = (await pathExists(from)) && from.toLowerCase().endsWith(".json") ? from : await findProjectFile(from);
|
|
1544
|
+
if (!file)
|
|
1545
|
+
throw new NoProjectError(`No modwright.json found at or above ${from}.`);
|
|
1546
|
+
return loadProject(file);
|
|
1547
|
+
}
|
|
1548
|
+
server.registerTool("convert", {
|
|
1549
|
+
title: "Convert between an authoring text format and a game binary",
|
|
1550
|
+
description: "One registered conversion from the `text/` converter registry: " +
|
|
1551
|
+
"BG3's lsx<->lsf and xml<->loca through divine, Cyberpunk's cr2w<->json through WolvenKit CLI. Dry-run by " +
|
|
1552
|
+
"default — resolves the tool and reports the argv without running it or writing a byte; apply runs the " +
|
|
1553
|
+
"tool into a scratch file and writes the result through core/safety (an existing destination is backed up " +
|
|
1554
|
+
"first). `cp2077.json-to-cr2w` renumbers every HandleId/BufferId into document-encounter order before " +
|
|
1555
|
+
"deserializing, since WolvenKit's own deserialize fails otherwise, and reports what it renumbered. Unless " +
|
|
1556
|
+
"verifyRoundTrip is set to false, every conversion also runs the round trip registered for " +
|
|
1557
|
+
"it (a byte compare for a binary source, a field diff against the ignore list for a text source) and reports " +
|
|
1558
|
+
"whether it came back clean.",
|
|
1559
|
+
inputSchema: {
|
|
1560
|
+
conversionId: z.enum(CONVERSION_IDS).describe(`The conversion to run. Known ids: ${CONVERSION_IDS.join(", ")}.`),
|
|
1561
|
+
source: z.string().describe("Absolute path to the source file."),
|
|
1562
|
+
dest: z.string().describe("Absolute path to write the converted result to."),
|
|
1563
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("dry-run resolves the tool and reports the argv without running it or writing a byte."),
|
|
1564
|
+
verifyRoundTrip: z.boolean().default(true).describe("Run the conversion's round-trip verification after an apply. Set false to skip it (e.g. when the inverse tool is not needed)."),
|
|
1565
|
+
path: z.string().optional().describe("A modwright.json path or directory, to resolve toolchain overrides (its own \"toolchain\" map) from. Omit to use PATH/env-var/candidate detection only."),
|
|
1566
|
+
},
|
|
1567
|
+
}, async ({ conversionId, source, dest, mode, verifyRoundTrip, path: projectPath }) => {
|
|
1568
|
+
let overrides;
|
|
1569
|
+
if (projectPath) {
|
|
1570
|
+
try {
|
|
1571
|
+
overrides = (await resolveProject(projectPath)).project.toolchain;
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
const report = await runConvert(conversionId, source, dest, { mode, verifyRoundTrip, locateTool: defaultTextLocateTool(overrides, mode === "apply" ? "all" : "none") });
|
|
1578
|
+
return json(report);
|
|
1579
|
+
}
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
return fail(`convert failed: ${error.message}`);
|
|
1582
|
+
}
|
|
1583
|
+
});
|
|
1584
|
+
server.registerTool("diff", {
|
|
1585
|
+
title: "Field-diff two authoring documents",
|
|
1586
|
+
description: "The field diff behind convert's round-trip verification and scaffold's dry-run preview: " +
|
|
1587
|
+
"compares two parsed documents of the given kind, not two byte " +
|
|
1588
|
+
"strings, with type-aware equality (case/slash-insensitive resource paths, epsilon-compared floats, exact " +
|
|
1589
|
+
"enums/ids) and an explicit ignore list (per-machine export metadata, render buffers, HandleId/BufferId " +
|
|
1590
|
+
"renumbering, tool-fixed version fields, LSX serialization artefacts). Rows are `{ path, kind: " +
|
|
1591
|
+
"\"added\"|\"removed\"|\"changed\", left?, right?, note? }`; `path` is a JSON-path for cr2w-json, a node path " +
|
|
1592
|
+
"with the attribute id for lsx, or a contentuid for loca-xml.",
|
|
1593
|
+
inputSchema: {
|
|
1594
|
+
kind: z.enum(DIFF_KINDS).describe("Which document format both sides are."),
|
|
1595
|
+
left: z.string().describe("Absolute path to the left/original file."),
|
|
1596
|
+
right: z.string().optional().describe("Absolute path to the right/comparison file. Omit when using \"template\" instead."),
|
|
1597
|
+
template: z
|
|
1598
|
+
.object({
|
|
1599
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1600
|
+
surface: z.enum(TEMPLATE_SURFACES),
|
|
1601
|
+
gameVersion: z.string(),
|
|
1602
|
+
key: z.string(),
|
|
1603
|
+
})
|
|
1604
|
+
.optional()
|
|
1605
|
+
.describe("Compare \"left\" against this cached template's content instead of a second file."),
|
|
1606
|
+
},
|
|
1607
|
+
}, async ({ kind, left, right, template }) => {
|
|
1608
|
+
let rightPath = right;
|
|
1609
|
+
if (template) {
|
|
1610
|
+
let resolved;
|
|
1611
|
+
try {
|
|
1612
|
+
resolved = await resolveProject(template.path);
|
|
1613
|
+
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
return fail(`Could not load project: ${error.message}`);
|
|
1616
|
+
}
|
|
1617
|
+
const got = await getTemplate(resolved.root, template.surface, template.gameVersion, template.key);
|
|
1618
|
+
if (!got)
|
|
1619
|
+
return fail(`No cached template at ${template.surface}/${template.gameVersion}/${template.key}.`);
|
|
1620
|
+
rightPath = got.contentPath;
|
|
1621
|
+
}
|
|
1622
|
+
if (!rightPath)
|
|
1623
|
+
return fail('diff requires either "right" or "template".');
|
|
1624
|
+
try {
|
|
1625
|
+
const [leftText, rightText] = await Promise.all([fs.readFile(left, "utf8"), fs.readFile(rightPath, "utf8")]);
|
|
1626
|
+
const result = fieldDiff(kind, leftText, rightText);
|
|
1627
|
+
return json({ action: "diff", kind, left, right: rightPath, ...result });
|
|
1628
|
+
}
|
|
1629
|
+
catch (error) {
|
|
1630
|
+
return fail(`diff failed: ${error.message}`);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
server.registerTool("template", {
|
|
1634
|
+
title: "The vanilla-extract template library",
|
|
1635
|
+
description: "Extract-on-demand, cache-per-game-version templates for a scaffold to build from. A template is never committed; every cached file carries a provenance " +
|
|
1636
|
+
"(vanilla-extract/own-work/authored) and a permission, and the cache is keyed by surface, game version and a " +
|
|
1637
|
+
"hash of the extraction inputs — a patch orphans the old subtree rather than silently reusing it. 'extract' " +
|
|
1638
|
+
"runs the surface's toolchain adapter (divine extract-package for BG3, WolvenKit CLI extract for Cyberpunk, " +
|
|
1639
|
+
"then an optional registered conversion) into the cache through a WritePlan, dry-run by default; a still-" +
|
|
1640
|
+
"valid cache entry (checked against the install's game version, the tool's version, the source container's " +
|
|
1641
|
+
"size/mtime, and the cached content's own sha256 — any miss re-extracts) is served without re-running the " +
|
|
1642
|
+
"toolchain, unless refresh is set. 'list'/'get' read the cache; 'get' also reports whether the entry it " +
|
|
1643
|
+
"found is still valid. 'invalidate' deletes one cached entry's whole key directory, dry-run by default. " +
|
|
1644
|
+
"Nothing here ever writes the tracked .modwright/templates/manifest.json — that record is a scaffold's job.",
|
|
1645
|
+
inputSchema: {
|
|
1646
|
+
action: z.enum(["list", "get", "extract", "invalidate"]),
|
|
1647
|
+
path: z.string().optional().describe("modwright.json path or a directory to search from. Default: current directory."),
|
|
1648
|
+
installPath: installPathArg,
|
|
1649
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("'extract'/'invalidate': dry-run describes without running a tool or touching disk."),
|
|
1650
|
+
surface: z.enum(TEMPLATE_SURFACES).optional().describe("Required for get/extract/invalidate; an optional filter for list."),
|
|
1651
|
+
gameVersion: z.string().optional().describe("Required for get/extract/invalidate (extract defaults to the resolved install's version when omitted); an optional filter for list."),
|
|
1652
|
+
key: z.string().optional().describe("Required for get/invalidate. 'extract' computes and returns its own key; never pass one in."),
|
|
1653
|
+
refresh: z.boolean().default(false).describe("'extract': force re-extraction even over an entry that would otherwise pass every invalidation check."),
|
|
1654
|
+
ref: z.string().optional().describe("'extract': a human-readable reference for this template, e.g. \"cp2077/garment/t2_146_pwa_jacket__collar_fur.ent\"."),
|
|
1655
|
+
kind: z.string().optional().describe("'extract': what this template is, e.g. \"garment-ent\", \"roottemplate\"."),
|
|
1656
|
+
permission: z.enum(TEMPLATE_PERMISSIONS).optional().describe("'extract': the permission this template's content carries."),
|
|
1657
|
+
sourceContainer: z.string().optional().describe("'extract': absolute path to the .pak/.archive the file lives inside."),
|
|
1658
|
+
sourcePath: z.string().optional().describe("'extract': the archive-internal path to the one file this template is."),
|
|
1659
|
+
conversion: z.enum(CONVERSION_IDS).optional().describe("'extract': a registered conversion to run on the extracted file before it is cached, e.g. \"cp2077.cr2w-to-json\"."),
|
|
1660
|
+
notes: z.string().optional().describe("'extract': free-text notes recorded on the template record."),
|
|
1661
|
+
},
|
|
1662
|
+
}, async ({ action, path: start, installPath, mode, surface, gameVersion, key, refresh, ref, kind, permission, sourceContainer, sourcePath, conversion, notes }) => {
|
|
1663
|
+
let resolved;
|
|
1664
|
+
try {
|
|
1665
|
+
resolved = await resolveProject(start);
|
|
1666
|
+
}
|
|
1667
|
+
catch (error) {
|
|
1668
|
+
return fail(`Could not load project: ${error.message}`);
|
|
1669
|
+
}
|
|
1670
|
+
const locateTool = defaultTextLocateTool(resolved.project.toolchain, action === "extract" && mode === "apply" ? "all" : action === "extract" ? "none" : "trusted");
|
|
1671
|
+
const templateGameVersion = async (surfaceId) => {
|
|
1672
|
+
const gameSurface = getSurface(surfaceId);
|
|
1673
|
+
if (!gameSurface)
|
|
1674
|
+
return undefined;
|
|
1675
|
+
const install = await resolveInstall(gameSurface, installPath).catch(() => undefined);
|
|
1676
|
+
return install ? installGameVersion(gameSurface, install) : undefined;
|
|
1677
|
+
};
|
|
1678
|
+
if (action === "list") {
|
|
1679
|
+
const entries = await listTemplates(resolved.root, { surface: surface, gameVersion });
|
|
1680
|
+
return json({ action, project: resolved.project.name, count: entries.length, templates: entries });
|
|
1681
|
+
}
|
|
1682
|
+
if (action === "get") {
|
|
1683
|
+
if (!surface || !gameVersion || !key)
|
|
1684
|
+
return fail('template get requires "surface", "gameVersion" and "key".');
|
|
1685
|
+
const got = await getTemplate(resolved.root, surface, gameVersion, key, { currentGameVersion: await templateGameVersion(surface), locateTool });
|
|
1686
|
+
if (!got)
|
|
1687
|
+
return fail(`No cached template at ${surface}/${gameVersion}/${key}.`);
|
|
1688
|
+
return json({ action, ...got });
|
|
1689
|
+
}
|
|
1690
|
+
if (action === "invalidate") {
|
|
1691
|
+
if (!surface || !gameVersion || !key)
|
|
1692
|
+
return fail('template invalidate requires "surface", "gameVersion" and "key".');
|
|
1693
|
+
const report = await invalidateTemplate(resolved.root, surface, gameVersion, key, mode);
|
|
1694
|
+
return json({ action, ...report });
|
|
1695
|
+
}
|
|
1696
|
+
if (!surface || !ref || !kind || !permission || !sourceContainer || !sourcePath) {
|
|
1697
|
+
return fail('template extract requires "surface", "ref", "kind", "permission", "sourceContainer" and "sourcePath".');
|
|
1698
|
+
}
|
|
1699
|
+
const resolvedGameVersion = gameVersion ?? (await templateGameVersion(surface)) ?? "unknown";
|
|
1700
|
+
try {
|
|
1701
|
+
const report = await extractTemplate({
|
|
1702
|
+
projectRoot: resolved.root,
|
|
1703
|
+
surface,
|
|
1704
|
+
ref,
|
|
1705
|
+
kind,
|
|
1706
|
+
permission: permission,
|
|
1707
|
+
sourceContainer,
|
|
1708
|
+
sourcePath,
|
|
1709
|
+
gameVersion: resolvedGameVersion,
|
|
1710
|
+
conversion: conversion,
|
|
1711
|
+
mode,
|
|
1712
|
+
locateTool,
|
|
1713
|
+
refresh,
|
|
1714
|
+
notes,
|
|
1715
|
+
});
|
|
1716
|
+
return json(report);
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
return fail(`template extract failed: ${error.message}`);
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
let scaffoldCatalog;
|
|
1723
|
+
function getScaffoldCatalog() {
|
|
1724
|
+
scaffoldCatalog ??= loadAllScaffolds(validators).then(({ loaded, problems }) => {
|
|
1725
|
+
if (problems.length > 0) {
|
|
1726
|
+
throw new Error(`scaffold catalogue failed to load:\n${problems.join("\n")}`);
|
|
1727
|
+
}
|
|
1728
|
+
return loaded;
|
|
1729
|
+
});
|
|
1730
|
+
return scaffoldCatalog;
|
|
1731
|
+
}
|
|
1732
|
+
let sequenceCatalog;
|
|
1733
|
+
function getSequenceCatalog(catalog) {
|
|
1734
|
+
sequenceCatalog ??= loadAllSequences(catalog).then(({ loaded, problems }) => {
|
|
1735
|
+
for (const problem of problems)
|
|
1736
|
+
process.stderr.write(`[modwright] sequence not loaded: ${problem}\n`);
|
|
1737
|
+
return loaded;
|
|
1738
|
+
});
|
|
1739
|
+
return sequenceCatalog;
|
|
1740
|
+
}
|
|
1741
|
+
server.registerTool("scaffold", {
|
|
1742
|
+
title: "Author new mod content from a declarative scaffold",
|
|
1743
|
+
description: "Declarative, reviewable scaffolds: list every shipped definition, " +
|
|
1744
|
+
"describe one (its inputs, the vanilla index queries it runs, the rules it must pass, the files it writes), or " +
|
|
1745
|
+
"run one against a project. Dry-run by default — resolves inputs, allocates deterministic ids (with project/" +
|
|
1746
|
+
"index/load-order collision checks), and renders the emit ops into a WritePlan without writing a byte; apply " +
|
|
1747
|
+
"writes through core/safety (existing files are backed up first), then re-runs the named `satisfies` rules plus " +
|
|
1748
|
+
"the surface's cheap rule set, writes the scaffold manifest, an `authored` ledger claim, and proposed rows into " +
|
|
1749
|
+
"tests/authored.yaml (for a game with a bridge; otherwise the rows stay in the report and the skip says why). `new-project` is the one scaffold that runs before a modwright.json exists at all — pass " +
|
|
1750
|
+
"`path` as the target directory and it is bootstrapped from `inputs.name`/the scaffold's own game. " +
|
|
1751
|
+
"`claude-assets` is not a scaffold and takes no scaffoldId: it writes ModWright's own Claude Code skills " +
|
|
1752
|
+
"and agents into the project's `.claude/`, recording each file's sha256 so a locally " +
|
|
1753
|
+
"edited one is reported with a diff and left alone rather than clobbered.",
|
|
1754
|
+
inputSchema: {
|
|
1755
|
+
action: z.enum(["list", "describe", "run", "plan", "claude-assets"]),
|
|
1756
|
+
game: z.string().optional().describe("'list' only: restrict to this game id/alias."),
|
|
1757
|
+
scaffoldId: z.string().optional().describe("Required for 'describe'/'run' — a listed scaffold's id, e.g. \"baldursgate3.new-spell\"."),
|
|
1758
|
+
sequenceId: z
|
|
1759
|
+
.string()
|
|
1760
|
+
.optional()
|
|
1761
|
+
.describe("'plan' only: an authoring sequence id from 'list' (its 'sequences'), e.g. \"cyberpunk2077.iconic-weapon\". A sequence is the ordered scaffolds one mod type needs; 'plan' reads the project's scaffold manifests to see which steps are done and reports the next step's exact run call, or the exact prompts (inputs and assets) still unanswered. Read-only."),
|
|
1762
|
+
assets: z
|
|
1763
|
+
.record(z.string())
|
|
1764
|
+
.optional()
|
|
1765
|
+
.describe("'plan' only: answers to the asset prompts a previous plan reported, keyed exactly by their 'answerAs' (\"<stepId>.<assetName>\"). Sequence inputs and per-step inputs (\"<stepId>.<inputName>\") go in 'inputs'."),
|
|
1766
|
+
path: z.string().optional().describe("'run' only: modwright.json path or a directory to search from (or, for a *.new-project scaffold with no project there yet, the directory to create one in). Default: current directory."),
|
|
1767
|
+
inputs: z.record(z.string()).optional().describe("'run' only: values for every input the scaffold's 'describe' lists, keyed by name."),
|
|
1768
|
+
mode: z.enum(["dry-run", "apply"]).default("dry-run").describe("'run' only: dry-run resolves and describes every write without touching disk."),
|
|
1769
|
+
installPath: installPathArg,
|
|
1770
|
+
force: z.boolean().default(false).describe("'run': proceed past a collision refusal, reporting each hit as a warning instead of refusing. 'claude-assets': overwrite locally edited asset files (each is backed up first)."),
|
|
1771
|
+
claudeSettings: z
|
|
1772
|
+
.boolean()
|
|
1773
|
+
.default(false)
|
|
1774
|
+
.describe("'claude-assets' only: also write .claude/settings.json with the project model/effort baseline. Off by default \u2014 ModWright does not change a repo's Claude Code settings unless asked; the generated skills and agents pin their own model and effort in frontmatter."),
|
|
1775
|
+
},
|
|
1776
|
+
}, async ({ action, game, scaffoldId, sequenceId, path: start, inputs, assets, mode, installPath, force, claudeSettings }) => {
|
|
1777
|
+
const catalog = await getScaffoldCatalog();
|
|
1778
|
+
if (action === "list") {
|
|
1779
|
+
const gameId = game ? getSurface(game)?.id : undefined;
|
|
1780
|
+
if (game && !gameId)
|
|
1781
|
+
return fail(`Unknown game "${game}". Known: ${surfaceIds().join(", ")}`);
|
|
1782
|
+
const filtered = gameId ? catalog.filter((l) => l.definition.game === gameId) : catalog;
|
|
1783
|
+
const sequences = await getSequenceCatalog(catalog);
|
|
1784
|
+
const filteredSequences = gameId ? sequences.filter((l) => l.definition.game === gameId) : sequences;
|
|
1785
|
+
return json({ action, count: filtered.length, scaffolds: summarize(filtered), sequences: summarizeSequences(filteredSequences) });
|
|
1786
|
+
}
|
|
1787
|
+
if (action === "plan") {
|
|
1788
|
+
if (!sequenceId)
|
|
1789
|
+
return fail(`"plan" requires "sequenceId". Call action:"list" and read its "sequences".`);
|
|
1790
|
+
const sequences = await getSequenceCatalog(catalog);
|
|
1791
|
+
const found = findSequence(sequences, sequenceId);
|
|
1792
|
+
if (!found)
|
|
1793
|
+
return fail(`Unknown sequence "${sequenceId}". Known: ${sequences.map((l) => l.definition.id).join(", ") || "(none)"}`);
|
|
1794
|
+
const dir = start ?? process.cwd();
|
|
1795
|
+
let resolved;
|
|
1796
|
+
try {
|
|
1797
|
+
resolved = await resolveProject(dir);
|
|
1798
|
+
}
|
|
1799
|
+
catch {
|
|
1800
|
+
resolved = undefined;
|
|
1801
|
+
}
|
|
1802
|
+
if (resolved && resolved.project.game !== found.definition.game) {
|
|
1803
|
+
return fail(`"${sequenceId}" is a ${found.definition.game} sequence; ${resolved.project.name} is a ${resolved.project.game} project.`);
|
|
1804
|
+
}
|
|
1805
|
+
const plan = await planSequence({ definition: found.definition, catalog, project: resolved, inputs, assets });
|
|
1806
|
+
return json({ action, ...plan, projectDir: resolved ? undefined : dir });
|
|
1807
|
+
}
|
|
1808
|
+
if (action === "claude-assets") {
|
|
1809
|
+
const dir = start ?? process.cwd();
|
|
1810
|
+
let resolved;
|
|
1811
|
+
try {
|
|
1812
|
+
resolved = await resolveProject(dir);
|
|
1813
|
+
}
|
|
1814
|
+
catch (error) {
|
|
1815
|
+
return fail(`Could not load project: ${error.message}`);
|
|
1816
|
+
}
|
|
1817
|
+
try {
|
|
1818
|
+
const { plan, writeReport, ...rest } = await generateClaudeAssets({
|
|
1819
|
+
project: resolved,
|
|
1820
|
+
mode,
|
|
1821
|
+
force,
|
|
1822
|
+
includeSettings: claudeSettings,
|
|
1823
|
+
});
|
|
1824
|
+
const opPath = (op) => ("path" in op ? op.path : undefined);
|
|
1825
|
+
const opBytes = (op) => "content" in op ? (typeof op.content === "string" ? Buffer.byteLength(op.content, "utf8") : op.content.byteLength) : undefined;
|
|
1826
|
+
return json({
|
|
1827
|
+
action,
|
|
1828
|
+
...rest,
|
|
1829
|
+
operations: plan.operations.map((op) => ({ kind: op.kind, path: opPath(op), bytes: opBytes(op) })),
|
|
1830
|
+
write: writeReport
|
|
1831
|
+
? {
|
|
1832
|
+
mode: writeReport.mode,
|
|
1833
|
+
applied: writeReport.applied,
|
|
1834
|
+
backupDir: writeReport.backupDir,
|
|
1835
|
+
next: writeReport.next,
|
|
1836
|
+
operations: writeReport.operations.map((result) => ({
|
|
1837
|
+
kind: result.op.kind,
|
|
1838
|
+
path: opPath(result.op),
|
|
1839
|
+
status: result.status,
|
|
1840
|
+
backedUpTo: result.backedUpTo,
|
|
1841
|
+
error: result.error,
|
|
1842
|
+
})),
|
|
1843
|
+
}
|
|
1844
|
+
: undefined,
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
catch (error) {
|
|
1848
|
+
return fail(`claude-assets failed: ${error.message}`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (!scaffoldId)
|
|
1852
|
+
return fail(`"${action}" requires "scaffoldId". Call action:"list" to see what is available.`);
|
|
1853
|
+
const found = findScaffold(catalog, scaffoldId);
|
|
1854
|
+
if (!found)
|
|
1855
|
+
return fail(`Unknown scaffold "${scaffoldId}". Known: ${catalog.map((l) => l.definition.id).join(", ")}`);
|
|
1856
|
+
if (action === "describe") {
|
|
1857
|
+
return json({ action, ...describeScaffold(found) });
|
|
1858
|
+
}
|
|
1859
|
+
const surface = getSurface(found.definition.game);
|
|
1860
|
+
if (!surface)
|
|
1861
|
+
return fail(`Scaffold "${scaffoldId}" names game "${found.definition.game}", which has no surface registered.`);
|
|
1862
|
+
const dir = start ?? process.cwd();
|
|
1863
|
+
let resolved;
|
|
1864
|
+
try {
|
|
1865
|
+
resolved = await resolveProject(dir);
|
|
1866
|
+
}
|
|
1867
|
+
catch (error) {
|
|
1868
|
+
if (!scaffoldId.endsWith(".new-project"))
|
|
1869
|
+
return fail(`Could not load project: ${error.message}`);
|
|
1870
|
+
const name = inputs?.name;
|
|
1871
|
+
if (!name)
|
|
1872
|
+
return fail(`No modwright.json found at or above ${dir}, and "${scaffoldId}" needs inputs.name to bootstrap one.`);
|
|
1873
|
+
resolved = bootstrapProject(dir, found.definition.game, name);
|
|
1874
|
+
}
|
|
1875
|
+
if (resolved.project.game !== found.definition.game) {
|
|
1876
|
+
return fail(`"${scaffoldId}" is a ${found.definition.game} scaffold; ${resolved.project.name} is a ${resolved.project.game} project.`);
|
|
1877
|
+
}
|
|
1878
|
+
const install = await resolveInstall(surface, installPath).catch(() => undefined);
|
|
1879
|
+
const store = await getStore(surface.id);
|
|
1880
|
+
try {
|
|
1881
|
+
const report = await runScaffold({
|
|
1882
|
+
project: resolved,
|
|
1883
|
+
surface,
|
|
1884
|
+
definition: found.definition,
|
|
1885
|
+
inputs: inputs ?? {},
|
|
1886
|
+
mode,
|
|
1887
|
+
install,
|
|
1888
|
+
store,
|
|
1889
|
+
registry: validators,
|
|
1890
|
+
force,
|
|
1891
|
+
});
|
|
1892
|
+
return json({ action, ...report });
|
|
1893
|
+
}
|
|
1894
|
+
catch (error) {
|
|
1895
|
+
return fail(`scaffold "${scaffoldId}" failed: ${error.message}`);
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
for (const entry of loadSkillPromptCatalog()) {
|
|
1899
|
+
server.registerPrompt(entry.name, {
|
|
1900
|
+
title: entry.title,
|
|
1901
|
+
description: entry.description,
|
|
1902
|
+
argsSchema: {
|
|
1903
|
+
path: z.string().optional().describe("modwright.json path or a directory to search up from. Default: the server's working directory."),
|
|
1904
|
+
},
|
|
1905
|
+
}, async ({ path: start }) => {
|
|
1906
|
+
const rendered = await renderSkillPrompt(entry, start);
|
|
1907
|
+
return {
|
|
1908
|
+
description: rendered.project ? `${entry.name} for ${rendered.project.name} (${rendered.project.game})` : entry.description,
|
|
1909
|
+
messages: [{ role: "user", content: { type: "text", text: rendered.text } }],
|
|
1910
|
+
};
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
const transport = new StdioServerTransport();
|
|
1914
|
+
await server.connect(transport);
|