smooth-operator-mcp 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -366,8 +366,8 @@ __export(installer_exports, {
366
366
  planHarnessInstall: () => planHarnessInstall,
367
367
  supportedHarnessTargets: () => supportedHarnessTargets
368
368
  });
369
- import { constants as constants2 } from "node:fs";
370
- import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, open as open2, rename as rename2, unlink as unlink3, writeFile } from "node:fs/promises";
369
+ import { constants as constants2, accessSync, existsSync } from "node:fs";
370
+ import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, open as open2, rename as rename3, unlink as unlink3, writeFile } from "node:fs/promises";
371
371
  import { execFile } from "node:child_process";
372
372
  import { randomUUID as randomUUID3 } from "node:crypto";
373
373
  import { homedir as homedir3, platform as platform2 } from "node:os";
@@ -394,7 +394,7 @@ function planHarnessInstall(target, options = {}) {
394
394
  return { kind: "cli", target: normalized, command: "codex", args: ["mcp", "add", SERVER_NAME, "--", cliEntry.command, ...cliEntry.args] };
395
395
  }
396
396
  if (normalized === "gemini") {
397
- return { kind: "cli", target: normalized, command: "gemini", args: ["mcp", "add", SERVER_NAME, cliEntry.command, ...cliEntry.args, "--scope", "user"] };
397
+ return { kind: "cli", target: normalized, command: "gemini", args: ["mcp", "add", "--scope", "user", SERVER_NAME, cliEntry.command, ...cliEntry.args] };
398
398
  }
