jorgex-stack 1.0.31 → 1.1.1

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/dist/cli.js CHANGED
@@ -5,8 +5,8 @@ import * as p6 from "@clack/prompts";
5
5
  import { pathToFileURL as pathToFileURL2 } from "url";
6
6
 
7
7
  // src/install.ts
8
- import fs13 from "fs";
9
- import path19 from "path";
8
+ import fs15 from "fs";
9
+ import path21 from "path";
10
10
  import * as p from "@clack/prompts";
11
11
 
12
12
  // src/adapters/opencode.ts
@@ -48,6 +48,10 @@ function loadCanonicalAgents(agentsDir) {
48
48
  return parseCanonicalAgent(source, file);
49
49
  });
50
50
  }
51
+ var DEVTOOLS_MCP_SERVER = "chrome-devtools";
52
+ function isCanonicalMcpServerEnabled(name, server, enabledServers) {
53
+ return !server.optional || server.defaultEnabled === true || enabledServers?.has(name) === true;
54
+ }
51
55
  function loadCanonicalMcp(stackDir) {
52
56
  return JSON.parse(fs.readFileSync(path.join(stackDir, "mcp", "servers.json"), "utf8"));
53
57
  }
@@ -221,7 +225,7 @@ function ensureModelMapFile() {
221
225
  // src/lib/detect.ts
222
226
  import path5 from "path";
223
227
  import { existsSync as existsSync3, statSync } from "fs";
224
- import { execFileSync, execSync } from "child_process";
228
+ import { execFileSync } from "child_process";
225
229
  function lookPath(cmd) {
226
230
  const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ".ps1", ""] : [""];
227
231
  for (const dir of (process.env.PATH ?? "").split(path5.delimiter)) {
@@ -233,17 +237,24 @@ function lookPath(cmd) {
233
237
  }
234
238
  return null;
235
239
  }
240
+ function planDetectedBinCommand(bin, args) {
241
+ if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(bin)) return { command: bin, args };
242
+ if ([bin, ...args].some((part) => /[&|<>()^%!"\r\n]/.test(part))) return null;
243
+ const quote = (part) => part === "" || /\s/.test(part) ? `"${part}"` : part;
244
+ return {
245
+ command: process.env.ComSpec ?? "cmd.exe",
246
+ args: ["/d", "/s", "/c", [quote(bin), ...args.map(quote)].join(" ")]
247
+ };
248
+ }
236
249
  function runDetectedBin(bin, args, timeoutMs) {
237
250
  try {
238
- if (process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)) {
239
- if (/[&|<>^%!"]/.test(bin)) return null;
240
- return execSync(`"${bin}" ${args.join(" ")}`, {
241
- encoding: "utf8",
242
- timeout: timeoutMs,
243
- stdio: ["ignore", "pipe", "pipe"]
244
- });
245
- }
246
- return execFileSync(bin, args, { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] });
251
+ const command = planDetectedBinCommand(bin, args);
252
+ if (command === null) return null;
253
+ return execFileSync(command.command, command.args, {
254
+ encoding: "utf8",
255
+ timeout: timeoutMs,
256
+ stdio: ["ignore", "pipe", "pipe"]
257
+ });
247
258
  } catch {
248
259
  return null;
249
260
  }
@@ -505,6 +516,14 @@ var GIT_GUARD_SCRIPT = "block-destructive-git.cjs";
505
516
  function yamlString(value) {
506
517
  return JSON.stringify(value);
507
518
  }
519
+ function isManagedOptionalStdioServer(server, value) {
520
+ if (!server.optional || server.transport !== "stdio" || value === null || typeof value !== "object" || Array.isArray(value)) {
521
+ return false;
522
+ }
523
+ const current = value;
524
+ const expectedCommand = [server.command, ...server.args ?? []];
525
+ return Object.keys(current).length === 2 && current.type === "local" && Array.isArray(current.command) && current.command.length === expectedCommand.length && current.command.every((arg, index) => arg === expectedCommand[index]);
526
+ }
508
527
  var opencodeAdapter = {
509
528
  id: "opencode",
510
529
  name: "OpenCode",
@@ -636,6 +655,7 @@ ${agent.body}`,
636
655
  const original = readTextIfExists(file);
637
656
  const contentSource = original === null || original.trim() === "" ? null : original;
638
657
  const isFreshConfig = contentSource === null;
658
+ const mcpOwnership = [];
639
659
  const content = upsertJson(contentSource, (root) => {
640
660
  root["$schema"] ??= "https://opencode.ai/config.json";
641
661
  const defaults = loadCanonicalDefaults(ctx.stackDir)["opencode"];
@@ -647,6 +667,22 @@ ${agent.body}`,
647
667
  }
648
668
  const mcp = root["mcp"] ??= {};
649
669
  for (const [name, server] of Object.entries(canonical.servers)) {
670
+ const existing = mcp[name];
671
+ const owned = ctx.ownedMcpServers?.has(name) === true;
672
+ if (!isCanonicalMcpServerEnabled(name, server, ctx.enabledMcpServers)) {
673
+ if (owned) {
674
+ if (isManagedOptionalStdioServer(server, existing)) delete mcp[name];
675
+ mcpOwnership.push({ server: name, owned: false });
676
+ }
677
+ continue;
678
+ }
679
+ if (server.optional && existing !== void 0) {
680
+ if (!owned || !isManagedOptionalStdioServer(server, existing)) {
681
+ if (owned) mcpOwnership.push({ server: name, owned: false });
682
+ ctx.warnings.push(`OpenCode: MCP opcional '${name}' ya pertenece a la configuraci\xF3n del usuario; se conserva.`);
683
+ continue;
684
+ }
685
+ }
650
686
  if (server.transport === "stdio") {
651
687
  if (server.command === "{{ENGRAM_BIN}}" && ctx.engramBin === null) {
652
688
  ctx.warnings.push(
@@ -656,6 +692,7 @@ ${agent.body}`,
656
692
  }
657
693
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
658
694
  mcp[name] = { type: "local", command: [command, ...server.args ?? []] };
695
+ if (server.optional && existing === void 0 && !owned) mcpOwnership.push({ server: name, owned: true });
659
696
  } else {
660
697
  const previous = mcp[name];
661
698
  const headers = {};
@@ -685,7 +722,7 @@ ${agent.body}`,
685
722
  }
686
723
  }
687
724
  });
688
- return [{ kind: "write", target: file, content }];
725
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
689
726
  },
690
727
  planUnmerge(mcp, hooks, ctx) {
691
728
  const actions = [];
@@ -694,15 +731,26 @@ ${agent.body}`,
694
731
  if (prompt !== null) {
695
732
  let content = removeMarkdownSection(prompt, "system-prompt");
696
733
  content = removeMarkdownSection(content, "engram-protocol");
734
+ content = removeMarkdownSection(content, "browser");
697
735
  actions.push({ kind: "write", target: systemPromptFile, content });
698
736
  }
699
737
  const configFile = path7.join(ctx.configDir, "opencode.json");
700
738
  const config = readTextIfExists(configFile);
701
739
  if (config !== null) {
740
+ const mcpOwnership = [];
702
741
  const content = upsertJson(config, (root) => {
703
742
  const mcpBlock = root["mcp"];
704
743
  if (mcpBlock) {
705
- for (const name of Object.keys(mcp.servers)) delete mcpBlock[name];
744
+ for (const [name, server] of Object.entries(mcp.servers)) {
745
+ if (!server.optional) {
746
+ delete mcpBlock[name];
747
+ continue;
748
+ }
749
+ if (ctx.ownedMcpServers?.has(name) === true) {
750
+ if (isManagedOptionalStdioServer(server, mcpBlock[name])) delete mcpBlock[name];
751
+ mcpOwnership.push({ server: name, owned: false });
752
+ }
753
+ }
706
754
  if (Object.keys(mcpBlock).length === 0) delete root["mcp"];
707
755
  }
708
756
  const plugin = root["plugin"];
@@ -713,7 +761,7 @@ ${agent.body}`,
713
761
  else root["plugin"] = kept;
714
762
  }
715
763
  });
716
- actions.push({ kind: "write", target: configFile, content });
764
+ actions.push({ kind: "write", target: configFile, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} });
717
765
  }
718
766
  const hooksFile = path7.join(ctx.configDir, "hooks.json");
719
767
  const hooksJson = readTextIfExists(hooksFile);
@@ -773,6 +821,14 @@ function hasEngramPlugin(configDir) {
773
821
  return false;
774
822
  }
775
823
  }
824
+ function isManagedOptionalStdioServer2(server, value) {
825
+ if (!server.optional || server.transport !== "stdio" || value === null || typeof value !== "object" || Array.isArray(value)) {
826
+ return false;
827
+ }
828
+ const current = value;
829
+ const expectedArgs = server.args ?? [];
830
+ return Object.keys(current).length === 3 && current.type === "stdio" && current.command === server.command && Array.isArray(current.args) && current.args.length === expectedArgs.length && current.args.every((arg, index) => arg === expectedArgs[index]);
831
+ }
776
832
  var claudeCodeAdapter = {
777
833
  id: "claude-code",
778
834
  name: "Claude Code",
@@ -860,9 +916,26 @@ ${agent.body}`,
860
916
  },
861
917
  planMainConfig(canonical, ctx) {
862
918
  const file = path8.join(path8.dirname(ctx.configDir), `${path8.basename(ctx.configDir)}.json`);
919
+ const mcpOwnership = [];
863
920
  const content = upsertJson(readTextIfExists(file), (root) => {
864
921
  const servers = root["mcpServers"] ??= {};
865
922
  for (const [name, server] of Object.entries(canonical.servers)) {
923
+ const existing = servers[name];
924
+ const owned = ctx.ownedMcpServers?.has(name) === true;
925
+ if (!isCanonicalMcpServerEnabled(name, server, ctx.enabledMcpServers)) {
926
+ if (owned) {
927
+ if (isManagedOptionalStdioServer2(server, existing)) delete servers[name];
928
+ mcpOwnership.push({ server: name, owned: false });
929
+ }
930
+ continue;
931
+ }
932
+ if (server.optional && existing !== void 0) {
933
+ if (!owned || !isManagedOptionalStdioServer2(server, existing)) {
934
+ if (owned) mcpOwnership.push({ server: name, owned: false });
935
+ ctx.warnings.push(`Claude Code: MCP opcional '${name}' ya pertenece a la configuraci\xF3n del usuario; se conserva.`);
936
+ continue;
937
+ }
938
+ }
866
939
  if (server.transport === "stdio") {
867
940
  if (server.command === "{{ENGRAM_BIN}}" && hasEngramPlugin(ctx.configDir)) {
868
941
  if (name in servers) delete servers[name];
@@ -879,6 +952,7 @@ ${agent.body}`,
879
952
  }
880
953
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
881
954
  servers[name] = { type: "stdio", command, args: server.args ?? [] };
955
+ if (server.optional && existing === void 0 && !owned) mcpOwnership.push({ server: name, owned: true });
882
956
  } else {
883
957
  const previous = servers[name];
884
958
  const headers = {};
@@ -894,7 +968,7 @@ ${agent.body}`,
894
968
  }
895
969
  }
896
970
  });
897
- return [{ kind: "write", target: file, content }];
971
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
898
972
  },
899
973
  planUnmerge(mcp, hooks, ctx) {
900
974
  const actions = [];
@@ -903,6 +977,7 @@ ${agent.body}`,
903
977
  if (prompt !== null) {
904
978
  let content = removeMarkdownSection(prompt, "system-prompt");
905
979
  content = removeMarkdownSection(content, "engram-protocol");
980
+ content = removeMarkdownSection(content, "browser");
906
981
  actions.push({ kind: "write", target: systemPromptFile, content });
907
982
  }
908
983
  const settingsFile = path8.join(ctx.configDir, "settings.json");
@@ -916,13 +991,23 @@ ${agent.body}`,
916
991
  const mainFile = path8.join(path8.dirname(ctx.configDir), `${path8.basename(ctx.configDir)}.json`);
917
992
  const main2 = readTextIfExists(mainFile);
918
993
  if (main2 !== null) {
994
+ const mcpOwnership = [];
919
995
  const content = upsertJson(main2, (root) => {
920
996
  const servers = root["mcpServers"];
921
997
  if (!servers) return;
922
- for (const name of Object.keys(mcp.servers)) delete servers[name];
998
+ for (const [name, server] of Object.entries(mcp.servers)) {
999
+ if (!server.optional) {
1000
+ delete servers[name];
1001
+ continue;
1002
+ }
1003
+ if (ctx.ownedMcpServers?.has(name) === true) {
1004
+ if (isManagedOptionalStdioServer2(server, servers[name])) delete servers[name];
1005
+ mcpOwnership.push({ server: name, owned: false });
1006
+ }
1007
+ }
923
1008
  if (Object.keys(servers).length === 0) delete root["mcpServers"];
924
1009
  });
925
- actions.push({ kind: "write", target: mainFile, content });
1010
+ actions.push({ kind: "write", target: mainFile, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} });
926
1011
  }
927
1012
  return actions;
928
1013
  }
@@ -940,6 +1025,14 @@ function tomlMultiline(value) {
940
1025
  ${value.replace(/\r\n/g, "\n").trim()}
941
1026
  '''`;
942
1027
  }
1028
+ function stdioMcpSection(server) {
1029
+ const args = (server.args ?? []).map(tomlString).join(", ");
1030
+ return `command = ${tomlString(server.command)}
1031
+ args = [${args}]`;
1032
+ }
1033
+ function isManagedOptionalStdioServer3(server, section) {
1034
+ return server.optional === true && server.transport === "stdio" && section?.trim() === stdioMcpSection(server);
1035
+ }
943
1036
  function hasActiveEngramPlugin(configDir) {
944
1037
  const config = readTextIfExists(path9.join(configDir, "config.toml"));
945
1038
  if (config === null) return false;
@@ -1043,6 +1136,7 @@ ${body}`
1043
1136
  const original = readTextIfExists(file);
1044
1137
  const contentSource = original === null || original.trim() === "" ? null : original;
1045
1138
  let content = contentSource;
1139
+ const mcpOwnership = [];
1046
1140
  if (contentSource === null) {
1047
1141
  const defaults = loadCanonicalDefaults(ctx.stackDir)["codex"] ?? {};
1048
1142
  for (const [key, value] of Object.entries(defaults)) {
@@ -1089,6 +1183,22 @@ ${body}`
1089
1183
  }
