jorgex-stack 1.0.30 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 fs14 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",
@@ -546,8 +565,6 @@ var opencodeAdapter = {
546
565
  ${deny}`);
547
566
  }
548
567
  if (!agent.spawn) lines.push(" task: deny");
549
- lines.push("tools:");
550
- lines.push(` write: ${agent.readonly ? "false" : "true"}`);
551
568
  }
552
569
  return [
553
570
  {
@@ -638,6 +655,7 @@ ${agent.body}`,
638
655
  const original = readTextIfExists(file);
639
656
  const contentSource = original === null || original.trim() === "" ? null : original;
640
657
  const isFreshConfig = contentSource === null;
658
+ const mcpOwnership = [];
641
659
  const content = upsertJson(contentSource, (root) => {
642
660
  root["$schema"] ??= "https://opencode.ai/config.json";
643
661
  const defaults = loadCanonicalDefaults(ctx.stackDir)["opencode"];
@@ -649,6 +667,22 @@ ${agent.body}`,
649
667
  }
650
668
  const mcp = root["mcp"] ??= {};
651
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
+ }
652
686
  if (server.transport === "stdio") {
653
687
  if (server.command === "{{ENGRAM_BIN}}" && ctx.engramBin === null) {
654
688
  ctx.warnings.push(
@@ -658,6 +692,7 @@ ${agent.body}`,
658
692
  }
659
693
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
660
694
  mcp[name] = { type: "local", command: [command, ...server.args ?? []] };
695
+ if (server.optional && existing === void 0 && !owned) mcpOwnership.push({ server: name, owned: true });
661
696
  } else {
662
697
  const previous = mcp[name];
663
698
  const headers = {};
@@ -687,7 +722,7 @@ ${agent.body}`,
687
722
  }
688
723
  }
689
724
  });
690
- return [{ kind: "write", target: file, content }];
725
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
691
726
  },
692
727
  planUnmerge(mcp, hooks, ctx) {
693
728
  const actions = [];
@@ -696,15 +731,26 @@ ${agent.body}`,
696
731
  if (prompt !== null) {
697
732
  let content = removeMarkdownSection(prompt, "system-prompt");
698
733
  content = removeMarkdownSection(content, "engram-protocol");
734
+ content = removeMarkdownSection(content, "browser");
699
735
  actions.push({ kind: "write", target: systemPromptFile, content });
700
736
  }
701
737
  const configFile = path7.join(ctx.configDir, "opencode.json");
702
738
  const config = readTextIfExists(configFile);
703
739
  if (config !== null) {
740
+ const mcpOwnership = [];
704
741
  const content = upsertJson(config, (root) => {
705
742
  const mcpBlock = root["mcp"];
706
743
  if (mcpBlock) {
707
- 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
+ }
708
754
  if (Object.keys(mcpBlock).length === 0) delete root["mcp"];
709
755
  }
710
756
  const plugin = root["plugin"];
@@ -715,7 +761,7 @@ ${agent.body}`,
715
761
  else root["plugin"] = kept;
716
762
  }
717
763
  });
718
- actions.push({ kind: "write", target: configFile, content });
764
+ actions.push({ kind: "write", target: configFile, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} });
719
765
  }
720
766
  const hooksFile = path7.join(ctx.configDir, "hooks.json");
721
767
  const hooksJson = readTextIfExists(hooksFile);
@@ -775,6 +821,14 @@ function hasEngramPlugin(configDir) {
775
821
  return false;
776
822
  }
777
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
+ }
778
832
  var claudeCodeAdapter = {
779
833
  id: "claude-code",
780
834
  name: "Claude Code",
@@ -803,16 +857,7 @@ description: ${yamlString2(agent.description)}
803
857
  keep-coding-instructions: true
804
858
  ---
805
859
  ${agent.body}`;
806
- const skillDescription = `${agent.description} Invoke to switch into ${agent.name} mode and apply its flow to the current task.`;
807
- const skill = `---
808
- name: ${agent.name}
809
- description: ${yamlString2(skillDescription)}
810
- ---
811
- ${agent.body}`;
812
- return [
813
- { file: `${agent.name}.md`, content: style, kind: "output-style" },
814
- { file: `${agent.name}/SKILL.md`, content: skill, kind: "skill" }
815
- ];
860
+ return [{ file: `${agent.name}.md`, content: style, kind: "output-style" }];
816
861
  }
817
862
  const lines = [`name: ${agent.name}`, `description: ${yamlString2(agent.description)}`];
818
863
  const tools = toolsFor(agent);
@@ -871,9 +916,26 @@ ${agent.body}`,
871
916
  },
872
917
  planMainConfig(canonical, ctx) {
873
918
  const file = path8.join(path8.dirname(ctx.configDir), `${path8.basename(ctx.configDir)}.json`);
919
+ const mcpOwnership = [];
874
920
  const content = upsertJson(readTextIfExists(file), (root) => {
875
921
  const servers = root["mcpServers"] ??= {};
876
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
+ }
877
939
  if (server.transport === "stdio") {
878
940
  if (server.command === "{{ENGRAM_BIN}}" && hasEngramPlugin(ctx.configDir)) {
879
941
  if (name in servers) delete servers[name];
@@ -890,6 +952,7 @@ ${agent.body}`,
890
952
  }
891
953
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
892
954
  servers[name] = { type: "stdio", command, args: server.args ?? [] };
955
+ if (server.optional && existing === void 0 && !owned) mcpOwnership.push({ server: name, owned: true });
893
956
  } else {
894
957
  const previous = servers[name];
895
958
  const headers = {};
@@ -905,7 +968,7 @@ ${agent.body}`,
905
968
  }
906
969
  }
907
970
  });
908
- return [{ kind: "write", target: file, content }];
971
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
909
972
  },
910
973
  planUnmerge(mcp, hooks, ctx) {
911
974
  const actions = [];
@@ -914,6 +977,7 @@ ${agent.body}`,
914
977
  if (prompt !== null) {
915
978
  let content = removeMarkdownSection(prompt, "system-prompt");
916
979
  content = removeMarkdownSection(content, "engram-protocol");
980
+ content = removeMarkdownSection(content, "browser");
917
981
  actions.push({ kind: "write", target: systemPromptFile, content });
918
982
  }
919
983
  const settingsFile = path8.join(ctx.configDir, "settings.json");
@@ -927,13 +991,23 @@ ${agent.body}`,
927
991
  const mainFile = path8.join(path8.dirname(ctx.configDir), `${path8.basename(ctx.configDir)}.json`);
928
992
  const main2 = readTextIfExists(mainFile);
929
993
  if (main2 !== null) {
994
+ const mcpOwnership = [];
930
995
  const content = upsertJson(main2, (root) => {
931
996
  const servers = root["mcpServers"];
932
997
  if (!servers) return;
933
- 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
+ }
934
1008
  if (Object.keys(servers).length === 0) delete root["mcpServers"];
935
1009
  });
936
- actions.push({ kind: "write", target: mainFile, content });
1010
+ actions.push({ kind: "write", target: mainFile, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} });
937
1011
  }
938
1012
  return actions;
939
1013
  }
@@ -951,6 +1025,14 @@ function tomlMultiline(value) {
951
1025
  ${value.replace(/\r\n/g, "\n").trim()}
952
1026
  '''`;
953
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
+ }
954
1036
  function hasActiveEngramPlugin(configDir) {
955
1037
  const config = readTextIfExists(path9.join(configDir, "config.toml"));
956
1038
  if (config === null) return false;
@@ -995,16 +1077,7 @@ var codexAdapter = {
995
1077
  `# Uso: codex --profile ${agent.name}`,
996
1078
  `developer_instructions = ${tomlMultiline(agent.body)}`
997
1079
  ];
998
- const skillDescription = `${agent.description} Invoke to switch into ${agent.name} mode and apply its flow to the current task.`;
999
- const skill = `---
1000
- name: ${agent.name}
1001
- description: ${JSON.stringify(skillDescription)}
1002
- ---
1003
- ${agent.body}`;
1004
- return [
1005
- { file: `${agent.name}.config.toml`, content: profileLines.join("\n") + "\n", kind: "profile" },
1006
- { file: `${agent.name}/SKILL.md`, content: skill, kind: "skill" }
1007
- ];
1080
+ return [{ file: `${agent.name}.config.toml`, content: profileLines.join("\n") + "\n", kind: "profile" }];
1008
1081
  }
1009
1082
  const lines = [
1010
1083
  `name = ${tomlString(agent.name)}`,
@@ -1063,6 +1136,7 @@ ${body}`
1063
1136
  const original = readTextIfExists(file);
1064
1137
  const contentSource = original === null || original.trim() === "" ? null : original;
1065
1138
  let content = contentSource;
1139
+ const mcpOwnership = [];
1066
1140
  if (contentSource === null) {
1067
1141
  const defaults = loadCanonicalDefaults(ctx.stackDir)["codex"] ?? {};
1068
1142
  for (const [key, value] of Object.entries(defaults)) {
@@ -1109,6 +1183,22 @@ ${body}`
1109
1183
  }
1110
1184
  for (const [name, server] of Object.entries(canonical.servers)) {
1111
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
+ }
1112
1202
  if (server.transport === "stdio") {
1113
1203
  if (server.command === "{{ENGRAM_BIN}}" && hasActiveEngramPlugin(ctx.configDir)) {
1114
1204
  if (content !== null) content = removeTomlSection(content, section);
@@ -1124,9 +1214,9 @@ ${body}`
1124
1214
  continue;
1125
1215
  }
1126
1216
  const command = server.command === "{{ENGRAM_BIN}}" ? ctx.engramBin : server.command;
1127
- const args = (server.args ?? []).map(tomlString).join(", ");
1128
1217
  content = upsertTomlSection(content, section, `command = ${tomlString(command)}
1129
- args = [${args}]`);
1218
+ args = [${(server.args ?? []).map(tomlString).join(", ")}]`);
1219
+ if (server.optional && existing === null && !owned) mcpOwnership.push({ server: name, owned: true });
1130
1220
  } else {
1131
1221
  const previousSection = readTomlSection(content, section);
1132
1222
  const prevUsesEnvHeaders = previousSection?.includes("env_http_headers") ?? false;
@@ -1147,7 +1237,7 @@ args = [${args}]`);
1147
1237
  }
1148
1238
  if (content === null) return [];
1149
1239
  if (!content.endsWith("\n")) content += "\n";
1150
- return [{ kind: "write", target: file, content }];
1240
+ return [{ kind: "write", target: file, content, ...mcpOwnership.length > 0 ? { mcpOwnership } : {} }];
1151
1241
  },
1152
1242
  planUnmerge(mcp, hooks, ctx) {
1153
1243
  const actions = [];
@@ -1156,14 +1246,28 @@ args = [${args}]`);
1156
1246
  if (prompt !== null) {
1157
1247
  let content = removeMarkdownSection(prompt, "system-prompt");
1158
1248
  content = removeMarkdownSection(content, "engram-protocol");
1249
+ content = removeMarkdownSection(content, "browser");
1159
1250
  actions.push({ kind: "write", target: systemPromptFile, content });
1160
1251
  }
1161
1252
  const configFile = path9.join(ctx.configDir, "config.toml");
1162
1253
  const config = readTextIfExists(configFile);
1163
1254
  if (config !== null) {
1164
1255
  let content = config;
1165
- for (const name of Object.keys(mcp.servers)) content = removeTomlSection(content, `mcp_servers.${name}`);
1166
- 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 } : {} });
1167
1271
  }