399
399
  if (normalized === "vscode") {
400
400
  return {
@@ -444,10 +444,21 @@ function normalizeTarget(target) {
444
444
  function resolveServerEntry() {
445
445
  const modulePath = fileURLToPath(import.meta.url);
446
446
  if (basename4(modulePath) === "smooth-operator.mjs") {
447
- return { command: process.execPath, args: [modulePath] };
447
+ return { command: resolveStableNodeExecutable(), args: [modulePath] };
448
448
  }
449
449
  return { command: "smooth-operator", args: [] };
450
450
  }
451
+ function resolveStableNodeExecutable() {
452
+ const candidates = platform2() === "darwin" ? ["/opt/homebrew/bin/node", "/usr/local/bin/node"] : ["/usr/local/bin/node", "/usr/bin/node"];
453
+ for (const candidate of candidates) {
454
+ try {
455
+ accessSync(candidate, constants2.X_OK);
456
+ return candidate;
457
+ } catch {
458
+ }
459
+ }
460
+ return process.execPath;
461
+ }
451
462
  async function runCliInstall(plan, executeCommand) {
452
463
  const runner = executeCommand ?? (async (command, args) => {
453
464
  await execFileAsync(command, [...args], {
@@ -556,7 +567,7 @@ async function installJsonConfig(target, plannedPath, options, allowOpenCodeJson
556
567
  `, { mode: 384, flag: "wx" });
557
568
  await chmod2(tempPath, 384);
558
569
  await rejectSymlink2(path, "configuration file");
559
- await rename2(tempPath, path);
570
+ await rename3(tempPath, path);
560
571
  return `Installed SmoothOperator in ${path}${backupPath ? ` (backup: ${backupPath})` : ""}. Restart the harness.`;
561
572
  } catch (error) {
562
573
  await unlink3(tempPath).catch(() => void 0);
@@ -614,6 +625,26 @@ async function chooseExistingOpenCodePath(plannedPath) {
614
625
  }
615
626
  return plannedPath;
616
627
  }
628
+ function isStaleEmbeddedCommand(command) {
629
+ if (typeof command !== "string" || !command.includes("/")) {
630
+ return false;
631
+ }
632
+ return !existsSync(command);
633
+ }
634
+ function isStaleEmbeddedCommandArray(command) {
635
+ if (!Array.isArray(command) || command.length === 0) {
636
+ return false;
637
+ }
638
+ const interpreter = command[0];
639
+ return typeof interpreter === "string" && interpreter.includes("/") && !existsSync(interpreter);
640
+ }
641
+ function isRepairableStdioEntry(existing, entry) {
642
+ return isStaleEmbeddedCommand(existing.command) && Array.isArray(existing.args) && existing.args.length === entry.args.length && existing.args.every((arg, index) => arg === entry.args[index]);
643
+ }
644
+ function isRepairableOpenCodeEntry(existing, desired) {
645
+ const repaired = { ...existing, command: desired.command };
646
+ return isStaleEmbeddedCommandArray(existing.command) && sameOpenCodeEntry(repaired, desired);
647
+ }
617
648
  function mergeMcpServersConfig(config, entry, path) {
618
649
  if (config.mcpServers !== void 0 && !isRecord3(config.mcpServers)) {
619
650
  throw new AppError("INSTALL_CONFIG_INVALID", `The mcpServers value in ${path} must be an object; refusing to replace it.`);
@@ -621,9 +652,16 @@ function mergeMcpServersConfig(config, entry, path) {
621
652
  const servers = isRecord3(config.mcpServers) ? { ...config.mcpServers } : {};
622
653
  const existing = servers[SERVER_NAME];
623
654
  if (existing !== void 0) {
624
- if (!isRecord3(existing) || !sameStdioEntry(existing, entry)) {
655
+ if (!isRecord3(existing)) {
625
656
  throw new AppError("INSTALL_CONFIG_CONFLICT", `The '${SERVER_NAME}' server in ${path} has a conflicting configuration; refusing to overwrite it.`);
626
657
  }
658
+ if (!sameStdioEntry(existing, entry)) {
659
+ if (!isRepairableStdioEntry(existing, entry)) {
660
+ throw new AppError("INSTALL_CONFIG_CONFLICT", `The '${SERVER_NAME}' server in ${path} has a conflicting configuration; refusing to overwrite it.`);
661
+ }
662
+ servers[SERVER_NAME] = { command: entry.command, args: [...entry.args] };
663
+ return { config: { ...config, mcpServers: servers }, alreadyConfigured: false };
664
+ }
627
665
  return { config, alreadyConfigured: true };
628
666
  }
629
667
  servers[SERVER_NAME] = { command: entry.command, args: [...entry.args] };
@@ -645,10 +683,24 @@ function mergeOpenCodeConfig(config, entry, path) {
645
683
  const existing = servers[SERVER_NAME];
646
684
  const desired = modernSchema ? { type: "local", command: [entry.command, ...entry.args] } : { type: "local", command: [entry.command, ...entry.args], enabled: true };
647
685
  if (existing !== void 0) {
648
- if (!isRecord3(existing) || !sameOpenCodeEntry(existing, desired)) {
686
+ if (!isRecord3(existing)) {
649
687
  throw new AppError("INSTALL_CONFIG_CONFLICT", `The '${SERVER_NAME}' server in ${path} has a conflicting configuration; refusing to overwrite it.`);
650
688
  }
651
- return { config, alreadyConfigured: true };
689
+ if (!sameOpenCodeEntry(existing, desired)) {
690
+ if (!isRepairableOpenCodeEntry(existing, desired)) {
691
+ throw new AppError("INSTALL_CONFIG_CONFLICT", `The '${SERVER_NAME}' server in ${path} has a conflicting configuration; refusing to overwrite it.`);
692
+ }
693
+ existing.command = desired.command;
694
+ } else {
695
+ return { config, alreadyConfigured: true };
696
+ }
697
+ servers[SERVER_NAME] = existing;
698
+ if (modernSchema) {
699
+ mcp.servers = servers;
700
+ } else {
701
+ Object.assign(mcp, servers);
702
+ }
703
+ return { config: { ...config, mcp }, alreadyConfigured: false };
652
704
  }
653
705
  servers[SERVER_NAME] = desired;
654
706
  if (modernSchema) {
@@ -882,6 +934,99 @@ var init_installer = __esm({
882
934
  }
883
935
  });
884
936
 
937
+ // src/server/ui.ts
938
+ var ui_exports = {};
939
+ __export(ui_exports, {
940
+ createUi: () => createUi
941
+ });
942
+ function colorEnabled(stdout) {
943
+ if (!stdout?.isTTY) return false;
944
+ if (process.env.NO_COLOR) return false;
945
+ const term = process.env.TERM ?? "";
946
+ return term !== "dumb";
947
+ }
948
+ function createUi(stdout) {
949
+ const enabled = colorEnabled(stdout);
950
+ const write = typeof stdout?.write === "function" ? stdout.write.bind(stdout) : (() => true);
951
+ const paint = (code, text) => enabled ? `${code}${text}${RESET}` : text;
952
+ return {
953
+ colors: enabled,
954
+ bold: (text) => paint(BOLD, text),
955
+ dim: (text) => paint(DIM, text),
956
+ cyan: (text) => paint(CYAN, text),
957
+ green: (text) => paint(GREEN, text),
958
+ yellow: (text) => paint(YELLOW, text),
959
+ red: (text) => paint(RED, text),
960
+ /** Application banner shown once at the top of the wizard. */
961
+ banner(name, tagline, version = "") {
962
+ const line = "\u2500".repeat(Math.max(name.length + tagline.length + 8, 44));
963
+ write(`
964
+ ${paint(CYAN, line)}
965
+ `);
966
+ write(` ${paint(BOLD, name)}${version ? ` ${paint(DIM, `v${version}`)}` : ""}
967
+ `);
968
+ write(` ${paint(CYAN, tagline)}
969
+ `);
970
+ write(`${paint(CYAN, line)}
971
+
972
+ `);
973
+ },
974
+ /** Numbered step header, e.g. "── [2/6] Headless mode ──". */
975
+ step(current, total, title) {
976
+ write(`
977
+ ${paint(BOLD, `[${current}/${total}] ${title}`)}
978
+ `);
979
+ },
980
+ /** Indented explanatory paragraph under a question. */
981
+ explain(lines) {
982
+ for (const line of lines) {
983
+ write(` ${paint(DIM, line)}
984
+ `);
985
+ }
986
+ },
987
+ /** One option row in a numbered choice list. */
988
+ option(index, label, description, recommended = false) {
989
+ const badge = recommended ? paint(GREEN, " (recommended)") : "";
990
+ write(` ${paint(CYAN, `${index})`)} ${paint(BOLD, label)}${badge}
991
+ `);
992
+ write(` ${paint(DIM, description)}
993
+ `);
994
+ },
995
+ keyValues(rows) {
996
+ const width = Math.max(...rows.map(([key]) => key.length));
997
+ for (const [key, value] of rows) {
998
+ write(` ${paint(DIM, key.padEnd(width))} ${value}
999
+ `);
1000
+ }
1001
+ },
1002
+ success(text) {
1003
+ write(`${paint(GREEN, "\u2714")} ${text}
1004
+ `);
1005
+ },
1006
+ failure(text) {
1007
+ write(`${paint(RED, "\u2716")} ${text}
1008
+ `);
1009
+ },
1010
+ note(text) {
1011
+ write(` ${paint(YELLOW, "\u203A")} ${paint(DIM, text)}
1012
+ `);
1013
+ }
1014
+ };
1015
+ }
1016
+ var RESET, BOLD, DIM, CYAN, GREEN, YELLOW, RED;
1017
+ var init_ui = __esm({
1018
+ "src/server/ui.ts"() {
1019
+ "use strict";
1020
+ RESET = "\x1B[0m";
1021
+ BOLD = "\x1B[1m";
1022
+ DIM = "\x1B[2m";
1023
+ CYAN = "\x1B[36m";
1024
+ GREEN = "\x1B[32m";
1025
+ YELLOW = "\x1B[33m";
1026
+ RED = "\x1B[31m";
1027
+ }
1028
+ });
1029
+
885
1030
  // src/server/installer-wizard.ts
886
1031
  var installer_wizard_exports = {};
887
1032
  __export(installer_wizard_exports, {
@@ -904,26 +1049,43 @@ function isInteractive() {
904
1049
  }
905
1050
  async function promptForHarness(opts) {
906
1051
  const { createInterface } = await import("node:readline/promises");
907
- const rl = createInterface({
908
- input: opts.stdin,
909
- output: opts.stdout
910
- });
1052
+ const ui = createUi(opts.stdout);
1053
+ const input = opts.stdin;
1054
+ const output = opts.stdout;
1055
+ const rl = createInterface({ input, output });
911
1056
  try {
912
- const answer = await rl.question(`Harness [opencode/claude-code/copilot/codex/gemini/vscode/cursor/windsurf/claude-desktop] [opencode]: `);
913
- const trimmed = answer.trim().toLowerCase();
914
- if (!trimmed) return "opencode";
915
- const map = {
916
- "claude": "claude-code",
917
- "github-copilot": "copilot",
918
- "codex-cli": "codex",
919
- "gemini-cli": "gemini",
920
- "vs-code": "vscode"
921
- };
922
- return map[trimmed] ?? trimmed;
1057
+ if (opts.stdout.isTTY) {
1058
+ ui.step(0, HARNESS_MENU.length, "Which harness should get a browser?");
1059
+ ui.explain(["The AI harness you use every day. Pick yours; you can re-run this later for others."]);
1060
+ HARNESS_MENU.forEach((entry, index) => ui.option(index + 1, entry.label, entry.description));
1061
+ }
1062
+ while (true) {
1063
+ const answer = await rl.question(`Choose 1-${HARNESS_MENU.length} or type a name [opencode]: `);
1064
+ const trimmed = answer.trim().toLowerCase();
1065
+ if (!trimmed) return "opencode";
1066
+ const numeric = Number.parseInt(trimmed, 10);
1067
+ if (`${numeric}` === trimmed && numeric >= 1 && numeric <= HARNESS_MENU.length) {
1068
+ return HARNESS_MENU[numeric - 1].id;
1069
+ }
1070
+ if (/^\d+$/.test(trimmed)) {
1071
+ continue;
1072
+ }
1073
+ return normalizeHarnessName(trimmed);
1074
+ }
923
1075
  } finally {
924
1076
  rl.close();
925
1077
  }
926
1078
  }
1079
+ function normalizeHarnessName(name) {
1080
+ const aliases = {
1081
+ "claude": "claude-code",
1082
+ "github-copilot": "copilot",
1083
+ "codex-cli": "codex",
1084
+ "gemini-cli": "gemini",
1085
+ "vs-code": "vscode"
1086
+ };
1087
+ return aliases[name] ?? name;
1088
+ }
927
1089
  function recommendedDefaults(homeDir) {
928
1090
  return {
929
1091
  mode: "managed",
@@ -934,52 +1096,188 @@ function recommendedDefaults(homeDir) {
934
1096
  dataDir: join7(homeDir ?? homedir4(), ".smooth-operator")
935
1097
  };
936
1098
  }
937
- async function runWizard(_harness, opts) {
1099
+ function tolerantQuestion(rl) {
1100
+ return {
1101
+ async question(prompt) {
1102
+ try {
1103
+ return await rl.question(prompt);
1104
+ } catch (error) {
1105
+ const code = error.code;
1106
+ const aborted = error instanceof Error && error.name === "AbortError";
1107
+ if (aborted || code === "ABORT_ERR" || code === "ERR_USE_AFTER_CLOSE") {
1108
+ return "";
1109
+ }
1110
+ throw error;
1111
+ }
1112
+ }
1113
+ };
1114
+ }
1115
+ async function askYesNo(session, prompt, fallback) {
1116
+ while (true) {
1117
+ const hint = fallback ? "[Y/n]" : "[y/N]";
1118
+ const answer = (await session.question(`${prompt} ${hint}: `)).trim().toLowerCase();
1119
+ if (!answer) return fallback;
1120
+ if (["y", "yes"].includes(answer)) return true;
1121
+ if (["n", "no"].includes(answer)) return false;
1122
+ }
1123
+ }
1124
+ function parseDomainList(raw) {
1125
+ const domains = raw.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
1126
+ const invalid = domains.find((domain) => !/^(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(domain));
1127
+ return invalid === void 0 ? domains : void 0;
1128
+ }
1129
+ async function runWizard(harness, opts) {
1130
+ const defaults = recommendedDefaults(opts.homeDir);
938
1131
  if (opts.yes) {
939
- return recommendedDefaults(opts.homeDir);
1132
+ return defaults;
940
1133
  }
941
1134
  const stdin = opts.stdin ?? process.stdin;
942
1135
  const stdout = opts.stdout ?? process.stdout;
943
1136
  const interactive = Boolean(stdin.isTTY && stdout.isTTY && !process.env.CI);
944
1137
  if (!interactive) {
945
- return recommendedDefaults(opts.homeDir);
1138
+ return defaults;
946
1139
  }
1140
+ const ui = createUi(stdout);
947
1141
  const { createInterface } = await import("node:readline/promises");
948
1142
  const rl = createInterface({
949
1143
  input: stdin,
950
1144
  output: stdout
951
1145
  });
1146
+ const session = tolerantQuestion(rl);
952
1147
  try {
953
- const modeAns = await rl.question("Browser mode: 1) managed private (recommended) 2) personal Chrome (connect) 3) disabled [1]: ");
954
- const modeMap = { "1": "managed", "2": "connect", "3": "disabled", "": "managed" };
955
- let mode = modeMap[modeAns.trim()] ?? "managed";
956
- if (!["managed", "connect", "disabled"].includes(mode)) mode = "managed";
1148
+ ui.banner("SmoothOperator Setup", `Give ${harness} a real Chrome it can drive`, opts.version ?? "2.2.0");
1149
+ ui.note(`Configuring: ${harness}`);
1150
+ ui.note("Answer each question, or press Enter to accept the recommended default.");
1151
+ ui.note(`You can re-run \`smooth-operator install ${harness}\` at any time to change these.`);
1152
+ ui.step(1, 6, "Browser mode");
1153
+ ui.explain([
1154
+ "Who owns the Chrome window your AI drives?",
1155
+ "",
1156
+ "Managed gives the AI its own private Chrome profile at ~/.smooth-operator/browser.",
1157
+ "Your daily browser stays untouched; logins for the AI live separately.",
1158
+ "",
1159
+ "Connect attaches to your real Chrome instead, so the AI uses everything",
1160
+ "you are already signed into. Only pick this if you need your existing logins.",
1161
+ "",
1162
+ "Disabled keeps the server but turns all browsing tools off."
1163
+ ]);
1164
+ ui.option(1, "Managed private Chrome", "Isolated profile owned by SmoothOperator. Safest default.", true);
1165
+ ui.option(2, "Personal Chrome (connect)", "Reuse your real browser and its existing sign-ins.");
1166
+ ui.option(3, "Disabled", "No browser. Tools that need a page will report an error.");
1167
+ let mode = "managed";
957
1168
  let browserUrl;
958
- if (mode === "connect") {
959
- browserUrl = "http://127.0.0.1:9222";
960
- }
961
- const headlessAns = await rl.question("Headless Chrome? (y/N) [N]: ");
962
- const headless = headlessAns.trim().toLowerCase() === "y";
963
- const allowedAns = await rl.question("Allowed domains (comma-separated, empty=all) []: ");
964
- const allowedDomains = allowedAns.trim() ? allowedAns.split(",").map((s) => s.trim()).filter(Boolean) : [];
965
- const blockedAns = await rl.question("Blocked domains (comma-separated) []: ");
966
- const blockedDomains = blockedAns.trim() ? blockedAns.split(",").map((s) => s.trim()).filter(Boolean) : [];
967
- const evalAns = await rl.question("Allow page JavaScript (browser_evaluate)? (y/N) [N]: ");
968
- const allowEval = evalAns.trim().toLowerCase() === "y";
969
- const dataDirAns = await rl.question(`Data directory [${join7(opts.homeDir ?? homedir4(), ".smooth-operator")}]: `);
970
- const dataDir = dataDirAns.trim() ? dataDirAns.trim() : join7(opts.homeDir ?? homedir4(), ".smooth-operator");
971
- if (mode === "connect") {
972
- try {
973
- const launched = await launchPersonalChrome({ dataDir, spawn: opts.spawn, probe: opts.probe ?? defaultProbe, port: 9222 });
974
- browserUrl = launched.url;
975
- } catch {
1169
+ let headless = false;
1170
+ let allowedDomains = [];
1171
+ let blockedDomains = [];
1172
+ let allowEval = false;
1173
+ let dataDir = defaults.dataDir;
1174
+ while (true) {
1175
+ const answer = (await session.question("Mode [1]: ")).trim();
1176
+ if (!answer || answer === "1") {
1177
+ mode = "managed";
1178
+ break;
1179
+ }
1180
+ if (answer === "2") {
1181
+ mode = "connect";
1182
+ browserUrl = "http://127.0.0.1:9222";
1183
+ break;
1184
+ }
1185
+ if (answer === "3") {
1186
+ mode = "disabled";
1187
+ break;
1188
+ }
1189
+ ui.failure("Enter 1, 2, or 3.");
1190
+ }
1191
+ let headlessChoice = false;
1192
+ if (mode !== "disabled") {
1193
+ ui.step(2, 6, "Headless mode");
1194
+ ui.explain([
1195
+ "Headless runs Chrome with no visible window - lighter and invisible.",
1196
+ "Visible Chrome lets you watch clicks happen and handle CAPTCHAs or",
1197
+ "logins yourself when a site pauses for human verification."
1198
+ ]);
1199
+ headlessChoice = await askYesNo(session, "Run Chrome headless (no window)?", false);
1200
+ ui.step(3, 6, "Allowed domains");
1201
+ ui.explain([
1202
+ "Restrict which sites the AI may open, e.g. docs.example.com, *.wikipedia.org",
1203
+ "Leave empty to allow every site. Blocked domains always win over allowed ones."
1204
+ ]);
1205
+ while (true) {
1206
+ const parsed = parseDomainList(await session.question("Allowed domains (comma-separated, Enter for all): "));
1207
+ if (parsed !== void 0) {
1208
+ allowedDomains = parsed;
1209
+ break;
1210
+ }
1211
+ ui.failure("That did not look like a domain list. Example: example.com, *.shop.test");
1212
+ }
1213
+ ui.step(4, 6, "Blocked domains");
1214
+ ui.explain(["Never open these sites, even when everything else is allowed."]);
1215
+ while (true) {
1216
+ const parsed = parseDomainList(await session.question("Blocked domains (comma-separated, Enter for none): "));
1217
+ if (parsed !== void 0) {
1218
+ blockedDomains = parsed;
1219
+ break;
1220
+ }
1221
+ ui.failure("That did not look like a domain list. Example: ads.example.com");
1222
+ }
1223
+ ui.step(5, 6, "JavaScript execution");
1224
+ ui.explain([
1225
+ "browser_evaluate runs arbitrary JavaScript on a page - powerful for scraping",
1226
+ "but it can also trigger bot defenses. Most users never need it on."
1227
+ ]);
1228
+ allowEval = await askYesNo(session, "Allow the AI to run JavaScript on pages?", false);
1229
+ ui.step(6, 6, "Data directory");
1230
+ ui.explain([
1231
+ "Where the private Chrome profile, logs, and downloads live.",
1232
+ "Permissions are locked to 0600 so only your user can read them."
1233
+ ]);
1234
+ while (dataDir === defaults.dataDir) {
1235
+ const answer = (await session.question(`Data directory [${defaults.dataDir}]: `)).trim();
1236
+ if (!answer) break;
1237
+ if (!answer.startsWith("/") || answer.replace(/\/+$/, "") === "") {
1238
+ ui.failure("Enter an absolute path other than the filesystem root.");
1239
+ continue;
1240
+ }
1241
+ dataDir = answer;
1242
+ break;
1243
+ }
1244
+ headless = headlessChoice;
1245
+ if (mode === "connect") {
1246
+ ui.note("Starting your personal Chrome with remote debugging on port 9222...");
1247
+ try {
1248
+ const launched = await launchPersonalChrome({ dataDir, spawn: opts.spawn, probe: opts.probe ?? defaultProbe, port: 9222 });
1249
+ browserUrl = launched.url;
1250
+ ui.success(`Connected to your Chrome at ${launched.url}`);
1251
+ } catch {
1252
+ browserUrl = "http://127.0.0.1:9222";
1253
+ ui.note("Could not reach Chrome on port 9222 yet - keeping the default URL.");
1254
+ }
976
1255
  }
977
1256
  }
1257
+ writeSummary(ui, harness, { mode, headless, allowedDomains, blockedDomains, allowEval, dataDir });
978
1258
  return { mode, headless, allowedDomains, blockedDomains, allowEval, dataDir, browserUrl };
979
1259
  } finally {
980
1260
  rl.close();
981
1261
  }
982
1262
  }
1263
+ function writeSummary(ui, harness, choices) {
1264
+ const modeLabel = {
1265
+ managed: "Managed private Chrome (isolated profile)",
1266
+ connect: "Your personal Chrome via debugging port",
1267
+ disabled: "Disabled - no browser tools"
1268
+ };
1269
+ ui.banner("Configuration Summary", `Ready to configure ${harness}`, "");
1270
+ ui.keyValues([
1271
+ ["Browser mode", choices.mode === "connect" ? modeLabel.connect : modeLabel[choices.mode] ?? choices.mode],
1272
+ ["Headless", choices.headless ? "yes - no visible window" : "no - you can watch and intervene"],
1273
+ ...choices.mode === "disabled" ? [] : [
1274
+ ["Allowed sites", choices.allowedDomains.length ? choices.allowedDomains.join(", ") : "all sites"],
1275
+ ["Blocked sites", choices.blockedDomains.length ? choices.blockedDomains.join(", ") : "none"],
1276
+ ["Page JavaScript", choices.allowEval ? "enabled" : "off (recommended)"],
1277
+ ["Data directory", choices.dataDir]
1278
+ ]
1279
+ ]);
1280
+ }
983
1281
  async function defaultProbe(url, timeoutMs) {
984
1282
  try {
985
1283
  const controller = new AbortController();
@@ -995,7 +1293,7 @@ async function defaultProbe(url, timeoutMs) {
995
1293
  }
996
1294
  async function persistWizardConfig(choices, homeDir) {
997
1295
  const { join: join8, dirname: dirname5, resolve: resolve6 } = await import("node:path");
998
- const { mkdir: mkdir4, chmod: chmod3, lstat: lstat4, readFile: readFile3, writeFile: writeFile2, rename: rename3 } = await import("node:fs/promises");
1296
+ const { mkdir: mkdir4, chmod: chmod3, lstat: lstat4, readFile: readFile3, writeFile: writeFile2, rename: rename4 } = await import("node:fs/promises");
999
1297
  const configPath = resolve6(join8(homeDir, ".smooth-operator/config.json"));
1000
1298
  await mkdir4(dirname5(configPath), { recursive: true, mode: 448 });
1001
1299
  await chmod3(dirname5(configPath), 448).catch(() => {
@@ -1041,7 +1339,8 @@ async function persistWizardConfig(choices, homeDir) {
1041
1339
  if (choices.dataDir !== defaultDataDir) {
1042
1340
  config.dataDir = choices.dataDir;
1043
1341
  }
1044
- const tmpPath = `${configPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
1342
+ const { randomUUID: randomUUID4 } = await import("node:crypto");
1343
+ const tmpPath = `${configPath}.tmp-${process.pid}-${randomUUID4()}`;
1045
1344
  await writeFile2(tmpPath, JSON.stringify(config, null, 2) + "\n", { mode: 384, flag: "wx" });
1046
1345
  await chmod3(tmpPath, 384);
1047
1346
  try {
@@ -1066,7 +1365,7 @@ async function persistWizardConfig(choices, homeDir) {
1066
1365
  } catch (error) {
1067
1366
  if (!isMissingPathError2(error)) throw error;
1068
1367
  }
1069
- await rename3(tmpPath, configPath);
1368
+ await rename4(tmpPath, configPath);
1070
1369
  await chmod3(configPath, 384);
1071
1370
  }
1072
1371
  async function launchPersonalChrome(opts) {
@@ -1094,12 +1393,24 @@ async function launchPersonalChrome(opts) {
1094
1393
  }
1095
1394
  throw new AppError2("BROWSER_CONNECT_TIMEOUT", `Chrome DevTools endpoint on port ${port} did not become ready after ${attempts} probes. Close Chrome or choose another port.`);
1096
1395
  }
1097
- var PROBE_INTERVAL_MS, DEFAULT_PROBE_ATTEMPTS;
1396
+ var PROBE_INTERVAL_MS, DEFAULT_PROBE_ATTEMPTS, HARNESS_MENU;
1098
1397
  var init_installer_wizard = __esm({
1099
1398
  "src/server/installer-wizard.ts"() {
1100
1399
  "use strict";
1400
+ init_ui();
1101
1401
  PROBE_INTERVAL_MS = 300;
1102
1402
  DEFAULT_PROBE_ATTEMPTS = 33;
1403
+ HARNESS_MENU = [
1404
+ { id: "opencode", label: "OpenCode", description: "Configures ~/.config/opencode/opencode.json" },
1405
+ { id: "claude-code", label: "Claude Code", description: "Runs `claude mcp add` for your user scope" },
1406
+ { id: "copilot", label: "GitHub Copilot CLI", description: "Runs `copilot mcp add`" },
1407
+ { id: "codex", label: "OpenAI Codex CLI", description: "Runs `codex mcp add`" },
1408
+ { id: "gemini", label: "Gemini CLI", description: "Runs `gemini mcp add` for your user scope" },
1409
+ { id: "vscode", label: "VS Code", description: "Runs `code --add-mcp`" },
1410
+ { id: "cursor", label: "Cursor", description: "Adds SmoothOperator to ~/.cursor/mcp.json" },
1411
+ { id: "windsurf", label: "Windsurf", description: "Adds SmoothOperator to Windsurf's mcp_config.json" },
1412
+ { id: "claude-desktop", label: "Claude Desktop", description: "Updates claude_desktop_config.json" }
1413
+ ];
1103
1414
  }
1104
1415
  });
1105
1416
 
@@ -1972,7 +2283,7 @@ init_errors();
1972
2283
  init_logger();
1973
2284
 
1974
2285
  // src/server/version.ts
1975
- var SERVER_VERSION = "2.1.1";
2286
+ var SERVER_VERSION = "2.2.1";
1976
2287
 
1977
2288
  // src/server/mcp.ts
1978
2289
  var EmptyInputSchema = z3.object({}).strict();
@@ -2797,7 +3108,7 @@ function boundToolError(result) {
2797
3108
  init_logger();
2798
3109
 
2799
3110
  // src/server/runtime.ts
2800
- import { chmod, lstat as lstat2, mkdir as mkdir2, open, readFile as readFile2, realpath as realpath2, unlink as unlink2 } from "node:fs/promises";
3111
+ import { chmod, lstat as lstat2, mkdir as mkdir2, open, readFile as readFile2, realpath as realpath2, rename as rename2, unlink as unlink2 } from "node:fs/promises";
2801
3112
  import { randomUUID as randomUUID2 } from "node:crypto";
2802
3113
  import { basename as basename3, dirname as dirname3, join as join5, resolve as resolve4 } from "node:path";
2803
3114
  import process3 from "node:process";
@@ -7548,12 +7859,41 @@ async function acquireBrowserProfileLease(profileDirectory) {
7548
7859
  throw new AppError("BROWSER_PROFILE_LOCK_FAILED", "The native browser profile has an unreadable lock. Verify that no SmoothOperator process is using it, then remove the lock file.", { retryable: true });
7549
7860
  }
7550
7861
  if (existing === "stale") {
7551
- throw new AppError("BROWSER_PROFILE_LOCK_FAILED", "The native browser profile has a stale lock. Verify that no SmoothOperator process is using it, then remove the lock file and retry.", { retryable: true });
7862
+ if (await reclaimStaleLock(lockPath)) {
7863
+ continue;
7864
+ }
7865
+ throw new AppError("BROWSER_PROFILE_LOCK_FAILED", "The native browser profile has a stale lock that could not be reclaimed. Verify that no SmoothOperator process is using it, then remove the lock file and retry.", { retryable: true });
7552
7866
  }
7553
7867
  }
7554
7868
  }
7555
7869
  throw new AppError("BROWSER_PROFILE_IN_USE", "The native browser profile became busy while it was being acquired.", { retryable: true });
7556
7870
  }
7871
+ async function reclaimStaleLock(lockPath) {
7872
+ try {
7873
+ const before = await lstat2(lockPath);
7874
+ const raw = await readFile2(lockPath, "utf8");
7875
+ const pid = JSON.parse(raw).pid;
7876
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
7877
+ return false;
7878
+ }
7879
+ try {
7880
+ process3.kill(pid, 0);
7881
+ return false;
7882
+ } catch (error) {
7883
+ if (fileSystemErrorCode(error) !== "ESRCH") {
7884
+ return false;
7885
+ }
7886
+ }
7887
+ const after = await lstat2(lockPath);
7888
+ if (after.ino !== before.ino || after.dev !== before.dev) {
7889
+ return false;
7890
+ }
7891
+ await rename2(lockPath, `${lockPath}.stale-${randomUUID2()}`);
7892
+ return true;
7893
+ } catch {
7894
+ return false;
7895
+ }
7896
+ }
7557
7897
  async function readProfileLock(lockPath) {
7558
7898
  let raw;
7559
7899
  try {
@@ -7771,10 +8111,27 @@ async function main(args = process4.argv.slice(2)) {
7771
8111
  if (!harness) {
7772
8112
  throw new AppError("CONFIG_INVALID", "The install command requires a harness target.");
7773
8113
  }
7774
- const wizardChoices = await runWizard2(harness, { yes, stdin: process4.stdin, stdout: process4.stdout, homeDir: homedir5(), env: process4.env });
8114
+ planHarnessInstall(harness, { homeDirectory: homedir5(), environment: process4.env });
8115
+ const wizardChoices = await runWizard2(harness, { yes, stdin: process4.stdin, stdout: process4.stdout, homeDir: homedir5(), env: process4.env, version: SERVER_VERSION });
7775
8116
  await persistWizardConfig2(wizardChoices, homedir5());
7776
- process4.stdout.write(`${await installHarness(harness)}
8117
+ const installMessage = await installHarness(harness);
8118
+ const { createUi: createUi2 } = await Promise.resolve().then(() => (init_ui(), ui_exports));
8119
+ const ui = createUi2(process4.stdout);
8120
+ if (process4.stdout.isTTY) {
8121
+ ui.banner("Installation Complete", `${harness} can now drive a browser`, SERVER_VERSION);
8122
+ ui.keyValues([
8123
+ ["Config file", `${homedir5()}/.smooth-operator/config.json`],
8124
+ ["Browser mode", wizardChoices.mode]
8125
+ ]);
8126
+ process4.stdout.write("\n");
8127
+ ui.step(0, 2, "Next steps");
8128
+ ui.option(1, "Restart the harness", "Quit and reopen it so it picks up the new MCP server.");
8129
+ ui.option(2, "Verify", "Ask your AI to run server_health and browser_doctor.");
8130
+ ui.success(installMessage);
8131
+ } else {
8132
+ process4.stdout.write(`${installMessage}
7777
8133
  `);
8134
+ }
7778
8135
  return;
7779
8136
  }
7780
8137
  if (args.length === 1 && (args[0] === "--version" || args[0] === "-V")) {