1090
1184
  for (const [name, server] of Object.entries(canonical.servers)) {
1091
1185
  const section = `mcp_servers.${name}`;
1186
+ const existing = readTomlSection(content, section);
1187
+ const owned = ctx.ownedMcpServers?.has(name) === true;
1188
+ if (!isCanonicalMcpServerEnabled(name, server, ctx.enabledMcpServers)) {
1189
+ if (owned) {
1190
+ if (isManagedOptionalStdioServer3(server, existing)) content = removeTomlSection(content, section);
1191
+ mcpOwnership.push({ server: name, owned: false });
1192
+ }
1193
+ continue;
1194
+ }
1195
+ if (server.optional && existing !== null) {
1196
+ if (!owned || !isManagedOptionalStdioServer3(server, existing)) {
1197
+ if (owned) mcpOwnership.push({ server: name, owned: false });
1198
+ ctx.warnings.push(`Codex: MCP opcional '${name}' ya pertenece a la configuraci\xF3n del usuario; se conserva.`);
1199
+ continue;
1200
+ }
1201
+ }
1092
1202
  if (server.transport === "stdio") {
1093
1203
  if (server.command === "{{ENGRAM_BIN}}" && hasActiveEngramPlugin(ctx.configDir)) {
1094
1204
  if (content !== null) content = removeTomlSection(content, section);
@@ -1104,9 +1214,9 @@ ${body}`
1104
1214
  continue;
1105
1215
  }
1106
1216
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
1107
- const args = (server.args ?? []).map(tomlString).join(", ");
1108
1217
  content = upsertTomlSection(content, section, `command = ${tomlString(command)}
1109
- args = [${args}]`);
1218
+ args = [${(server.args ?? []).map(tomlString).join(", ")}]`);
1219
+ if (server.optional && existing === null && !owned) mcpOwnership.push({ server: name, owned: true });
1110
1220
  } else {
1111
1221
  const previousSection = readTomlSection(content, section);
1112
1222
  const prevUsesEnvHeaders = previousSection?.includes("env_http_headers") ?? false;
@@ -1127,7 +1237,7 @@ args = [${args}]`);
1127
1237
  }
1128
1238
  if (content === null) return [];
1129
1239
  if (!content.endsWith("\n")) content += "\n";
1130
- return [{ kind: "write", target: file, content }];
1240
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
1131
1241
  },
1132
1242
  planUnmerge(mcp, hooks, ctx) {
1133
1243
  const actions = [];
@@ -1136,14 +1246,28 @@ args = [${args}]`);
1136
1246
  if (prompt !== null) {
1137
1247
  let content = removeMarkdownSection(prompt, "system-prompt");
1138
1248
  content = removeMarkdownSection(content, "engram-protocol");
1249
+ content = removeMarkdownSection(content, "browser");
1139
1250
  actions.push({ kind: "write", target: systemPromptFile, content });
1140
1251
  }
1141
1252
  const configFile = path9.join(ctx.configDir, "config.toml");
1142
1253
  const config = readTextIfExists(configFile);
1143
1254
  if (config !== null) {
1144
1255
  let content = config;
1145
- for (const name of Object.keys(mcp.servers)) content = removeTomlSection(content, `mcp_servers.${name}`);
1146
- actions.push({ kind: "write", target: configFile, content });
1256
+ const mcpOwnership = [];
1257
+ for (const [name, server] of Object.entries(mcp.servers)) {
1258
+ const section = `mcp_servers.${name}`;
1259
+ if (!server.optional) {
1260
+ content = removeTomlSection(content, section);
1261
+ continue;
1262
+ }
1263
+ if (ctx.ownedMcpServers?.has(name) === true) {
1264
+ if (isManagedOptionalStdioServer3(server, readTomlSection(content, section))) {
1265
+ content = removeTomlSection(content, section);
1266
+ }
1267
+ mcpOwnership.push({ server: name, owned: false });
1268
+ }
1269
+ }
1270
+ actions.push({ kind: "write", target: configFile, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} });
1147
1271
  }
1148
1272
  const hooksFile = path9.join(ctx.configDir, "hooks.json");
1149
1273
  const hooksJson = readTextIfExists(hooksFile);
@@ -1409,6 +1533,10 @@ function planSystemPrompt(adapter, ctx) {
1409
1533
  normalize2(fs10.readFileSync(path14.join(ctx.stackDir, "system-prompt", "engram-protocol.md"), "utf8"))
1410
1534
  );
1411
1535
  const composedAgentsMd = composeProgrammaticSystemPrompt(ctx.stackDir, agentsMd, ctx.mode);
1536
+ const browser = [
1537
+ ctx.playwrightCliEnabled ? "browser-playwright.md" : null,
1538
+ ctx.enabledMcpServers?.has(DEVTOOLS_MCP_SERVER) ? "browser-chrome-devtools.md" : null
1539
+ ].filter((file) => file !== null).map((file) => normalize2(fs10.readFileSync(path14.join(ctx.stackDir, "system-prompt", file), "utf8"))).join("\n\n");
1412
1540
  let content = readTextIfExists(target);
1413
1541
  content = upsertMarkdownSection(content, "system-prompt", composedAgentsMd);
1414
1542
  if (adapter.injectEngramProtocol(ctx)) {
@@ -1416,6 +1544,7 @@ function planSystemPrompt(adapter, ctx) {
1416
1544
  } else {
1417
1545
  content = removeMarkdownSection(content, "engram-protocol");
1418
1546
  }
1547
+ content = browser === "" ? removeMarkdownSection(content, "browser") : upsertMarkdownSection(content, "browser", browser);
1419
1548
  return [{ kind: "write", target, content }];
1420
1549
  }
1421
1550
 
@@ -1517,13 +1646,256 @@ function planPlugins(adapter, ctx) {
1517
1646
  });
1518
1647
  }
1519
1648
 
1649
+ // src/lib/external-tools.ts
1650
+ import fs13 from "fs";
1651
+ import os2 from "os";
1652
+ import path19 from "path";
1653
+ import { execFileSync as execFileSync2 } from "child_process";
1654
+ var PLAYWRIGHT_CLI = {
1655
+ packageName: "@playwright/cli",
1656
+ bin: "playwright-cli",
1657
+ version: "0.1.17",
1658
+ browserInstallAction: "install-browser"
1659
+ };
1660
+ function parsePlaywrightCliVersion(output) {
1661
+ if (output === null) return null;
1662
+ return /^(?:playwright-cli\s+)?(\d+\.\d+\.\d+)$/i.exec(output.trim())?.[1] ?? null;
1663
+ }
1664
+ function resolvePlaywrightCliState({ binPath, versionOutput }) {
1665
+ if (binPath === null) return { status: "absent", binPath: null, detectedVersion: null };
1666
+ const detectedVersion = parsePlaywrightCliVersion(versionOutput);
1667
+ if (detectedVersion === null) return { status: "broken", binPath, detectedVersion: null };
1668
+ return {
1669
+ status: detectedVersion === PLAYWRIGHT_CLI.version ? "current" : "outdated",
1670
+ binPath,
1671
+ detectedVersion
1672
+ };
1673
+ }
1674
+ function detectPlaywrightCli() {
1675
+ const binPath = lookPath(PLAYWRIGHT_CLI.bin);
1676
+ return resolvePlaywrightCliState({
1677
+ binPath,
1678
+ versionOutput: binPath ? runDetectedBin(binPath, ["--version"], 5e3) : null
1679
+ });
1680
+ }
1681
+ function resolvePnpmBin() {
1682
+ const pnpm = lookPath("pnpm");
1683
+ if (pnpm !== null && !pnpm.toLowerCase().endsWith(".ps1")) return pnpm;
1684
+ const pnpmCmd = lookPath("pnpm.cmd");
1685
+ return pnpmCmd !== null && !pnpmCmd.toLowerCase().endsWith(".ps1") ? pnpmCmd : null;
1686
+ }
1687
+ function isPlaywrightBrowserReady(env = process.env, platform = process.platform, homeDir = os2.homedir()) {
1688
+ const configuredPath = env.PLAYWRIGHT_BROWSERS_PATH;
1689
+ const cacheDir = configuredPath ?? (platform === "win32" ? path19.join(env.LOCALAPPDATA ?? path19.join(homeDir, "AppData", "Local"), "ms-playwright") : platform === "darwin" ? path19.join(homeDir, "Library", "Caches", "ms-playwright") : path19.join(env.XDG_CACHE_HOME ?? path19.join(homeDir, ".cache"), "ms-playwright"));
1690
+ if (configuredPath === "0") return { status: "missing", path: cacheDir, errorCode: "DISABLED" };
1691
+ try {
1692
+ const ready = fs13.readdirSync(cacheDir, { withFileTypes: true }).some(
1693
+ (entry) => entry.isDirectory() && /^chromium(?:_headless_shell)?-/.test(entry.name)
1694
+ );
1695
+ return ready ? { status: "ready", path: cacheDir } : { status: "missing", path: cacheDir };
1696
+ } catch (error) {
1697
+ const errorCode = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN";
1698
+ return errorCode === "ENOENT" ? { status: "missing", path: cacheDir, errorCode } : { status: "unreadable", path: cacheDir, errorCode };
1699
+ }
1700
+ }
1701
+ function planPlaywrightCliCommand(action, pnpmBin) {
1702
+ const pinnedPackage = `${PLAYWRIGHT_CLI.packageName}@${PLAYWRIGHT_CLI.version}`;
1703
+ switch (action) {
1704
+ case "install":
1705
+ case "update":
1706
+ return { command: pnpmBin, args: ["add", "--global", pinnedPackage] };
1707
+ case "remove":
1708
+ return { command: pnpmBin, args: ["remove", "--global", PLAYWRIGHT_CLI.packageName] };
1709
+ case "install-browser":
1710
+ return { command: pnpmBin, args: ["dlx", pinnedPackage, PLAYWRIGHT_CLI.browserInstallAction] };
1711
+ }
1712
+ }
1713
+ function executePlaywrightToolAction(action, pnpmBin = resolvePnpmBin()) {
1714
+ if (pnpmBin === null) return false;
1715
+ const command = planPlaywrightCliCommand(action, pnpmBin);
1716
+ const invocation = planDetectedBinCommand(command.command, command.args);
1717
+ if (invocation === null) return false;
1718
+ try {
1719
+ execFileSync2(invocation.command, invocation.args, { stdio: "inherit" });
1720
+ return true;
1721
+ } catch {
1722
+ return false;
1723
+ }
1724
+ }
1725
+
1726
+ // src/lib/tool-preferences.ts
1727
+ import fs14 from "fs";
1728
+ import path20 from "path";
1729
+ var PLAYWRIGHT_CLI_PREFERENCE_VERSION = 1;
1730
+ var DEVTOOLS_MCP_PREFERENCE_VERSION = 1;
1731
+ function readPreference(file) {
1732
+ try {
1733
+ return { raw: fs14.readFileSync(file, "utf8"), errorCode: null };
1734
+ } catch (error) {
1735
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN";
1736
+ return code === "ENOENT" ? { raw: null, errorCode: null } : { raw: null, errorCode: code };
1737
+ }
1738
+ }
1739
+ function playwrightCliPreferenceFile(stateDir = dataDir()) {
1740
+ return path20.join(stateDir, "playwright-cli.json");
1741
+ }
1742
+ function parsePlaywrightCliPreference(raw) {
1743
+ try {
1744
+ const value = JSON.parse(raw);
1745
+ if (value.version !== PLAYWRIGHT_CLI_PREFERENCE_VERSION || typeof value.enabled !== "boolean") return void 0;
1746
+ return value.enabled;
1747
+ } catch {
1748
+ return void 0;
1749
+ }
1750
+ }
1751
+ function playwrightCliPreferenceError(file = playwrightCliPreferenceFile()) {
1752
+ const { raw, errorCode } = readPreference(file);
1753
+ if (errorCode !== null) {
1754
+ return `Playwright CLI: no se pudo leer la preferencia en ${file} (${errorCode}). Corrige o borra ese archivo antes de reintentar.`;
1755
+ }
1756
+ if (raw === null || parsePlaywrightCliPreference(raw) !== void 0) return null;
1757
+ return `Playwright CLI: preferencia inv\xE1lida en ${file}. Corrige o borra ese archivo antes de reintentar.`;
1758
+ }
1759
+ function loadPlaywrightCliPreference(file = playwrightCliPreferenceFile()) {
1760
+ const { raw } = readPreference(file);
1761
+ if (raw === null) return void 0;
1762
+ return parsePlaywrightCliPreference(raw);
1763
+ }
1764
+ function savePlaywrightCliPreference(file, enabled) {
1765
+ const error = playwrightCliPreferenceError(file);
1766
+ if (error !== null) throw new Error(error);
1767
+ writeText(file, JSON.stringify({ version: PLAYWRIGHT_CLI_PREFERENCE_VERSION, enabled }) + "\n");
1768
+ }
1769
+ function devtoolsMcpPreferenceFile(stateDir = dataDir()) {
1770
+ return path20.join(stateDir, "devtools-mcp.json");
1771
+ }
1772
+ function isRecord(value) {
1773
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1774
+ }
1775
+ function isRuntimeId(value) {
1776
+ return value === "claude-code" || value === "codex" || value === "opencode";
1777
+ }
1778
+ function parseDevtoolsMcpState(raw) {
1779
+ try {
1780
+ const value = JSON.parse(raw);
1781
+ if (!isRecord(value) || value.version !== DEVTOOLS_MCP_PREFERENCE_VERSION || !isRecord(value.enabled) || !isRecord(value.owned)) return null;
1782
+ const enabled = {};
1783
+ for (const [runtime, selected] of Object.entries(value.enabled)) {
1784
+ if (!isRuntimeId(runtime) || typeof selected !== "boolean") return null;
1785
+ enabled[runtime] = selected;
1786
+ }
1787
+ const owned = {};
1788
+ for (const [runtime, servers] of Object.entries(value.owned)) {
1789
+ if (!isRuntimeId(runtime) || !isRecord(servers)) return null;
1790
+ const managed = {};
1791
+ for (const [server, marked] of Object.entries(servers)) {
1792
+ if (marked !== true) return null;
1793
+ managed[server] = true;
1794
+ }
1795
+ if (Object.keys(managed).length > 0) owned[runtime] = managed;
1796
+ }
1797
+ return { version: DEVTOOLS_MCP_PREFERENCE_VERSION, enabled, owned };
1798
+ } catch {
1799
+ return null;
1800
+ }
1801
+ }
1802
+ function loadDevtoolsMcpState(file) {
1803
+ const empty = { version: DEVTOOLS_MCP_PREFERENCE_VERSION, enabled: {}, owned: {} };
1804
+ const { raw } = readPreference(file);
1805
+ if (raw === null) return empty;
1806
+ return parseDevtoolsMcpState(raw) ?? empty;
1807
+ }
1808
+ function devtoolsMcpPreferenceError(file = devtoolsMcpPreferenceFile()) {
1809
+ const { raw, errorCode } = readPreference(file);
1810
+ if (errorCode !== null) {
1811
+ return `Chrome DevTools MCP: no se pudo leer la preferencia en ${file} (${errorCode}). Corrige o borra ese archivo antes de reintentar.`;
1812
+ }
1813
+ if (raw === null || parseDevtoolsMcpState(raw) !== null) return null;
1814
+ return `Chrome DevTools MCP: preferencia inv\xE1lida en ${file}. Corrige o borra ese archivo antes de reintentar.`;
1815
+ }
1816
+ function saveDevtoolsMcpState(file, state) {
1817
+ const error = devtoolsMcpPreferenceError(file);
1818
+ if (error !== null) throw new Error(error);
1819
+ writeText(file, JSON.stringify(state) + "\n");
1820
+ }
1821
+ function loadDevtoolsMcpPreference(file, runtime) {
1822
+ return loadDevtoolsMcpState(file).enabled[runtime] === true;
1823
+ }
1824
+ function saveDevtoolsMcpPreference(file, runtime, enabled) {
1825
+ const state = loadDevtoolsMcpState(file);
1826
+ state.enabled[runtime] = enabled;
1827
+ saveDevtoolsMcpState(file, state);
1828
+ }
1829
+ function loadDevtoolsMcpOwnership(file, runtime, server) {
1830
+ return loadDevtoolsMcpState(file).owned[runtime]?.[server] === true;
1831
+ }
1832
+ function saveDevtoolsMcpOwnership(file, runtime, server, owned) {
1833
+ const state = loadDevtoolsMcpState(file);
1834
+ if (owned) {
1835
+ (state.owned[runtime] ??= {})[server] = true;
1836
+ } else {
1837
+ delete state.owned[runtime]?.[server];
1838
+ if (state.owned[runtime] !== void 0 && Object.keys(state.owned[runtime]).length === 0) delete state.owned[runtime];
1839
+ }
1840
+ saveDevtoolsMcpState(file, state);
1841
+ }
1842
+ function browserPreferenceErrors(stateDir = dataDir()) {
1843
+ return [
1844
+ playwrightCliPreferenceError(playwrightCliPreferenceFile(stateDir)),
1845
+ devtoolsMcpPreferenceError(devtoolsMcpPreferenceFile(stateDir))
1846
+ ].filter((error) => error !== null);
1847
+ }
1848
+
1520
1849
  // src/install.ts