1168
1272
  const hooksFile = path9.join(ctx.configDir, "hooks.json");
1169
1273
  const hooksJson = readTextIfExists(hooksFile);
@@ -1379,13 +1483,6 @@ import path13 from "path";
1379
1483
  var PROGRAMMATIC_ROOT = ["modes", "programmatic"];
1380
1484
  var PROGRAMMATIC_MARKER = "<!-- jorgex:programmatic-mode -->";
1381
1485
  var LEGACY_RESULT_CONTRACT_SECTION = /\n?##\s+Result contract[\s\S]*$/;
1382
- var LEGACY_SKILL_DELEGATION_SECTION = /\n?##\s+Formato obligatorio[\s\S]*$/;
1383
- var LEGACY_DELEGATION_LINE = /- For each `→ \[agent\]: \.\.\.` line, launch the corresponding specialist\./;
1384
- var LEGACY_PROGRAMMATIC_PHRASES = [
1385
- [/\bResult contract\b/g, "strict JSON handoff"],
1386
- [/Status \/ Delegations \/ Risks/g, "status, delegations, and risks"],
1387
- [LEGACY_DELEGATION_LINE, "- Process the JSON `delegations[]` array and launch the corresponding specialist."]
1388
- ];
1389
1486
  var normalize = (value) => value.replace(/\r\n/g, "\n");
