msdevflow 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,25 @@ Claude Code + Codex -> 两套受管副本
58
58
 
59
59
  每个物理目标都包含完整套件,薄路由器不复制核心业务规则。已有 entry 只有在 manifest 精确匹配自身名称时才允许更新;由旧版包管理的 `msdevflow` 目录会在新套件全部验证后安全迁移,来源不明的目录、文件或符号链接会阻断安装。
60
60
 
61
+ ### WSL 与 Windows Agent
62
+
63
+ setup 以 **Agent 可执行文件实际所在环境** 决定安装侧,而不是只看命令从哪个 shell 发起:
64
+
65
+ - Agent 只安装在 Windows:可以从 WSL 调用 Windows `npx.cmd`,setup 会标记 `Runtime mode: windows-bridge-from-wsl`,并把 Skill、Python venv 和 GitCode CLI 安装在 Windows 用户环境。
66
+ - Agent 只安装在 WSL:必须使用 WSL 内的 Linux Node.js/npm;若误用了 Windows Node,setup 会在写入任何 Python、npm 或 Skill 文件前停止。
67
+ - 同名 Agent 同时存在于 Windows 与 WSL:setup 不猜目标;请从目标环境的终端重新运行。
68
+
69
+ 准备 WSL 原生 Agent 时先确认以下命令全部解析为 Linux 路径,而不是 `/mnt/<drive>/...` 或 `.cmd`:
70
+
71
+ ```bash
72
+ type -a node npm npx opencode
73
+ npx msdevflow@latest setup
74
+ ```
75
+
76
+ 示例使用 OpenCode;使用 Claude Code 或 Codex 时把 `opencode` 替换为实际 Agent 命令。
77
+
78
+ Windows bridge 模式应从 WSL 中解析到 Windows Agent;也可直接在 Windows Terminal/PowerShell 中运行 setup。此前失败后可直接重跑,setup 会安全复用或修复带受管 marker 的 runtime,不需要手动删除。
79
+
61
80
  自定义目录:
62
81
 