1521
1850
  var ADAPTERS = {
1522
1851
  opencode: opencodeAdapter,
1523
1852
  "claude-code": claudeCodeAdapter,
1524
1853
  codex: codexAdapter
1525
1854
  };
1526
- function makeContext(adapter, configDir, mode = DEFAULT_INSTALL_MODE_PREFERENCE) {
1855
+ function executePlaywrightToolAction2(action) {
1856
+ return executePlaywrightToolAction(action, resolvePnpmBin());
1857
+ }
1858
+ function resolvePlaywrightToolPlan(consent) {
1859
+ if (consent.command !== "install" || consent.targetDir) return { actions: [] };
1860
+ const approved = consent.interactive ? consent.yes ? consent.explicitToolSelection : consent.confirmed : consent.yes && consent.explicitToolSelection;
1861
+ return approved ? { actions: ["install", "install-browser"], persistEnabledOnSuccess: true } : { actions: [] };
1862
+ }
1863
+ async function runPlaywrightToolPlan(plan, deps) {
1864
+ for (const action of plan.actions) {
1865
+ try {
1866
+ if (!await deps.run(action)) return { ok: false, failedAction: action };
1867
+ } catch {
1868
+ return { ok: false, failedAction: action };
1869
+ }
1870
+ }
1871
+ if (plan.persistEnabledOnSuccess) {
1872
+ try {
1873
+ deps.persistEnabled(true);
1874
+ } catch {
1875
+ return { ok: false, failedAction: "persist" };
1876
+ }
1877
+ }
1878
+ return { ok: true };
1879
+ }
1880
+ function enabledMcpServers(runtime, explicitDevtoolsEnabled, useBrowserPreferences = true) {
1881
+ const devtoolsEnabled = explicitDevtoolsEnabled ?? (useBrowserPreferences && loadDevtoolsMcpPreference(devtoolsMcpPreferenceFile(), runtime));
1882
+ return devtoolsEnabled ? /* @__PURE__ */ new Set([DEVTOOLS_MCP_SERVER]) : /* @__PURE__ */ new Set();
1883
+ }
1884
+ function ownedMcpServers(runtime, useBrowserPreferences = true) {
1885
+ if (!useBrowserPreferences) return /* @__PURE__ */ new Set();
1886
+ const file = devtoolsMcpPreferenceFile();
1887
+ return loadDevtoolsMcpOwnership(file, runtime, DEVTOOLS_MCP_SERVER) ? /* @__PURE__ */ new Set([DEVTOOLS_MCP_SERVER]) : /* @__PURE__ */ new Set();
1888
+ }
1889
+ function persistMcpOwnershipChanges(runtime, plan) {
1890
+ const latest = /* @__PURE__ */ new Map();
1891
+ for (const action of plan) {
1892
+ if (action.kind !== "write") continue;
1893
+ for (const change of action.mcpOwnership ?? []) latest.set(change.server, change.owned);
1894
+ }
1895
+ const file = devtoolsMcpPreferenceFile();
1896
+ for (const [server, owned] of latest) saveDevtoolsMcpOwnership(file, runtime, server, owned);
1897
+ }
1898
+ function makeContext(adapter, configDir, mode = DEFAULT_INSTALL_MODE_PREFERENCE, useBrowserPreferences = true) {
1527
1899
  const models = loadModelMap()[adapter.id];
1528
1900
  if (!models) return null;
1529
1901
  return {
@@ -1533,7 +1905,10 @@ function makeContext(adapter, configDir, mode = DEFAULT_INSTALL_MODE_PREFERENCE)
1533
1905
  subagentConcurrency: mode.subagentConcurrency,
1534
1906
  engramBin: detectEngram(),
1535
1907
  models,
1536
- warnings: []
1908
+ warnings: [],
1909
+ enabledMcpServers: enabledMcpServers(adapter.id, void 0, useBrowserPreferences),
1910
+ playwrightCliEnabled: useBrowserPreferences && loadPlaywrightCliPreference() === true,
1911
+ ownedMcpServers: ownedMcpServers(adapter.id, useBrowserPreferences)
1537
1912
  };
1538
1913
  }
1539
1914
  function buildPlan(adapter, ctx) {
@@ -1554,14 +1929,16 @@ function diffPlan(plan) {
1554
1929
  if (current === null) return { action, status: "create" };
1555
1930
  return { action, status: current === action.content ? "unchanged" : "update" };
1556
1931
  }
1557
- if (!fs13.existsSync(action.target)) return { action, status: "create" };
1932
+ if (!fs15.existsSync(action.target)) return { action, status: "create" };
1558
1933
  return { action, status: sameFileContent(action.source, action.target) ? "unchanged" : "update" };
1559
1934
  });
1560
1935
  }
