@edgemodders/edgedeck-mcp 0.27.3 → 0.28.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/README.md CHANGED
@@ -23,19 +23,20 @@ Tools:
23
23
  | `edgedeck_list` | Every mod: on or off, version, kinds, status, size; the frameworks line after a game patch |
24
24
  | `edgedeck_mod` | One mod in detail: files, versions, Nexus info |
25
25
  | `edgedeck_add` | Add a zip, 7z, rar, single mod file or folder to the library |
26
- | `edgedeck_install_project` | Add or refresh a REDcode or WolvenKit project's packed output |
26
+ | `edgedeck_install_project` | Add or refresh a REDcode or WolvenKit project's packed output; `sameName` (`follow` or `version`) settles a mod of the same name from another folder |
27
27
  | `edgedeck_turn` | Turn a mod on or off |
28
28
  | `edgedeck_move` | Move a mod up or down; higher wins a conflict |
29
+ | `edgedeck_rename` | A new name for a mod in EDGEdeck, for all its versions; empty puts the first name back |
29
30
  | `edgedeck_remove` | Remove a mod from the library |
30
31
  | `edgedeck_pending` | What the next deploy would add, replace, put back or remove, and why |
31
- | `edgedeck_deploy` | Put the active loadout in the game (refuses while the game runs) |
32
+ | `edgedeck_deploy` | Put the active loadout in the game (refuses while the game runs); `mod` deploys that mod's changes only |
32
33
  | `edgedeck_undo` | Take everything EDGEdeck deployed out again, putting backups back |
33
34
  | `edgedeck_changes` | Deployed files changed or missing in the game folder since |
34
35
  | `edgedeck_conflicts` | Files two enabled mods both have, and who wins |
35
36
  | `edgedeck_loadout` | Save, switch to (load) or list loadouts |
36
37
  | `edgedeck_vortex` | List and import the mods Vortex has for the game (Vortex is not changed) |
37
38
 
38
- Nothing touches the game folder until `edgedeck_deploy` or `edgedeck_undo`; the server tells the assistant to ask the player first.
39
+ Nothing touches the game folder until `edgedeck_deploy` or `edgedeck_undo`; the server tells the assistant to ask the player first. These two and `edgedeck_remove` also carry `_meta: { "anthropic/requiresUserInteraction": true }`, so Claude Code asks you before every call, in every permission mode, auto mode included.
39
40
 
40
41
  ## The command line
41
42
 
@@ -43,7 +44,7 @@ Nothing touches the game folder until `edgedeck_deploy` or `edgedeck_undo`; the
43
44
  npx -p @edgemodders/edgedeck-mcp edgedeck list