1390
1487
  function loadProgrammaticAddendum(stackDir, fileName) {
1391
1488
  return normalize(fs9.readFileSync(path13.join(stackDir, ...PROGRAMMATIC_ROOT, fileName), "utf8")).trim();
@@ -1399,10 +1496,7 @@ ${addendum}
1399
1496
  `;
1400
1497
  }
1401
1498
  function stripLegacyResultContract(body) {
1402
- return LEGACY_PROGRAMMATIC_PHRASES.reduce(
1403
- (text2, [pattern, replacement]) => text2.replace(pattern, replacement),
1404
- normalize(body).replace(LEGACY_RESULT_CONTRACT_SECTION, "")
1405
- ).trimEnd();
1499
+ return normalize(body).replace(LEGACY_RESULT_CONTRACT_SECTION, "").replace(/\bResult contract\b/g, "strict JSON handoff").trimEnd();
1406
1500
  }
1407
1501
  function concurrencyRule(concurrency) {
1408
1502
  if (concurrency === "parallel") {
@@ -1422,22 +1516,13 @@ function composeProgrammaticSystemPrompt(stackDir, content, mode) {
1422
1516
  }
1423
1517
  function composeProgrammaticAgentBody(stackDir, agent, mode, concurrency) {
1424
1518
  if (mode !== "programmatic") return normalize(agent.body);
1425
- const fileName = agent.mode === "primary" ? "orchestrator.addendum.md" : "subagent.addendum.md";
1426
- let addendum = loadProgrammaticAddendum(stackDir, fileName);
1427
1519
  if (agent.mode === "primary") {
1428
- addendum = addendum.replace("{{CONCURRENCY_RULE}}", concurrencyRule(concurrency ?? "serial"));
1520
+ const addendum2 = loadProgrammaticAddendum(stackDir, "orchestrator.addendum.md").replace("{{CONCURRENCY_RULE}}", concurrencyRule(concurrency ?? "serial"));
1521
+ return appendAddendum(agent.body, addendum2);
1429
1522
  }
1523
+ const addendum = loadProgrammaticAddendum(stackDir, "subagent.addendum.md");
1430
1524
  return appendAddendum(stripLegacyResultContract(agent.body), addendum);
1431
1525
  }
1432
- function composeProgrammaticSkillBody(stackDir, skillPath, content, mode) {
1433
- if (mode !== "programmatic") return normalize(content);
1434
- if (skillPath !== path13.join("agent-delegation", "SKILL.md")) return normalize(content);
1435
- const base = normalize(content).replace(
1436
- "Las delegaciones van **en tu output final**, en el formato de abajo. El orquestador las lee y decide a qui\xE9n invocar.",
1437
- "Las delegaciones van como strings en el JSON final `delegations[]`. El orquestador las lee y decide a qui\xE9n invocar."
1438
- ).replace(LEGACY_SKILL_DELEGATION_SECTION, "").trimEnd();
1439
- return appendAddendum(base, loadProgrammaticAddendum(stackDir, "agent-delegation.addendum.md"));
1440
- }
1441
1526
 
1442
1527
  // src/components/system-prompt.ts
1443
1528
  var normalize2 = (s) => s.replace(/\r\n/g, "\n");
@@ -1448,6 +1533,10 @@ function planSystemPrompt(adapter, ctx) {
1448
1533
  normalize2(fs10.readFileSync(path14.join(ctx.stackDir, "system-prompt", "engram-protocol.md"), "utf8"))
1449
1534
  );
1450
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");
1451
1540
  let content = readTextIfExists(target);
1452
1541
  content = upsertMarkdownSection(content, "system-prompt", composedAgentsMd);
1453
1542
  if (adapter.injectEngramProtocol(ctx)) {
@@ -1455,18 +1544,18 @@ function planSystemPrompt(adapter, ctx) {
1455
1544
  } else {
1456
1545
  content = removeMarkdownSection(content, "engram-protocol");
1457
1546
  }
1547
+ content = browser === "" ? removeMarkdownSection(content, "browser") : upsertMarkdownSection(content, "browser", browser);
1458
1548
  return [{ kind: "write", target, content }];
1459
1549
  }
1460
1550
 
1461
1551
  // src/components/agents.ts
1462
1552
  import path15 from "path";
1463
1553
  function planAgents(adapter, ctx) {
1464
- const { agentsDir, commandsDir, outputStylesDir, skillsDir, profilesDir, scriptsDir } = adapter.paths(ctx.configDir);
1554
+ const { agentsDir, commandsDir, outputStylesDir, profilesDir, scriptsDir } = adapter.paths(ctx.configDir);
1465
1555
  const dirFor = {
1466
1556
  agent: agentsDir,
1467
1557
  command: commandsDir,
1468
1558
  "output-style": outputStylesDir,
1469
- skill: skillsDir,
1470
1559
  profile: profilesDir
1471
1560
  };
1472
1561
  const scriptsBase = scriptsDir.replace(/\\/g, "/");
@@ -1489,43 +1578,35 @@ function planAgents(adapter, ctx) {
1489
1578
 
1490
1579
  // src/components/skills.ts
1491
1580
  import path16 from "path";
1492
- import fs11 from "fs";
1493
1581
  function planSkills(adapter, ctx) {
1494
1582
  const { skillsDir } = adapter.paths(ctx.configDir);
1495
1583
  const source = path16.join(ctx.stackDir, "skills");
1496
1584
  return listFilesRecursive(source).map((file) => {
1497
1585
  const relative = path16.relative(source, file);
1498
- if (relative === path16.join("agent-delegation", "SKILL.md") && ctx.mode === "programmatic") {
1499
- return {
1500
- kind: "write",
1501
- target: path16.join(skillsDir, relative),
1502
- content: composeProgrammaticSkillBody(ctx.stackDir, relative, fs11.readFileSync(file, "utf8"), ctx.mode)
1503
- };
1504
- }
1505
1586
  return { kind: "copy", source: file, target: path16.join(skillsDir, relative) };
1506
1587
  });
1507
1588
  }
1508
1589
 
1509
1590
  // src/components/commands.ts
1510
1591
  import path17 from "path";
1511
- import fs12 from "fs";
1592
+ import fs11 from "fs";
1512
1593
  function planCommands(adapter, ctx) {
1513
1594
  const { commandsDir } = adapter.paths(ctx.configDir);
1514
1595
  const source = path17.join(ctx.stackDir, "commands");
1515
- if (!fs12.existsSync(source)) return [];
1596
+ if (!fs11.existsSync(source)) return [];
1516
1597
  const commandFiles = [
1517
1598
  ...listMarkdownFiles(source),
1518
1599
  ...listMarkdownFiles(path17.join(source, adapter.id))
1519
1600
  ];
1520
1601
  return commandFiles.map(({ file, fullPath }) => {
1521
- const raw = fs12.readFileSync(fullPath, "utf8").replace(/\r\n/g, "\n");
1602
+ const raw = fs11.readFileSync(fullPath, "utf8").replace(/\r\n/g, "\n");
1522
1603
  const rendered = adapter.renderCommand(file, raw);
1523
1604
  return { kind: "write", target: path17.join(commandsDir, rendered.file), content: rendered.content };
1524
1605
  });
1525
1606
  }
1526
1607
  function listMarkdownFiles(dir) {
1527
- if (!fs12.existsSync(dir)) return [];
1528
- return fs12.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => ({ file: entry.name, fullPath: path17.join(dir, entry.name) }));
1608
+ if (!fs11.existsSync(dir)) return [];
1609
+ return fs11.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => ({ file: entry.name, fullPath: path17.join(dir, entry.name) }));
1529
1610
  }
1530
1611
 
1531
1612
  // src/components/hooks.ts
@@ -1540,22 +1621,22 @@ function planMcp(adapter, ctx) {
1540
1621
 
1541
1622
  // src/components/plugins.ts
1542
1623
  import path18 from "path";
1543
- import fs13 from "fs";
1624
+ import fs12 from "fs";
1544
1625
  function planPlugins(adapter, ctx) {
1545
1626
  const { pluginsDir } = adapter.paths(ctx.configDir);
1546
1627
  if (pluginsDir === null) return [];
1547
1628
  const source = path18.join(ctx.stackDir, "plugins", adapter.id);
1548
- if (!fs13.existsSync(source)) return [];
1629
+ if (!fs12.existsSync(source)) return [];
1549
1630
  return listFilesRecursive(source).filter((f) => f.endsWith(".ts")).map((sourceFile) => {
1550
1631
  const target = path18.join(pluginsDir, path18.relative(source, sourceFile));
1551
- const raw = fs13.readFileSync(sourceFile, "utf8");
1632
+ const raw = fs12.readFileSync(sourceFile, "utf8");
1552
1633
  let content = raw;
1553
1634
  if (content.includes('"{{ENGRAM_BIN}}"')) {
1554
1635
  content = content.replace(/"\{\{ENGRAM_BIN\}\}"/g, JSON.stringify(ctx.engramBin ?? "engram"));
1555
1636
  }
1556
1637
  if (content.includes('"{{ENGRAM_PROTOCOL}}"')) {
1557
1638
  const protocol = stripLeadingHtmlComments(
1558
- fs13.readFileSync(path18.join(ctx.stackDir, "system-prompt", "engram-protocol.md"), "utf8").replace(/\r\n/g, "\n")
1639
+ fs12.readFileSync(path18.join(ctx.stackDir, "system-prompt", "engram-protocol.md"), "utf8").replace(/\r\n/g, "\n")
1559
1640
  );
1560
1641
  content = content.replace(/"\{\{ENGRAM_PROTOCOL\}\}"/g, JSON.stringify(protocol));
1561
1642
  }
@@ -1565,13 +1646,256 @@ function planPlugins(adapter, ctx) {
1565
1646
  });
1566
1647
  }
1567
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+)\s*$/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
+
1568
1849
  // src/install.ts
1569
1850
  var ADAPTERS = {
1570
1851
  opencode: opencodeAdapter,
1571
1852
  "claude-code": claudeCodeAdapter,
1572
1853
  codex: codexAdapter
1573
1854
  };
1574
- 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) {
1575
1899
  const models = loadModelMap()[adapter.id];
1576
1900
  if (!models) return null;
1577
1901
  return {
@@ -1581,7 +1905,10 @@ function makeContext(adapter, configDir, mode = DEFAULT_INSTALL_MODE_PREFERENCE)
1581
1905
  subagentConcurrency: mode.subagentConcurrency,
1582
1906
  engramBin: detectEngram(),
1583
1907
  models,
1584
- warnings: []
1908
+ warnings: [],
1909
+ enabledMcpServers: enabledMcpServers(adapter.id, void 0, useBrowserPreferences),
1910
+ playwrightCliEnabled: useBrowserPreferences && loadPlaywrightCliPreference() === true,
1911
+ ownedMcpServers: ownedMcpServers(adapter.id, useBrowserPreferences)
1585
1912
  };
1586
1913
  }
1587
1914
  function buildPlan(adapter, ctx) {
@@ -1602,14 +1929,16 @@ function diffPlan(plan) {
1602
1929
  if (current === null) return { action, status: "create" };
1603
1930
  return { action, status: current === action.content ? "unchanged" : "update" };
1604
1931
  }
1605
- if (!fs14.existsSync(action.target)) return { action, status: "create" };
1932
+ if (!fs15.existsSync(action.target)) return { action, status: "create" };
1606
1933
  return { action, status: sameFileContent(action.source, action.target) ? "unchanged" : "update" };
1607
1934
  });
1608
1935
  }
1609
- function applyChanges(changes) {
1936
+ function applyChanges(changes, onMcpOwnershipWritten) {
1610
1937
  for (const { action } of changes) {
1611
- if (action.kind === "write") writeText(action.target, action.content);
1612
- 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);
1613
1942
  }
1614
1943
  }
1615
1944
  function collectAllCurrentTargets(mode = DEFAULT_INSTALL_MODE_PREFERENCE) {
@@ -1626,7 +1955,7 @@ function collectAllCurrentTargets(mode = DEFAULT_INSTALL_MODE_PREFERENCE) {
1626
1955
  continue;
1627
1956
  }
1628
1957
  try {
1629
- 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));
1630
1959
  } catch (error) {
1631
1960
  complete = false;
1632
1961
  warnings.push(
@@ -1642,6 +1971,17 @@ async function runInstall(opts) {
1642
1971
  const engramBin = detectEngram();
1643
1972
  const modePreference = opts.mode === void 0 ? opts.targetDir === void 0 ? loadInstallModePreference() : DEFAULT_INSTALL_MODE_PREFERENCE : normalizeInstallModePreference(opts.mode);
1644
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;
1645
1985
  const modelMap = loadModelMap();
1646
1986
  if (useManifest) ensureModelMapFile();
1647
1987
  p.log.info(engramBin ? `Engram detectado: ${engramBin} (se respeta, D7)` : "Engram NO detectado.");
@@ -1655,6 +1995,7 @@ async function runInstall(opts) {
1655
1995
  }
1656
1996
  let exitCode = 0;
1657
1997
  let successfulRuns = 0;
1998
+ const successfulContexts = [];
1658
1999
  for (const id of opts.runtimes) {
1659
2000
  const adapter = ADAPTERS[id];
1660
2001
  if (!adapter) {
@@ -1680,7 +2021,16 @@ async function runInstall(opts) {
1680
2021
  subagentConcurrency: modePreference.subagentConcurrency,
1681
2022
  engramBin,
1682
2023
  models,
1683
- 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
+ }
1684
2034
  };
1685
2035
  let plan = buildPlan(adapter, ctx);
1686
2036
  let diff = diffPlan(plan);
@@ -1696,24 +2046,30 @@ async function runInstall(opts) {
1696
2046
  if (orphans.length > 0) p.log.info(`${orphans.length} hu\xE9rfanos de versiones previas a eliminar`);
1697
2047
  for (const w of ctx.warnings) p.log.warn(w);
1698
2048
  if (opts.dryRun) {
1699
- for (const c of changes.slice(0, 40)) p.log.message(` ${c.status === "create" ? "+" : "~"} ${c.action.target}`);
1700
- 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`);
1701
2054
  for (const o of orphans) p.log.message(` - ${o}`);
1702
2055
  continue;
1703
2056
  }
1704
2057
  const writeManifest = () => {
1705
2058
  if (!useManifest) return;
1706
- 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)));
1707
2060
  const keepTarget = (target) => !unmergeTargets.has(target);
1708
- const liveOwned = plan.map((a) => path19.resolve(a.target)).filter(keepTarget);
1709
- 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);
1710
2063
  const owned = canOrphan ? liveOwned : [.../* @__PURE__ */ new Set([...previousOwned, ...liveOwned])];
1711
2064
  writeRuntimeManifest(id, { configDir, owned, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
1712
2065
  };
1713
2066
  if (changes.length === 0 && orphans.length === 0) {
1714
2067
  writeManifest();
2068
+ if (useManifest) persistMcpOwnershipChanges(id, plan);
2069
+ persistDevtoolsSelection();
1715
2070
  p.log.success(`${adapter.name}: ya al d\xEDa (idempotente).`);
1716
2071
  successfulRuns++;
2072
+ successfulContexts.push({ adapter, ctx });
1717
2073
  continue;
1718
2074
  }
1719
2075
  if (!opts.yes && process.stdout.isTTY) {
@@ -1731,10 +2087,10 @@ async function runInstall(opts) {
1731
2087
  }
1732
2088
  const backup = useManifest ? createBackup([...updates.map((c) => c.action.target), ...orphans], `install-${id}`) : null;
1733
2089
  if (backup) p.log.info(`Backup: ${backup.id} (${backup.files.length} archivos)`);
1734
- applyChanges(changes);
1735
- 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);
1736
2092
  for (const orphan of orphans) {
1737
- fs14.rmSync(orphan, { force: true });
2093
+ fs15.rmSync(orphan, { force: true });
1738
2094
  pruneEmptyDirs(orphan, pruneRoot);
1739
2095
  }
1740
2096
  const verifyCtx = { ...ctx, warnings: [] };
@@ -1745,26 +2101,98 @@ async function runInstall(opts) {
1745
2101
  exitCode = 1;
1746
2102
  } else {
1747
2103
  writeManifest();
2104
+ if (useManifest) persistMcpOwnershipChanges(id, plan);
2105
+ persistDevtoolsSelection();
1748
2106
  p.log.success(`${adapter.name}: ${changes.length} archivos aplicados y verificados (idempotente).`);
1749
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
+ }
1750
2164
  }