1561
- function applyChanges(changes) {
1936
+ function applyChanges(changes, onMcpOwnershipWritten) {
1562
1937
  for (const { action } of changes) {
1563
- if (action.kind === "write") writeText(action.target, action.content);
1564
- else copyFile(action.source, action.target);
1938
+ if (action.kind === "write") {
1939
+ writeText(action.target, action.content);
1940
+ if (action.mcpOwnership !== void 0) onMcpOwnershipWritten?.(action);
1941
+ } else copyFile(action.source, action.target);
1565
1942
  }
1566
1943
  }
1567
1944
  function collectAllCurrentTargets(mode = DEFAULT_INSTALL_MODE_PREFERENCE) {
@@ -1578,7 +1955,7 @@ function collectAllCurrentTargets(mode = DEFAULT_INSTALL_MODE_PREFERENCE) {
1578
1955
  continue;
1579
1956
  }
1580
1957
  try {
1581
- for (const action of buildPlan(adapter, ctx)) targets.add(path19.resolve(action.target));
1958
+ for (const action of buildPlan(adapter, ctx)) targets.add(path21.resolve(action.target));
1582
1959
  } catch (error) {
1583
1960
  complete = false;
1584
1961
  warnings.push(
@@ -1594,6 +1971,17 @@ async function runInstall(opts) {
1594
1971
  const engramBin = detectEngram();
1595
1972
  const modePreference = opts.mode === void 0 ? opts.targetDir === void 0 ? loadInstallModePreference() : DEFAULT_INSTALL_MODE_PREFERENCE : normalizeInstallModePreference(opts.mode);
1596
1973
  const useManifest = opts.targetDir === void 0;
1974
+ const preferenceErrors = useManifest ? browserPreferenceErrors() : [];
1975
+ if (preferenceErrors.length > 0) {
1976
+ for (const error of preferenceErrors) p.log.error(error);
1977
+ p.outro("Install cancelado: corrige las preferencias de navegador antes de reintentar.");
1978
+ return 1;
1979
+ }
1980
+ const toolPlan = opts.playwrightToolConsent === void 0 ? null : resolvePlaywrightToolPlan({
1981
+ ...opts.playwrightToolConsent,
1982
+ targetDir: opts.targetDir !== void 0 || opts.playwrightToolConsent.targetDir
1983
+ });
1984
+ const projectPlaywrightPrompt = opts.dryRun && toolPlan?.persistEnabledOnSuccess === true;
1597
1985
  const modelMap = loadModelMap();
1598
1986
  if (useManifest) ensureModelMapFile();
1599
1987
  p.log.info(engramBin ? `Engram detectado: ${engramBin} (se respeta, D7)` : "Engram NO detectado.");
@@ -1607,6 +1995,7 @@ async function runInstall(opts) {
1607
1995
  }
1608
1996
  let exitCode = 0;
1609
1997
  let successfulRuns = 0;
1998
+ const successfulContexts = [];
1610
1999
  for (const id of opts.runtimes) {
1611
2000
  const adapter = ADAPTERS[id];
1612
2001
  if (!adapter) {
@@ -1632,7 +2021,16 @@ async function runInstall(opts) {
1632
2021
  subagentConcurrency: modePreference.subagentConcurrency,
1633
2022
  engramBin,
1634
2023
  models,
1635
- warnings: []
2024
+ warnings: [],
2025
+ enabledMcpServers: enabledMcpServers(id, opts.devtoolsMcpSelection?.[id], useManifest),
2026
+ playwrightCliEnabled: projectPlaywrightPrompt || useManifest && loadPlaywrightCliPreference() === true,
2027
+ ownedMcpServers: ownedMcpServers(id, useManifest)
2028
+ };
2029
+ const persistDevtoolsSelection = () => {
2030
+ const selection = opts.devtoolsMcpSelection?.[id];
2031
+ if (useManifest && selection !== void 0) {
2032
+ saveDevtoolsMcpPreference(devtoolsMcpPreferenceFile(), id, selection);
2033
+ }
1636
2034
  };
1637
2035
  let plan = buildPlan(adapter, ctx);
1638
2036
  let diff = diffPlan(plan);
@@ -1648,24 +2046,30 @@ async function runInstall(opts) {
1648
2046
  if (orphans.length > 0) p.log.info(`${orphans.length} hu\xE9rfanos de versiones previas a eliminar`);
1649
2047
  for (const w of ctx.warnings) p.log.warn(w);
1650
2048
  if (opts.dryRun) {
1651
- for (const c of changes.slice(0, 40)) p.log.message(` ${c.status === "create" ? "+" : "~"} ${c.action.target}`);
1652
- if (changes.length > 40) p.log.message(` \u2026 y ${changes.length - 40} m\xE1s`);
2049
+ const preview = changes.slice(0, 40);
2050
+ const projectedPrompt = projectPlaywrightPrompt ? changes.find((change) => change.action.target === adapter.paths(configDir).systemPromptFile) : void 0;
2051
+ if (projectedPrompt && !preview.includes(projectedPrompt)) preview.push(projectedPrompt);
2052
+ for (const c of preview) p.log.message(` ${c.status === "create" ? "+" : "~"} ${c.action.target}`);
2053
+ if (changes.length > preview.length) p.log.message(` \u2026 y ${changes.length - preview.length} m\xE1s`);
1653
2054
  for (const o of orphans) p.log.message(` - ${o}`);
1654
2055
  continue;
1655
2056
  }
1656
2057
  const writeManifest = () => {
1657
2058
  if (!useManifest) return;
1658
- const unmergeTargets = new Set(adapter.planUnmerge(canonicalMcp, canonicalHooks, ctx).map((a) => path19.resolve(a.target)));
2059
+ const unmergeTargets = new Set(adapter.planUnmerge(canonicalMcp, canonicalHooks, ctx).map((a) => path21.resolve(a.target)));
1659
2060
  const keepTarget = (target) => !unmergeTargets.has(target);
1660
- const liveOwned = plan.map((a) => path19.resolve(a.target)).filter(keepTarget);
1661
- const previousOwned = (prevManifest?.owned ?? []).map((target) => path19.resolve(target)).filter(keepTarget);
2061
+ const liveOwned = plan.map((a) => path21.resolve(a.target)).filter(keepTarget);
2062
+ const previousOwned = (prevManifest?.owned ?? []).map((target) => path21.resolve(target)).filter(keepTarget);
1662
2063
  const owned = canOrphan ? liveOwned : [.../* @__PURE__ */ new Set([...previousOwned, ...liveOwned])];
1663
2064
  writeRuntimeManifest(id, { configDir, owned, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
1664
2065
  };
1665
2066
  if (changes.length === 0 && orphans.length === 0) {
1666
2067
  writeManifest();
2068
+ if (useManifest) persistMcpOwnershipChanges(id, plan);
2069
+ persistDevtoolsSelection();
1667
2070
  p.log.success(`${adapter.name}: ya al d\xEDa (idempotente).`);
1668
2071
  successfulRuns++;
2072
+ successfulContexts.push({ adapter, ctx });
1669
2073
  continue;
1670
2074
  }
1671
2075
  if (!opts.yes && process.stdout.isTTY) {
@@ -1683,10 +2087,10 @@ async function runInstall(opts) {
1683
2087
  }
1684
2088
  const backup = useManifest ? createBackup([...updates.map((c) => c.action.target), ...orphans], `install-${id}`) : null;
1685
2089
  if (backup) p.log.info(`Backup: ${backup.id} (${backup.files.length} archivos)`);
1686
- applyChanges(changes);
1687
- const pruneRoot = useManifest ? HOME : path19.dirname(configDir);
2090
+ applyChanges(changes, useManifest ? (action) => persistMcpOwnershipChanges(id, [action]) : void 0);
2091
+ const pruneRoot = useManifest ? HOME : path21.dirname(configDir);
1688
2092
  for (const orphan of orphans) {
1689
- fs13.rmSync(orphan, { force: true });
2093
+ fs15.rmSync(orphan, { force: true });
1690
2094
  pruneEmptyDirs(orphan, pruneRoot);
1691
2095
  }
1692
2096
  const verifyCtx = { ...ctx, warnings: [] };
@@ -1697,26 +2101,98 @@ async function runInstall(opts) {
1697
2101
  exitCode = 1;
1698
2102
  } else {
1699
2103
  writeManifest();
2104
+ if (useManifest) persistMcpOwnershipChanges(id, plan);
2105
+ persistDevtoolsSelection();
1700
2106
  p.log.success(`${adapter.name}: ${changes.length} archivos aplicados y verificados (idempotente).`);
1701
2107
  successfulRuns++;
2108
+ successfulContexts.push({ adapter, ctx });
2109
+ }
2110
+ }
2111
+ if (toolPlan?.actions.length) {
2112
+ if (opts.dryRun) {
2113
+ p.log.info("Playwright CLI: instalaci\xF3n global y navegador previstos (dry-run; no se ejecutan).");
2114
+ } else if (exitCode === 0) {
2115
+ const result = await runPlaywrightToolPlan(toolPlan, opts.playwrightToolDeps ?? {
2116
+ run: async (action) => executePlaywrightToolAction2(action),
2117
+ persistEnabled: (enabled) => savePlaywrightCliPreference(playwrightCliPreferenceFile(), enabled)
2118
+ });
2119
+ if (!result.ok) {
2120
+ const reason = result.failedAction === "install" ? "no se pudo instalar el paquete global" : result.failedAction === "install-browser" ? "no se pudo descargar el navegador" : "se instalaron los componentes, pero no se pudo guardar la preferencia";
2121
+ p.log.error(`Playwright CLI: ${reason}; la preferencia no se ha marcado como habilitada. Ejecuta 'jorgex-stack install --playwright' para reintentar.`);
2122
+ exitCode = 1;
2123
+ } else {
2124
+ let promptReconciliationFailed = false;
2125
+ for (const { adapter, ctx } of successfulContexts) {
2126
+ const browserCtx = { ...ctx, playwrightCliEnabled: true, warnings: [] };
2127
+ try {
2128
+ const browserChanges = diffPlan(planSystemPrompt(adapter, browserCtx)).filter((change) => change.status !== "unchanged");
2129
+ if (browserChanges.length === 0) continue;
2130
+ const browserUpdates = browserChanges.filter((change) => change.status === "update");
2131
+ const backup = useManifest ? createBackup(browserUpdates.map((change) => change.action.target), `install-browser-${adapter.id}`) : null;
2132
+ if (backup) p.log.info(`Backup: ${backup.id} (${backup.files.length} archivos)`);
2133
+ applyChanges(browserChanges);
2134
+ const dirty = diffPlan(planSystemPrompt(adapter, { ...browserCtx, warnings: [] })).filter((change) => change.status !== "unchanged");
2135
+ if (dirty.length > 0) {
2136
+ p.log.error(`${adapter.name}: verificaci\xF3n de la gu\xEDa de navegador FALL\xD3 (${dirty.length} acciones inestables).`);
2137
+ promptReconciliationFailed = true;
2138
+ }
2139
+ } catch (error) {
2140
+ p.log.error(`${adapter.name}: no se pudo actualizar la gu\xEDa de navegador (${error instanceof Error ? error.message : String(error)}).`);
2141
+ exitCode = 1;
2142
+ promptReconciliationFailed = true;
2143
+ }
2144
+ }
2145
+ if (promptReconciliationFailed) {
2146
+ exitCode = 1;
2147
+ p.log.error("Playwright CLI y navegador se han instalado y la preferencia est\xE1 activada, pero la gu\xEDa de navegador qued\xF3 en estado parcial. Ejecuta 'jorgex-stack sync' para repararla.");
2148
+ } else {
2149
+ p.log.success("Playwright CLI y navegador instalados.");
2150
+ }
2151
+ }
2152
+ }
2153
+ } else if (useManifest && opts.playwrightToolConsent?.command === "sync" && loadPlaywrightCliPreference() === true) {
2154
+ const cli = detectPlaywrightCli();
2155
+ if (cli.status !== "current") {
2156
+ p.log.warn("Playwright CLI sigue habilitado, pero el paquete no est\xE1 listo; sync no instala herramientas. Ejecuta 'jorgex-stack install --playwright'.");
2157
+ } else {
2158
+ const browserCache = isPlaywrightBrowserReady();
2159
+ if (browserCache.status === "unreadable") {
2160
+ p.log.warn(`Playwright CLI sigue habilitado, pero no se puede leer la cach\xE9 de navegadores en ${browserCache.path} (${browserCache.errorCode}). Revisa permisos o ejecuta 'jorgex-stack install --playwright'.`);
2161
+ } else if (browserCache.status === "missing") {
2162
+ p.log.warn("Playwright CLI sigue habilitado, pero falta el navegador; sync no descarga navegadores. Ejecuta 'jorgex-stack install --playwright'.");
2163
+ }
1702
2164
  }
1703
2165
  }
1704
2166
  if (useManifest && !opts.dryRun && exitCode === 0 && successfulRuns > 0) {
1705
2167
  saveInstallModePreference(installModePreferenceFile(), modePreference);
1706
2168
  }
1707
- p.outro(opts.dryRun ? "Dry-run: no se ha escrito nada." : "Hecho.");
2169
+ p.outro(opts.dryRun ? exitCode === 0 ? "Dry-run: no se ha escrito nada." : "Dry-run completado con errores (revisa arriba)." : exitCode === 0 ? "Hecho." : "Install completado con errores (revisa arriba).");
1708
2170
  return exitCode;
1709
2171
  }
1710
2172
 
1711
2173
  // src/uninstall.ts
1712
- import fs14 from "fs";
1713
- import path20 from "path";
2174
+ import fs16 from "fs";
2175
+ import path22 from "path";
1714
2176
  import * as p2 from "@clack/prompts";
2177
+ function resolvePlaywrightUninstallPlan(input) {
2178
+ return {
2179
+ actions: input.removePackage ? ["remove"] : [],
2180
+ preserveBrowserData: true
2181
+ };
2182
+ }
1715
2183
  async function runUninstall(opts) {
1716
2184
  p2.intro(`jorgex-stack ${opts.dryRun ? "uninstall (dry-run)" : "uninstall"}`);
2185
+ const useBrowserPreferences = opts.targetDir === void 0;
2186
+ const preferenceErrors = useBrowserPreferences ? browserPreferenceErrors() : [];
2187
+ if (preferenceErrors.length > 0) {
2188
+ for (const error of preferenceErrors) p2.log.error(error);
2189
+ p2.outro("Uninstall cancelado: corrige las preferencias de navegador antes de reintentar.");
2190
+ return 1;
2191
+ }
1717
2192
  const stackDir = stackRoot();
1718
2193
  const mcp = loadCanonicalMcp(stackDir);
1719
2194
  const hooks = loadCanonicalHooks(stackDir);
2195
+ let exitCode = 0;
1720
2196
  let removeEngram = opts.removeEngram;
1721
2197
  if (!removeEngram && !opts.yes && !opts.dryRun && process.stdout.isTTY) {
1722
2198
  const answer = await p2.confirm({
@@ -1734,13 +2210,15 @@ async function runUninstall(opts) {
1734
2210
  p2.log.info("Engram se conserva: memorias, binario y registro intactos (usa --remove-engram para desregistrarlo).");
1735
2211
  }
1736
2212
  const retained = /* @__PURE__ */ new Set();
1737
- for (const keep of Object.values(ADAPTERS)) {
1738
- if (opts.runtimes.includes(keep.id)) continue;
1739
- const detection = keep.detect();
1740
- if (!detection.installed) continue;
1741
- const keepCtx = makeContext(keep, detection.configDir);
1742
- if (!keepCtx) continue;
1743
- for (const action of buildPlan(keep, keepCtx)) retained.add(path20.resolve(action.target));
2213
+ if (useBrowserPreferences) {
2214
+ for (const keep of Object.values(ADAPTERS)) {
2215
+ if (opts.runtimes.includes(keep.id)) continue;
2216
+ const detection = keep.detect();
2217
+ if (!detection.installed) continue;
2218
+ const keepCtx = makeContext(keep, detection.configDir);
2219
+ if (!keepCtx) continue;
2220
+ for (const action of buildPlan(keep, keepCtx)) retained.add(path22.resolve(action.target));
2221
+ }
1744
2222
  }
1745
2223
  for (const id of opts.runtimes) {
1746
2224
  const adapter = ADAPTERS[id];
@@ -1754,60 +2232,106 @@ async function runUninstall(opts) {
1754
2232
  p2.log.warn(`${adapter.name} no detectado \u2014 omitido.`);
1755
2233
  continue;
1756
2234
  }
1757
- const ctx = makeContext(adapter, configDir);
2235
+ const ctx = makeContext(adapter, configDir, void 0, useBrowserPreferences);
1758
2236
  if (!ctx) continue;
1759
2237
  ctx.preserveEngram = !removeEngram;
1760
2238
  const unmerge = adapter.planUnmerge(mcpForUnmerge, hooks, ctx);
1761
- const mergedTargets = new Set(unmerge.map((a) => path20.resolve(a.target)));
2239
+ const mergedTargets = new Set(unmerge.map((a) => path22.resolve(a.target)));
1762
2240
  const usingRealConfig = opts.targetDir === void 0;
1763
2241
  const prevOwned = usingRealConfig ? readManifest().runtimes[id]?.owned ?? [] : [];
1764
- const pruneRoot = usingRealConfig ? HOME : path20.dirname(configDir);
2242
+ const pruneRoot = usingRealConfig ? HOME : path22.dirname(configDir);
1765
2243
  const planTargets = [
1766
- .../* @__PURE__ */ new Set([...buildPlan(adapter, ctx).map((a) => path20.resolve(a.target)), ...prevOwned.map((t) => path20.resolve(t))])
1767
- ].filter((t) => !mergedTargets.has(t) && fs14.existsSync(t));
2244
+ .../* @__PURE__ */ new Set([...buildPlan(adapter, ctx).map((a) => path22.resolve(a.target)), ...prevOwned.map((t) => path22.resolve(t))])
2245
+ ].filter((t) => !mergedTargets.has(t) && fs16.existsSync(t));
1768
2246
  const deleteTargets = planTargets.filter(
1769
- (t) => !retained.has(t) && !(ctx.preserveEngram && path20.basename(t) === "engram.ts") && isContainedIn(t, pruneRoot)
2247
+ (t) => !retained.has(t) && !(ctx.preserveEngram && path22.basename(t) === "engram.ts") && isContainedIn(t, pruneRoot)
1770
2248
  );
1771
2249
  const sharedKept = planTargets.length - deleteTargets.length;
1772
2250
  p2.log.step(`${adapter.name} \u2192 ${configDir}`);
1773
2251
  p2.log.info(`${deleteTargets.length} archivos a borrar, ${unmerge.length} archivos compartidos a limpiar`);
1774
2252
  if (sharedKept > 0) p2.log.info(`${sharedKept} archivos se conservan: otros runtimes instalados los siguen usando.`);
1775
2253
  if (opts.dryRun) continue;
1776
- const backup = createBackup(
1777
- [...deleteTargets, ...unmerge.map((a) => a.target).filter((t) => fs14.existsSync(t))],
1778
- `uninstall-${id}`
1779
- );
2254
+ let backup;
2255
+ try {
2256
+ backup = createBackup(
2257
+ [...deleteTargets, ...unmerge.map((a) => a.target).filter((t) => fs16.existsSync(t))],
2258
+ `uninstall-${id}`
2259
+ );
2260
+ } catch (error) {
2261
+ p2.log.error(`${adapter.name}: no se pudo respaldar una configuraci\xF3n ilegible en ${configDir} \u2014 ${error instanceof Error ? error.message : String(error)}.`);
2262
+ exitCode = 1;
2263
+ continue;
2264
+ }
1780
2265
  if (backup) p2.log.info(`Backup: ${backup.id} (${backup.files.length} archivos)`);
1781
2266
  for (const target of deleteTargets) {
1782
- fs14.rmSync(target, { force: true });
2267
+ fs16.rmSync(target, { force: true });
1783
2268
  pruneEmptyDirs(target, pruneRoot);
1784
2269
  }
1785
2270
  for (const action of unmerge) {
1786
2271
  if (action.kind !== "write") continue;
1787
2272
  if (action.content.trim() === "") {
1788
- fs14.rmSync(action.target, { force: true });
2273
+ fs16.rmSync(action.target, { force: true });
1789
2274
  } else {
1790
2275
  writeText(action.target, action.content);
1791
2276
  }
2277
+ if (usingRealConfig) {
2278
+ for (const change of action.mcpOwnership ?? []) {
2279
+ saveDevtoolsMcpOwnership(devtoolsMcpPreferenceFile(), id, change.server, change.owned);
2280
+ }
2281
+ }
1792
2282
  }
1793
2283
  if (usingRealConfig) removeRuntimeManifest(id);
1794
2284
  p2.log.success(`${adapter.name}: stack retirado (lo tuyo queda intacto).`);
1795
2285
  }
1796
- p2.outro(opts.dryRun ? "Dry-run: no se ha tocado nada." : "Hecho. Usa 'restore' si quieres volver atr\xE1s.");
1797
- return 0;
2286
+ const playwrightPlan = resolvePlaywrightUninstallPlan({
2287
+ removePackage: opts.targetDir === void 0 && opts.removePlaywright
2288
+ });
2289
+ if (opts.removePlaywright && opts.targetDir !== void 0) {
2290
+ p2.log.info("Playwright CLI: --target-dir conserva el paquete global y los datos del navegador.");
2291
+ } else if (playwrightPlan.actions.length > 0) {
2292
+ if (opts.dryRun) {
2293
+ p2.log.info("Playwright CLI: se retirar\xEDa solo el paquete global; los datos y navegadores se conservan.");
2294
+ } else if (executePlaywrightToolAction2("remove")) {
2295
+ try {
2296
+ savePlaywrightCliPreference(playwrightCliPreferenceFile(), false);
2297
+ p2.log.success("Playwright CLI: paquete global retirado; los datos y navegadores se conservan.");
2298
+ } catch (error) {
2299
+ p2.log.error(`Playwright CLI: paquete global retirado, pero no se pudo guardar la preferencia (${error instanceof Error ? error.message : String(error)}). Corrige la preferencia antes de reintentar.`);
2300
+ exitCode = 1;
2301
+ }
2302
+ } else {
2303
+ p2.log.error("Playwright CLI: no se pudo retirar el paquete global; los datos y la preferencia se conservan.");
2304
+ exitCode = 1;
2305
+ }
2306
+ } else {
2307
+ p2.log.info("Playwright CLI: paquete global y datos del navegador conservados (usa --remove-playwright para retirar solo el paquete).");
2308
+ }
2309
+ p2.outro(opts.dryRun ? "Dry-run: no se ha tocado nada." : exitCode === 0 ? "Hecho. Usa 'restore' si quieres volver atr\xE1s." : "Uninstall completado con errores (revisa arriba).");
2310
+ return exitCode;
1798
2311
  }
1799
2312
 
1800
2313
  // src/doctor.ts
1801
- import path21 from "path";
1802
- import fs15 from "fs";
2314
+ import path23 from "path";
2315
+ import fs17 from "fs";
1803
2316
  import * as p3 from "@clack/prompts";
1804
2317
  function engramVersion(bin) {
1805
2318
  const out = runDetectedBin(bin, ["--version"], 5e3);
1806
2319
  if (out === null) return null;
1807
2320
  return /(\d+\.\d+\.\d+)/.exec(out)?.[1] ?? out.trim().split("\n")[0] ?? null;
1808
2321
  }
2322
+ function resolvePlaywrightDoctorState(input) {
2323
+ if (input.enabled !== true) return { status: "disabled" };
2324
+ if (input.cli.status === "absent") return { status: "missing", missing: "package" };
2325
+ if (input.cli.status === "broken") return { status: "broken" };
2326
+ if (input.cli.status === "outdated") return { status: "outdated" };
2327
+ if (input.browserCache?.status === "unreadable") {
2328
+ return { status: "unreadable", path: input.browserCache.path, errorCode: input.browserCache.errorCode };
2329
+ }
2330
+ if (!input.browserReady) return { status: "missing", missing: "browser" };
2331
+ return { status: "healthy" };
2332
+ }
1809
2333
  function context7KeyConfigured(id, configDir) {
1810
- const file = id === "codex" ? path21.join(configDir, "config.toml") : id === "claude-code" ? path21.join(path21.dirname(configDir), `${path21.basename(configDir)}.json`) : path21.join(configDir, "opencode.json");
2334
+ const file = id === "codex" ? path23.join(configDir, "config.toml") : id === "claude-code" ? path23.join(path23.dirname(configDir), `${path23.basename(configDir)}.json`) : path23.join(configDir, "opencode.json");
1811
2335
  const content = readTextIfExists(file);
1812
2336
  if (content === null) return null;
1813
2337
  const match = /CONTEXT7_API_KEY"?\s*[:=]\s*"([^"]*)"/.exec(content);
@@ -1830,13 +2354,44 @@ async function runDoctor() {
1830
2354
  p3.log.success(`Engram: ${version} (${engramBin})`);
1831
2355
  }
1832
2356
  }
1833
- const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path21.join(HOME, ".engram");
1834
- const engramDb = path21.join(engramDataDir, "engram.db");
1835
- if (fs15.existsSync(engramDb)) {
1836
- const sizeMb = (fs15.statSync(engramDb).size / 1024 / 1024).toFixed(1);
2357
+ const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path23.join(HOME, ".engram");
2358
+ const engramDb = path23.join(engramDataDir, "engram.db");
2359
+ if (fs17.existsSync(engramDb)) {
2360
+ const sizeMb = (fs17.statSync(engramDb).size / 1024 / 1024).toFixed(1);
1837
2361
  p3.log.info(`Engram DB: ${engramDb} (${sizeMb} MB de memorias \u2014 el stack no la toca JAM\xC1S).`);
1838
2362
  }
1839
- if (!fs15.existsSync(modelMapFile())) p3.log.info("model-map: a\xFAn no creado (se crea en el primer install o con 'models').");
2363
+ if (!fs17.existsSync(modelMapFile())) p3.log.info("model-map: a\xFAn no creado (se crea en el primer install o con 'models').");
2364
+ const preferenceErrors = browserPreferenceErrors();
2365
+ if (preferenceErrors.length > 0) {
2366
+ for (const error of preferenceErrors) p3.log.error(error);
2367
+ problems += preferenceErrors.length;
2368
+ } else {
2369
+ const browserCache = isPlaywrightBrowserReady();
2370
+ const playwright = resolvePlaywrightDoctorState({
2371
+ enabled: loadPlaywrightCliPreference(),
2372
+ cli: detectPlaywrightCli(),
2373
+ browserReady: browserCache.status === "ready",
2374
+ browserCache
2375
+ });
2376
+ if (playwright.status === "disabled") {
2377
+ p3.log.info("Playwright CLI: deshabilitado (opcional). Usa 'install --playwright' para instalarlo de forma expl\xEDcita.");
2378
+ } else if (playwright.status === "healthy") {
2379
+ p3.log.success("Playwright CLI: paquete y navegador listos.");
2380
+ } else if (playwright.status === "missing") {
2381
+ const target = playwright.missing === "package" ? "el paquete global" : "el navegador de Playwright";
2382
+ p3.log.warn(`Playwright CLI: habilitado, pero falta ${target} \u2192 ejecuta 'jorgex-stack install --playwright'.`);
2383
+ problems++;
2384
+ } else if (playwright.status === "broken") {
2385
+ p3.log.error("Playwright CLI: el binario detectado no responde correctamente \u2192 ejecuta 'jorgex-stack install --playwright'.");
2386
+ problems++;
2387
+ } else if (playwright.status === "unreadable") {
2388
+ p3.log.error(`Playwright CLI: no se puede leer la cach\xE9 de navegadores en ${playwright.path} (${playwright.errorCode}) \u2192 revisa permisos o ejecuta 'jorgex-stack install --playwright'.`);
2389
+ problems++;
2390
+ } else {
2391
+ p3.log.warn("Playwright CLI: versi\xF3n distinta del pin aprobado \u2192 ejecuta 'jorgex-stack update' o 'install --playwright'.");
2392
+ problems++;
2393
+ }
2394
+ }
1840
2395
  const manifest = readManifest();
1841
2396
  const modePreference = loadInstallModePreference();
1842
2397
  const current = collectAllCurrentTargets(modePreference);
@@ -1875,10 +2430,10 @@ async function runDoctor() {
1875
2430
  p3.log.warn(`${adapter.name}: ${orphans.length} archivos hu\xE9rfanos de versiones previas \u2192 ejecuta 'sync'.`);
1876
2431
  problems++;
1877
2432
  }
1878
- if (adapter.id === "codex" && fs15.existsSync(path21.join(detection.configDir, "hooks.json"))) {
2433
+ if (adapter.id === "codex" && fs17.existsSync(path23.join(detection.configDir, "hooks.json"))) {
1879
2434
  p3.log.info("Codex: recuerda que los hooks requieren aprobaci\xF3n manual \u2014 verifica con /hooks dentro de codex.");
1880
2435
  }
1881
- if (adapter.id === "codex" && fs15.existsSync(path21.join(detection.configDir, "AGENTS.override.md"))) {
2436
+ if (adapter.id === "codex" && fs17.existsSync(path23.join(detection.configDir, "AGENTS.override.md"))) {
1882
2437
  p3.log.warn(
1883
2438
  "Codex: existe ~/.codex/AGENTS.override.md \u2014 tiene prioridad ABSOLUTA y tapa el AGENTS.md gestionado por el stack."
1884
2439
  );
@@ -1892,17 +2447,17 @@ async function runDoctor() {
1892
2447
  }
1893
2448
 
1894
2449
  // src/update.ts
1895
- import fs18 from "fs";
1896
- import path24 from "path";
1897
- import os3 from "os";
1898
- import { execFileSync as execFileSync4 } from "child_process";
2450
+ import fs20 from "fs";
2451
+ import path26 from "path";
2452
+ import os4 from "os";
2453
+ import { execFileSync as execFileSync5 } from "child_process";
1899
2454
  import * as p4 from "@clack/prompts";
1900
2455
 
1901
2456
  // src/lib/github.ts
1902
- import fs16 from "fs";
1903
- import path22 from "path";
1904
- import { execFileSync as execFileSync2 } from "child_process";
1905
- import os2 from "os";
2457
+ import fs18 from "fs";
2458
+ import path24 from "path";
2459
+ import { execFileSync as execFileSync3 } from "child_process";
2460
+ import os3 from "os";
1906
2461
  import { Readable } from "stream";
1907
2462
  import { pipeline } from "stream/promises";
1908
2463
  var cachedToken;
@@ -1970,19 +2525,19 @@ async function latestGithubCommit(repo) {
1970
2525
  }
1971
2526
  }
1972
2527
  function validateExtractedTree(destDir) {
1973
- const resolved = path22.resolve(destDir);
2528
+ const resolved = path24.resolve(destDir);
1974
2529
  const walk = (dir) => {
1975
2530
  let entries;
1976
2531
  try {
1977
- entries = fs16.readdirSync(dir, { withFileTypes: true });
2532
+ entries = fs18.readdirSync(dir, { withFileTypes: true });
1978
2533
  } catch {
1979
2534
  return false;
1980
2535
  }
1981
2536
  for (const entry of entries) {
1982
- const full = path22.join(dir, entry.name);
2537
+ const full = path24.join(dir, entry.name);
1983
2538
  let stat;
1984
2539
  try {
1985
- stat = fs16.lstatSync(full);
2540
+ stat = fs18.lstatSync(full);
1986
2541
  } catch {
1987
2542
  return false;
1988
2543
  }
@@ -1998,15 +2553,15 @@ function validateExtractedTree(destDir) {
1998
2553
  }
1999
2554
  function resolveTarBin() {
2000
2555
  if (process.platform !== "win32") return "tar";
2001
- const winTar = path22.join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "tar.exe");
2002
- return fs16.existsSync(winTar) ? winTar : "tar";
2556
+ const winTar = path24.join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "tar.exe");
2557
+ return fs18.existsSync(winTar) ? winTar : "tar";
2003
2558
  }
2004
2559
  async function downloadRepoTarball(repo, sha, destDir, validateSubdir) {
2005
2560
  const url = `https://codeload.github.com/${repo}/tar.gz/${sha}`;
2006
- const tmp = path22.join(os2.tmpdir(), `jorgex-tarball-${Date.now()}.tar.gz`);
2561
+ const tmp = path24.join(os3.tmpdir(), `jorgex-tarball-${Date.now()}.tar.gz`);
2007
2562
  const fail = (reason) => {
2008
2563
  try {
2009
- fs16.rmSync(destDir, { recursive: true, force: true });
2564
+ fs18.rmSync(destDir, { recursive: true, force: true });
2010
2565
  } catch {
2011
2566
  }
2012
2567
  return { ok: false, reason };
@@ -2025,46 +2580,46 @@ async function downloadRepoTarball(repo, sha, destDir, validateSubdir) {
2025
2580
  if (!res.body) return fail("respuesta HTTP sin cuerpo");
2026
2581
  await pipeline(
2027
2582
  Readable.fromWeb(res.body),
2028
- fs16.createWriteStream(tmp)
2583
+ fs18.createWriteStream(tmp)
2029
2584
  );
2030
- fs16.rmSync(destDir, { recursive: true, force: true });
2031
- fs16.mkdirSync(destDir, { recursive: true });
2585
+ fs18.rmSync(destDir, { recursive: true, force: true });
2586
+ fs18.mkdirSync(destDir, { recursive: true });
2032
2587
  try {
2033
- execFileSync2(resolveTarBin(), ["-xzf", tmp, "--strip-components=1", "-C", destDir], { stdio: "pipe" });
2588
+ execFileSync3(resolveTarBin(), ["-xzf", tmp, "--strip-components=1", "-C", destDir], { stdio: "pipe" });
2034
2589
  } catch (err) {
2035
2590
  const e = err;
2036
2591
  const detail = (e.stderr?.toString().trim() || e.message || "").split("\n")[0];
2037
2592
  return fail(detail ? `tar fall\xF3: ${detail}` : "tar no disponible o fall\xF3 la extracci\xF3n");
2038
2593
  }
2039
- const resolvedDest = path22.resolve(destDir);
2040
- const validateRoot = validateSubdir ? path22.resolve(resolvedDest, validateSubdir) : resolvedDest;
2594
+ const resolvedDest = path24.resolve(destDir);
2595
+ const validateRoot = validateSubdir ? path24.resolve(resolvedDest, validateSubdir) : resolvedDest;
2041
2596
  if (validateRoot !== resolvedDest && !isContainedIn(validateRoot, resolvedDest)) {
2042
2597
  return fail(`la ruta de validaci\xF3n "${validateSubdir}" escapa del destino`);
2043
2598
  }
2044
- if (fs16.existsSync(validateRoot) && !validateExtractedTree(validateRoot)) {
2599
+ if (fs18.existsSync(validateRoot) && !validateExtractedTree(validateRoot)) {
2045
2600
  return fail("el \xE1rbol extra\xEDdo contiene symlinks o rutas fuera del destino");
2046
2601
  }
2047
- const validated = fs16.existsSync(validateRoot);
2602
+ const validated = fs18.existsSync(validateRoot);
2048
2603
  return { ok: true, validated };
2049
2604
  } catch (err) {
2050
2605
  const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
2051
2606
  return fail(timedOut ? "timeout de descarga (120s)" : err instanceof Error ? `fallo de red: ${err.message}` : "error desconocido");
2052
2607
  } finally {
2053
2608
  try {
2054
- fs16.rmSync(tmp, { force: true });
2609
+ fs18.rmSync(tmp, { force: true });
2055
2610
  } catch {
2056
2611
  }
2057
2612
  }
2058
2613
  }
2059
2614
 
2060
2615
  // src/lib/skill-update.ts
2061
- import fs17 from "fs";
2062
- import path23 from "path";
2063
- import { execFileSync as execFileSync3 } from "child_process";
2616
+ import fs19 from "fs";
2617
+ import path25 from "path";
2618
+ import { execFileSync as execFileSync4 } from "child_process";
2064
2619
  var PROTECTED_SKILLS = /* @__PURE__ */ new Set(["agent-delegation", "work-lifecycle", "xreview"]);
2065
2620
  function sameTextContentNormalized(a, b) {
2066
- const ba = fs17.readFileSync(a);
2067
- const bb = fs17.readFileSync(b);
2621
+ const ba = fs19.readFileSync(a);
2622
+ const bb = fs19.readFileSync(b);
2068
2623
  if (ba.equals(bb)) return true;
2069
2624
  const sa = ba.toString("utf8").replace(/\r\n/g, "\n");
2070
2625
  const sb = bb.toString("utf8").replace(/\r\n/g, "\n");
@@ -2072,10 +2627,10 @@ function sameTextContentNormalized(a, b) {
2072
2627
  }
2073
2628
  function diffSkillDirs(upstreamDir, localDir) {
2074
2629
  const upstreamFiles = new Set(
2075
- listFilesRecursive(upstreamDir).map((f) => path23.relative(upstreamDir, f))
2630
+ listFilesRecursive(upstreamDir).map((f) => path25.relative(upstreamDir, f))
2076
2631
  );
2077
2632
  const localFiles = new Set(
2078
- listFilesRecursive(localDir).map((f) => path23.relative(localDir, f))
2633
+ listFilesRecursive(localDir).map((f) => path25.relative(localDir, f))
2079
2634
  );
2080
2635
  const added = [];
2081
2636
  const modified = [];
@@ -2083,7 +2638,7 @@ function diffSkillDirs(upstreamDir, localDir) {
2083
2638
  for (const rel of upstreamFiles) {
2084
2639
  if (!localFiles.has(rel)) {
2085
2640
  added.push(rel);
2086
- } else if (!sameTextContentNormalized(path23.join(upstreamDir, rel), path23.join(localDir, rel))) {
2641
+ } else if (!sameTextContentNormalized(path25.join(upstreamDir, rel), path25.join(localDir, rel))) {
2087
2642
  modified.push(rel);
2088
2643
  }
2089
2644
  }
@@ -2101,7 +2656,7 @@ function diffSkillDirs(upstreamDir, localDir) {
2101
2656
  var DIFF_MAX_LINES = 400;
2102
2657
  function renderSkillDiff(upstreamDir, localDir) {
2103
2658
  try {
2104
- execFileSync3("git", ["diff", "--no-index", "--stat", "--", localDir, upstreamDir], {
2659
+ execFileSync4("git", ["diff", "--no-index", "--stat", "--", localDir, upstreamDir], {
2105
2660
  stdio: "pipe",
2106
2661
  encoding: "utf8"
2107
2662
  });
@@ -2118,7 +2673,7 @@ function renderSkillDiff(upstreamDir, localDir) {
2118
2673
  const stat = se.stdout;
2119
2674
  let fullDiff = "";
2120
2675
  try {
2121
- execFileSync3("git", ["diff", "--no-index", "--", localDir, upstreamDir], {
2676
+ execFileSync4("git", ["diff", "--no-index", "--", localDir, upstreamDir], {
2122
2677
  stdio: "pipe",
2123
2678
  encoding: "utf8"
2124
2679
  });
@@ -2145,8 +2700,8 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2145
2700
  if (PROTECTED_SKILLS.has(name)) {
2146
2701
  throw new Error(`La skill "${name}" es propia del stack y no se actualiza desde upstream.`);
2147
2702
  }
2148
- const upstreamsFile = upstreamsFilePath ?? path23.join(path23.dirname(stackRoot()), "upstreams.json");
2149
- const raw = fs17.readFileSync(upstreamsFile, "utf8");
2703
+ const upstreamsFile = upstreamsFilePath ?? path25.join(path25.dirname(stackRoot()), "upstreams.json");
2704
+ const raw = fs19.readFileSync(upstreamsFile, "utf8");
2150
2705
  const data = JSON.parse(raw);
2151
2706
  const skillEntry = data?.skills?.[name];
2152
2707
  if (!skillEntry) {
@@ -2155,8 +2710,8 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2155
2710
  if (skillEntry.kind === "release") {
2156
2711
  throw new Error(`La skill "${name}" es de tipo release y no se actualiza con replaceSkill.`);
2157
2712
  }
2158
- const skillsRoot = localSkillsRoot ?? path23.join(stackRoot(), "skills");
2159
- const localSkillDir = path23.join(skillsRoot, name);
2713
+ const skillsRoot = localSkillsRoot ?? path25.join(stackRoot(), "skills");
2714
+ const localSkillDir = path25.join(skillsRoot, name);
2160
2715
  const localFiles = listFilesRecursive(localSkillDir);
2161
2716
  if (localFiles.length > 0) {
2162
2717
  createBackup(localFiles, `skill-update-${name}`, backupsRoot2);
@@ -2165,24 +2720,24 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2165
2720
  try {
2166
2721
  const upstreamFiles = listFilesRecursive(upstreamSkillDir);
2167
2722
  for (const src of upstreamFiles) {
2168
- const st = fs17.lstatSync(src);
2723
+ const st = fs19.lstatSync(src);
2169
2724
  if (st.isSymbolicLink()) {
2170
2725
  throw new Error(`Symlink rechazado en upstream de skill "${name}": ${src}`);
2171
2726
  }
2172
- const rel = path23.relative(upstreamSkillDir, src);
2173
- const dest = path23.join(stagingDir, rel);
2174
- ensureDir(path23.dirname(dest));
2727
+ const rel = path25.relative(upstreamSkillDir, src);
2728
+ const dest = path25.join(stagingDir, rel);
2729
+ ensureDir(path25.dirname(dest));
2175
2730
  copyFile(src, dest);
2176
2731
  }
2177
2732
  const oldDir = `${localSkillDir}.old-${process.pid}`;
2178
- if (fs17.existsSync(localSkillDir)) {
2179
- fs17.renameSync(localSkillDir, oldDir);
2733
+ if (fs19.existsSync(localSkillDir)) {
2734
+ fs19.renameSync(localSkillDir, oldDir);
2180
2735
  }
2181
- fs17.renameSync(stagingDir, localSkillDir);
2182
- fs17.rmSync(oldDir, { recursive: true, force: true });
2736
+ fs19.renameSync(stagingDir, localSkillDir);
2737
+ fs19.rmSync(oldDir, { recursive: true, force: true });
2183
2738
  } catch (err) {
2184
2739
  try {
2185
- fs17.rmSync(stagingDir, { recursive: true, force: true });
2740
+ fs19.rmSync(stagingDir, { recursive: true, force: true });
2186
2741
  } catch {
2187
2742
  }
2188
2743
  throw err;
@@ -2196,8 +2751,8 @@ function rateLimitHint(prefix) {
2196
2751
  return ghPresentButTokenFailed() ? `${prefix} Tienes gh instalado pero \`gh auth token\` no devolvi\xF3 credencial (\xBFsesi\xF3n caducada?) \u2014 prueba \`gh auth login\` o define GH_TOKEN.` : `${prefix} Define GH_TOKEN o inicia sesi\xF3n en gh CLI.`;
2197
2752
  }
2198
2753
  function loadUpstreams() {
2199
- const file = path24.join(path24.dirname(stackRoot()), "upstreams.json");
2200
- return JSON.parse(fs18.readFileSync(file, "utf8"));
2754
+ const file = path26.join(path26.dirname(stackRoot()), "upstreams.json");
2755
+ return JSON.parse(fs20.readFileSync(file, "utf8"));
2201
2756
  }
2202
2757
  function skillsToScan(maintainer, upstreams) {
2203
2758
  return maintainer ? Object.keys(upstreams.skills) : [];
@@ -2214,8 +2769,30 @@ async function latestNpmVersion(pkg) {
2214
2769
  return null;
2215
2770
  }
2216
2771
  }
2217
- async function runUpdateCheck(localVersion) {
2772
+ function resolvePlaywrightUpdateCheck(input) {
2773
+ if (input.enabled !== true) return null;
2774
+ if (input.cli.status === "current") {
2775
+ return {
2776
+ level: "success",
2777
+ message: `Playwright CLI: ${PLAYWRIGHT_CLI.version} \u2014 al d\xEDa con el pin aprobado.`
2778
+ };
2779
+ }
2780
+ const local = input.cli.detectedVersion ?? (input.cli.status === "absent" ? "no instalado" : "no verificable");
2781
+ return {
2782
+ level: "warn",
2783
+ message: `Playwright CLI: ${local} local, pin aprobado ${PLAYWRIGHT_CLI.version} \u2192 ejecuta 'jorgex-stack update' o 'jorgex-stack install --playwright'.`
2784
+ };
2785
+ }
2786
+ async function runUpdateCheck(localVersion, includeBrowserState = true) {
2218
2787
  p4.intro("jorgex-stack update --check");
2788
+ if (includeBrowserState) {
2789
+ const preferenceErrors = browserPreferenceErrors();
2790
+ if (preferenceErrors.length > 0) {
2791
+ for (const error of preferenceErrors) p4.log.error(error);
2792
+ p4.outro("Check cancelado: corrige las preferencias de navegador antes de reintentar.");
2793
+ return 1;
2794
+ }
2795
+ }
2219
2796
  const upstreams = loadUpstreams();
2220
2797
  const npmLatest = await latestNpmVersion("jorgex-stack");
2221
2798
  if (npmLatest === null)
@@ -2240,6 +2817,13 @@ async function runUpdateCheck(localVersion) {
2240
2817
  `engram: ${local} local, ${latest} disponible. Tu instalaci\xF3n NO se toca (D7) \u2014 actualiza t\xFA: github.com/${engramRepo}/releases`
2241
2818
  );
2242
2819
  }
2820
+ if (includeBrowserState && loadPlaywrightCliPreference() === true) {
2821
+ const playwright = resolvePlaywrightUpdateCheck({
2822
+ enabled: true,
2823
+ cli: detectPlaywrightCli()
2824
+ });
2825
+ if (playwright) p4.log[playwright.level](playwright.message);
2826
+ }
2243
2827
  const checkSkillNames = skillsToScan(isGitClone(), upstreams);
2244
2828
  if (checkSkillNames.length === 0) {
2245
2829
  p4.log.info(
@@ -2299,7 +2883,7 @@ function isEngramRunning() {
2299
2883
  const pgrep = lookPath("pgrep");
2300
2884
  if (!pgrep) return null;
2301
2885
  try {
2302
- execFileSync4(pgrep, ["-x", "engram"], { stdio: "ignore" });
2886
+ execFileSync5(pgrep, ["-x", "engram"], { stdio: "ignore" });
2303
2887
  return true;
2304
2888
  } catch {
2305
2889
  return false;
@@ -2309,8 +2893,8 @@ function isEngramRunning() {
2309
2893
  return null;
2310
2894
  }
2311
2895
  }
2312
- function isGitClone(projectRoot = path24.dirname(stackRoot())) {
2313
- return fs18.existsSync(path24.join(projectRoot, ".git"));
2896
+ function isGitClone(projectRoot = path26.dirname(stackRoot())) {
2897
+ return fs20.existsSync(path26.join(projectRoot, ".git"));
2314
2898
  }
2315
2899
  var STACK_METHOD_CLONE = "git pull + pnpm install + pnpm build";
2316
2900
  function resolvePnpm() {
@@ -2320,29 +2904,29 @@ function resolvePnpm() {
2320
2904
  }
2321
2905
  function cleanupTmp(dir) {
2322
2906
  try {
2323
- fs18.rmSync(dir, { recursive: true, force: true });
2907
+ fs20.rmSync(dir, { recursive: true, force: true });
2324
2908
  } catch {
2325
2909
  }
2326
2910
  }
2327
2911
  function updateStackGitClone() {
2328
- const projectRoot = path24.dirname(stackRoot());
2912
+ const projectRoot = path26.dirname(stackRoot());
2329
2913
  const git = lookPath("git");
2330
2914
  if (!git) throw new Error("git no encontrado en PATH.");
2331
2915
  const pnpm = resolvePnpm();
2332
2916
  p4.log.info("Ejecutando git pull\u2026");
2333
- execFileSync4(git, ["pull"], { cwd: projectRoot, stdio: "inherit" });
2917
+ execFileSync5(git, ["pull"], { cwd: projectRoot, stdio: "inherit" });
2334
2918
  p4.log.info("Ejecutando pnpm install\u2026");
2335
- execFileSync4(pnpm, ["install"], { cwd: projectRoot, stdio: "inherit" });
2919
+ execFileSync5(pnpm, ["install"], { cwd: projectRoot, stdio: "inherit" });
2336
2920
  p4.log.info("Ejecutando pnpm build\u2026");
2337
- execFileSync4(pnpm, ["build"], { cwd: projectRoot, stdio: "inherit" });
2921
+ execFileSync5(pnpm, ["build"], { cwd: projectRoot, stdio: "inherit" });
2338
2922
  }
2339
2923
  function updateStackGlobal() {
2340
2924
  const pnpm = resolvePnpm();
2341
2925
  p4.log.info("Ejecutando pnpm add -g jorgex-stack@latest\u2026");
2342
- execFileSync4(pnpm, ["add", "-g", "jorgex-stack@latest"], { stdio: "inherit" });
2926
+ execFileSync5(pnpm, ["add", "-g", "jorgex-stack@latest"], { stdio: "inherit" });
2343
2927
  }
2344
2928
  async function downloadSkillToTemp(repo, sha, skillPath) {
2345
- const root = fs18.mkdtempSync(path24.join(os3.tmpdir(), "jorgex-skill-"));
2929
+ const root = fs20.mkdtempSync(path26.join(os4.tmpdir(), "jorgex-skill-"));
2346
2930
  try {
2347
2931
  const result = await downloadRepoTarball(repo, sha, root, skillPath);
2348
2932
  if (!result.ok) {
@@ -2350,15 +2934,15 @@ async function downloadSkillToTemp(repo, sha, skillPath) {
2350
2934
  return { error: result.reason };
2351
2935
  }
2352
2936
  if (skillPath) {
2353
- const sub = path24.resolve(path24.join(root, skillPath));
2937
+ const sub = path26.resolve(path26.join(root, skillPath));
2354
2938
  if (!isContainedIn(sub, root)) {
2355
2939
  cleanupTmp(root);
2356
2940
  return { error: `la ruta "${skillPath}" escapa del directorio temporal` };
2357
2941
  }
2358
- if (fs18.existsSync(sub)) return { dir: sub, root };
2942
+ if (fs20.existsSync(sub)) return { dir: sub, root };
2359
2943
  const lastSeg = skillPath.split("/").pop();
2360
- const sub2 = path24.resolve(path24.join(root, lastSeg));
2361
- if (isContainedIn(sub2, root) && fs18.existsSync(sub2)) {
2944
+ const sub2 = path26.resolve(path26.join(root, lastSeg));
2945
+ if (isContainedIn(sub2, root) && fs20.existsSync(sub2)) {
2362
2946
  if (!validateExtractedTree(sub2)) {
2363
2947
  cleanupTmp(root);
2364
2948
  return { error: `el sub\xE1rbol "${lastSeg}" contiene symlinks o rutas fuera del destino` };
@@ -2377,11 +2961,11 @@ async function downloadSkillToTemp(repo, sha, skillPath) {
2377
2961
  function pruneEngramDbBackups() {
2378
2962
  try {
2379
2963
  const dir = dataDir();
2380
- if (!fs18.existsSync(dir)) return;
2381
- const backups = fs18.readdirSync(dir).filter((f) => f.startsWith("engram-db-backup-") && f.endsWith(".db")).map((f) => ({ name: f, mtime: fs18.statSync(path24.join(dir, f)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
2964
+ if (!fs20.existsSync(dir)) return;
2965
+ const backups = fs20.readdirSync(dir).filter((f) => f.startsWith("engram-db-backup-") && f.endsWith(".db")).map((f) => ({ name: f, mtime: fs20.statSync(path26.join(dir, f)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
2382
2966
  for (const old of backups.slice(3)) {
2383
2967
  try {
2384
- fs18.rmSync(path24.join(dir, old.name));
2968
+ fs20.rmSync(path26.join(dir, old.name));
2385
2969
  } catch {
2386
2970
  }
2387
2971
  }
@@ -2389,18 +2973,18 @@ function pruneEngramDbBackups() {
2389
2973
  }
2390
2974
  }
2391
2975
  function rotateLockedBinary(binPath, sweepRoot = HOME) {
2392
- if (!fs18.existsSync(binPath)) return null;
2393
- const dir = path24.dirname(binPath);
2394
- const base = path24.basename(binPath);
2976
+ if (!fs20.existsSync(binPath)) return null;
2977
+ const dir = path26.dirname(binPath);
2978
+ const base = path26.basename(binPath);
2395
2979
  const escapedBase = base.replace(/[.*+?^$()|[\]{}\\]/g, "\\$&");
2396
2980
  const oldPattern = new RegExp("^" + escapedBase + "\\.old-\\d+$");
2397
- const resolvedDir = path24.resolve(dir);
2398
- if (resolvedDir === path24.resolve(sweepRoot) || isContainedIn(resolvedDir, sweepRoot)) {
2981
+ const resolvedDir = path26.resolve(dir);
2982
+ if (resolvedDir === path26.resolve(sweepRoot) || isContainedIn(resolvedDir, sweepRoot)) {
2399
2983
  try {
2400
- for (const entry of fs18.readdirSync(dir)) {
2984
+ for (const entry of fs20.readdirSync(dir)) {
2401
2985
  if (oldPattern.test(entry)) {
2402
2986
  try {
2403
- fs18.rmSync(path24.join(dir, entry), { force: true });
2987
+ fs20.rmSync(path26.join(dir, entry), { force: true });
2404
2988
  } catch {
2405
2989
  }
2406
2990
  }
@@ -2408,8 +2992,8 @@ function rotateLockedBinary(binPath, sweepRoot = HOME) {
2408
2992
  } catch {
2409
2993
  }
2410
2994
  }
2411
- const rotated = path24.join(dir, `${base}.old-${Date.now()}`);
2412
- fs18.renameSync(binPath, rotated);
2995
+ const rotated = path26.join(dir, `${base}.old-${Date.now()}`);
2996
+ fs20.renameSync(binPath, rotated);
2413
2997
  return rotated;
2414
2998
  }
2415
2999
  async function updateEngram(engramRepo, latestVersion) {
@@ -2418,19 +3002,19 @@ async function updateEngram(engramRepo, latestVersion) {
2418
3002
  "Engram est\xE1 en ejecuci\xF3n: los procesos vivos seguir\xE1n usando la versi\xF3n antigua hasta que reinicies los clientes (Claude Code/OpenCode/Codex)."
2419
3003
  );
2420
3004
  }
2421
- const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path24.join(HOME, ".engram");
2422
- const engramDb = path24.join(engramDataDir, "engram.db");
2423
- if (fs18.existsSync(engramDb)) {
3005
+ const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path26.join(HOME, ".engram");
3006
+ const engramDb = path26.join(engramDataDir, "engram.db");
3007
+ if (fs20.existsSync(engramDb)) {
2424
3008
  const doBackup = await p4.confirm({
2425
3009
  message: `\xBFHacer backup de la DB de Engram antes de actualizar? (${engramDb})`,
2426
3010
  initialValue: true
2427
3011
  });
2428
3012
  if (!p4.isCancel(doBackup) && doBackup) {
2429
3013
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2430
- const dest = path24.join(dataDir(), `engram-db-backup-${ts}.db`);
3014
+ const dest = path26.join(dataDir(), `engram-db-backup-${ts}.db`);
2431
3015
  try {
2432
- if (!fs18.existsSync(dataDir())) fs18.mkdirSync(dataDir(), { recursive: true });
2433
- fs18.copyFileSync(engramDb, dest);
3016
+ if (!fs20.existsSync(dataDir())) fs20.mkdirSync(dataDir(), { recursive: true });
3017
+ fs20.copyFileSync(engramDb, dest);
2434
3018
  p4.log.success(`DB respaldada en ${dest} (la DB original NO se modifica jam\xE1s).`);
2435
3019
  pruneEngramDbBackups();
2436
3020
  } catch (err) {
@@ -2443,7 +3027,7 @@ async function updateEngram(engramRepo, latestVersion) {
2443
3027
  if (brew) {
2444
3028
  let brewManages = false;
2445
3029
  try {
2446
- execFileSync4(brew, ["list", "engram"], { stdio: "pipe" });
3030
+ execFileSync5(brew, ["list", "engram"], { stdio: "pipe" });
2447
3031
  brewManages = true;
2448
3032
  } catch {
2449
3033
  }
@@ -2451,7 +3035,7 @@ async function updateEngram(engramRepo, latestVersion) {
2451
3035
  anyChannelTried = true;
2452
3036
  p4.log.info("Actualizando engram con brew\u2026");
2453
3037
  try {
2454
- execFileSync4(brew, ["upgrade", "engram"], { stdio: "inherit" });
3038
+ execFileSync5(brew, ["upgrade", "engram"], { stdio: "inherit" });
2455
3039
  return true;
2456
3040
  } catch (err) {
2457
3041
  p4.log.error(`brew upgrade engram fall\xF3: ${err instanceof Error ? err.message : err}`);
@@ -2475,15 +3059,15 @@ async function updateEngram(engramRepo, latestVersion) {
2475
3059
  }
2476
3060
  p4.log.info(`Actualizando engram con go install (${latestVersion})\u2026`);
2477
3061
  try {
2478
- execFileSync4(
3062
+ execFileSync5(
2479
3063
  go,
2480
3064
  ["install", `github.com/Gentleman-Programming/engram/cmd/engram@v${latestVersion}`],
2481
3065
  { stdio: "inherit" }
2482
3066
  );
2483
- const rollbackOk = resolveEngramRollback({ installOk: true, rotated, bin, binExists: fs18.existsSync(bin ?? "") });
3067
+ const rollbackOk = resolveEngramRollback({ installOk: true, rotated, bin, binExists: fs20.existsSync(bin ?? "") });
2484
3068
  if (rollbackOk.action === "restore") {
2485
3069
  try {
2486
- fs18.renameSync(rotated, bin);
3070
+ fs20.renameSync(rotated, bin);
2487
3071
  p4.log.warn(rollbackOk.messages.onRestore);
2488
3072
  } catch {
2489
3073
  p4.log.warn(rollbackOk.messages.onRenameFail);
@@ -2491,10 +3075,10 @@ async function updateEngram(engramRepo, latestVersion) {
2491
3075
  }
2492
3076
  return true;
2493
3077
  } catch (err) {
2494
- const rollbackFail = resolveEngramRollback({ installOk: false, rotated, bin, binExists: fs18.existsSync(bin ?? "") });
3078
+ const rollbackFail = resolveEngramRollback({ installOk: false, rotated, bin, binExists: fs20.existsSync(bin ?? "") });
2495
3079
  if (rollbackFail.action === "restore") {
2496
3080
  try {
2497
- fs18.renameSync(rotated, bin);
3081
+ fs20.renameSync(rotated, bin);
2498
3082
  p4.log.info(rollbackFail.messages.onRestore);
2499
3083
  } catch {
2500
3084
  p4.log.error(rollbackFail.messages.onRenameFail);
@@ -2579,14 +3163,26 @@ function buildEligibleSkillUpdates(skills) {
2579
3163
  }
2580
3164
  return result;
2581
3165
  }
2582
- async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
3166
+ function resolveUpdateSyncRequired(updated) {
3167
+ return updated.some((item) => item === "stack" || item === "skill" || item.startsWith("skill:"));
3168
+ }
3169
+ async function runInteractiveUpdate(localVersion, yes, dryRun = false, includeBrowserState = true) {
2583
3170
  if (dryRun || yes || !process.stdout.isTTY) {
2584
- return { exitCode: await runUpdateCheck(localVersion), appliedUpdates: false };
3171
+ return { exitCode: await runUpdateCheck(localVersion, includeBrowserState), appliedUpdates: false, syncRequired: false };
2585
3172
  }
2586
3173
  p4.intro("jorgex-stack update");
3174
+ if (includeBrowserState) {
3175
+ const preferenceErrors = browserPreferenceErrors();
3176
+ if (preferenceErrors.length > 0) {
3177
+ for (const error of preferenceErrors) p4.log.error(error);
3178
+ p4.outro("Update cancelado: corrige las preferencias de navegador antes de reintentar.");
3179
+ return { exitCode: 1, appliedUpdates: false, syncRequired: false };
3180
+ }
3181
+ }
2587
3182
  const upstreams = loadUpstreams();
2588
3183
  let exitCode = 0;
2589
3184
  let appliedUpdates = false;
3185
+ const updated = [];
2590
3186
  const maintainer = isGitClone();
2591
3187
  const spin = p4.spinner();
2592
3188
  spin.start("Consultando versiones upstream\u2026");
@@ -2645,6 +3241,21 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2645
3241
  });
2646
3242
  }
2647
3243
  }
3244
+ let playwrightNeedsUpdate = false;
3245
+ if (includeBrowserState && loadPlaywrightCliPreference() === true) {
3246
+ const playwright = detectPlaywrightCli();
3247
+ if (playwright.status === "current") {
3248
+ p4.log.success("Playwright CLI: al d\xEDa.");
3249
+ } else {
3250
+ playwrightNeedsUpdate = true;
3251
+ const current = playwright.detectedVersion ?? playwright.status;
3252
+ updateItems.push({
3253
+ value: "playwright-cli",
3254
+ label: `Playwright CLI: ${current} \u2192 pin aprobado`,
3255
+ hint: "pnpm add --global @playwright/cli@0.1.17"
3256
+ });
3257
+ }
3258
+ }
2648
3259
  const typedSkillHeads = skillHeads;
2649
3260
  const loggedRepos = /* @__PURE__ */ new Map();
2650
3261
  for (const { name, repo, info, head } of typedSkillHeads) {
@@ -2686,7 +3297,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2686
3297
  }
2687
3298
  if (updateItems.length === 0) {
2688
3299
  p4.outro("Todo al d\xEDa. No hay actualizaciones disponibles.");
2689
- return { exitCode: 0, appliedUpdates: false };
3300
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2690
3301
  }
2691
3302
  const selected = await p4.multiselect({
2692
3303
  message: "Selecciona qu\xE9 actualizar (espacio para marcar, intro para confirmar):",
@@ -2696,11 +3307,11 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2696
3307
  });
2697
3308
  if (p4.isCancel(selected)) {
2698
3309
  p4.outro("Update cancelado.");
2699
- return { exitCode: 0, appliedUpdates: false };
3310
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2700
3311
  }
2701
3312
  if (selected.length === 0) {
2702
3313
  p4.outro("Nada seleccionado.");
2703
- return { exitCode: 0, appliedUpdates: false };
3314
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2704
3315
  }
2705
3316
  const sel = selected;
2706
3317
  if (stackNeedsUpdate && sel.includes("stack")) {
@@ -2721,6 +3332,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2721
3332
  }
2722
3333
  p4.log.success("Stack actualizado correctamente.");
2723
3334
  appliedUpdates = true;
3335
+ updated.push("stack");
2724
3336
  } catch (err) {
2725
3337
  p4.log.error(`Stack: error al actualizar \u2014 ${err instanceof Error ? err.message : err}`);
2726
3338
  exitCode = 1;
@@ -2752,7 +3364,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2752
3364
  continue;
2753
3365
  }
2754
3366
  const { dir: tmpDir, root: tmpRoot } = tmpResult;
2755
- const localSkillDir = path24.join(stackRoot(), "skills", skillInfo.name);
3367
+ const localSkillDir = path26.join(stackRoot(), "skills", skillInfo.name);
2756
3368
  const diff = renderSkillDiff(tmpDir, localSkillDir);
2757
3369
  if (diff) {
2758
3370
  p4.log.info(`Diff de ${skillInfo.name}:
@@ -2776,6 +3388,7 @@ ${diff}`);
2776
3388
  replaceSkill(skillInfo.name, tmpDir, skillInfo.head, {});
2777
3389
  p4.log.success(`skill ${skillInfo.name}: actualizada y re-pineada a ${skillInfo.head.slice(0, 7)}.`);
2778
3390
  appliedUpdates = true;
3391
+ updated.push("skill");
2779
3392
  } catch (err) {
2780
3393
  p4.log.error(`skill ${skillInfo.name}: error \u2014 ${err instanceof Error ? err.message : err}`);
2781
3394
  exitCode = 1;
@@ -2783,6 +3396,27 @@ ${diff}`);
2783
3396
  cleanupTmp(tmpRoot);
2784
3397
  }
2785
3398
  }
3399
+ if (playwrightNeedsUpdate && sel.includes("playwright-cli")) {
3400
+ const confirmPlaywright = await p4.confirm({
3401
+ message: "Actualizar Playwright CLI global al pin aprobado con pnpm add --global?",
3402
+ initialValue: true
3403
+ });
3404
+ if (p4.isCancel(confirmPlaywright) || !confirmPlaywright) {
3405
+ p4.log.info("Playwright CLI: actualizaci\xF3n omitida.");
3406
+ } else {
3407
+ const packageUpdated = executePlaywrightToolAction2("update");
3408
+ const browserInstalled = packageUpdated && executePlaywrightToolAction2("install-browser");
3409
+ if (packageUpdated && browserInstalled) {
3410
+ p4.log.success("Playwright CLI actualizado al pin aprobado.");
3411
+ appliedUpdates = true;
3412
+ updated.push("playwright-cli");
3413
+ } else {
3414
+ const failedStep = packageUpdated ? "descargar el navegador" : "actualizar el paquete global";
3415
+ p4.log.error(`Playwright CLI: no se pudo ${failedStep}. Ejecuta 'jorgex-stack install --playwright' para reintentar el paquete y el navegador.`);
3416
+ exitCode = 1;
3417
+ }
3418
+ }
3419
+ }
2786
3420
  if (engramNeedsUpdate && sel.includes("engram") && engramData?.version) {
2787
3421
  const confirmEngram = await p4.confirm({
2788
3422
  message: `Actualizar engram a v${engramData.version} (canal nativo: brew \u2192 go install \u2192 URL)`,
@@ -2803,15 +3437,17 @@ ${diff}`);
2803
3437
  p4.log.warn("Engram actualizado, pero no se pudo verificar la versi\xF3n \u2014 comprueba con engram --version");
2804
3438
  }
2805
3439
  p4.log.info("Reinicia los clientes (Claude Code/OpenCode/Codex) para que sus MCP usen la versi\xF3n nueva.");
3440
+ appliedUpdates = true;
3441
+ updated.push("engram");
2806
3442
  }
2807
3443
  }
2808
3444
  }
2809
3445
  p4.outro(exitCode === 0 ? "Update completado." : "Update completado con errores (revisa arriba).");
2810
- return { exitCode, appliedUpdates };
3446
+ return { exitCode, appliedUpdates, syncRequired: resolveUpdateSyncRequired(updated) };
2811
3447
  }
2812
3448
 
2813
3449
  // src/models-picker.ts
2814
- import path25 from "path";
3450
+ import path27 from "path";
2815
3451
  import * as p5 from "@clack/prompts";
2816
3452
  var TIERS = ["strong", "standard", "cheap"];
2817
3453
  var EFFORTS = ["low", "medium", "high", "xhigh"];
@@ -2835,7 +3471,7 @@ function opencodeLiveModels(binPath) {
2835
3471
  }
2836
3472
  function agentsByTier() {
2837
3473
  const grouped = { strong: [], standard: [], cheap: [] };
2838
- for (const agent of loadCanonicalAgents(path25.join(stackRoot(), "agents"))) {
3474
+ for (const agent of loadCanonicalAgents(path27.join(stackRoot(), "agents"))) {
2839
3475
  if (agent.mode === "subagent") grouped[agent.tier].push(agent.name);
2840
3476
  }
2841
3477
  return grouped;
@@ -3015,16 +3651,16 @@ function cancelled() {
3015
3651
  }
3016
3652
 
3017
3653
  // src/lib/release.ts
3018
- import fs19 from "fs";
3019
- import path26 from "path";
3020
- import { execFileSync as execFileSync5 } from "child_process";
3654
+ import fs21 from "fs";
3655
+ import path28 from "path";
3656
+ import { execFileSync as execFileSync6 } from "child_process";
3021
3657
  import { fileURLToPath as fileURLToPath2 } from "url";
3022
3658
  function findPackageJson() {
3023
- let dir = path26.dirname(fileURLToPath2(import.meta.url));
3659
+ let dir = path28.dirname(fileURLToPath2(import.meta.url));
3024
3660
  for (let i = 0; i < 6; i++) {
3025
- const candidate = path26.join(dir, "package.json");
3026
- if (fs19.existsSync(candidate)) return candidate;
3027
- dir = path26.dirname(dir);
3661
+ const candidate = path28.join(dir, "package.json");
3662
+ if (fs21.existsSync(candidate)) return candidate;
3663
+ dir = path28.dirname(dir);
3028
3664
  }
3029
3665
  throw new Error("No se encontr\xF3 package.json cerca del CLI.");
3030
3666
  }
@@ -3033,7 +3669,7 @@ function readPackageVersion() {
3033
3669
  }
3034
3670
  function readPackageMetadata() {
3035
3671
  const packageJson = findPackageJson();
3036
- const raw = fs19.readFileSync(packageJson, "utf8");
3672
+ const raw = fs21.readFileSync(packageJson, "utf8");
3037
3673
  const parsed = JSON.parse(raw);
3038
3674
  const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
3039
3675
  const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
@@ -3070,6 +3706,10 @@ function parseFlags(args) {
3070
3706
  list: false,
3071
3707
  check: false,
3072
3708
  removeEngram: false,
3709
+ playwright: false,
3710
+ removePlaywright: false,
3711
+ devtools: false,
3712
+ noDevtools: false,
3073
3713
  positional: [],
3074
3714
  unknownFlags: []
3075
3715
  };
@@ -3107,6 +3747,10 @@ function parseFlags(args) {
3107
3747
  else if (arg === "--list") flags.list = true;
3108
3748
  else if (arg === "--check") flags.check = true;
3109
3749
  else if (arg === "--remove-engram") flags.removeEngram = true;
3750
+ else if (arg === "--playwright") flags.playwright = true;
3751
+ else if (arg === "--remove-playwright") flags.removePlaywright = true;
3752
+ else if (arg === "--devtools") flags.devtools = true;
3753
+ else if (arg === "--no-devtools") flags.noDevtools = true;
3110
3754
  else if (arg.startsWith("-")) flags.unknownFlags.push(arg);
3111
3755
  else flags.positional.push(arg);
3112
3756
  }
@@ -3173,6 +3817,48 @@ Corrige o borra ${preferenceFile}, o vuelve a ejecutar con --mode human|programm
3173
3817
  subagentConcurrency: concurrency
3174
3818
  };
3175
3819
  }
3820
+ async function resolvePlaywrightToolConsent(command, flags) {
3821
+ const interactive = Boolean(process.stdout.isTTY);
3822
+ let confirmed = false;
3823
+ if (command === "install" && interactive && !flags.yes && !flags.dryRun && flags.targetDir === void 0) {
3824
+ const answer = await p6.confirm({
3825
+ message: "Recomendado: \xBFinstalar Playwright CLI global y descargar sus navegadores?",
3826
+ initialValue: false
3827
+ });
3828
+ if (p6.isCancel(answer)) return null;
3829
+ confirmed = answer === true;
3830
+ }
3831
+ return {
3832
+ command,
3833
+ interactive,
3834
+ yes: flags.yes,
3835
+ targetDir: flags.targetDir !== void 0,
3836
+ explicitToolSelection: flags.playwright,
3837
+ confirmed
3838
+ };
3839
+ }
3840
+ async function resolveDevtoolsMcpSelection(command, flags, runtimes) {
3841
+ if (flags.devtools && flags.noDevtools) {
3842
+ console.error("Usa solo uno de --devtools o --no-devtools.");
3843
+ process.exitCode = 1;
3844
+ return null;
3845
+ }
3846
+ if (flags.devtools || flags.noDevtools) {
3847
+ return Object.fromEntries(runtimes.map((runtime) => [runtime, flags.devtools]));
3848
+ }
3849
+ if (command !== "install" || flags.yes || flags.dryRun || flags.targetDir !== void 0 || !process.stdout.isTTY) {
3850
+ return {};
3851
+ }
3852
+ const file = devtoolsMcpPreferenceFile();
3853
+ const selected = await p6.multiselect({
3854
+ message: "Chrome DevTools MCP avanzado (full: ~29 tools y ~5.8\u20137.7k tokens de schemas). \xBFEn qu\xE9 runtimes activarlo?",
3855
+ options: runtimes.map((runtime) => ({ value: runtime, label: ADAPTERS[runtime]?.name ?? runtime })),
3856
+ initialValues: runtimes.filter((runtime) => loadDevtoolsMcpPreference(file, runtime))
3857
+ });
3858
+ if (p6.isCancel(selected)) return null;
3859
+ const enabled = new Set(selected);
3860
+ return Object.fromEntries(runtimes.map((runtime) => [runtime, enabled.has(runtime)]));
3861
+ }
3176
3862
  function parseCliArgs(argv) {
3177
3863
  const [first, ...rest] = argv;
3178
3864
  const isCommand = COMMANDS.includes(first ?? "install");
@@ -3227,8 +3913,13 @@ Opciones:
3227
3913
  --target-dir <dir> Dir alternativo (pruebas de paridad; requiere 1 runtime)
3228
3914
  --dry-run Muestra el plan sin escribir nada
3229
3915
  --yes, -y No interactivo
3916
+ --playwright Autoriza Playwright CLI global y sus navegadores (requerido con --yes/sin TTY)
3917
+ --devtools (install/sync) activa Chrome DevTools MCP para los runtimes destino (opt-in)
3918
+ --no-devtools (install/sync) desactiva Chrome DevTools MCP (incompatible con --devtools)
3230
3919
  --remove-engram (uninstall) desregistra Engram de los runtimes;
3231
3920
  memorias y binario quedan intactos igualmente
3921
+ --remove-playwright (uninstall) retira solo el paquete global de Playwright;
3922
+ nunca perfiles, cach\xE9 ni navegadores
3232
3923
 
3233
3924
  Ver PRD.md para el dise\xF1o completo.`);
3234
3925
  }
@@ -3272,17 +3963,31 @@ Flags disponibles: jorgex-stack --help`
3272
3963
  process.exitCode = 1;
3273
3964
  return;
3274
3965
  }
3275
- if (!await ensureOpenCodeModelsForInstall(command, flags, runtimes)) {
3966
+ const devtoolsMcpSelection = await resolveDevtoolsMcpSelection(command, flags, runtimes);
3967
+ if (devtoolsMcpSelection === null) return;
3968
+ const playwrightToolConsent = await resolvePlaywrightToolConsent(command, flags);
3969
+ if (playwrightToolConsent === null) return;
3970
+ const hasOpenCodeModels = await ensureOpenCodeModelsForInstall(command, flags, runtimes);
3971
+ if (!hasOpenCodeModels) {
3276
3972
  process.exitCode = 1;
3277
3973
  return;
3278
3974
  }
3279
- process.exitCode = await runInstall({ runtimes, targetDir: flags.targetDir, dryRun: flags.dryRun, yes: flags.yes, mode });
3975
+ const installExitCode = await runInstall({
3976
+ runtimes,
3977
+ targetDir: flags.targetDir,
3978
+ dryRun: flags.dryRun,
3979
+ yes: flags.yes,
3980
+ mode,
3981
+ playwrightToolConsent,
3982
+ devtoolsMcpSelection
3983
+ });
3984
+ process.exitCode = installExitCode;
3280
3985
  return;
3281
3986
  }
3282
3987
  case "uninstall": {
3283
3988
  const runtimes = await resolveRuntimes(flags);
3284
3989
  if (runtimes === null) return;
3285
- if (runtimes.length === 0) {
3990
+ if (runtimes.length === 0 && !flags.removePlaywright) {
3286
3991
  console.error("Ning\xFAn runtime detectado (opencode, claude-code, codex).");
3287
3992
  process.exitCode = 1;
3288
3993
  return;
@@ -3292,7 +3997,8 @@ Flags disponibles: jorgex-stack --help`
3292
3997
  targetDir: flags.targetDir,
3293
3998
  dryRun: flags.dryRun,
3294
3999
  yes: flags.yes,
3295
- removeEngram: flags.removeEngram
4000
+ removeEngram: flags.removeEngram,
4001
+ removePlaywright: flags.removePlaywright
3296
4002
  });
3297
4003
  return;
3298
4004
  }
@@ -3302,11 +4008,11 @@ Flags disponibles: jorgex-stack --help`
3302
4008
  }
3303
4009
  case "update": {
3304
4010
  if (flags.check) {
3305
- process.exitCode = await runUpdateCheck(VERSION);
4011
+ process.exitCode = await runUpdateCheck(VERSION, flags.targetDir === void 0);
3306
4012
  return;
3307
4013
  }
3308
4014
  if (flags.dryRun) {
3309
- process.exitCode = await runUpdateCheck(VERSION);
4015
+ process.exitCode = await runUpdateCheck(VERSION, flags.targetDir === void 0);
3310
4016
  return;
3311
4017
  }
3312
4018
  const runtimes = await resolveRuntimes(flags);
@@ -3333,11 +4039,16 @@ Flags disponibles: jorgex-stack --help`
3333
4039
  } else if (runtimes.length > 0) {
3334
4040
  console.error("No hay modo guardado; se omite el sync previo y se contin\xFAa con update. Usa --mode expl\xEDcito si quieres sincronizar.");
3335
4041
  }
3336
- const result = await runInteractiveUpdate(VERSION, flags.yes, flags.dryRun);
4042
+ const result = await runInteractiveUpdate(
4043
+ VERSION,
4044
+ flags.yes,
4045
+ flags.dryRun,
4046
+ flags.targetDir === void 0
4047
+ );
3337
4048
  process.exitCode = result.exitCode;
3338
- if (result.exitCode === 0 && result.appliedUpdates && runtimes.length > 0 && !canSync) {
4049
+ if (result.syncRequired && runtimes.length > 0 && (result.exitCode !== 0 || !canSync)) {
3339
4050
  p6.log.warn("Skills/stack actualizados, pero el sync con los runtimes sigue pendiente. Ejecuta jorgex-stack sync --mode human|programmatic.");
3340
- } else if (result.exitCode === 0 && result.appliedUpdates && runtimes.length > 0 && canSync && !flags.yes && process.stdout.isTTY) {
4051
+ } else if (result.exitCode === 0 && result.syncRequired && runtimes.length > 0 && canSync && !flags.yes && process.stdout.isTTY) {
3341
4052
  const apply = await p6.confirm({ message: "\xBFRe-aplicar a los runtimes ahora? (sync)" });
3342
4053
  if (!p6.isCancel(apply) && apply) {
3343
4054
  process.exitCode = await runInstall({
@@ -3350,7 +4061,7 @@ Flags disponibles: jorgex-stack --help`
3350
4061
  } else {
3351
4062
  console.log("Sin aplicar. Cuando quieras: jorgex-stack sync");
3352
4063
  }
3353
- } else if (result.exitCode === 0 && result.appliedUpdates && runtimes.length > 0 && canSync && (flags.yes || !process.stdout.isTTY)) {
4064
+ } else if (result.exitCode === 0 && result.syncRequired && runtimes.length > 0 && canSync && (flags.yes || !process.stdout.isTTY)) {
3354
4065
  console.log("Skills/stack actualizados. Ejecuta jorgex-stack sync para aplicarlos a los runtimes.");
3355
4066
  }
3356
4067
  return;
@@ -3400,7 +4111,7 @@ Flags disponibles: jorgex-stack --help`
3400
4111
  }
3401
4112
  }
3402
4113
  if (process.argv[1] !== void 0 && import.meta.url === pathToFileURL2(process.argv[1]).href) {
3403
- main().catch((err) => {
4114
+ await main().catch((err) => {
3404
4115
  console.error(err instanceof Error ? err.message : String(err));
3405
4116
  process.exitCode = 1;
3406
4117
  });