44
45
  ```
45
46
 
46
- or, installed with `npm i -g @edgemodders/edgedeck-mcp`, just `edgedeck list`. Commands: `list`, `mod`, `add`, `install-project`, `on`, `off`, `up`, `down`, `remove`, `pending`, `deploy`, `undo`, `changes`, `conflicts`, `loadout save|load|list`, `vortex list|import`, `mcp`. Options `--library`, `--game`, and `--json` for machine output.
47
+ or, installed with `npm i -g @edgemodders/edgedeck-mcp`, just `edgedeck list`. Commands: `list`, `mod`, `add`, `install-project`, `on`, `off`, `up`, `down`, `rename`, `remove`, `pending`, `deploy`, `undo`, `changes`, `conflicts`, `loadout save|load|list`, `vortex list|import`, `mcp`. Options `--library`, `--game`, and `--json` for machine output.
47
48
 
48
49
  ## More
49
50
 
package/bin/edgedeck.mjs CHANGED
@@ -5,11 +5,15 @@
5
5
  // edgedeck list [--size] what is in the library, its size and status (--size: largest first)
6
6
  // edgedeck mod <id> one mod in full: files, sizes, source, Nexus data, conflicts
7
7
  // edgedeck add <file|folder> [--name N] [--option FOLDER]
8
- // edgedeck install-project <.cpmodproj> add a REDcode or WolvenKit project's packed output, or refresh it, and turn it on
8
+ // edgedeck install-project <.cpmodproj> [--follow | --keep-both]
9
+ // add a REDcode or WolvenKit project's packed output, or refresh it, and turn it on;
10
+ // beside a mod of the same name: --follow makes that mod follow this project,
11
+ // --keep-both adds the project as another version of it, on in its place
9
12
  // edgedeck on <id> | off <id> turn a mod on or off
10
13
  // edgedeck up <id> | down <id> change the order (first wins conflicts)
14
+ // edgedeck rename <id> [name...] a new name in EDGEdeck, for all its versions (no name: the first one back)
11
15
  // edgedeck remove <id>
12
- // edgedeck deploy [--keep PATH...] [--restore PATH...]
16
+ // edgedeck deploy [--keep PATH...] [--restore PATH...] [--mod ID] --mod: that mod's changes only
13
17
  // edgedeck undo
14
18
  // edgedeck changes files changed in the game folder since the deploy
15
19
  // edgedeck pending what the next deploy adds, replaces, puts back or removes, and why
@@ -66,13 +70,14 @@ if (cmd === "mcp") {
66
70
  case "list": out(await v.list(s, !!flags.size)); break;
67
71
  case "mod": out(v.mod(s, need(rest[0], "a mod id"))); break;
68
72
  case "add": out(await v.add(s, need(rest[0], "a file or folder"), flags.name, flags.option)); break;
69
- case "install-project": out(v.installProject(s, need(rest[0], "a .cpmodproj file"))); break;
73
+ case "install-project": out(v.installProject(s, need(rest[0], "a .cpmodproj file"), flags.follow ? "follow" : flags["keep-both"] ? "version" : undefined)); break;
70
74
  case "on":
71
75
  case "off": out(v.turn(s, need(rest[0], "a mod id"), cmd === "on")); break;
72
76
  case "up":
73
77
  case "down": out(v.move(s, need(rest[0], "a mod id"), cmd)); break;
78
+ case "rename": out(v.rename(s, need(rest[0], "a mod id"), rest.slice(1).join(" "))); break;
74
79
  case "remove": out(v.remove(s, need(rest[0], "a mod id"))); break;
75
- case "deploy": out(await v.deploy(s, flags.keep ?? [], flags.restore ?? [])); break;
80
+ case "deploy": out(await v.deploy(s, flags.keep ?? [], flags.restore ?? [], typeof flags.mod === "string" ? flags.mod : undefined)); break;
76
81
  case "undo": out(v.undo(s)); break;
77
82
  case "changes": out(v.changes(s)); break;
78
83
  case "pending": out(v.pending(s)); break;
package/dist/core.js CHANGED
@@ -3739,6 +3739,7 @@ __export(core_exports, {
3739
3739
  deploy: () => deploy,
3740
3740
  deployRedmod: () => deployRedmod,
3741
3741
  deployReportsFolder: () => deployReportsFolder,
3742
+ deployedRedmodPackages: () => deployedRedmodPackages,
3742
3743
  describeHandler: () => describeHandler,
3743
3744
  detectChanges: () => detectChanges,
3744
3745
  dotnet10Root: () => dotnet10Root,
@@ -3784,6 +3785,7 @@ __export(core_exports, {
3784
3785
  isGameFolder: () => isGameFolder,
3785
3786
  keepDownload: () => keepDownload,
3786
3787
  latestBridgeRelease: () => latestBridgeRelease,
3788
+ launchTarget: () => launchTarget,
3787
3789
  libraryFolder: () => libraryFolder,
3788
3790
  librarySize: () => librarySize,
3789
3791
  listLoadouts: () => listLoadouts,
@@ -3792,8 +3794,10 @@ __export(core_exports, {
3792
3794
  loadoutNameProblem: () => loadoutNameProblem,
3793
3795
  loadoutState: () => loadoutState,
3794
3796
  loadoutsFolder: () => loadoutsFolder,
3797
+ lostFiles: () => lostFiles,
3795
3798
  makeActive: () => makeActive,
3796
3799
  md5Of: () => md5Of,
3800
+ modDeployPaths: () => modDeployPaths,
3797
3801
  modFolder: () => modFolder,
3798
3802
  modPageUrl: () => modPageUrl,
3799
3803
  modSize: () => modSize,
@@ -3817,6 +3821,7 @@ __export(core_exports, {
3817
3821
  placeAbove: () => placeAbove,
3818
3822
  placeNextTo: () => placeNextTo,
3819
3823
  plan: () => plan,
3824
+ projectMod: () => projectMod,
3820
3825
  projectName: () => projectName,
3821
3826
  putInSection: () => putInSection,
3822
3827
  readLoadout: () => readLoadout,
@@ -3828,8 +3833,10 @@ __export(core_exports, {
3828
3833
  registerNxmHandler: () => registerNxmHandler,
3829
3834
  removeMod: () => removeMod,
3830
3835
  removeOlderBridges: () => removeOlderBridges,
3836
+ renameFamily: () => renameFamily,
3831
3837
  renameLoadout: () => renameLoadout,
3832
3838
  renameSection: () => renameSection,
3839
+ sameNameMod: () => sameNameMod,
3833
3840
  saveLibrary: () => saveLibrary,
3834
3841
  saveLoadout: () => saveLoadout,
3835
3842
  saveMod: () => saveMod,
@@ -3847,6 +3854,7 @@ __export(core_exports, {
3847
3854
  steamLibraries: () => steamLibraries,
3848
3855
  syncActiveLoadout: () => syncActiveLoadout,
3849
3856
  syncProjects: () => syncProjects,
3857
+ takeFamilyName: () => takeFamilyName,
3850
3858
  tokenUser: () => tokenUser,
3851
3859
  undo: () => undo,
3852
3860
  unpackOrCopy: () => unpackOrCopy,
@@ -4467,6 +4475,11 @@ function gameRunning() {
4467
4475
  return false;
4468
4476
  }
4469
4477
  }
4478
+ function launchTarget(gamePath, how) {
4479
+ const steam = how === "steam" || how === "auto" && /[\\/]steamapps[\\/]common[\\/]/i.test(gamePath);
4480
+ if (steam) return { kind: "steam", url: "steam://run/1091500//-modded/" };
4481
+ return { kind: "exe", exe: gameExe(gamePath), args: ["-modded"] };
4482
+ }
4470
4483
  function isGameFolder(gamePath) {
4471
4484
  return !!gamePath && fs6.existsSync(gameExe(gamePath));
4472
4485
  }
@@ -4757,6 +4770,18 @@ function detectChanges(gamePath) {
4757
4770
  }
4758
4771
  return changes;
4759
4772
  }
4773
+ function lostFiles(p) {
4774
+ const out = /* @__PURE__ */ new Map();
4775
+ for (const c of p.conflicts) {
4776
+ for (const loser of c.losers) {
4777
+ const k = `${loser}\0${c.winner}`;
4778
+ const l = out.get(k) ?? { mod: loser, files: 0, winner: c.winner };
4779
+ l.files++;
4780
+ out.set(k, l);
4781
+ }
4782
+ }
4783
+ return [...out.values()];
4784
+ }
4760
4785
  function modsTouched(result) {
4761
4786
  return [...new Set(result.changes.filter((c) => c.kind !== "removed" && c.mod !== Generated).map((c) => c.mod))];
4762
4787
  }
@@ -4849,28 +4874,46 @@ function cleanDirs(gamePath, manifest) {
4849
4874
  }
4850
4875
  manifest.dirs = manifest.dirs.filter((d) => fs8.existsSync(path7.join(gamePath, ...d.split("/"))));
4851
4876
  }
4852
- function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress, gameVersion2) {
4877
+ function modDeployPaths(gamePath, p, id) {
4878
+ const paths = new Set((readManifest(gamePath)?.files ?? []).filter((e) => e.mod === id).map((e) => key(e.path)));
4879
+ for (const d of p.desired) if (d.mod?.id === id) paths.add(key(d.rel));
4880
+ return paths;
4881
+ }
4882
+ function modlistFor(lib, files) {
4883
+ const rank = new Map(lib.mods.map((m, i) => [m.id, i]));
4884
+ const archives = files.filter((e) => e.mod !== Generated && e.path.toLowerCase().startsWith("archive/pc/mod/") && e.path.toLowerCase().endsWith(".archive")).sort((a, b) => (rank.get(a.mod) ?? 1e9) - (rank.get(b.mod) ?? 1e9) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
4885
+ return archives.length ? archives.map((e) => path7.posix.basename(e.path)).join("\r\n") + "\r\n" : "";
4886
+ }
4887
+ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress, gameVersion2, only) {
4853
4888
  if (vortexManages(gamePath)) throw new Error("Vortex manages this game folder. Purge in Vortex first (Mods > Purge), then deploy with EDGEdeck.");
4854
4889
  if (!isGameFolder(gamePath)) throw new Error(`${gamePath} does not look like the Cyberpunk 2077 folder (no bin\\x64\\Cyberpunk2077.exe).`);
4855
4890
  const p = plan(lib, gamePath);
4856
4891
  const manifest = readManifest(gamePath) ?? { version: 1, library: lib.root, gamePath, method: p.method, time: "", files: [], dirs: [] };
4857
4892
  manifest.library = lib.root;
4858
- const result = { linked: 0, removed: 0, kept: 0, backedUp: 0, method: manifest.method, skipped: [], changes: [], modsInGame: 0 };
4893
+ const result = { linked: 0, removed: 0, kept: 0, backedUp: 0, method: manifest.method, skipped: [], changes: [], modsInGame: 0, lost: lostFiles(p) };
4859
4894
  const entries = new Map(manifest.files.map((e) => [key(e.path), e]));
4895
+ const scope = only ? modDeployPaths(gamePath, p, only) : void 0;
4896
+ const inScope = (rel) => !scope || scope.has(key(rel));
4897
+ const stale = p.stale.filter((e) => inScope(e.path) && !(scope && e.mod === Generated));
4898
+ const add = p.add.filter((d) => inScope(d.rel) && !(scope && d.content !== void 0));
4899
+ if (scope) {
4900
+ const waiting = new Set([...p.stale.map((e) => e.path), ...p.add.map((d) => d.rel)].filter((r) => !scope.has(key(r)) && key(r) !== key(ModListPath)).map(key));
4901
+ result.waiting = waiting.size;
4902
+ }
4860
4903
  for (const [rel, decision] of decisions) {
4861
4904
  const d = p.desired.find((x) => key(x.rel) === key(rel));
4862
4905
  if (decision === "keep" && d?.mod) {
4863
4906
  fs8.copyFileSync(gameFile(gamePath, rel), d.src);
4864
4907
  }
4865
4908
  }
4866
- const total = p.stale.length + p.add.length;
4909
+ const total = stale.length + add.length;
4867
4910
  let done = 0;
4868
4911
  const save = () => {
4869
4912
  manifest.time = (/* @__PURE__ */ new Date()).toISOString();
4870
4913
  if (gameVersion2) manifest.gameVersion = gameVersion2;
4871
4914
  writeManifest(gamePath, manifest);
4872
4915
  };
4873
- for (const entry of p.stale) {
4916
+ const takeOut = (entry) => {
4874
4917
  const decided = decisions.get(entry.path);
4875
4918
  if (removeDeployed(lib, gamePath, manifest, entry, decided !== void 0, result.skipped)) {
4876
4919
  entries.delete(key(entry.path));
@@ -4878,10 +4921,8 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
4878
4921
  result.changes.push({ path: entry.path, mod: entry.mod, kind: "removed", ...entry.backup ? { backup: true } : {} });
4879
4922
  }
4880
4923
  manifest.files = [...entries.values()];
4881
- progress?.(++done, total);
4882
- }
4883
- if (p.stale.length) cleanDirs(gamePath, manifest);
4884
- for (const d of p.add) {
4924
+ };
4925
+ const putIn = (d) => {
4885
4926
  const target = gameFile(gamePath, d.rel);
4886
4927
  const existing = entries.get(key(d.rel));
4887
4928
  let backup = existing?.backup ?? false;
@@ -4889,7 +4930,7 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
4889
4930
  const state = stillOurs(existing, manifest.method, gamePath);
4890
4931
  if (state === "changed" && !decisions.has(d.rel)) {
4891
4932
  result.skipped.push(d.rel);
4892
- continue;
4933
+ return;
4893
4934
  }
4894
4935
  fs8.rmSync(target, { force: true });
4895
4936
  } else if (fs8.existsSync(target)) {
@@ -4915,10 +4956,29 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
4915
4956
  manifest.files = [...entries.values()];
4916
4957
  result.linked++;
4917
4958
  result.changes.push({ path: d.rel, mod: entry.mod, kind: existing ? "replaced" : "added", ...backup && !existing ? { backup: true } : {} });
4959
+ };
4960
+ for (const entry of stale) {
4961
+ takeOut(entry);
4962
+ progress?.(++done, total);
4963
+ }
4964
+ if (stale.length) cleanDirs(gamePath, manifest);
4965
+ for (const d of add) {
4966
+ putIn(d);
4918
4967
  progress?.(++done, total);
4919
4968
  if (done % 200 === 0) save();
4920
4969
  }
4921
- result.kept = p.desired.length - result.linked - result.skipped.length;
4970
+ if (scope) {
4971
+ const content = modlistFor(lib, manifest.files);
4972
+ const e = entries.get(key(ModListPath));
4973
+ if (!content && e) {
4974
+ takeOut(e);
4975
+ cleanDirs(gamePath, manifest);
4976
+ } else if (content && (!e || generatedDiffers(gameFile(gamePath, ModListPath), content))) {
4977
+ putIn({ rel: ModListPath, mod: void 0, src: "", content });
4978
+ }
4979
+ }
4980
+ result.kept = (scope ? p.desired.filter((d) => scope.has(key(d.rel))).length : p.desired.length) - result.linked - result.skipped.length;
4981
+ if (scope) result.kept = Math.max(0, result.kept);
4922
4982
  result.modsInGame = new Set(manifest.files.map((e) => e.mod).filter((m) => m !== Generated)).size;
4923
4983
  save();
4924
4984
  return result;
@@ -4992,6 +5052,7 @@ function rednodePackageId(mod) {
4992
5052
  return void 0;
4993
5053
  }
4994
5054
  function familyKey(mod) {
5055
+ if (mod.family) return mod.family;
4995
5056
  if (mod.source.modId) return `nexus:${mod.source.modId}`;
4996
5057
  const pkg = rednodePackageId(mod);
4997
5058
  if (pkg) return `rednode:${pkg}`;
@@ -5044,6 +5105,7 @@ function settleNewVersion(lib, mod) {
5044
5105
  const fam = familyOf(lib, mod);
5045
5106
  const others = fam.mods.filter((m) => m.id !== mod.id);
5046
5107
  if (!others.length) return {};
5108
+ takeFamilyName(lib, mod, others);
5047
5109
  const previous = others.find((m) => m.enabled) ?? [...others].sort(newestFirst)[0];
5048
5110
  if (mod.version && previous.version && versionNewer(previous.version, mod.version)) {
5049
5111
  if (mod.enabled) {
@@ -5066,6 +5128,31 @@ function settleNewVersion(lib, mod) {
5066
5128
  }
5067
5129
  return { replaced: previous };
5068
5130
  }
5131
+ function renameFamily(lib, mod, name) {
5132
+ const fam = familyOf(lib, mod);
5133
+ const to = name.trim();
5134
+ for (const m of fam.mods) {
5135
+ m.family = fam.key;
5136
+ if (!to) {
5137
+ if (m.renamedFrom !== void 0) m.name = m.renamedFrom;
5138
+ delete m.renamedFrom;
5139
+ } else if (m.name !== to) {
5140
+ m.renamedFrom ??= m.name;
5141
+ m.name = to;
5142
+ if (m.renamedFrom === to) delete m.renamedFrom;
5143
+ }
5144
+ saveMod(lib, m);
5145
+ }
5146
+ return familyOf(lib, mod);
5147
+ }
5148
+ function takeFamilyName(lib, mod, others) {
5149
+ const renamed = others.find((m) => m.renamedFrom !== void 0);
5150
+ if (!renamed || mod.name === renamed.name) return;
5151
+ mod.family = familyKey(renamed);
5152
+ mod.renamedFrom = mod.name;
5153
+ mod.name = renamed.name;
5154
+ saveMod(lib, mod);
5155
+ }
5069
5156
  function familiesWithTwoOn(lib) {
5070
5157
  return families(lib).filter((f) => f.mods.filter((m) => m.enabled).length > 1);
5071
5158
  }
@@ -5671,11 +5758,48 @@ function syncProjects(lib) {
5671
5758
  }
5672
5759
  return refreshed;
5673
5760
  }
5674
- function installProject(lib, projectFile) {
5761
+ function projectMod(lib, projectFile) {
5762
+ return lib.mods.find((m) => m.source.type === "project" && m.source.path?.toLowerCase() === projectFile.toLowerCase());
5763
+ }
5764
+ function sameNameMod(lib, projectFile) {
5765
+ if (projectMod(lib, projectFile)) return void 0;
5766
+ const name = baseName(projectName(projectFile));
5767
+ const match = name ? lib.mods.find((m) => baseName(m.name) === name || m.renamedFrom !== void 0 && baseName(m.renamedFrom) === name) : void 0;
5768
+ return match && familyOf(lib, match).active;
5769
+ }
5770
+ function devVersion(lib, family) {
5771
+ const taken = new Set(lib.mods.filter((m) => familyKey(m) === family).map((m) => m.version.toLowerCase()));
5772
+ if (!taken.has("dev")) return "dev";
5773
+ let n = 2;
5774
+ while (taken.has(`dev ${n}`)) n++;
5775
+ return `dev ${n}`;
5776
+ }
5777
+ function installProject(lib, projectFile, beside) {
5675
5778
  const c = classifyProject(projectFile);
5676
5779
  if (!c.placements.length) throw new Error(`${projectName(projectFile)} has no packed output yet. Pack the project first (packed/ next to the project file).`);
5677
- const existing = lib.mods.find((m) => m.source.type === "project" && m.source.path?.toLowerCase() === projectFile.toLowerCase());
5780
+ let existing = projectMod(lib, projectFile);
5781
+ if (!existing && beside?.how === "follow") {
5782
+ existing = beside.mod;
5783
+ existing.family = familyKey(existing);
5784
+ existing.source = { type: "project", path: projectFile };
5785
+ }
5678
5786
  if (!existing) {
5787
+ if (beside?.how === "version") {
5788
+ const family = familyKey(beside.mod);
5789
+ const previous = familyOf(lib, beside.mod);
5790
+ const mod2 = addMod(lib, c.placements, { name: projectName(projectFile), version: devVersion(lib, family), source: { type: "project", path: projectFile } });
5791
+ mod2.family = family;
5792
+ takeFamilyName(lib, mod2, previous.mods);
5793
+ placeAbove(lib, mod2, previous.active);
5794
+ for (const m of previous.mods) {
5795
+ if (m.enabled) {
5796
+ m.enabled = false;
5797
+ saveMod(lib, m);
5798
+ }
5799
+ }
5800
+ saveMod(lib, mod2);
5801
+ return { mod: mod2, added: true, beside };
5802
+ }
5679
5803
  const mod = addMod(lib, c.placements, { name: projectName(projectFile), version: "dev", source: { type: "project", path: projectFile } });
5680
5804
  return { mod, added: true };
5681
5805
  }
@@ -5689,9 +5813,10 @@ function installProject(lib, projectFile) {
5689
5813
  for (const f of existing.files) if (!keep.has(f)) fs10.rmSync(sourcePath(lib, existing, f), { force: true });
5690
5814
  existing.files = [...keep].sort();
5691
5815
  existing.kinds = c.kinds;
5816
+ activateVersion(lib, existing);
5692
5817
  existing.enabled = true;
5693
5818
  saveMod(lib, existing);
5694
- return { mod: existing, added: false };
5819
+ return { mod: existing, added: false, ...beside ? { beside } : {} };
5695
5820
  }
5696
5821
 
5697
5822
  // src/redmod.ts
@@ -5714,8 +5839,18 @@ function enabledRedmodPackages(lib) {
5714
5839
  }
5715
5840
  return names;
5716
5841
  }
5717
- async function deployRedmod(lib, gamePath, log) {
5718
- const packages = enabledRedmodPackages(lib);
5842
+ function deployedRedmodPackages(lib, gamePath) {
5843
+ const inGame = new Set((readManifest(gamePath)?.files ?? []).map((e) => /^mods\/([^/]+)\//i.exec(e.path)?.[1]).filter((n) => !!n));
5844
+ const names = [];
5845
+ for (const mod of lib.mods) {
5846
+ for (const f of mod.files) {
5847
+ const m = /^mods\/([^/]+)\//i.exec(f);
5848
+ if (m && inGame.has(m[1]) && !names.includes(m[1])) names.push(m[1]);
5849
+ }
5850
+ }
5851
+ return names;
5852
+ }
5853
+ async function deployRedmod(lib, gamePath, log, packages = enabledRedmodPackages(lib)) {
5719
5854
  const exe = redModExe(gamePath);
5720
5855
  if (!packages.length) return { ran: false, packages, output: "no REDmod packages enabled" };
5721
5856
  if (!fs11.existsSync(exe)) return { ran: false, packages, output: `redMod.exe not found at ${exe}; the REDmod tools are a free DLC on the game's store page` };