1751
2165
  }
1752
2166
  if (useManifest && !opts.dryRun && exitCode === 0 && successfulRuns > 0) {
1753
2167
  saveInstallModePreference(installModePreferenceFile(), modePreference);
1754
2168
  }
1755
- 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).");
1756
2170
  return exitCode;
1757
2171
  }
1758
2172
 
1759
2173
  // src/uninstall.ts
1760
- import fs15 from "fs";
1761
- import path20 from "path";
2174
+ import fs16 from "fs";
2175
+ import path22 from "path";
1762
2176
  import * as p2 from "@clack/prompts";
2177
+ function resolvePlaywrightUninstallPlan(input) {
2178
+ return {
2179
+ actions: input.removePackage ? ["remove"] : [],
2180
+ preserveBrowserData: true
2181
+ };
2182
+ }
1763
2183
  async function runUninstall(opts) {
1764
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
+ }
1765
2192
  const stackDir = stackRoot();
1766
2193
  const mcp = loadCanonicalMcp(stackDir);
1767
2194
  const hooks = loadCanonicalHooks(stackDir);
2195
+ let exitCode = 0;
1768
2196
  let removeEngram = opts.removeEngram;
1769
2197
  if (!removeEngram && !opts.yes && !opts.dryRun && process.stdout.isTTY) {
1770
2198
  const answer = await p2.confirm({
@@ -1782,13 +2210,15 @@ async function runUninstall(opts) {
1782
2210
  p2.log.info("Engram se conserva: memorias, binario y registro intactos (usa --remove-engram para desregistrarlo).");
1783
2211
  }
1784
2212
  const retained = /* @__PURE__ */ new Set();
1785
- for (const keep of Object.values(ADAPTERS)) {
1786
- if (opts.runtimes.includes(keep.id)) continue;
1787
- const detection = keep.detect();
1788
- if (!detection.installed) continue;
1789
- const keepCtx = makeContext(keep, detection.configDir);
1790
- if (!keepCtx) continue;
1791
- 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
+ }
1792
2222
  }
1793
2223
  for (const id of opts.runtimes) {
1794
2224
  const adapter = ADAPTERS[id];
@@ -1802,60 +2232,106 @@ async function runUninstall(opts) {
1802
2232
  p2.log.warn(`${adapter.name} no detectado \u2014 omitido.`);
1803
2233
  continue;
1804
2234
  }
1805
- const ctx = makeContext(adapter, configDir);
2235
+ const ctx = makeContext(adapter, configDir, void 0, useBrowserPreferences);
1806
2236
  if (!ctx) continue;
1807
2237
  ctx.preserveEngram = !removeEngram;
1808
2238
  const unmerge = adapter.planUnmerge(mcpForUnmerge, hooks, ctx);
1809
- const mergedTargets = new Set(unmerge.map((a) => path20.resolve(a.target)));
2239
+ const mergedTargets = new Set(unmerge.map((a) => path22.resolve(a.target)));
1810
2240
  const usingRealConfig = opts.targetDir === void 0;
1811
2241
  const prevOwned = usingRealConfig ? readManifest().runtimes[id]?.owned ?? [] : [];
1812
- const pruneRoot = usingRealConfig ? HOME : path20.dirname(configDir);
2242
+ const pruneRoot = usingRealConfig ? HOME : path22.dirname(configDir);
1813
2243
  const planTargets = [
1814
- .../* @__PURE__ */ new Set([...buildPlan(adapter, ctx).map((a) => path20.resolve(a.target)), ...prevOwned.map((t) => path20.resolve(t))])
1815
- ].filter((t) => !mergedTargets.has(t) && fs15.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));
1816
2246
  const deleteTargets = planTargets.filter(
1817
- (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)
1818
2248
  );
1819
2249
  const sharedKept = planTargets.length - deleteTargets.length;
1820
2250
  p2.log.step(`${adapter.name} \u2192 ${configDir}`);
1821
2251
  p2.log.info(`${deleteTargets.length} archivos a borrar, ${unmerge.length} archivos compartidos a limpiar`);
1822
2252
  if (sharedKept > 0) p2.log.info(`${sharedKept} archivos se conservan: otros runtimes instalados los siguen usando.`);
1823
2253
  if (opts.dryRun) continue;
1824
- const backup = createBackup(
1825
- [...deleteTargets, ...unmerge.map((a) => a.target).filter((t) => fs15.existsSync(t))],
1826
- `uninstall-${id}`
1827
- );
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
+ }
1828
2265
  if (backup) p2.log.info(`Backup: ${backup.id} (${backup.files.length} archivos)`);
1829
2266
  for (const target of deleteTargets) {
1830
- fs15.rmSync(target, { force: true });
2267
+ fs16.rmSync(target, { force: true });
1831
2268
  pruneEmptyDirs(target, pruneRoot);
1832
2269
  }
1833
2270
  for (const action of unmerge) {
1834
2271
  if (action.kind !== "write") continue;
1835
2272
  if (action.content.trim() === "") {
1836
- fs15.rmSync(action.target, { force: true });
2273
+ fs16.rmSync(action.target, { force: true });
1837
2274
  } else {
1838
2275
  writeText(action.target, action.content);
1839
2276
  }
2277
+ if (usingRealConfig) {
2278
+ for (const change of action.mcpOwnership ?? []) {
2279
+ saveDevtoolsMcpOwnership(devtoolsMcpPreferenceFile(), id, change.server, change.owned);
2280
+ }
2281
+ }
1840
2282
  }
1841
2283
  if (usingRealConfig) removeRuntimeManifest(id);
1842
2284
  p2.log.success(`${adapter.name}: stack retirado (lo tuyo queda intacto).`);
1843
2285
  }
1844
- p2.outro(opts.dryRun ? "Dry-run: no se ha tocado nada." : "Hecho. Usa 'restore' si quieres volver atr\xE1s.");
1845
- 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;
1846
2311
  }
1847
2312
 
1848
2313
  // src/doctor.ts
1849
- import path21 from "path";
1850
- import fs16 from "fs";
2314
+ import path23 from "path";
2315
+ import fs17 from "fs";
1851
2316
  import * as p3 from "@clack/prompts";