63
82
  ```bash
package/lib/bootstrap.js CHANGED
@@ -764,11 +764,13 @@ function outputLines(output) {
764
764
  return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
765
765
  }
766
766
 
767
- export function resolveExecutable(name, run, platform) {
768
- const invocation = platform === "win32"
767
+ function executableLookup(name, platform) {
768
+ return platform === "win32"
769
769
  ? { command: "where.exe", args: [name] }
770
770
  : { command: "sh", args: ["-c", "command -v -- \"$1\"", "sh", name] };
771
- const result = runOptional(run, invocation);
771
+ }
772
+
773
+ function executableFromLookup(result, platform) {
772
774
  if (result.status !== 0) {
773
775
  return "";
774
776
  }
@@ -779,10 +781,237 @@ export function resolveExecutable(name, run, platform) {
779
781
  return candidates[0] || "";
780
782
  }
781
783
 
784
+ export function resolveExecutable(name, run, platform) {
785
+ return executableFromLookup(
786
+ runOptional(run, executableLookup(name, platform)),
787
+ platform,
788
+ );
789
+ }
790
+
791
+ async function resolveExecutableAsync(name, run, platform) {
792
+ return executableFromLookup(
793
+ await runOptionalAsync(run, executableLookup(name, platform)),
794
+ platform,
795
+ );
796
+ }
797
+
782
798
  export function detectClients(run, platform) {
783
799
  return SUPPORTED_CLIENTS.filter((client) => Boolean(resolveExecutable(client, run, platform)));
784
800
  }
785
801
 
802
+ function wslDistroFromUnc(value) {
803
+ return typeof value === "string"
804
+ ? value.match(/^\\\\wsl(?:\.localhost|\$)\\([^\\]+)/i)?.[1] || ""
805
+ : "";
806
+ }
807
+
808
+ function windowsNodeFromWsl(environment, platform) {
809
+ // Windows Node keeps process.platform=win32 when a WSL shell launches npx.cmd.
810
+ if (platform !== "win32") {
811
+ return null;
812
+ }
813
+ const distro = environment.WSL_DISTRO_NAME
814
+ || wslDistroFromUnc(environment.INIT_CWD)
815
+ || wslDistroFromUnc(environment.PWD);
816
+ if (!distro && !environment.WSL_INTEROP) {
817
+ return null;
818
+ }
819
+ return { distro: distro || "" };
820
+ }
821
+
822
+ async function detectClientExecutables(run, platform) {
823
+ const entries = await Promise.all(SUPPORTED_CLIENTS.map(async (client) => [
824
+ client,
825
+ await resolveExecutableAsync(client, run, platform),
826
+ ]));
827
+ return Object.fromEntries(entries.filter(([, executable]) => executable));
828
+ }
829
+
830
+ function wslClientLookup(distro) {
831
+ const args = [];
832
+ if (distro) {
833
+ args.push("--distribution", distro);
834
+ }
835
+ const script = [
836
+ "for name do",
837
+ " old_ifs=$IFS",
838
+ " IFS=: ",
839
+ " for dir in $PATH; do",
840
+ " IFS=$old_ifs",
841
+ " [ -n \"$dir\" ] || dir=.",
842
+ " for candidate in \"$dir/$name\" \"$dir/$name.cmd\" \"$dir/$name.exe\" \"$dir/$name.bat\"; do",
843
+ " [ -f \"$candidate\" ] || continue",
844
+ " case \"$candidate\" in",
845
+ " /mnt/[a-zA-Z]/*)",
846
+ " [ -f \"${candidate}.cmd\" ] && candidate=\"${candidate}.cmd\"",
847
+ " windows=$(wslpath -w -- \"$candidate\" 2>/dev/null) || continue",
848
+ " printf '%s\\twindows\\t%s\\n' \"$name\" \"$windows\"",
849
+ " ;;",
850
+ " *)",
851
+ " [ -x \"$candidate\" ] || continue",
852
+ " native=$(readlink -f -- \"$candidate\" 2>/dev/null || printf '%s' \"$candidate\")",
853
+ " case \"$native\" in",
854
+ " /mnt/[a-zA-Z]/*)",
855
+ " [ -f \"${native}.cmd\" ] && native=\"${native}.cmd\"",
856
+ " windows=$(wslpath -w -- \"$native\" 2>/dev/null) || continue",
857
+ " printf '%s\\twindows\\t%s\\n' \"$name\" \"$windows\"",
858
+ " ;;",
859
+ " *) printf '%s\\twsl\\t%s\\n' \"$name\" \"$native\" ;;",
860
+ " esac",
861
+ " ;;",
862
+ " esac",
863
+ " break",
864
+ " done",
865
+ " IFS=: ",
866
+ " done",
867
+ " IFS=$old_ifs",
868
+ "done",
869
+ ].join("\n");
870
+ args.push("--exec", "sh", "-lc", script, "sh", ...SUPPORTED_CLIENTS);
871
+ return { command: "wsl.exe", args };
872
+ }
873
+
874
+ async function detectWslClientExecutables(run, distro) {
875
+ const result = await runOptionalAsync(run, wslClientLookup(distro));
876
+ if (result.status !== 0) {
877
+ return { wsl: {}, windows: {} };
878
+ }
879
+ const clients = { wsl: {}, windows: {} };
880
+ for (const line of result.stdout.split(/\r?\n/)) {
881
+ const [name, side, executable] = line.split("\t", 3);
882
+ if (
883
+ SUPPORTED_CLIENTS.includes(name)
884
+ && (side === "wsl" || side === "windows")
885
+ && executable
886
+ && !clients[side][name]
887
+ ) {
888
+ clients[side][name] = executable;
889
+ }
890
+ }
891
+ return clients;
892
+ }
893
+
894
+ async function inspectMixedWslClients(run, context) {
895
+ const [windowsPath, wslLookup] = await Promise.all([
896
+ detectClientExecutables(run, "win32"),
897
+ detectWslClientExecutables(run, context.distro),
898
+ ]);
899
+ return {
900
+ ...context,
901
+ windows: { ...wslLookup.windows, ...windowsPath },
902
+ wsl: wslLookup.wsl,
903
+ };
904
+ }
905
+
906
+ function clientLocations(inspection, clients) {
907
+ return clients.map((client) => ({
908
+ client,
909
+ windows: inspection.windows[client] || "",
910
+ wsl: inspection.wsl[client] || "",
911
+ }));
912
+ }
913
+
914
+ function mixedClientError(inspection, clients, reason) {
915
+ const locations = clientLocations(inspection, clients);
916
+ const details = locations.map(({ client, windows, wsl }) => [
917
+ `${client}:`,
918
+ windows ? `Windows=${windows}` : "Windows=not-found",
919
+ wsl ? `WSL=${wsl}` : "WSL=not-found",
920
+ ].join(" ")).join("; ");
921
+ const guidance = `Use Node.js >=18 and npm installed inside WSL, verify "type -a node npm npx ${clients.join(" ")}" resolves to Linux paths, then rerun "npx msdevflow@latest setup" from WSL. To configure a Windows agent instead, run setup from a Windows terminal. No changes were applied.`;
922
+ return new BootstrapError(`${reason} ${details}. ${guidance}`, 2);
923
+ }
924
+
925
+ function resolveMixedWslTargets(inspection, clients) {
926
+ const locations = clientLocations(inspection, clients);
927
+ const ambiguous = locations.filter(({ windows, wsl }) => windows && wsl);
928
+ if (ambiguous.length) {
929
+ throw mixedClientError(
930
+ inspection,
931
+ ambiguous.map(({ client }) => client),
932
+ "The selected agent exists in both Windows and WSL, so setup cannot safely choose which environment owns it.",
933
+ );
934
+ }
935
+ const wslOnly = locations.filter(({ windows, wsl }) => !windows && wsl);
936
+ if (wslOnly.length) {
937
+ throw mixedClientError(
938
+ inspection,
939
+ wslOnly.map(({ client }) => client),
940
+ "The selected agent exists only inside WSL, but msdevflow is running with Windows Node.js.",
941
+ );
942
+ }
943
+ const missing = locations.filter(({ windows, wsl }) => !windows && !wsl);
944
+ if (missing.length) {
945
+ throw mixedClientError(
946
+ inspection,
947
+ missing.map(({ client }) => client),
948
+ "The selected agent was not found in either Windows or WSL.",
949
+ );
950
+ }
951
+ return {
952
+ mode: "windows-bridge-from-wsl",
953
+ agentExecutables: Object.fromEntries(
954
+ locations.map(({ client, windows }) => [client, windows]),
955
+ ),
956
+ };
957
+ }
958
+
959
+ async function resolveSetupContext(
960
+ options,
961
+ environment,
962
+ platform,
963
+ run,
964
+ runLong,
965
+ selectClients,
966
+ progress,
967
+ ) {
968
+ const context = windowsNodeFromWsl(environment, platform);
969
+ if (!context) {
970
+ return {
971
+ targetSelection: await resolveClientTargets(
972
+ options,
973
+ run,
974
+ platform,
975
+ selectClients,
976
+ ),
977
+ runtime: {
978
+ mode: platform === "win32" ? "windows-native" : "posix-native",
979
+ agentExecutables: {},
980
+ },
981
+ };
982
+ }
983
+
984
+ const inspection = await progress.run(
985
+ "Inspecting Windows and WSL agent installations",
986
+ () => inspectMixedWslClients(runLong, context),
987
+ );
988
+ let targetSelection;
989
+ if (options.targetsSpecified) {
990
+ targetSelection = {
991
+ clients: options.targets,
992
+ source: "explicit",
993
+ detected: [],
994
+ };
995
+ } else if (options.skillsDir) {
996
+ targetSelection = {
997
+ clients: ["claude"],
998
+ source: "legacy-skills-dir",
999
+ detected: [],
1000
+ };
1001
+ } else {
1002
+ const detected = SUPPORTED_CLIENTS.filter(
1003
+ (client) => inspection.windows[client] || inspection.wsl[client],
1004
+ );
1005
+ targetSelection = detected.length
1006
+ ? { clients: detected, source: "detected", detected }
1007
+ : { clients: await selectClients(), source: "selected", detected: [] };
1008
+ }
1009
+ return {
1010
+ targetSelection,
1011
+ runtime: resolveMixedWslTargets(inspection, targetSelection.clients),
1012
+ };
1013
+ }
1014
+
786
1015
  async function selectClientTargets(input, output, promptOutput = output) {
787
1016
  if (!input.isTTY || !output.isTTY) {
788
1017
  throw new BootstrapError(
@@ -849,60 +1078,95 @@ function hasPythonShebang(file) {
849
1078
  }
850
1079
  }
851
1080
 
852
- function pythonScriptDirectories(run, platform) {
853
- const candidates = platform === "win32"
1081
+ function pythonCandidates(platform) {
1082
+ return platform === "win32"
854
1083
  ? [{ command: "py", prefix: ["-3"] }, { command: "python", prefix: [] }, { command: "python3", prefix: [] }]
855
1084
  : [{ command: "python3", prefix: [] }, { command: "python", prefix: [] }];
1085
+ }
1086
+
1087
+ function parsePythonScriptDirectory(result) {
1088
+ if (result.status !== 0) {
1089
+ return "";
1090
+ }
1091
+ try {
1092
+ const directory = JSON.parse(result.stdout.trim());
1093
+ return typeof directory === "string" ? directory : "";
1094
+ } catch {
1095
+ return "";
1096
+ }
1097
+ }
1098
+
1099
+ function pythonScriptDirectories(run, platform) {
1100
+ const probe = "import json,sysconfig; print(json.dumps(sysconfig.get_path('scripts')))";
1101
+ return pythonCandidates(platform)
1102
+ .map((candidate) => parsePythonScriptDirectory(runOptional(run, {
1103
+ command: candidate.command,
1104
+ args: [...candidate.prefix, "-c", probe],
1105
+ })))
1106
+ .filter(Boolean);
1107
+ }
1108
+
1109
+ async function pythonScriptDirectoriesAsync(run, platform) {
856
1110
  const probe = "import json,sysconfig; print(json.dumps(sysconfig.get_path('scripts')))";
857
1111
  const directories = [];
858
- for (const candidate of candidates) {
859
- const result = runOptional(run, {
1112
+ for (const candidate of pythonCandidates(platform)) {
1113
+ const directory = parsePythonScriptDirectory(await runOptionalAsync(run, {
860
1114
  command: candidate.command,
861
1115
  args: [...candidate.prefix, "-c", probe],
862
- });
863
- if (result.status !== 0) {
864
- continue;
865
- }
866
- try {
867
- const directory = JSON.parse(result.stdout.trim());
868
- if (typeof directory === "string" && directory) {
869
- directories.push(directory);
870
- }
871
- } catch {
872
- continue;
1116
+ }));
1117
+ if (directory) {
1118
+ directories.push(directory);
873
1119
  }
874
1120
  }
875
1121
  return directories;
876
1122
  }
877
1123
 
878
- function isPythonGitcode(executable, run, platform) {
879
- if (/[\\/]Python[^\\/]*[\\/]Scripts[\\/]gitcode(?:\.exe)?$/i.test(executable)
880
- || /[\\/]pipx[\\/]/i.test(executable)
881
- || hasPythonShebang(executable)) {
882
- return true;
883
- }
1124
+ function isKnownPythonGitcodePath(executable) {
1125
+ return /[\\/]Python[^\\/]*[\\/]Scripts[\\/]gitcode(?:\.exe)?$/i.test(executable)
1126
+ || /[\\/]pipx[\\/]/i.test(executable)
1127
+ || hasPythonShebang(executable);
1128
+ }
1129
+
1130
+ function executableMatchesPythonScripts(executable, directories, platform) {
884
1131
  const directory = normalizedPath(executableDirectory(executable, platform), platform);
885
- return pythonScriptDirectories(run, platform)
886
- .some((candidate) => normalizedPath(candidate, platform) === directory);
1132
+ return directories.some((candidate) => normalizedPath(candidate, platform) === directory);
887
1133
  }
888
1134
 
889
- export function diagnoseGitcode(run, platform) {
890
- const executable = resolveExecutable("gitcode", run, platform);
891
- if (!executable) {
892
- return {
893
- classification: "absent",
894
- existingExecutable: null,
895
- workflowCommand: "gitcode",
896
- };
897
- }
898
- if (isPythonGitcode(executable, run, platform)) {
899
- return {
900
- classification: "python",
901
- existingExecutable: executable,
902
- workflowCommand: "gitcode-npm",
903
- };
904
- }
905
- const doctor = runOptional(run, executableCommand(executable, ["doctor", "install", "--json"], platform));
1135
+ function isPythonGitcode(executable, run, platform) {
1136
+ return isKnownPythonGitcodePath(executable)
1137
+ || executableMatchesPythonScripts(
1138
+ executable,
1139
+ pythonScriptDirectories(run, platform),
1140
+ platform,
1141
+ );
1142
+ }
1143
+
1144
+ async function isPythonGitcodeAsync(executable, run, platform) {
1145
+ return isKnownPythonGitcodePath(executable)
1146
+ || executableMatchesPythonScripts(
1147
+ executable,
1148
+ await pythonScriptDirectoriesAsync(run, platform),
1149
+ platform,
1150
+ );
1151
+ }
1152
+
1153
+ function absentGitcodeDiagnosis() {
1154
+ return {
1155
+ classification: "absent",
1156
+ existingExecutable: null,
1157
+ workflowCommand: "gitcode",
1158
+ };
1159
+ }
1160
+
1161
+ function pythonGitcodeDiagnosis(executable) {
1162
+ return {
1163
+ classification: "python",
1164
+ existingExecutable: executable,
1165
+ workflowCommand: "gitcode-npm",
1166
+ };
1167
+ }
1168
+
1169
+ function npmGitcodeDiagnosis(executable, doctor) {
906
1170
  if (doctor.status === 0) {
907
1171
  try {
908
1172
  const metadata = JSON.parse(doctor.stdout);
@@ -920,23 +1184,74 @@ export function diagnoseGitcode(run, platform) {
920
1184
  throw new BootstrapError(`Existing gitcode has unknown ownership: ${executable}. Refusing to overwrite it.`, 3);
921
1185
  }
922
1186
 
923
- function detectPython(run, platform) {
924
- const candidates = platform === "win32"
925
- ? [{ command: "py", prefix: ["-3"] }, { command: "python", prefix: [] }, { command: "python3", prefix: [] }]
926
- : [{ command: "python3", prefix: [] }, { command: "python", prefix: [] }];
1187
+ export function diagnoseGitcode(run, platform) {
1188
+ const executable = resolveExecutable("gitcode", run, platform);
1189
+ if (!executable) {
1190
+ return absentGitcodeDiagnosis();
1191
+ }
1192
+ if (isPythonGitcode(executable, run, platform)) {
1193
+ return pythonGitcodeDiagnosis(executable);
1194
+ }
1195
+ return npmGitcodeDiagnosis(
1196
+ executable,
1197
+ runOptional(run, executableCommand(executable, ["doctor", "install", "--json"], platform)),
1198
+ );
1199
+ }
1200
+
1201
+ async function diagnoseGitcodeAsync(run, platform) {
1202
+ const executable = await resolveExecutableAsync("gitcode", run, platform);
1203
+ if (!executable) {
1204
+ return absentGitcodeDiagnosis();
1205
+ }
1206
+ if (await isPythonGitcodeAsync(executable, run, platform)) {
1207
+ return pythonGitcodeDiagnosis(executable);
1208
+ }
1209
+ return npmGitcodeDiagnosis(
1210
+ executable,
1211
+ await runOptionalAsync(
1212
+ run,
1213
+ executableCommand(executable, ["doctor", "install", "--json"], platform),
1214
+ ),
1215
+ );
1216
+ }
1217
+
1218
+ function parsePythonProbe(result) {
1219
+ if (result.status !== 0) {
1220
+ return null;
1221
+ }
1222
+ try {
1223
+ const data = JSON.parse(result.stdout.trim());
1224
+ if (data.version[0] > 3 || (data.version[0] === 3 && data.version[1] >= 10)) {
1225
+ return data;
1226
+ }
1227
+ } catch {
1228
+ return null;
1229
+ }
1230
+ return null;
1231
+ }
1232
+
1233
+ function pythonProbe(candidate) {
927
1234
  const probe = "import json,sys; print(json.dumps({'executable':sys.executable,'version':list(sys.version_info[:3])}))";
928
- for (const candidate of candidates) {
929
- const result = runOptional(run, { command: candidate.command, args: [...candidate.prefix, "-c", probe] });
930
- if (result.status !== 0) {
931
- continue;
1235
+ return { command: candidate.command, args: [...candidate.prefix, "-c", probe] };
1236
+ }
1237
+
1238
+ function detectPython(run, platform) {
1239
+ for (const candidate of pythonCandidates(platform)) {
1240
+ const data = parsePythonProbe(runOptional(run, pythonProbe(candidate)));
1241
+ if (data) {
1242
+ return data;
932
1243
  }
933
- try {
934
- const data = JSON.parse(result.stdout.trim());
935
- if (data.version[0] > 3 || (data.version[0] === 3 && data.version[1] >= 10)) {
936
- return data;
937
- }
938
- } catch {
939
- continue;
1244
+ }
1245
+ throw new BootstrapError("Python >=3.10 is required for workflow scripts.", 2);
1246
+ }
1247
+
1248
+ async function detectPythonAsync(run, platform) {
1249
+ for (const candidate of pythonCandidates(platform)) {
1250
+ const python = parsePythonProbe(
1251
+ await runOptionalAsync(run, pythonProbe(candidate)),
1252
+ );
1253
+ if (python) {
1254
+ return python;
940
1255
  }
941
1256
  }
942
1257
  throw new BootstrapError("Python >=3.10 is required for workflow scripts.", 2);
@@ -1004,7 +1319,7 @@ export function pythonRuntimeDetails(
1004
1319
  return pythonRuntimeAt(directory, python, platform);
1005
1320
  }
1006
1321
 
1007
- function validatePythonRuntime(runtime, run) {
1322
+ async function validatePythonRuntime(runtime, run) {
1008
1323
  let marker;
1009
1324
  try {
1010
1325
  const metadata = lstatSync(runtime.directory);
@@ -1043,10 +1358,10 @@ function validatePythonRuntime(runtime, run) {
1043
1358
  closeSync(descriptor);
1044
1359
  }
1045
1360
  }
1046
- if (runOptional(run, {
1361
+ if ((await runOptionalAsync(run, {
1047
1362
  command: runtime.executable,
1048
1363
  args: ["-m", "pip", "--version"],
1049
- }).status !== 0) {
1364
+ })).status !== 0) {
1050
1365
  return "repair";
1051
1366
  }
1052
1367
  return "current";
@@ -1069,8 +1384,8 @@ function writePythonRuntimeMarker(runtime) {
1069
1384
  writeFileSync(runtime.marker, PYTHON_RUNTIME_MARKER_CONTENT, { encoding: "utf8", flag: "wx" });
1070
1385
  }
1071
1386
 
1072
- async function ensurePythonRuntime(runtime, status, runLong, run) {
1073
- if (validatePythonRuntime(runtime, run) !== status) {
1387
+ async function ensurePythonRuntime(runtime, status, runLong) {
1388
+ if ((await validatePythonRuntime(runtime, runLong)) !== status) {
1074
1389
  throw new BootstrapError(
1075
1390
  `Managed Python runtime changed after confirmation: ${runtime.directory}`,
1076
1391
  3,
@@ -1155,11 +1470,15 @@ export function gitcodeInstallDetails(
1155
1470
  npmCommand: ["npm", "install", "-g", GITCODE_PACKAGE, "--prefix", installPrefix, `--registry=${REGISTRY}`],
1156
1471
  };
1157
1472
  }
1473
+ const installPrefix = npmPrefix || "<npm-global-prefix>";
1158
1474
  return {
1159
1475
  mode: classification === "npm-official" ? "upgrade-npm" : "standard",
1160
1476
  workflowCommand: "gitcode",
1161
- target: npmPrefix || "<npm-global-prefix>",
1162
- cliTarget: null,
1477
+ target: installPrefix,
1478
+ cliTarget: pathApi.join(
1479
+ installPrefix,
1480
+ ...(platform === "win32" ? ["gitcode.cmd"] : ["bin", "gitcode"]),
1481
+ ),
1163
1482
  wrapperDir: null,
1164
1483
  wrapper: null,
1165
1484
  npmCommand: ["npm", "install", "-g", GITCODE_PACKAGE, `--registry=${REGISTRY}`],
@@ -1255,12 +1574,20 @@ async function installGitcode(plan, runLong, run, platform) {
1255
1574
  };
1256
1575
  }
1257
1576
  const executable = resolveExecutable("gitcode", run, platform);
1258
- if (!executable) {
1259
- throw new BootstrapError("GitCode npm CLI was installed but the gitcode executable is not available on PATH.");
1577
+ if (executable) {
1578
+ return {
1579
+ command: plan.gitcodeInstall.workflowCommand,
1580
+ executable,
1581
+ };
1260
1582
  }
1583
+ // The parent process keeps its original PATH after npm creates a global shim.
1584
+ verifyFile(
1585
+ plan.gitcodeInstall.cliTarget,
1586
+ `GitCode npm CLI was installed but its executable was not found on PATH or at ${plan.gitcodeInstall.cliTarget}.`,
1587
+ );
1261
1588
  return {
1262
1589
  command: plan.gitcodeInstall.workflowCommand,
1263
- executable,
1590
+ executable: plan.gitcodeInstall.cliTarget,
1264
1591
  };
1265
1592
  }
1266
1593
 
@@ -1286,6 +1613,10 @@ function safeAuthStatus(output) {
1286
1613
  function printPlan(plan, write) {
1287
1614
  write("Setup plan (no changes made yet):");
1288
1615
  write(` Client targets: ${plan.clientTargets.clients.join(", ")} (${plan.clientTargets.source})`);
1616
+ write(` Runtime mode: ${plan.clientTargets.runtimeMode}`);
1617
+ for (const [client, executable] of Object.entries(plan.clientTargets.agentExecutables)) {
1618
+ write(` Agent executable (${client}): ${executable}`);
1619
+ }
1289
1620
  write(` Bundled skill suite: ${plan.skillInstalls[0].details.source}`);
1290
1621
  for (const { layout, details } of plan.skillInstalls) {
1291
1622
  write(` Skill root: ${details.target}`);
@@ -1422,6 +1753,7 @@ async function authenticateGitcode(
1422
1753
  function textResult(result, write) {
1423
1754
  write("Setup completed:");
1424
1755
  write(` Client targets: ${result.clients.join(", ")}`);
1756
+ write(` Runtime mode: ${result.runtimeMode}`);
1425
1757
  for (const skill of result.skills) {
1426
1758
  write(` Skill suite: ${skill.status} at ${skill.target} (${skill.clients.join(", ")})`);
1427
1759
  write(` Entries: ${skill.entries.map((entry) => entry.name).join(", ")}`);
@@ -1456,16 +1788,20 @@ export async function runSetup(options, dependencies = {}) {
1456
1788
  writeProgress,
1457
1789
  dependencies.timers,
1458
1790
  );
1459
- const targetSelection = await resolveClientTargets(
1791
+ const setupContext = await resolveSetupContext(
1460
1792
  options,
1461
- run,
1793
+ environment,
1462
1794
  platform,
1795
+ run,
1796
+ runLong,
1463
1797
  dependencies.selectClients || (() => selectClientTargets(
1464
1798
  process.stdin,
1465
1799
  process.stdout,
1466
1800
  options.json ? process.stderr : process.stdout,
1467
1801
  )),
1802
+ progress,
1468
1803
  );
1804
+ const targetSelection = setupContext.targetSelection;
1469
1805
  const layouts = resolveLayouts(
1470
1806
  options,
1471
1807
  targetSelection.clients,
@@ -1482,6 +1818,8 @@ export async function runSetup(options, dependencies = {}) {
1482
1818
  }));
1483
1819
  const clientTargets = {
1484
1820
  ...targetSelection,
1821
+ runtimeMode: setupContext.runtime.mode,
1822
+ agentExecutables: setupContext.runtime.agentExecutables,
1485
1823
  warnings: clientTargetWarnings(targetSelection.clients, layouts),
1486
1824
  };
1487
1825
 
@@ -1499,7 +1837,42 @@ export async function runSetup(options, dependencies = {}) {
1499
1837
  platform,
1500
1838
  )),
1501
1839
  );
1502
- const diagnosis = diagnoseGitcode(run, platform);
1840
+ const { diagnosis, python, pythonRuntime } = await progress.run(
1841
+ "Inspecting local GitCode and Python environment",
1842
+ async () => {
1843
+ const [diagnosisResult, pythonResult] = await Promise.all([
1844
+ diagnoseGitcodeAsync(runLong, platform),
1845
+ detectPythonAsync(runLong, platform),
1846
+ ]);
1847
+ const configuredPythonRuntime = pythonRuntimeDetails(
1848
+ pythonResult,
1849
+ environment,
1850
+ platform,
1851
+ );
1852
+ const runtime = dependencies.pythonRuntimeDir
1853
+ ? pythonRuntimeAt(dependencies.pythonRuntimeDir, pythonResult, platform)
1854
+ : configuredPythonRuntime;
1855
+ const [venvCapability, runtimeStatus] = await Promise.all([
1856
+ runOptionalAsync(runLong, {
1857
+ command: pythonResult.executable,
1858
+ args: ["-m", "venv", "--help"],
1859
+ }),
1860
+ validatePythonRuntime(runtime, runLong),
1861
+ ]);
1862
+ if (venvCapability.status !== 0) {
1863
+ throw new BootstrapError(
1864
+ `Python venv is unavailable for ${pythonResult.executable}. Install the venv component for this Python (for example python3-venv on Debian/Ubuntu) and rerun setup.`,
1865
+ 2,
1866
+ );
1867
+ }
1868
+ runtime.status = runtimeStatus;
1869
+ return {
1870
+ diagnosis: diagnosisResult,
1871
+ python: pythonResult,
1872
+ pythonRuntime: runtime,
1873
+ };
1874
+ },
1875
+ );
1503
1876
  diagnosis.officialLatest = latestResult.status === 0 ? latestResult.stdout.trim() || null : null;
1504
1877
  const gitcodeInstall = gitcodeInstallDetails(diagnosis.classification, environment, platform, npmPrefix);
1505
1878
  if (gitcodeInstall.wrapper) {
@@ -1511,22 +1884,6 @@ export async function runSetup(options, dependencies = {}) {
1511
1884
  platform,
1512
1885
  );
1513
1886
 
1514
- const python = detectPython(run, platform);
1515
- const venvCapability = runOptional(run, {
1516
- command: python.executable,
1517
- args: ["-m", "venv", "--help"],
1518
- });
1519
- if (venvCapability.status !== 0) {
1520
- throw new BootstrapError(
1521
- `Python venv is unavailable for ${python.executable}. Install the venv component for this Python (for example python3-venv on Debian/Ubuntu) and rerun setup.`,
1522
- 2,
1523
- );
1524
- }
1525
- const configuredPythonRuntime = pythonRuntimeDetails(python, environment, platform);
1526
- const pythonRuntime = dependencies.pythonRuntimeDir
1527
- ? pythonRuntimeAt(dependencies.pythonRuntimeDir, python, platform)
1528
- : configuredPythonRuntime;
1529
- pythonRuntime.status = validatePythonRuntime(pythonRuntime, run);
1530
1887
  const venvInvocation = {
1531
1888
  command: python.executable,
1532
1889
  args: ["-m", "venv", pythonRuntime.directory],
@@ -1554,14 +1911,29 @@ export async function runSetup(options, dependencies = {}) {
1554
1911
  if (!options.yes && !(await (dependencies.confirm || confirmPlan)(process.stdin, process.stdout))) {
1555
1912
  throw new BootstrapError("Setup cancelled; no changes were applied.", 2);
1556
1913
  }
1557
- const currentDiagnosis = diagnoseGitcode(run, platform);
1558
- if (currentDiagnosis.classification !== diagnosis.classification
1559
- || currentDiagnosis.existingExecutable !== diagnosis.existingExecutable) {
1560
- throw new BootstrapError("GitCode command ownership changed after confirmation; refusing to install.", 3);
1561
- }
1562
- if (gitcodeInstall.wrapper) {
1563
- validateWrapper(gitcodeInstall, platform);
1564
- }
1914
+ await progress.run("Revalidating environment ownership", async () => {
1915
+ const runtimeContext = windowsNodeFromWsl(environment, platform);
1916
+ if (clientTargets.runtimeMode === "windows-bridge-from-wsl") {
1917
+ const currentRuntime = resolveMixedWslTargets(
1918
+ await inspectMixedWslClients(runLong, runtimeContext),
1919
+ clientTargets.clients,
1920
+ );
1921
+ if (JSON.stringify(currentRuntime) !== JSON.stringify(setupContext.runtime)) {
1922
+ throw new BootstrapError(
1923
+ "Agent installation locations changed after confirmation; refusing to install.",
1924
+ 3,
1925
+ );
1926
+ }
1927
+ }
1928
+ const currentDiagnosis = await diagnoseGitcodeAsync(runLong, platform);
1929
+ if (currentDiagnosis.classification !== diagnosis.classification
1930
+ || currentDiagnosis.existingExecutable !== diagnosis.existingExecutable) {
1931
+ throw new BootstrapError("GitCode command ownership changed after confirmation; refusing to install.", 3);
1932
+ }
1933
+ if (gitcodeInstall.wrapper) {
1934
+ validateWrapper(gitcodeInstall, platform);
1935
+ }
1936
+ });
1565
1937
 
1566
1938
  await progress.run(
1567
1939
  pythonRuntime.status === "absent"
@@ -1569,7 +1941,7 @@ export async function runSetup(options, dependencies = {}) {
1569
1941
  : pythonRuntime.status === "repair"
1570
1942
  ? "Repairing the managed Python runtime"
1571
1943
  : "Validating the managed Python runtime",
1572
- () => ensurePythonRuntime(pythonRuntime, pythonRuntime.status, runLong, run),
1944
+ () => ensurePythonRuntime(pythonRuntime, pythonRuntime.status, runLong),
1573
1945
  );
1574
1946
  await progress.run("Installing reviewed Python dependencies", () => runLong(pipInvocation));
1575
1947
  await progress.run(
@@ -1613,6 +1985,8 @@ export async function runSetup(options, dependencies = {}) {
1613
1985
  git: gitVersion,
1614
1986
  clients: clientTargets.clients,
1615
1987
  targetSource: clientTargets.source,
1988
+ runtimeMode: clientTargets.runtimeMode,
1989
+ agentExecutables: clientTargets.agentExecutables,
1616
1990
  warnings: clientTargets.warnings,
1617
1991
  skills,
1618
1992
  gitcode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msdevflow",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Install the msdevflow GitCode skill and its runtime dependencies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -273,6 +273,8 @@ Claude Code + Codex -> 两套受管副本
273
273
 
274
274
  每个物理目标都包含一个 `msd` 核心和十个 `msd-<action>` 路由器;业务规则仍只有一份。
275
275
 
276
+ WSL 中的安装侧按 Agent 可执行文件实际所在环境决定:Agent 只在 Windows 时允许通过 Windows Node bridge 安装到 Windows 用户环境;Agent 只在 WSL 时必须使用 WSL 内 Linux Node/npm;同名 Agent 同时存在于两侧时停止而不猜测。WSL 原生模式先用 `type -a node npm npx opencode`(以实际选中的 Agent 命令替换 `opencode`) 确认所选命令解析为 Linux 路径,而不是 `/mnt/<drive>/...` 或 `.cmd`。此前失败后可直接按正确模式重跑,受管 runtime 会被复用或修复,无需手动删除。
277
+
276
278
  显式选择客户端、只读预检或自定义目录:
277
279
 
278
280
  ```bash