@@ -6045,6 +6180,13 @@ function importFromVortex(lib, mods) {
6045
6180
  }
6046
6181
 
6047
6182
  // src/verbs.ts
6183
+ function lostProjectsText(lib, lost) {
6184
+ const lines = lost.flatMap((l) => {
6185
+ const mod = lib.mods.find((m) => m.id === l.mod);
6186
+ return mod?.source.type === "project" ? [`the game still has ${l.winner} for ${l.files} file(s), not the project ${l.mod} (move ${l.mod} up to let it win)`] : [];
6187
+ });
6188
+ return lines.length ? `; ${lines.join("; ")}` : "";
6189
+ }
6048
6190
  var DefaultGame = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Cyberpunk 2077";
6049
6191
  function openSession(opts) {
6050
6192
  const libraryRoot = opts.library ?? defaultLibraryPath();
@@ -6139,12 +6281,20 @@ ${mod.files.join("\n")}`;
6139
6281
  }
6140
6282
  },
6141
6283
  /** Adds a REDcode or WolvenKit project's packed output, or refreshes the mod that follows it, and turns it on. */
6142
- installProject(s, projectFile) {
6284
+ /**
6285
+ * `sameName` answers for a mod of the same name that does not follow this project: follow (it
6286
+ * follows this project from now on) or version (the project is another version of it, on in its
6287
+ * place). Without it, the project is a mod of its own and the text says what it sits beside.
6288
+ */
6289
+ installProject(s, projectFile, sameName) {
6143
6290
  if (!fs14.existsSync(projectFile)) throw new Error(`${projectFile} does not exist`);
6144
- const { mod, added } = installProject(s.lib, projectFile);
6291
+ const other = sameNameMod(s.lib, projectFile);
6292
+ const before = other && `${other.id}${other.source.type === "project" && other.source.path ? ` (${path13.dirname(other.source.path)})` : ""}`;
6293
+ const { mod, added, beside } = installProject(s.lib, projectFile, other && sameName ? { mod: other, how: sameName } : void 0);
6294
+ const note = beside?.how === "follow" ? `; ${before} follows this project now` : beside ? `; another version of ${before}, on in its place` : other ? `; note: the library also has ${before} under the same name, a separate mod; the one higher in the list wins the files they share (call again with sameName follow or version to settle it)` : "";
6145
6295
  return {
6146
- data: { id: mod.id, name: mod.name, version: mod.version, kinds: mod.kinds, files: mod.files.length, added },
6147
- text: `${added ? "added" : "refreshed"} ${mod.id} from its project (${mod.files.length} files, ${mod.kinds.join(", ")}); it is on, deploy to put it in the game`
6296
+ data: { id: mod.id, name: mod.name, version: mod.version, kinds: mod.kinds, files: mod.files.length, added, sameName: other?.id, beside: beside?.how },
6297
+ text: `${added ? "added" : "refreshed"} ${mod.id} from its project (${mod.files.length} files, ${mod.kinds.join(", ")}); it is on, deploy to put it in the game${note}`
6148
6298
  };
6149
6299
  },
6150
6300
  turn(s, id, on) {
@@ -6158,22 +6308,37 @@ ${mod.files.join("\n")}`;
6158
6308
  moveMod(s.lib, mod, direction === "up" ? -1 : 1);
6159
6309
  return { data: { order: s.lib.info.order }, text: s.lib.info.order.join("\n") };
6160
6310
  },