1852
2317
  function engramVersion(bin) {
1853
2318
  const out = runDetectedBin(bin, ["--version"], 5e3);
1854
2319
  if (out === null) return null;
1855
2320
  return /(\d+\.\d+\.\d+)/.exec(out)?.[1] ?? out.trim().split("\n")[0] ?? null;
1856
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
+ }
1857
2333
  function context7KeyConfigured(id, configDir) {
1858
- 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");
1859
2335
  const content = readTextIfExists(file);
1860
2336
  if (content === null) return null;
1861
2337
  const match = /CONTEXT7_API_KEY"?\s*[:=]\s*"([^"]*)"/.exec(content);
@@ -1878,13 +2354,44 @@ async function runDoctor() {
1878
2354
  p3.log.success(`Engram: ${version} (${engramBin})`);
1879
2355
  }
1880
2356
  }
1881
- const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path21.join(HOME, ".engram");
1882
- const engramDb = path21.join(engramDataDir, "engram.db");
1883
- if (fs16.existsSync(engramDb)) {
1884
- const sizeMb = (fs16.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);
1885
2361
  p3.log.info(`Engram DB: ${engramDb} (${sizeMb} MB de memorias \u2014 el stack no la toca JAM\xC1S).`);
1886
2362
  }
1887
- if (!fs16.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
+ }
1888
2395
  const manifest = readManifest();
1889
2396
  const modePreference = loadInstallModePreference();
1890
2397
  const current = collectAllCurrentTargets(modePreference);
@@ -1923,10 +2430,10 @@ async function runDoctor() {
1923
2430
  p3.log.warn(`${adapter.name}: ${orphans.length} archivos hu\xE9rfanos de versiones previas \u2192 ejecuta 'sync'.`);
1924
2431
  problems++;
1925
2432
  }
1926
- if (adapter.id === "codex" && fs16.existsSync(path21.join(detection.configDir, "hooks.json"))) {
2433
+ if (adapter.id === "codex" && fs17.existsSync(path23.join(detection.configDir, "hooks.json"))) {
1927
2434
  p3.log.info("Codex: recuerda que los hooks requieren aprobaci\xF3n manual \u2014 verifica con /hooks dentro de codex.");
1928
2435
  }
1929
- if (adapter.id === "codex" && fs16.existsSync(path21.join(detection.configDir, "AGENTS.override.md"))) {
2436
+ if (adapter.id === "codex" && fs17.existsSync(path23.join(detection.configDir, "AGENTS.override.md"))) {
1930
2437
  p3.log.warn(
1931
2438
  "Codex: existe ~/.codex/AGENTS.override.md \u2014 tiene prioridad ABSOLUTA y tapa el AGENTS.md gestionado por el stack."
1932
2439
  );
@@ -1940,17 +2447,17 @@ async function runDoctor() {
1940
2447
  }
1941
2448
 
1942
2449
  // src/update.ts
1943
- import fs19 from "fs";
1944
- import path24 from "path";
1945
- import os3 from "os";
1946
- 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";
1947
2454
  import * as p4 from "@clack/prompts";
1948
2455
 
1949
2456
  // src/lib/github.ts
1950
- import fs17 from "fs";
1951
- import path22 from "path";
1952
- import { execFileSync as execFileSync2 } from "child_process";
1953
- 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";
1954
2461
  import { Readable } from "stream";
1955
2462
  import { pipeline } from "stream/promises";
1956
2463
  var cachedToken;
@@ -2018,19 +2525,19 @@ async function latestGithubCommit(repo) {
2018
2525
  }
2019
2526
  }
2020
2527
  function validateExtractedTree(destDir) {
2021
- const resolved = path22.resolve(destDir);
2528
+ const resolved = path24.resolve(destDir);
2022
2529
  const walk = (dir) => {
2023
2530
  let entries;
2024
2531
  try {
2025
- entries = fs17.readdirSync(dir, { withFileTypes: true });
2532
+ entries = fs18.readdirSync(dir, { withFileTypes: true });
2026
2533
  } catch {
2027
2534
  return false;
2028
2535
  }
2029
2536
  for (const entry of entries) {
2030
- const full = path22.join(dir, entry.name);
2537
+ const full = path24.join(dir, entry.name);
2031
2538
  let stat;
2032
2539
  try {
2033
- stat = fs17.lstatSync(full);
2540
+ stat = fs18.lstatSync(full);
2034
2541
  } catch {
2035
2542
  return false;
2036
2543
  }
@@ -2046,15 +2553,15 @@ function validateExtractedTree(destDir) {
2046
2553
  }
2047
2554
  function resolveTarBin() {
2048
2555
  if (process.platform !== "win32") return "tar";
2049
- const winTar = path22.join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "tar.exe");
2050
- return fs17.existsSync(winTar) ? winTar : "tar";
2556
+ const winTar = path24.join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "tar.exe");
2557
+ return fs18.existsSync(winTar) ? winTar : "tar";
2051
2558
  }
2052
2559
  async function downloadRepoTarball(repo, sha, destDir, validateSubdir) {
2053
2560
  const url = `https://codeload.github.com/${repo}/tar.gz/${sha}`;
2054
- 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`);
2055
2562
  const fail = (reason) => {
2056
2563
  try {
2057
- fs17.rmSync(destDir, { recursive: true, force: true });
2564
+ fs18.rmSync(destDir, { recursive: true, force: true });
2058
2565
  } catch {
2059
2566
  }
2060
2567
  return { ok: false, reason };
@@ -2073,46 +2580,46 @@ async function downloadRepoTarball(repo, sha, destDir, validateSubdir) {
2073
2580
  if (!res.body) return fail("respuesta HTTP sin cuerpo");
2074
2581
  await pipeline(
2075
2582
  Readable.fromWeb(res.body),
2076
- fs17.createWriteStream(tmp)
2583
+ fs18.createWriteStream(tmp)
2077
2584
  );
2078
- fs17.rmSync(destDir, { recursive: true, force: true });
2079
- fs17.mkdirSync(destDir, { recursive: true });
2585
+ fs18.rmSync(destDir, { recursive: true, force: true });
2586
+ fs18.mkdirSync(destDir, { recursive: true });
2080
2587
  try {
2081
- execFileSync2(resolveTarBin(), ["-xzf", tmp, "--strip-components=1", "-C", destDir], { stdio: "pipe" });
2588
+ execFileSync3(resolveTarBin(), ["-xzf", tmp, "--strip-components=1", "-C", destDir], { stdio: "pipe" });
2082
2589
  } catch (err) {
2083
2590
  const e = err;
2084
2591
  const detail = (e.stderr?.toString().trim() || e.message || "").split("\n")[0];
2085
2592
  return fail(detail ? `tar fall\xF3: ${detail}` : "tar no disponible o fall\xF3 la extracci\xF3n");
2086
2593
  }
2087
- const resolvedDest = path22.resolve(destDir);
2088
- const validateRoot = validateSubdir ? path22.resolve(resolvedDest, validateSubdir) : resolvedDest;
2594
+ const resolvedDest = path24.resolve(destDir);
2595
+ const validateRoot = validateSubdir ? path24.resolve(resolvedDest, validateSubdir) : resolvedDest;
2089
2596
  if (validateRoot !== resolvedDest && !isContainedIn(validateRoot, resolvedDest)) {
2090
2597
  return fail(`la ruta de validaci\xF3n "${validateSubdir}" escapa del destino`);
2091
2598
  }
2092
- if (fs17.existsSync(validateRoot) && !validateExtractedTree(validateRoot)) {
2599
+ if (fs18.existsSync(validateRoot) && !validateExtractedTree(validateRoot)) {
2093
2600
  return fail("el \xE1rbol extra\xEDdo contiene symlinks o rutas fuera del destino");
2094
2601
  }
2095
- const validated = fs17.existsSync(validateRoot);
2602
+ const validated = fs18.existsSync(validateRoot);
2096
2603
  return { ok: true, validated };
2097
2604
  } catch (err) {
2098
2605
  const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
2099
2606
  return fail(timedOut ? "timeout de descarga (120s)" : err instanceof Error ? `fallo de red: ${err.message}` : "error desconocido");
2100
2607
  } finally {
2101
2608
  try {
2102
- fs17.rmSync(tmp, { force: true });
2609
+ fs18.rmSync(tmp, { force: true });
2103
2610
  } catch {
2104
2611
  }
2105
2612
  }
2106
2613
  }
2107
2614
 
2108
2615
  // src/lib/skill-update.ts
2109
- import fs18 from "fs";
2110
- import path23 from "path";
2111
- 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";
2112
2619
  var PROTECTED_SKILLS = /* @__PURE__ */ new Set(["agent-delegation", "work-lifecycle", "xreview"]);
2113
2620
  function sameTextContentNormalized(a, b) {
2114
- const ba = fs18.readFileSync(a);
2115
- const bb = fs18.readFileSync(b);
2621
+ const ba = fs19.readFileSync(a);
2622
+ const bb = fs19.readFileSync(b);
2116
2623
  if (ba.equals(bb)) return true;
2117
2624
  const sa = ba.toString("utf8").replace(/\r\n/g, "\n");
2118
2625
  const sb = bb.toString("utf8").replace(/\r\n/g, "\n");
@@ -2120,10 +2627,10 @@ function sameTextContentNormalized(a, b) {
2120
2627
  }
2121
2628
  function diffSkillDirs(upstreamDir, localDir) {
2122
2629
  const upstreamFiles = new Set(
2123
- listFilesRecursive(upstreamDir).map((f) => path23.relative(upstreamDir, f))
2630
+ listFilesRecursive(upstreamDir).map((f) => path25.relative(upstreamDir, f))
2124
2631
  );
2125
2632
  const localFiles = new Set(
2126
- listFilesRecursive(localDir).map((f) => path23.relative(localDir, f))
2633
+ listFilesRecursive(localDir).map((f) => path25.relative(localDir, f))
2127
2634
  );
2128
2635
  const added = [];
2129
2636
  const modified = [];
@@ -2131,7 +2638,7 @@ function diffSkillDirs(upstreamDir, localDir) {
2131
2638
  for (const rel of upstreamFiles) {
2132
2639
  if (!localFiles.has(rel)) {
2133
2640
  added.push(rel);
2134
- } else if (!sameTextContentNormalized(path23.join(upstreamDir, rel), path23.join(localDir, rel))) {
2641
+ } else if (!sameTextContentNormalized(path25.join(upstreamDir, rel), path25.join(localDir, rel))) {
2135
2642
  modified.push(rel);
2136
2643
  }
2137
2644
  }
@@ -2149,7 +2656,7 @@ function diffSkillDirs(upstreamDir, localDir) {
2149
2656
  var DIFF_MAX_LINES = 400;
2150
2657
  function renderSkillDiff(upstreamDir, localDir) {
2151
2658
  try {
2152
- execFileSync3("git", ["diff", "--no-index", "--stat", "--", localDir, upstreamDir], {
2659
+ execFileSync4("git", ["diff", "--no-index", "--stat", "--", localDir, upstreamDir], {
2153
2660
  stdio: "pipe",
2154
2661
  encoding: "utf8"
2155
2662
  });
@@ -2166,7 +2673,7 @@ function renderSkillDiff(upstreamDir, localDir) {
2166
2673
  const stat = se.stdout;
2167
2674
  let fullDiff = "";
2168
2675
  try {
2169
- execFileSync3("git", ["diff", "--no-index", "--", localDir, upstreamDir], {
2676
+ execFileSync4("git", ["diff", "--no-index", "--", localDir, upstreamDir], {
2170
2677
  stdio: "pipe",
2171
2678
  encoding: "utf8"
2172
2679
  });
@@ -2193,8 +2700,8 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2193
2700
  if (PROTECTED_SKILLS.has(name)) {
2194
2701
  throw new Error(`La skill "${name}" es propia del stack y no se actualiza desde upstream.`);
2195
2702
  }
2196
- const upstreamsFile = upstreamsFilePath ?? path23.join(path23.dirname(stackRoot()), "upstreams.json");
2197
- const raw = fs18.readFileSync(upstreamsFile, "utf8");
2703
+ const upstreamsFile = upstreamsFilePath ?? path25.join(path25.dirname(stackRoot()), "upstreams.json");
2704
+ const raw = fs19.readFileSync(upstreamsFile, "utf8");
2198
2705
  const data = JSON.parse(raw);
2199
2706
  const skillEntry = data?.skills?.[name];
2200
2707
  if (!skillEntry) {
@@ -2203,8 +2710,8 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2203
2710
  if (skillEntry.kind === "release") {
2204
2711
  throw new Error(`La skill "${name}" es de tipo release y no se actualiza con replaceSkill.`);
2205
2712
  }
2206
- const skillsRoot = localSkillsRoot ?? path23.join(stackRoot(), "skills");
2207
- const localSkillDir = path23.join(skillsRoot, name);
2713
+ const skillsRoot = localSkillsRoot ?? path25.join(stackRoot(), "skills");
2714
+ const localSkillDir = path25.join(skillsRoot, name);
2208
2715
  const localFiles = listFilesRecursive(localSkillDir);
2209
2716
  if (localFiles.length > 0) {
2210
2717
  createBackup(localFiles, `skill-update-${name}`, backupsRoot2);
@@ -2213,24 +2720,24 @@ function replaceSkill(name, upstreamSkillDir, newCommit, opts) {
2213
2720
  try {
2214
2721
  const upstreamFiles = listFilesRecursive(upstreamSkillDir);
2215
2722
  for (const src of upstreamFiles) {
2216
- const st = fs18.lstatSync(src);
2723
+ const st = fs19.lstatSync(src);
2217
2724
  if (st.isSymbolicLink()) {
2218
2725
  throw new Error(`Symlink rechazado en upstream de skill "${name}": ${src}`);
2219
2726
  }
2220
- const rel = path23.relative(upstreamSkillDir, src);
2221
- const dest = path23.join(stagingDir, rel);
2222
- ensureDir(path23.dirname(dest));
2727
+ const rel = path25.relative(upstreamSkillDir, src);
2728
+ const dest = path25.join(stagingDir, rel);
2729
+ ensureDir(path25.dirname(dest));
2223
2730
  copyFile(src, dest);
2224
2731
  }
2225
2732
  const oldDir = `${localSkillDir}.old-${process.pid}`;
2226
- if (fs18.existsSync(localSkillDir)) {
2227
- fs18.renameSync(localSkillDir, oldDir);
2733
+ if (fs19.existsSync(localSkillDir)) {
2734
+ fs19.renameSync(localSkillDir, oldDir);
2228
2735
  }
2229
- fs18.renameSync(stagingDir, localSkillDir);
2230
- fs18.rmSync(oldDir, { recursive: true, force: true });
2736
+ fs19.renameSync(stagingDir, localSkillDir);
2737
+ fs19.rmSync(oldDir, { recursive: true, force: true });
2231
2738
  } catch (err) {
2232
2739
  try {
2233
- fs18.rmSync(stagingDir, { recursive: true, force: true });
2740
+ fs19.rmSync(stagingDir, { recursive: true, force: true });
2234
2741
  } catch {
2235
2742
  }
2236
2743
  throw err;
@@ -2244,8 +2751,8 @@ function rateLimitHint(prefix) {
2244
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.`;
2245
2752
  }
2246
2753
  function loadUpstreams() {
2247
- const file = path24.join(path24.dirname(stackRoot()), "upstreams.json");
2248
- return JSON.parse(fs19.readFileSync(file, "utf8"));
2754
+ const file = path26.join(path26.dirname(stackRoot()), "upstreams.json");
2755
+ return JSON.parse(fs20.readFileSync(file, "utf8"));
2249
2756
  }
2250
2757
  function skillsToScan(maintainer, upstreams) {
2251
2758
  return maintainer ? Object.keys(upstreams.skills) : [];
@@ -2262,8 +2769,30 @@ async function latestNpmVersion(pkg) {
2262
2769
  return null;
2263
2770
  }
2264
2771
  }
2265
- 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) {
2266
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
+ }
2267
2796
  const upstreams = loadUpstreams();
2268
2797
  const npmLatest = await latestNpmVersion("jorgex-stack");
2269
2798
  if (npmLatest === null)
@@ -2288,6 +2817,13 @@ async function runUpdateCheck(localVersion) {
2288
2817
  `engram: ${local} local, ${latest} disponible. Tu instalaci\xF3n NO se toca (D7) \u2014 actualiza t\xFA: github.com/${engramRepo}/releases`
2289
2818
  );
2290
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
+ }
2291
2827
  const checkSkillNames = skillsToScan(isGitClone(), upstreams);
2292
2828
  if (checkSkillNames.length === 0) {
2293
2829
  p4.log.info(
@@ -2347,7 +2883,7 @@ function isEngramRunning() {
2347
2883
  const pgrep = lookPath("pgrep");
2348
2884
  if (!pgrep) return null;
2349
2885
  try {
2350
- execFileSync4(pgrep, ["-x", "engram"], { stdio: "ignore" });
2886
+ execFileSync5(pgrep, ["-x", "engram"], { stdio: "ignore" });
2351
2887
  return true;
2352
2888
  } catch {
2353
2889
  return false;
@@ -2357,8 +2893,8 @@ function isEngramRunning() {
2357
2893
  return null;
2358
2894
  }
2359
2895
  }
2360
- function isGitClone(projectRoot = path24.dirname(stackRoot())) {
2361
- return fs19.existsSync(path24.join(projectRoot, ".git"));
2896
+ function isGitClone(projectRoot = path26.dirname(stackRoot())) {
2897
+ return fs20.existsSync(path26.join(projectRoot, ".git"));
2362
2898
  }
2363
2899
  var STACK_METHOD_CLONE = "git pull + pnpm install + pnpm build";
2364
2900
  function resolvePnpm() {
@@ -2368,29 +2904,29 @@ function resolvePnpm() {
2368
2904
  }
2369
2905
  function cleanupTmp(dir) {
2370
2906
  try {
2371
- fs19.rmSync(dir, { recursive: true, force: true });
2907
+ fs20.rmSync(dir, { recursive: true, force: true });
2372
2908
  } catch {
2373
2909
  }
2374
2910
  }
2375
2911
  function updateStackGitClone() {
2376
- const projectRoot = path24.dirname(stackRoot());
2912
+ const projectRoot = path26.dirname(stackRoot());
2377
2913
  const git = lookPath("git");
2378
2914
  if (!git) throw new Error("git no encontrado en PATH.");
2379
2915
  const pnpm = resolvePnpm();
2380
2916
  p4.log.info("Ejecutando git pull\u2026");
2381
- execFileSync4(git, ["pull"], { cwd: projectRoot, stdio: "inherit" });
2917
+ execFileSync5(git, ["pull"], { cwd: projectRoot, stdio: "inherit" });
2382
2918
  p4.log.info("Ejecutando pnpm install\u2026");
2383
- execFileSync4(pnpm, ["install"], { cwd: projectRoot, stdio: "inherit" });
2919
+ execFileSync5(pnpm, ["install"], { cwd: projectRoot, stdio: "inherit" });
2384
2920
  p4.log.info("Ejecutando pnpm build\u2026");
2385
- execFileSync4(pnpm, ["build"], { cwd: projectRoot, stdio: "inherit" });
2921
+ execFileSync5(pnpm, ["build"], { cwd: projectRoot, stdio: "inherit" });
2386
2922
  }
2387
2923
  function updateStackGlobal() {
2388
2924
  const pnpm = resolvePnpm();
2389
2925
  p4.log.info("Ejecutando pnpm add -g jorgex-stack@latest\u2026");
2390
- execFileSync4(pnpm, ["add", "-g", "jorgex-stack@latest"], { stdio: "inherit" });
2926
+ execFileSync5(pnpm, ["add", "-g", "jorgex-stack@latest"], { stdio: "inherit" });
2391
2927
  }
2392
2928
  async function downloadSkillToTemp(repo, sha, skillPath) {
2393
- const root = fs19.mkdtempSync(path24.join(os3.tmpdir(), "jorgex-skill-"));
2929
+ const root = fs20.mkdtempSync(path26.join(os4.tmpdir(), "jorgex-skill-"));
2394
2930
  try {
2395
2931
  const result = await downloadRepoTarball(repo, sha, root, skillPath);
2396
2932
  if (!result.ok) {
@@ -2398,15 +2934,15 @@ async function downloadSkillToTemp(repo, sha, skillPath) {
2398
2934
  return { error: result.reason };
2399
2935
  }
2400
2936
  if (skillPath) {
2401
- const sub = path24.resolve(path24.join(root, skillPath));
2937
+ const sub = path26.resolve(path26.join(root, skillPath));
2402
2938
  if (!isContainedIn(sub, root)) {
2403
2939
  cleanupTmp(root);
2404
2940
  return { error: `la ruta "${skillPath}" escapa del directorio temporal` };
2405
2941
  }
2406
- if (fs19.existsSync(sub)) return { dir: sub, root };
2942
+ if (fs20.existsSync(sub)) return { dir: sub, root };
2407
2943
  const lastSeg = skillPath.split("/").pop();
2408
- const sub2 = path24.resolve(path24.join(root, lastSeg));
2409
- if (isContainedIn(sub2, root) && fs19.existsSync(sub2)) {
2944
+ const sub2 = path26.resolve(path26.join(root, lastSeg));
2945
+ if (isContainedIn(sub2, root) && fs20.existsSync(sub2)) {
2410
2946
  if (!validateExtractedTree(sub2)) {
2411
2947
  cleanupTmp(root);
2412
2948
  return { error: `el sub\xE1rbol "${lastSeg}" contiene symlinks o rutas fuera del destino` };
@@ -2425,11 +2961,11 @@ async function downloadSkillToTemp(repo, sha, skillPath) {
2425
2961
  function pruneEngramDbBackups() {
2426
2962
  try {
2427
2963
  const dir = dataDir();
2428
- if (!fs19.existsSync(dir)) return;
2429
- const backups = fs19.readdirSync(dir).filter((f) => f.startsWith("engram-db-backup-") && f.endsWith(".db")).map((f) => ({ name: f, mtime: fs19.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);
2430
2966
  for (const old of backups.slice(3)) {
2431
2967
  try {
2432
- fs19.rmSync(path24.join(dir, old.name));
2968
+ fs20.rmSync(path26.join(dir, old.name));
2433
2969
  } catch {
2434
2970
  }
2435
2971
  }
@@ -2437,18 +2973,18 @@ function pruneEngramDbBackups() {
2437
2973
  }
2438
2974
  }
2439
2975
  function rotateLockedBinary(binPath, sweepRoot = HOME) {
2440
- if (!fs19.existsSync(binPath)) return null;
2441
- const dir = path24.dirname(binPath);
2442
- const base = path24.basename(binPath);
2976
+ if (!fs20.existsSync(binPath)) return null;
2977
+ const dir = path26.dirname(binPath);
2978
+ const base = path26.basename(binPath);
2443
2979
  const escapedBase = base.replace(/[.*+?^$()|[\]{}\\]/g, "\\$&");
2444
2980
  const oldPattern = new RegExp("^" + escapedBase + "\\.old-\\d+$");
2445
- const resolvedDir = path24.resolve(dir);
2446
- if (resolvedDir === path24.resolve(sweepRoot) || isContainedIn(resolvedDir, sweepRoot)) {
2981
+ const resolvedDir = path26.resolve(dir);
2982
+ if (resolvedDir === path26.resolve(sweepRoot) || isContainedIn(resolvedDir, sweepRoot)) {
2447
2983
  try {
2448
- for (const entry of fs19.readdirSync(dir)) {
2984
+ for (const entry of fs20.readdirSync(dir)) {
2449
2985
  if (oldPattern.test(entry)) {
2450
2986
  try {
2451
- fs19.rmSync(path24.join(dir, entry), { force: true });
2987
+ fs20.rmSync(path26.join(dir, entry), { force: true });
2452
2988
  } catch {
2453
2989
  }
2454
2990
  }
@@ -2456,8 +2992,8 @@ function rotateLockedBinary(binPath, sweepRoot = HOME) {
2456
2992
  } catch {
2457
2993
  }
2458
2994
  }
2459
- const rotated = path24.join(dir, `${base}.old-${Date.now()}`);
2460
- fs19.renameSync(binPath, rotated);
2995
+ const rotated = path26.join(dir, `${base}.old-${Date.now()}`);
2996
+ fs20.renameSync(binPath, rotated);
2461
2997
  return rotated;
2462
2998
  }
2463
2999
  async function updateEngram(engramRepo, latestVersion) {
@@ -2466,19 +3002,19 @@ async function updateEngram(engramRepo, latestVersion) {
2466
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)."
2467
3003
  );
2468
3004
  }
2469
- const engramDataDir = process.env.ENGRAM_DATA_DIR ?? path24.join(HOME, ".engram");
2470
- const engramDb = path24.join(engramDataDir, "engram.db");
2471
- if (fs19.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)) {
2472
3008
  const doBackup = await p4.confirm({
2473
3009
  message: `\xBFHacer backup de la DB de Engram antes de actualizar? (${engramDb})`,
2474
3010
  initialValue: true
2475
3011
  });
2476
3012
  if (!p4.isCancel(doBackup) && doBackup) {
2477
3013
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2478
- const dest = path24.join(dataDir(), `engram-db-backup-${ts}.db`);
3014
+ const dest = path26.join(dataDir(), `engram-db-backup-${ts}.db`);
2479
3015
  try {
2480
- if (!fs19.existsSync(dataDir())) fs19.mkdirSync(dataDir(), { recursive: true });
2481
- fs19.copyFileSync(engramDb, dest);
3016
+ if (!fs20.existsSync(dataDir())) fs20.mkdirSync(dataDir(), { recursive: true });
3017
+ fs20.copyFileSync(engramDb, dest);
2482
3018
  p4.log.success(`DB respaldada en ${dest} (la DB original NO se modifica jam\xE1s).`);
2483
3019
  pruneEngramDbBackups();
2484
3020
  } catch (err) {
@@ -2491,7 +3027,7 @@ async function updateEngram(engramRepo, latestVersion) {
2491
3027
  if (brew) {
2492
3028
  let brewManages = false;
2493
3029
  try {
2494
- execFileSync4(brew, ["list", "engram"], { stdio: "pipe" });
3030
+ execFileSync5(brew, ["list", "engram"], { stdio: "pipe" });
2495
3031
  brewManages = true;
2496
3032
  } catch {
2497
3033
  }
@@ -2499,7 +3035,7 @@ async function updateEngram(engramRepo, latestVersion) {
2499
3035
  anyChannelTried = true;
2500
3036
  p4.log.info("Actualizando engram con brew\u2026");
2501
3037
  try {
2502
- execFileSync4(brew, ["upgrade", "engram"], { stdio: "inherit" });
3038
+ execFileSync5(brew, ["upgrade", "engram"], { stdio: "inherit" });
2503
3039
  return true;
2504
3040
  } catch (err) {
2505
3041
  p4.log.error(`brew upgrade engram fall\xF3: ${err instanceof Error ? err.message : err}`);
@@ -2523,15 +3059,15 @@ async function updateEngram(engramRepo, latestVersion) {
2523
3059
  }
2524
3060
  p4.log.info(`Actualizando engram con go install (${latestVersion})\u2026`);
2525
3061
  try {
2526
- execFileSync4(
3062
+ execFileSync5(
2527
3063
  go,
2528
3064
  ["install", `github.com/Gentleman-Programming/engram/cmd/engram@v${latestVersion}`],
2529
3065
  { stdio: "inherit" }
2530
3066
  );
2531
- const rollbackOk = resolveEngramRollback({ installOk: true, rotated, bin, binExists: fs19.existsSync(bin ?? "") });
3067
+ const rollbackOk = resolveEngramRollback({ installOk: true, rotated, bin, binExists: fs20.existsSync(bin ?? "") });
2532
3068
  if (rollbackOk.action === "restore") {
2533
3069
  try {
2534
- fs19.renameSync(rotated, bin);
3070
+ fs20.renameSync(rotated, bin);
2535
3071
  p4.log.warn(rollbackOk.messages.onRestore);
2536
3072
  } catch {
2537
3073
  p4.log.warn(rollbackOk.messages.onRenameFail);
@@ -2539,10 +3075,10 @@ async function updateEngram(engramRepo, latestVersion) {
2539
3075
  }
2540
3076
  return true;
2541
3077
  } catch (err) {
2542
- const rollbackFail = resolveEngramRollback({ installOk: false, rotated, bin, binExists: fs19.existsSync(bin ?? "") });
3078
+ const rollbackFail = resolveEngramRollback({ installOk: false, rotated, bin, binExists: fs20.existsSync(bin ?? "") });
2543
3079
  if (rollbackFail.action === "restore") {
2544
3080
  try {
2545
- fs19.renameSync(rotated, bin);
3081
+ fs20.renameSync(rotated, bin);
2546
3082
  p4.log.info(rollbackFail.messages.onRestore);
2547
3083
  } catch {
2548
3084
  p4.log.error(rollbackFail.messages.onRenameFail);
@@ -2627,14 +3163,26 @@ function buildEligibleSkillUpdates(skills) {
2627
3163
  }
2628
3164
  return result;
2629
3165
  }
2630
- 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) {
2631
3170
  if (dryRun || yes || !process.stdout.isTTY) {
2632
- return { exitCode: await runUpdateCheck(localVersion), appliedUpdates: false };
3171
+ return { exitCode: await runUpdateCheck(localVersion, includeBrowserState), appliedUpdates: false, syncRequired: false };
2633
3172
  }
2634
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
+ }
2635
3182
  const upstreams = loadUpstreams();
2636
3183
  let exitCode = 0;
2637
3184
  let appliedUpdates = false;
3185
+ const updated = [];
2638
3186
  const maintainer = isGitClone();
2639
3187
  const spin = p4.spinner();
2640
3188
  spin.start("Consultando versiones upstream\u2026");
@@ -2693,6 +3241,21 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2693
3241
  });
2694
3242
  }
2695
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
+ }
2696
3259
  const typedSkillHeads = skillHeads;
2697
3260
  const loggedRepos = /* @__PURE__ */ new Map();
2698
3261
  for (const { name, repo, info, head } of typedSkillHeads) {
@@ -2734,7 +3297,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2734
3297
  }
2735
3298
  if (updateItems.length === 0) {
2736
3299
  p4.outro("Todo al d\xEDa. No hay actualizaciones disponibles.");
2737
- return { exitCode: 0, appliedUpdates: false };
3300
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2738
3301
  }
2739
3302
  const selected = await p4.multiselect({
2740
3303
  message: "Selecciona qu\xE9 actualizar (espacio para marcar, intro para confirmar):",
@@ -2744,11 +3307,11 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2744
3307
  });
2745
3308
  if (p4.isCancel(selected)) {
2746
3309
  p4.outro("Update cancelado.");
2747
- return { exitCode: 0, appliedUpdates: false };
3310
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2748
3311
  }
2749
3312
  if (selected.length === 0) {
2750
3313
  p4.outro("Nada seleccionado.");
2751
- return { exitCode: 0, appliedUpdates: false };
3314
+ return { exitCode: 0, appliedUpdates: false, syncRequired: false };
2752
3315
  }
2753
3316
  const sel = selected;
2754
3317
  if (stackNeedsUpdate && sel.includes("stack")) {
@@ -2769,6 +3332,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2769
3332
  }
2770
3333
  p4.log.success("Stack actualizado correctamente.");
2771
3334
  appliedUpdates = true;
3335
+ updated.push("stack");
2772
3336
  } catch (err) {
2773
3337
  p4.log.error(`Stack: error al actualizar \u2014 ${err instanceof Error ? err.message : err}`);
2774
3338
  exitCode = 1;
@@ -2800,7 +3364,7 @@ async function runInteractiveUpdate(localVersion, yes, dryRun = false) {
2800
3364
  continue;
2801
3365
  }
2802
3366
  const { dir: tmpDir, root: tmpRoot } = tmpResult;
2803
- const localSkillDir = path24.join(stackRoot(), "skills", skillInfo.name);
3367
+ const localSkillDir = path26.join(stackRoot(), "skills", skillInfo.name);
2804
3368
  const diff = renderSkillDiff(tmpDir, localSkillDir);
2805
3369
  if (diff) {
2806
3370
  p4.log.info(`Diff de ${skillInfo.name}:
@@ -2824,6 +3388,7 @@ ${diff}`);
2824
3388
  replaceSkill(skillInfo.name, tmpDir, skillInfo.head, {});
2825
3389
  p4.log.success(`skill ${skillInfo.name}: actualizada y re-pineada a ${skillInfo.head.slice(0, 7)}.`);
2826
3390
  appliedUpdates = true;
3391
+ updated.push("skill");
2827
3392
  } catch (err) {
2828
3393
  p4.log.error(`skill ${skillInfo.name}: error \u2014 ${err instanceof Error ? err.message : err}`);
2829
3394
  exitCode = 1;
@@ -2831,6 +3396,27 @@ ${diff}`);
2831
3396
  cleanupTmp(tmpRoot);
2832
3397
  }
2833
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
+ }
2834
3420
  if (engramNeedsUpdate && sel.includes("engram") && engramData?.version) {
2835
3421
  const confirmEngram = await p4.confirm({
2836
3422
  message: `Actualizar engram a v${engramData.version} (canal nativo: brew \u2192 go install \u2192 URL)`,
@@ -2851,15 +3437,17 @@ ${diff}`);
2851
3437
  p4.log.warn("Engram actualizado, pero no se pudo verificar la versi\xF3n \u2014 comprueba con engram --version");
2852
3438
  }
2853
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");
2854
3442
  }
2855
3443
  }
2856
3444
  }
2857
3445
  p4.outro(exitCode === 0 ? "Update completado." : "Update completado con errores (revisa arriba).");
2858
- return { exitCode, appliedUpdates };
3446
+ return { exitCode, appliedUpdates, syncRequired: resolveUpdateSyncRequired(updated) };
2859
3447
  }
2860
3448
 
2861
3449
  // src/models-picker.ts
2862
- import path25 from "path";
3450
+ import path27 from "path";
2863
3451
  import * as p5 from "@clack/prompts";
2864
3452
  var TIERS = ["strong", "standard", "cheap"];
2865
3453
  var EFFORTS = ["low", "medium", "high", "xhigh"];
@@ -2883,7 +3471,7 @@ function opencodeLiveModels(binPath) {
2883
3471
  }
2884
3472
  function agentsByTier() {
2885
3473
  const grouped = { strong: [], standard: [], cheap: [] };
2886
- for (const agent of loadCanonicalAgents(path25.join(stackRoot(), "agents"))) {
3474
+ for (const agent of loadCanonicalAgents(path27.join(stackRoot(), "agents"))) {
2887
3475
  if (agent.mode === "subagent") grouped[agent.tier].push(agent.name);
2888
3476
  }
2889
3477
  return grouped;
@@ -3063,16 +3651,16 @@ function cancelled() {
3063
3651
  }
3064
3652
 
3065
3653
  // src/lib/release.ts
3066
- import fs20 from "fs";
3067
- import path26 from "path";
3068
- 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";
3069
3657
  import { fileURLToPath as fileURLToPath2 } from "url";
3070
3658
  function findPackageJson() {
3071
- let dir = path26.dirname(fileURLToPath2(import.meta.url));
3659
+ let dir = path28.dirname(fileURLToPath2(import.meta.url));
3072
3660
  for (let i = 0; i < 6; i++) {
3073
- const candidate = path26.join(dir, "package.json");
3074
- if (fs20.existsSync(candidate)) return candidate;
3075
- dir = path26.dirname(dir);
3661
+ const candidate = path28.join(dir, "package.json");
3662
+ if (fs21.existsSync(candidate)) return candidate;
3663
+ dir = path28.dirname(dir);
3076
3664
  }
3077
3665
  throw new Error("No se encontr\xF3 package.json cerca del CLI.");
3078
3666
  }
@@ -3081,7 +3669,7 @@ function readPackageVersion() {
3081
3669
  }
3082
3670
  function readPackageMetadata() {
3083
3671
  const packageJson = findPackageJson();
3084
- const raw = fs20.readFileSync(packageJson, "utf8");
3672
+ const raw = fs21.readFileSync(packageJson, "utf8");
3085
3673
  const parsed = JSON.parse(raw);
3086
3674
  const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
3087
3675
  const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
@@ -3118,6 +3706,10 @@ function parseFlags(args) {
3118
3706
  list: false,
3119
3707
  check: false,
3120
3708
  removeEngram: false,
3709
+ playwright: false,
3710
+ removePlaywright: false,
3711
+ devtools: false,
3712
+ noDevtools: false,
3121
3713
  positional: [],
3122
3714
  unknownFlags: []
3123
3715
  };
@@ -3155,6 +3747,10 @@ function parseFlags(args) {
3155
3747
  else if (arg === "--list") flags.list = true;
3156
3748
  else if (arg === "--check") flags.check = true;
3157
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;
3158
3754
  else if (arg.startsWith("-")) flags.unknownFlags.push(arg);
3159
3755
  else flags.positional.push(arg);
3160
3756
  }
@@ -3221,6 +3817,48 @@ Corrige o borra ${preferenceFile}, o vuelve a ejecutar con --mode human|programm
3221
3817
  subagentConcurrency: concurrency
3222
3818
  };
3223
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
+ }
3224
3862
  function parseCliArgs(argv) {
3225
3863
  const [first, ...rest] = argv;
3226
3864
  const isCommand = COMMANDS.includes(first ?? "install");
@@ -3275,8 +3913,13 @@ Opciones:
3275
3913
  --target-dir <dir> Dir alternativo (pruebas de paridad; requiere 1 runtime)
3276
3914
  --dry-run Muestra el plan sin escribir nada
3277
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)
3278
3919
  --remove-engram (uninstall) desregistra Engram de los runtimes;
3279
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
3280
3923
 
3281
3924
  Ver PRD.md para el dise\xF1o completo.`);
3282
3925
  }
@@ -3320,17 +3963,31 @@ Flags disponibles: jorgex-stack --help`
3320
3963
  process.exitCode = 1;
3321
3964
  return;
3322
3965
  }
3323
- 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) {
3324
3972
  process.exitCode = 1;
3325
3973
  return;
3326
3974
  }
3327
- 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;
3328
3985
  return;
3329
3986
  }
3330
3987
  case "uninstall": {
3331
3988
  const runtimes = await resolveRuntimes(flags);
3332
3989
  if (runtimes === null) return;
3333
- if (runtimes.length === 0) {
3990
+ if (runtimes.length === 0 && !flags.removePlaywright) {
3334
3991
  console.error("Ning\xFAn runtime detectado (opencode, claude-code, codex).");
3335
3992
  process.exitCode = 1;
3336
3993
  return;
@@ -3340,7 +3997,8 @@ Flags disponibles: jorgex-stack --help`
3340
3997
  targetDir: flags.targetDir,
3341
3998
  dryRun: flags.dryRun,
3342
3999
  yes: flags.yes,
3343
- removeEngram: flags.removeEngram
4000
+ removeEngram: flags.removeEngram,
4001
+ removePlaywright: flags.removePlaywright
3344
4002
  });
3345
4003
  return;
3346
4004
  }
@@ -3350,11 +4008,11 @@ Flags disponibles: jorgex-stack --help`
3350
4008
  }
3351
4009
  case "update": {
3352
4010
  if (flags.check) {
3353
- process.exitCode = await runUpdateCheck(VERSION);
4011
+ process.exitCode = await runUpdateCheck(VERSION, flags.targetDir === void 0);
3354
4012
  return;
3355
4013
  }
3356
4014
  if (flags.dryRun) {
3357
- process.exitCode = await runUpdateCheck(VERSION);
4015
+ process.exitCode = await runUpdateCheck(VERSION, flags.targetDir === void 0);
3358
4016
  return;
3359
4017
  }
3360
4018
  const runtimes = await resolveRuntimes(flags);
@@ -3381,11 +4039,16 @@ Flags disponibles: jorgex-stack --help`
3381
4039
  } else if (runtimes.length > 0) {
3382
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.");
3383
4041
  }
3384
- 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
+ );
3385
4048
  process.exitCode = result.exitCode;
3386
- if (result.exitCode === 0 && result.appliedUpdates && runtimes.length > 0 && !canSync) {
4049
+ if (result.syncRequired && runtimes.length > 0 && (result.exitCode !== 0 || !canSync)) {
3387
4050
  p6.log.warn("Skills/stack actualizados, pero el sync con los runtimes sigue pendiente. Ejecuta jorgex-stack sync --mode human|programmatic.");
3388
- } 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) {
3389
4052
  const apply = await p6.confirm({ message: "\xBFRe-aplicar a los runtimes ahora? (sync)" });
3390
4053
  if (!p6.isCancel(apply) && apply) {
3391
4054
  process.exitCode = await runInstall({
@@ -3398,7 +4061,7 @@ Flags disponibles: jorgex-stack --help`
3398
4061
  } else {
3399
4062
  console.log("Sin aplicar. Cuando quieras: jorgex-stack sync");
3400
4063
  }
3401
- } 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)) {
3402
4065
  console.log("Skills/stack actualizados. Ejecuta jorgex-stack sync para aplicarlos a los runtimes.");
3403
4066
  }
3404
4067
  return;
@@ -3448,7 +4111,7 @@ Flags disponibles: jorgex-stack --help`
3448
4111
  }
3449
4112
  }
3450
4113
  if (process.argv[1] !== void 0 && import.meta.url === pathToFileURL2(process.argv[1]).href) {
3451
- main().catch((err) => {
4114
+ await main().catch((err) => {
3452
4115
  console.error(err instanceof Error ? err.message : String(err));
3453
4116
  process.exitCode = 1;
3454
4117
  });