6311
+ /** A new name in EDGEdeck for a mod and all its versions; an empty name puts the first name back. */
6312
+ rename(s, id, name) {
6313
+ const mod = find(s, id);
6314
+ const before = familyOf(s.lib, mod).name;
6315
+ const fam = renameFamily(s.lib, mod, name);
6316
+ return {
6317
+ data: { id: mod.id, name: fam.name, versions: fam.mods.map((m) => m.id) },
6318
+ text: `${before} is now ${fam.name}${fam.mods.length > 1 ? ` (${fam.mods.length} versions)` : ""}; its files and the game do not change`
6319
+ };
6320
+ },
6161
6321
  remove(s, id) {
6162
6322
  const mod = find(s, id);
6163
6323
  removeMod(s.lib, mod);
6164
6324
  return { data: { removed: mod.id }, text: `removed ${mod.id} from the library; its deployed files leave on the next deploy` };
6165
6325
  },
6166
- /** Deploys. A file changed outside EDGEdeck is skipped unless named in keep (import it) or restore (overwrite it). */
6167
- async deploy(s, keep = [], restore = []) {
6326
+ /**
6327
+ * Deploys. A file changed outside EDGEdeck is skipped unless named in keep (import it) or restore
6328
+ * (overwrite it). With `only` (a mod id), deploys that mod's changes and leaves the rest waiting.
6329
+ */
6330
+ async deploy(s, keep = [], restore = [], only) {
6168
6331
  gameReady(s);
6169
6332
  if (gameRunning()) throw new Error("Cyberpunk 2077 is running; it keeps mod files open. Quit the game, then deploy");
6333
+ if (only) find(s, only);
6170
6334
  const decisions = /* @__PURE__ */ new Map();
6171
6335
  for (const p of keep) decisions.set(p, "keep");
6172
6336
  for (const p of restore) decisions.set(p, "restore");
6173
- const result = deploy(s.lib, s.game, decisions, void 0, await gameVersion(s.game));
6174
- const redmod = enabledRedmodPackages(s.lib).length ? await deployRedmod(s.lib, s.game) : void 0;
6337
+ const result = deploy(s.lib, s.game, decisions, void 0, await gameVersion(s.game), only);
6338
+ const packages = only ? result.changes.some((c) => c.path.toLowerCase().startsWith("mods/")) ? deployedRedmodPackages(s.lib, s.game) : [] : enabledRedmodPackages(s.lib);
6339
+ const redmod = packages.length ? await deployRedmod(s.lib, s.game, void 0, packages) : void 0;
6175
6340
  const report = writeDeployReport(s.lib, s.game, result);
6176
- const text = `deployed (${result.method}): ${result.linked} added for ${modsTouched(result).length} mod(s), ${result.removed} removed, ${result.kept} unchanged; ${result.modsInGame} mod(s) in the game; report ${report}${result.skipped.length ? `; skipped (changed in game folder, answer with keep or restore): ${result.skipped.join(", ")}` : ""}${redmod ? `; REDmod: ${redmod.ran ? `${redmod.packages.length} prepared` : redmod.output}` : ""}`;
6341
+ const text = `deployed${only ? ` ${only} only` : ""} (${result.method}): ${result.linked} added for ${modsTouched(result).length} mod(s), ${result.removed} removed, ${result.kept} unchanged; ${result.modsInGame} mod(s) in the game; report ${report}${result.skipped.length ? `; skipped (changed in game folder, answer with keep or restore): ${result.skipped.join(", ")}` : ""}${redmod ? `; REDmod: ${redmod.ran ? `${redmod.packages.length} prepared` : redmod.output}` : ""}${result.waiting ? `; ${result.waiting} file(s) of other mods' changes still waiting` : ""}${lostProjectsText(s.lib, result.lost)}`;
6177
6342
  return { data: { ...result, redmod, report }, text };
6178
6343
  },
6179
6344
  undo(s) {
@@ -6271,6 +6436,7 @@ ${mod.files.join("\n")}`;
6271
6436
  deploy,
6272
6437
  deployRedmod,
6273
6438
  deployReportsFolder,
6439
+ deployedRedmodPackages,
6274
6440
  describeHandler,
6275
6441
  detectChanges,
6276
6442
  dotnet10Root,
@@ -6316,6 +6482,7 @@ ${mod.files.join("\n")}`;
6316
6482
  isGameFolder,
6317
6483
  keepDownload,
6318
6484
  latestBridgeRelease,
6485
+ launchTarget,
6319
6486
  libraryFolder,
6320
6487
  librarySize,
6321
6488
  listLoadouts,
@@ -6324,8 +6491,10 @@ ${mod.files.join("\n")}`;
6324
6491
  loadoutNameProblem,
6325
6492
  loadoutState,
6326
6493
  loadoutsFolder,
6494
+ lostFiles,
6327
6495
  makeActive,
6328
6496
  md5Of,
6497
+ modDeployPaths,
6329
6498
  modFolder,
6330
6499
  modPageUrl,
6331
6500
  modSize,
@@ -6349,6 +6518,7 @@ ${mod.files.join("\n")}`;
6349
6518
  placeAbove,
6350
6519
  placeNextTo,
6351
6520
  plan,
6521
+ projectMod,
6352
6522
  projectName,
6353
6523
  putInSection,
6354
6524
  readLoadout,
@@ -6360,8 +6530,10 @@ ${mod.files.join("\n")}`;
6360
6530
  registerNxmHandler,
6361
6531
  removeMod,
6362
6532
  removeOlderBridges,
6533
+ renameFamily,
6363
6534
  renameLoadout,
6364
6535
  renameSection,
6536
+ sameNameMod,
6365
6537
  saveLibrary,
6366
6538
  saveLoadout,
6367
6539
  saveMod,
@@ -6379,6 +6551,7 @@ ${mod.files.join("\n")}`;
6379
6551
  steamLibraries,
6380
6552
  syncActiveLoadout,
6381
6553
  syncProjects,
6554
+ takeFamilyName,
6382
6555
  tokenUser,
6383
6556
  undo,
6384
6557
  unpackOrCopy,
package/dist/mcp.js CHANGED
@@ -36932,6 +36932,7 @@ function rednodePackageId(mod) {
36932
36932
  return void 0;
36933
36933
  }
36934
36934
  function familyKey(mod) {
36935
+ if (mod.family) return mod.family;
36935
36936
  if (mod.source.modId) return `nexus:${mod.source.modId}`;
36936
36937
  const pkg = rednodePackageId(mod);
36937
36938
  if (pkg) return `rednode:${pkg}`;
@@ -36963,10 +36964,25 @@ function families(lib) {
36963
36964
  function familyOf(lib, mod) {
36964
36965
  return families(lib).find((f) => f.key === familyKey(mod)) ?? { key: familyKey(mod), name: mod.name, nexusId: mod.source.modId, mods: [mod], active: mod, on: mod.enabled };
36965
36966
  }
36967
+ function activateVersion(lib, chosen) {
36968
+ const fam = familyOf(lib, chosen);
36969
+ const previous = fam.active;
36970
+ const on = fam.on;
36971
+ if (previous.id !== chosen.id) placeAbove(lib, chosen, previous);
36972
+ for (const m of fam.mods) {
36973
+ const want = m.id === chosen.id ? on : false;
36974
+ if (m.enabled !== want) {
36975
+ m.enabled = want;
36976
+ saveMod(lib, m);
36977
+ }
36978
+ }
36979
+ return { previous, on };
36980
+ }
36966
36981
  function settleNewVersion(lib, mod) {
36967
36982
  const fam = familyOf(lib, mod);
36968
36983
  const others = fam.mods.filter((m) => m.id !== mod.id);
36969
36984
  if (!others.length) return {};
36985
+ takeFamilyName(lib, mod, others);
36970
36986
  const previous = others.find((m) => m.enabled) ?? [...others].sort(newestFirst)[0];
36971
36987
  if (mod.version && previous.version && versionNewer(previous.version, mod.version)) {
36972
36988
  if (mod.enabled) {
@@ -36989,6 +37005,31 @@ function settleNewVersion(lib, mod) {
36989
37005
  }
36990
37006
  return { replaced: previous };
36991
37007
  }
37008
+ function renameFamily(lib, mod, name) {
37009
+ const fam = familyOf(lib, mod);
37010
+ const to = name.trim();
37011
+ for (const m of fam.mods) {
37012
+ m.family = fam.key;
37013
+ if (!to) {
37014
+ if (m.renamedFrom !== void 0) m.name = m.renamedFrom;
37015
+ delete m.renamedFrom;
37016
+ } else if (m.name !== to) {
37017
+ m.renamedFrom ??= m.name;
37018
+ m.name = to;
37019
+ if (m.renamedFrom === to) delete m.renamedFrom;
37020
+ }
37021
+ saveMod(lib, m);
37022
+ }
37023
+ return familyOf(lib, mod);
37024
+ }
37025
+ function takeFamilyName(lib, mod, others) {
37026
+ const renamed = others.find((m) => m.renamedFrom !== void 0);
37027
+ if (!renamed || mod.name === renamed.name) return;
37028
+ mod.family = familyKey(renamed);
37029
+ mod.renamedFrom = mod.name;
37030
+ mod.name = renamed.name;
37031
+ saveMod(lib, mod);
37032
+ }
36992
37033
 
36993
37034
  // src/deploy.ts
36994
37035
  var fs4 = __toESM(require("node:fs"));
@@ -37178,6 +37219,18 @@ function detectChanges(gamePath) {
37178
37219
  }
37179
37220
  return changes;
37180
37221
  }
37222
+ function lostFiles(p) {
37223
+ const out = /* @__PURE__ */ new Map();
37224
+ for (const c of p.conflicts) {
37225
+ for (const loser of c.losers) {
37226
+ const k = `${loser}\0${c.winner}`;
37227
+ const l = out.get(k) ?? { mod: loser, files: 0, winner: c.winner };
37228
+ l.files++;
37229
+ out.set(k, l);
37230
+ }
37231
+ }
37232
+ return [...out.values()];
37233
+ }
37181
37234
  function modsTouched(result) {
37182
37235
  return [...new Set(result.changes.filter((c) => c.kind !== "removed" && c.mod !== Generated).map((c) => c.mod))];
37183
37236
  }
@@ -37267,28 +37320,46 @@ function cleanDirs(gamePath, manifest) {
37267
37320
  }
37268
37321
  manifest.dirs = manifest.dirs.filter((d) => fs4.existsSync(path4.join(gamePath, ...d.split("/"))));
37269
37322
  }
37270
- function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress, gameVersion2) {
37323
+ function modDeployPaths(gamePath, p, id) {
37324
+ const paths = new Set((readManifest(gamePath)?.files ?? []).filter((e) => e.mod === id).map((e) => key(e.path)));
37325
+ for (const d of p.desired) if (d.mod?.id === id) paths.add(key(d.rel));
37326
+ return paths;
37327
+ }
37328
+ function modlistFor(lib, files) {
37329
+ const rank = new Map(lib.mods.map((m, i) => [m.id, i]));
37330
+ const archives = files.filter((e) => e.mod !== Generated && e.path.toLowerCase().startsWith("archive/pc/mod/") && e.path.toLowerCase().endsWith(".archive")).sort((a, b) => (rank.get(a.mod) ?? 1e9) - (rank.get(b.mod) ?? 1e9) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
37331
+ return archives.length ? archives.map((e) => path4.posix.basename(e.path)).join("\r\n") + "\r\n" : "";
37332
+ }
37333
+ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress, gameVersion2, only) {
37271
37334
  if (vortexManages(gamePath)) throw new Error("Vortex manages this game folder. Purge in Vortex first (Mods > Purge), then deploy with EDGEdeck.");
37272
37335
  if (!isGameFolder(gamePath)) throw new Error(`${gamePath} does not look like the Cyberpunk 2077 folder (no bin\\x64\\Cyberpunk2077.exe).`);
37273
37336
  const p = plan(lib, gamePath);
37274
37337
  const manifest = readManifest(gamePath) ?? { version: 1, library: lib.root, gamePath, method: p.method, time: "", files: [], dirs: [] };
37275
37338
  manifest.library = lib.root;
37276
- const result = { linked: 0, removed: 0, kept: 0, backedUp: 0, method: manifest.method, skipped: [], changes: [], modsInGame: 0 };
37339
+ const result = { linked: 0, removed: 0, kept: 0, backedUp: 0, method: manifest.method, skipped: [], changes: [], modsInGame: 0, lost: lostFiles(p) };
37277
37340
  const entries = new Map(manifest.files.map((e) => [key(e.path), e]));
37341
+ const scope = only ? modDeployPaths(gamePath, p, only) : void 0;
37342
+ const inScope = (rel) => !scope || scope.has(key(rel));
37343
+ const stale = p.stale.filter((e) => inScope(e.path) && !(scope && e.mod === Generated));
37344
+ const add = p.add.filter((d) => inScope(d.rel) && !(scope && d.content !== void 0));
37345
+ if (scope) {
37346
+ const waiting = new Set([...p.stale.map((e) => e.path), ...p.add.map((d) => d.rel)].filter((r) => !scope.has(key(r)) && key(r) !== key(ModListPath)).map(key));
37347
+ result.waiting = waiting.size;
37348
+ }
37278
37349
  for (const [rel, decision] of decisions) {
37279
37350
  const d = p.desired.find((x) => key(x.rel) === key(rel));
37280
37351
  if (decision === "keep" && d?.mod) {
37281
37352
  fs4.copyFileSync(gameFile(gamePath, rel), d.src);
37282
37353
  }
37283
37354
  }
37284
- const total = p.stale.length + p.add.length;
37355
+ const total = stale.length + add.length;
37285
37356
  let done = 0;
37286
37357
  const save = () => {
37287
37358
  manifest.time = (/* @__PURE__ */ new Date()).toISOString();
37288
37359
  if (gameVersion2) manifest.gameVersion = gameVersion2;
37289
37360
  writeManifest(gamePath, manifest);
37290
37361
  };
37291
- for (const entry of p.stale) {
37362
+ const takeOut = (entry) => {
37292
37363
  const decided = decisions.get(entry.path);
37293
37364
  if (removeDeployed(lib, gamePath, manifest, entry, decided !== void 0, result.skipped)) {
37294
37365
  entries.delete(key(entry.path));
@@ -37296,10 +37367,8 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
37296
37367
  result.changes.push({ path: entry.path, mod: entry.mod, kind: "removed", ...entry.backup ? { backup: true } : {} });
37297
37368
  }
37298
37369
  manifest.files = [...entries.values()];
37299
- progress?.(++done, total);
37300
- }
37301
- if (p.stale.length) cleanDirs(gamePath, manifest);
37302
- for (const d of p.add) {
37370
+ };
37371
+ const putIn = (d) => {
37303
37372
  const target = gameFile(gamePath, d.rel);
37304
37373
  const existing = entries.get(key(d.rel));
37305
37374
  let backup = existing?.backup ?? false;
@@ -37307,7 +37376,7 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
37307
37376
  const state = stillOurs(existing, manifest.method, gamePath);
37308
37377
  if (state === "changed" && !decisions.has(d.rel)) {
37309
37378
  result.skipped.push(d.rel);
37310
- continue;
37379
+ return;
37311
37380
  }
37312
37381
  fs4.rmSync(target, { force: true });
37313
37382
  } else if (fs4.existsSync(target)) {
@@ -37333,10 +37402,29 @@ function deploy(lib, gamePath, decisions = /* @__PURE__ */ new Map(), progress,
37333
37402
  manifest.files = [...entries.values()];
37334
37403
  result.linked++;
37335
37404
  result.changes.push({ path: d.rel, mod: entry.mod, kind: existing ? "replaced" : "added", ...backup && !existing ? { backup: true } : {} });
37405
+ };
37406
+ for (const entry of stale) {
37407
+ takeOut(entry);
37408
+ progress?.(++done, total);
37409
+ }
37410
+ if (stale.length) cleanDirs(gamePath, manifest);
37411
+ for (const d of add) {
37412
+ putIn(d);
37336
37413
  progress?.(++done, total);
37337
37414
  if (done % 200 === 0) save();
37338
37415
  }
37339
- result.kept = p.desired.length - result.linked - result.skipped.length;
37416
+ if (scope) {
37417
+ const content = modlistFor(lib, manifest.files);
37418
+ const e = entries.get(key(ModListPath));
37419
+ if (!content && e) {
37420
+ takeOut(e);
37421
+ cleanDirs(gamePath, manifest);
37422
+ } else if (content && (!e || generatedDiffers(gameFile(gamePath, ModListPath), content))) {
37423
+ putIn({ rel: ModListPath, mod: void 0, src: "", content });
37424
+ }
37425
+ }
37426
+ result.kept = (scope ? p.desired.filter((d) => scope.has(key(d.rel))).length : p.desired.length) - result.linked - result.skipped.length;
37427
+ if (scope) result.kept = Math.max(0, result.kept);
37340
37428
  result.modsInGame = new Set(manifest.files.map((e) => e.mod).filter((m) => m !== Generated)).size;
37341
37429
  save();
37342
37430
  return result;
@@ -37620,11 +37708,48 @@ function classifyProject(projectFile) {
37620
37708
  if (!fs7.existsSync(packed)) return { placements: [], kinds: [], unplaced: [], ignored: [] };
37621
37709
  return classify(packed, projectName(projectFile));
37622
37710
  }
37623
- function installProject(lib, projectFile) {
37711
+ function projectMod(lib, projectFile) {
37712
+ return lib.mods.find((m) => m.source.type === "project" && m.source.path?.toLowerCase() === projectFile.toLowerCase());
37713
+ }
37714
+ function sameNameMod(lib, projectFile) {
37715
+ if (projectMod(lib, projectFile)) return void 0;
37716
+ const name = baseName(projectName(projectFile));
37717
+ const match = name ? lib.mods.find((m) => baseName(m.name) === name || m.renamedFrom !== void 0 && baseName(m.renamedFrom) === name) : void 0;
37718
+ return match && familyOf(lib, match).active;
37719
+ }
37720
+ function devVersion(lib, family) {
37721
+ const taken = new Set(lib.mods.filter((m) => familyKey(m) === family).map((m) => m.version.toLowerCase()));
37722
+ if (!taken.has("dev")) return "dev";
37723
+ let n = 2;
37724
+ while (taken.has(`dev ${n}`)) n++;
37725
+ return `dev ${n}`;
37726
+ }
37727
+ function installProject(lib, projectFile, beside) {
37624
37728
  const c = classifyProject(projectFile);
37625
37729
  if (!c.placements.length) throw new Error(`${projectName(projectFile)} has no packed output yet. Pack the project first (packed/ next to the project file).`);
37626
- const existing = lib.mods.find((m) => m.source.type === "project" && m.source.path?.toLowerCase() === projectFile.toLowerCase());
37730
+ let existing = projectMod(lib, projectFile);
37731
+ if (!existing && beside?.how === "follow") {
37732
+ existing = beside.mod;
37733
+ existing.family = familyKey(existing);
37734
+ existing.source = { type: "project", path: projectFile };
37735
+ }
37627
37736
  if (!existing) {
37737
+ if (beside?.how === "version") {
37738
+ const family = familyKey(beside.mod);
37739
+ const previous = familyOf(lib, beside.mod);
37740
+ const mod2 = addMod(lib, c.placements, { name: projectName(projectFile), version: devVersion(lib, family), source: { type: "project", path: projectFile } });
37741
+ mod2.family = family;
37742
+ takeFamilyName(lib, mod2, previous.mods);
37743
+ placeAbove(lib, mod2, previous.active);
37744
+ for (const m of previous.mods) {
37745
+ if (m.enabled) {
37746
+ m.enabled = false;
37747
+ saveMod(lib, m);
37748
+ }
37749
+ }
37750
+ saveMod(lib, mod2);
37751
+ return { mod: mod2, added: true, beside };
37752
+ }
37628
37753
  const mod = addMod(lib, c.placements, { name: projectName(projectFile), version: "dev", source: { type: "project", path: projectFile } });
37629
37754
  return { mod, added: true };
37630
37755
  }
@@ -37638,9 +37763,10 @@ function installProject(lib, projectFile) {
37638
37763
  for (const f of existing.files) if (!keep.has(f)) fs7.rmSync(sourcePath(lib, existing, f), { force: true });
37639
37764
  existing.files = [...keep].sort();
37640
37765
  existing.kinds = c.kinds;
37766
+ activateVersion(lib, existing);
37641
37767
  existing.enabled = true;
37642
37768
  saveMod(lib, existing);
37643
- return { mod: existing, added: false };
37769
+ return { mod: existing, added: false, ...beside ? { beside } : {} };
37644
37770
  }
37645
37771
 
37646
37772
  // src/redmod.ts
@@ -37663,8 +37789,18 @@ function enabledRedmodPackages(lib) {
37663
37789
  }
37664
37790
  return names;
37665
37791
  }
37666
- async function deployRedmod(lib, gamePath, log) {
37667
- const packages = enabledRedmodPackages(lib);
37792
+ function deployedRedmodPackages(lib, gamePath) {
37793
+ const inGame = new Set((readManifest(gamePath)?.files ?? []).map((e) => /^mods\/([^/]+)\//i.exec(e.path)?.[1]).filter((n) => !!n));
37794
+ const names = [];
37795
+ for (const mod of lib.mods) {
37796
+ for (const f of mod.files) {
37797
+ const m = /^mods\/([^/]+)\//i.exec(f);
37798
+ if (m && inGame.has(m[1]) && !names.includes(m[1])) names.push(m[1]);
37799
+ }
37800
+ }
37801
+ return names;
37802
+ }
37803
+ async function deployRedmod(lib, gamePath, log, packages = enabledRedmodPackages(lib)) {
37668
37804
  const exe = redModExe(gamePath);
37669
37805
  if (!packages.length) return { ran: false, packages, output: "no REDmod packages enabled" };
37670
37806
  if (!fs8.existsSync(exe)) return { ran: false, packages, output: `redMod.exe not found at ${exe}; the REDmod tools are a free DLC on the game's store page` };
@@ -37879,6 +38015,13 @@ function importFromVortex(lib, mods) {
37879
38015
  }
37880
38016
 
37881
38017
  // src/verbs.ts
38018
+ function lostProjectsText(lib, lost) {
38019
+ const lines = lost.flatMap((l) => {
38020
+ const mod = lib.mods.find((m) => m.id === l.mod);
38021
+ return mod?.source.type === "project" ? [`the game still has ${l.winner} for ${l.files} file(s), not the project ${l.mod} (move ${l.mod} up to let it win)`] : [];
38022
+ });
38023
+ return lines.length ? `; ${lines.join("; ")}` : "";
38024
+ }
37882
38025
  var DefaultGame = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Cyberpunk 2077";
37883
38026
  function openSession(opts) {
37884
38027
  const libraryRoot = opts.library ?? defaultLibraryPath();
@@ -37973,12 +38116,20 @@ ${mod.files.join("\n")}`;
37973
38116
  }
37974
38117
  },
37975
38118
  /** Adds a REDcode or WolvenKit project's packed output, or refreshes the mod that follows it, and turns it on. */
37976
- installProject(s, projectFile) {
38119
+ /**
38120
+ * `sameName` answers for a mod of the same name that does not follow this project: follow (it
38121
+ * follows this project from now on) or version (the project is another version of it, on in its
38122
+ * place). Without it, the project is a mod of its own and the text says what it sits beside.
38123
+ */
38124
+ installProject(s, projectFile, sameName) {
37977
38125
  if (!fs11.existsSync(projectFile)) throw new Error(`${projectFile} does not exist`);
37978
- const { mod, added } = installProject(s.lib, projectFile);
38126
+ const other = sameNameMod(s.lib, projectFile);
38127
+ const before = other && `${other.id}${other.source.type === "project" && other.source.path ? ` (${path11.dirname(other.source.path)})` : ""}`;
38128
+ const { mod, added, beside } = installProject(s.lib, projectFile, other && sameName ? { mod: other, how: sameName } : void 0);
38129
+ const note = beside?.how === "follow" ? `; ${before} follows this project now` : beside ? `; another version of ${before}, on in its place` : other ? `; note: the library also has ${before} under the same name, a separate mod; the one higher in the list wins the files they share (call again with sameName follow or version to settle it)` : "";
37979
38130
  return {
37980
- data: { id: mod.id, name: mod.name, version: mod.version, kinds: mod.kinds, files: mod.files.length, added },
37981
- text: `${added ? "added" : "refreshed"} ${mod.id} from its project (${mod.files.length} files, ${mod.kinds.join(", ")}); it is on, deploy to put it in the game`
38131
+ data: { id: mod.id, name: mod.name, version: mod.version, kinds: mod.kinds, files: mod.files.length, added, sameName: other?.id, beside: beside?.how },
38132
+ text: `${added ? "added" : "refreshed"} ${mod.id} from its project (${mod.files.length} files, ${mod.kinds.join(", ")}); it is on, deploy to put it in the game${note}`
37982
38133
  };
37983
38134
  },
37984
38135
  turn(s, id, on) {
@@ -37992,22 +38143,37 @@ ${mod.files.join("\n")}`;
37992
38143
  moveMod(s.lib, mod, direction === "up" ? -1 : 1);
37993
38144
  return { data: { order: s.lib.info.order }, text: s.lib.info.order.join("\n") };
37994
38145
  },
38146
+ /** A new name in EDGEdeck for a mod and all its versions; an empty name puts the first name back. */
38147
+ rename(s, id, name) {
38148
+ const mod = find(s, id);
38149
+ const before = familyOf(s.lib, mod).name;
38150
+ const fam = renameFamily(s.lib, mod, name);
38151
+ return {
38152
+ data: { id: mod.id, name: fam.name, versions: fam.mods.map((m) => m.id) },
38153
+ text: `${before} is now ${fam.name}${fam.mods.length > 1 ? ` (${fam.mods.length} versions)` : ""}; its files and the game do not change`
38154
+ };
38155
+ },
37995
38156
  remove(s, id) {
37996
38157
  const mod = find(s, id);
37997
38158
  removeMod(s.lib, mod);
37998
38159
  return { data: { removed: mod.id }, text: `removed ${mod.id} from the library; its deployed files leave on the next deploy` };
37999
38160
  },
38000
- /** Deploys. A file changed outside EDGEdeck is skipped unless named in keep (import it) or restore (overwrite it). */
38001
- async deploy(s, keep = [], restore = []) {
38161
+ /**
38162
+ * Deploys. A file changed outside EDGEdeck is skipped unless named in keep (import it) or restore
38163
+ * (overwrite it). With `only` (a mod id), deploys that mod's changes and leaves the rest waiting.
38164
+ */
38165
+ async deploy(s, keep = [], restore = [], only) {
38002
38166
  gameReady(s);
38003
38167
  if (gameRunning()) throw new Error("Cyberpunk 2077 is running; it keeps mod files open. Quit the game, then deploy");
38168
+ if (only) find(s, only);
38004
38169
  const decisions = /* @__PURE__ */ new Map();
38005
38170
  for (const p of keep) decisions.set(p, "keep");
38006
38171
  for (const p of restore) decisions.set(p, "restore");
38007
- const result = deploy(s.lib, s.game, decisions, void 0, await gameVersion(s.game));
38008
- const redmod = enabledRedmodPackages(s.lib).length ? await deployRedmod(s.lib, s.game) : void 0;
38172
+ const result = deploy(s.lib, s.game, decisions, void 0, await gameVersion(s.game), only);
38173
+ const packages = only ? result.changes.some((c) => c.path.toLowerCase().startsWith("mods/")) ? deployedRedmodPackages(s.lib, s.game) : [] : enabledRedmodPackages(s.lib);
38174
+ const redmod = packages.length ? await deployRedmod(s.lib, s.game, void 0, packages) : void 0;
38009
38175
  const report = writeDeployReport(s.lib, s.game, result);
38010
- const text = `deployed (${result.method}): ${result.linked} added for ${modsTouched(result).length} mod(s), ${result.removed} removed, ${result.kept} unchanged; ${result.modsInGame} mod(s) in the game; report ${report}${result.skipped.length ? `; skipped (changed in game folder, answer with keep or restore): ${result.skipped.join(", ")}` : ""}${redmod ? `; REDmod: ${redmod.ran ? `${redmod.packages.length} prepared` : redmod.output}` : ""}`;
38176
+ const text = `deployed${only ? ` ${only} only` : ""} (${result.method}): ${result.linked} added for ${modsTouched(result).length} mod(s), ${result.removed} removed, ${result.kept} unchanged; ${result.modsInGame} mod(s) in the game; report ${report}${result.skipped.length ? `; skipped (changed in game folder, answer with keep or restore): ${result.skipped.join(", ")}` : ""}${redmod ? `; REDmod: ${redmod.ran ? `${redmod.packages.length} prepared` : redmod.output}` : ""}${result.waiting ? `; ${result.waiting} file(s) of other mods' changes still waiting` : ""}${lostProjectsText(s.lib, result.lost)}`;
38011
38177
  return { data: { ...result, redmod, report }, text };
38012
38178
  },
38013
38179
  undo(s) {
@@ -38093,6 +38259,7 @@ ${JSON.stringify(v.data, null, 2)}` }] };
38093
38259
  };
38094
38260
  }
38095
38261
  var session = () => openSession(options);
38262
+ var AskFirst = { "anthropic/requiresUserInteraction": true };
38096
38263
  var server = new McpServer({ name: "edgedeck", version: version2 }, { instructions: Instructions });
38097
38264
  server.registerTool(
38098
38265
  "edgedeck_list",
@@ -38127,10 +38294,13 @@ server.registerTool(
38127
38294
  "edgedeck_install_project",
38128
38295
  {
38129
38296
  title: "Install a project",
38130
- description: "Takes a REDcode or WolvenKit project's packed/ folder into the library: adds it as version dev the first time, and on later calls replaces the files of the mod that follows the project. The mod is turned on; deploy to put it in the game. Pack the project first.",
38131
- inputSchema: { project: external_exports.string().describe("Full path of the .cpmodproj file") }
38297
+ description: "Takes a REDcode or WolvenKit project's packed/ folder into the library: adds it as version dev the first time, and on later calls replaces the files of the mod that follows the project. The mod is turned on; deploy to put it in the game. Pack the project first. When the library has a mod of the same name that does not follow this project (another project folder, or a download), the result says so; ask the player and call again with sameName.",
38298
+ inputSchema: {
38299
+ project: external_exports.string().describe("Full path of the .cpmodproj file"),
38300
+ sameName: external_exports.enum(["follow", "version"]).optional().describe("For a mod of the same name: follow = it follows this project from now on (no second mod); version = the project becomes another version of it, on in its place")
38301
+ }
38132
38302
  },
38133
- ({ project }) => run4(() => verbs.installProject(session(), project))()
38303
+ ({ project, sameName }) => run4(() => verbs.installProject(session(), project, sameName))()
38134
38304
  );
38135
38305
  server.registerTool(
38136
38306
  "edgedeck_turn",
@@ -38151,13 +38321,23 @@ server.registerTool(
38151
38321
  },
38152
38322
  ({ id, direction }) => run4(() => verbs.move(session(), id, direction))()
38153
38323
  );
38324
+ server.registerTool(
38325
+ "edgedeck_rename",
38326
+ {
38327
+ title: "Rename a mod",
38328
+ description: "Gives a mod a new name in EDGEdeck, for all its versions; its files and the game do not change. New versions of it take the name too. An empty name puts the first name back.",
38329
+ inputSchema: { id: external_exports.string(), name: external_exports.string().describe("The new name, or empty for the first one") }
38330
+ },
38331
+ ({ id, name }) => run4(() => verbs.rename(session(), id, name))()
38332
+ );
38154
38333
  server.registerTool(
38155
38334
  "edgedeck_remove",
38156
38335
  {
38157
38336
  title: "Remove a mod",
38158
38337
  description: "Deletes a mod from the library. Its deployed files leave the game on the next deploy. The kept download, if any, stays.",
38159
38338
  inputSchema: { id: external_exports.string() },
38160
- annotations: { destructiveHint: true }
38339
+ annotations: { destructiveHint: true },
38340
+ _meta: AskFirst
38161
38341
  },
38162
38342
  ({ id }) => run4(() => verbs.remove(session(), id))()
38163
38343
  );
@@ -38175,17 +38355,23 @@ server.registerTool(
38175
38355
  {
38176
38356
  title: "Deploy",
38177
38357
  description: "Hardlinks (or copies, on another drive) the files of every mod that is on into the game folder, writes the archive load order, prepares REDmod packages and records a manifest. A file changed in the game folder since the last deploy is skipped and named; call again with keep (import the changed file into the library) or restore (put the mod's file back). Refuses while Vortex manages the game folder.",
38178
- inputSchema: { keep: external_exports.array(external_exports.string()).optional().describe("Game-folder paths whose changed file to keep"), restore: external_exports.array(external_exports.string()).optional().describe("Game-folder paths to overwrite with the mod's file") },
38179
- annotations: { destructiveHint: true }
38358
+ inputSchema: {
38359
+ keep: external_exports.array(external_exports.string()).optional().describe("Game-folder paths whose changed file to keep"),
38360
+ restore: external_exports.array(external_exports.string()).optional().describe("Game-folder paths to overwrite with the mod's file"),
38361
+ mod: external_exports.string().optional().describe("A mod id: deploy only that mod's changes and leave other mods' changes waiting")
38362
+ },
38363
+ annotations: { destructiveHint: true },
38364
+ _meta: AskFirst
38180
38365
  },
38181
- ({ keep, restore }) => run4(() => verbs.deploy(session(), keep, restore))()
38366
+ ({ keep, restore, mod }) => run4(() => verbs.deploy(session(), keep, restore, mod))()
38182
38367
  );
38183
38368
  server.registerTool(
38184
38369
  "edgedeck_undo",
38185
38370
  {
38186
38371
  title: "Undo deploy",
38187
38372
  description: "Removes every file EDGEdeck deployed, and nothing else, and puts backed-up files back.",
38188
- annotations: { destructiveHint: true }
38373
+ annotations: { destructiveHint: true },
38374
+ _meta: AskFirst
38189
38375
  },
38190
38376
  run4(() => verbs.undo(session()))
38191
38377
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgemodders/edgedeck-mcp",
3
- "version": "0.27.3",
3
+ "version": "0.28.0",
4
4
  "description": "EDGEdeck's MCP server and command line: manage Cyberpunk 2077 mods (add, turn on or off, order, deploy, undo, conflicts, loadouts) from an AI assistant or a script.",
5
5
  "license": "MIT",
6
6
  "author": "Edgemodders",