msdevflow 0.7.4 → 0.7.6

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.
Files changed (28) hide show
  1. package/README.md +39 -40
  2. package/lib/bootstrap.js +485 -103
  3. package/package.json +2 -2
  4. package/skill/{msdevflow → msd}/README.md +54 -39
  5. package/skill/{msdevflow → msd}/SKILL.md +26 -35
  6. package/skill/{msdevflow → msd}/references/ci-and-review.md +2 -2
  7. package/skill/{msdevflow → msd}/references/command-capabilities.md +1 -1
  8. package/skill/{msdevflow → msd}/references/create-issue.md +1 -1
  9. package/skill/{msdevflow → msd}/references/design-and-development.md +1 -1
  10. package/skill/{msdevflow → msd}/references/openlibing-ci.md +9 -2
  11. package/skill/{msdevflow → msd}/references/pr-and-ci.md +2 -2
  12. package/skill/{msdevflow → msd}/references/recovery.md +1 -1
  13. package/skill/{msdevflow → msd}/references/review-and-merge.md +2 -2
  14. package/skill/{msdevflow → msd}/references/setup-and-issue.md +2 -2
  15. package/skill/{msdevflow → msd}/references/state-and-safety.md +1 -1
  16. package/skill/{msdevflow → msd}/scripts/openlibing_ci.py +62 -21
  17. package/skill/msd-ci/SKILL.md +15 -0
  18. package/skill/msd-code-review/SKILL.md +15 -0
  19. package/skill/msd-create-issue/SKILL.md +15 -0
  20. package/skill/msd-develop/SKILL.md +15 -0
  21. package/skill/msd-discover/SKILL.md +15 -0
  22. package/skill/msd-feedback/SKILL.md +15 -0
  23. package/skill/msd-issue/SKILL.md +15 -0
  24. package/skill/msd-merge/SKILL.md +15 -0
  25. package/skill/msd-openlibing-auth/SKILL.md +15 -0
  26. package/skill/msd-pr/SKILL.md +15 -0
  27. /package/skill/{msdevflow → msd}/references/code-review.md +0 -0
  28. /package/skill/{msdevflow → msd}/scripts/requirements.txt +0 -0
package/lib/bootstrap.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  readdirSync,
12
12
  readSync,
13
13
  renameSync,
14
+ rmdirSync,
14
15
  rmSync,
15
16
  unlinkSync,
16
17
  writeFileSync,
@@ -24,7 +25,27 @@ import { fileURLToPath } from "node:url";
24
25
  const REGISTRY = "https://registry.npmjs.org";
25
26
  const GITCODE_PACKAGE = "@gitcode-cli/cli@latest";
26
27
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
- const BUNDLED_SKILL_DIR = path.join(PACKAGE_ROOT, "skill", "msdevflow");
28
+ const BUNDLED_SKILLS_DIR = path.join(PACKAGE_ROOT, "skill");
29
+ const CORE_SKILL_NAME = "msd";
30
+ const LEGACY_SKILL_NAME = "msdevflow";
31
+ const PYTHON_RUNTIME_MARKER = ".msdevflow-python-runtime";
32
+ const PYTHON_RUNTIME_MARKER_CONTENT = "managed-by=msdevflow\n";
33
+ export const ACTIONS = [
34
+ "discover",
35
+ "create-issue",
36
+ "issue",
37
+ "develop",
38
+ "pr",
39
+ "ci",
40
+ "openlibing-auth",
41
+ "feedback",
42
+ "code-review",
43
+ "merge",
44
+ ];
45
+ export const MANAGED_SKILL_NAMES = [
46
+ CORE_SKILL_NAME,
47
+ ...ACTIONS.map((action) => `${CORE_SKILL_NAME}-${action}`),
48
+ ];
28
49
  const SUPPORTED_CLIENTS = ["claude", "codex", "opencode"];
29
50
  const REQUIRED_SCHEMAS = {
30
51
  "auth login": ["web"],
@@ -70,8 +91,8 @@ client is detected, interactive setup asks which clients to install; non-interac
70
91
  setup requires --targets. OpenCode reuses a Claude or shared Agent Skills target
71
92
  instead of creating a third copy.
72
93
 
73
- The setup installs or updates the bundled msdevflow skill, installs or upgrades
74
- the official GitCode npm CLI, and installs all reviewed Python runtime
94
+ The setup installs or updates the bundled msd core and msd-<action> skills,
95
+ installs or upgrades the official GitCode npm CLI, and installs all reviewed Python runtime
75
96
  dependencies. It never downloads or launches Chromium. If GitCode is not
76
97
  authenticated, it starts the CLI's official browser login; setup never reads or
77
98
  prints a token.`;
@@ -167,29 +188,33 @@ function defaultAgentsSkillsDir(options, environment, platform) {
167
188
  || path.join(environmentHome(environment, platform), ".agents", "skills");
168
189
  }
169
190
 
170
- function createSkillLayout(kind, clients, skillsDir, bundledSkillDir) {
191
+ function createSkillLayout(kind, clients, skillsDir, bundledSkillsDir) {
171
192
  const resolvedSkillsDir = path.resolve(skillsDir);
172
- const workflowDir = path.join(resolvedSkillsDir, "msdevflow");
173
- const bundledWorkflowDir = path.resolve(bundledSkillDir);
193
+ const resolvedBundledSkillsDir = path.resolve(bundledSkillsDir);
194
+ const coreDir = path.join(resolvedSkillsDir, CORE_SKILL_NAME);
195
+ const bundledCoreDir = path.join(resolvedBundledSkillsDir, CORE_SKILL_NAME);
174
196
  return {
175
197
  kind,
176
198
  clients,
177
199
  skillsDir: resolvedSkillsDir,
178
- workflowDir,
179
- workflowManifest: path.join(workflowDir, "SKILL.md"),
180
- pythonRequirements: path.join(workflowDir, "scripts", "requirements.txt"),
181
- bundledSkillDir: bundledWorkflowDir,
182
- bundledManifest: path.join(bundledWorkflowDir, "SKILL.md"),
183
- bundledPythonRequirements: path.join(bundledWorkflowDir, "scripts", "requirements.txt"),
200
+ coreDir,
201
+ coreManifest: path.join(coreDir, "SKILL.md"),
202
+ pythonRequirements: path.join(coreDir, "scripts", "requirements.txt"),
203
+ legacyDir: path.join(resolvedSkillsDir, LEGACY_SKILL_NAME),
204
+ legacyManifest: path.join(resolvedSkillsDir, LEGACY_SKILL_NAME, "SKILL.md"),
205
+ bundledSkillsDir: resolvedBundledSkillsDir,
206
+ bundledCoreDir,
207
+ bundledCoreManifest: path.join(bundledCoreDir, "SKILL.md"),
208
+ bundledPythonRequirements: path.join(bundledCoreDir, "scripts", "requirements.txt"),
184
209
  };
185
210
  }
186
211
 
187
- export function resolveLayout(options, environment = process.env, bundledSkillDir = BUNDLED_SKILL_DIR) {
212
+ export function resolveLayout(options, environment = process.env, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
188
213
  return createSkillLayout(
189
214
  "claude",
190
215
  ["claude"],
191
216
  defaultClaudeSkillsDir(options, environment, process.platform),
192
- bundledSkillDir,
217
+ bundledSkillsDir,
193
218
  );
194
219
  }
195
220
 
@@ -197,7 +222,7 @@ export function resolveLayouts(
197
222
  options,
198
223
  clients,
199
224
  environment = process.env,
200
- bundledSkillDir = BUNDLED_SKILL_DIR,
225
+ bundledSkillsDir = BUNDLED_SKILLS_DIR,
201
226
  platform = process.platform,
202
227
  ) {
203
228
  const layouts = [];
@@ -207,7 +232,7 @@ export function resolveLayouts(
207
232
  "claude",
208
233
  openCodeUsesClaude ? ["claude", "opencode"] : ["claude"],
209
234
  defaultClaudeSkillsDir(options, environment, platform),
210
- bundledSkillDir,
235
+ bundledSkillsDir,
211
236
  ));
212
237
  }
213
238
  if (clients.includes("codex") || (clients.includes("opencode") && !openCodeUsesClaude)) {
@@ -219,12 +244,12 @@ export function resolveLayouts(
219
244
  "agents",
220
245
  sharedClients,
221
246
  defaultAgentsSkillsDir(options, environment, platform),
222
- bundledSkillDir,
247
+ bundledSkillsDir,
223
248
  ));
224
249
  }
225
250
  const unique = new Map();
226
251
  for (const layout of layouts) {
227
- const key = normalizedPath(layout.workflowDir, platform);
252
+ const key = normalizedPath(layout.skillsDir, platform);
228
253
  const existing = unique.get(key);
229
254
  if (existing) {
230
255
  existing.clients = [...new Set([...existing.clients, ...layout.clients])];
@@ -440,7 +465,7 @@ function requireFile(file, description) {
440
465
  verifyFile(file, `${description} not found: ${file}. Reinstall the msdevflow npm package.`);
441
466
  }
442
467
 
443
- function skillManifestIsOwned(file) {
468
+ function skillManifestIsOwned(file, expectedName) {
444
469
  try {
445
470
  const metadata = lstatSync(file);
446
471
  if (metadata.isSymbolicLink() || !metadata.isFile()) {
@@ -451,7 +476,7 @@ function skillManifestIsOwned(file) {
451
476
  return false;
452
477
  }
453
478
  const names = [...frontmatter[1].matchAll(/^name:\s*([^\s#]+)\s*$/gm)];
454
- return names.length === 1 && names[0][1] === "msdevflow";
479
+ return names.length === 1 && names[0][1] === expectedName;
455
480
  } catch (error) {
456
481
  if (error?.code === "ENOENT") {
457
482
  return false;
@@ -491,26 +516,25 @@ function skillDigest(root) {
491
516
  return digest.digest("hex");
492
517
  }
493
518
 
494
- function skillInstallDetails(layout) {
495
- requireFile(layout.bundledManifest, "bundled msdevflow skill manifest");
496
- requireFile(layout.bundledPythonRequirements, "bundled workflow Python requirements file");
497
- if (!skillManifestIsOwned(layout.bundledManifest)) {
498
- throw new BootstrapError("Bundled skill manifest does not identify msdevflow.", 3);
519
+ function installedSkillDetails(source, target, name) {
520
+ const sourceManifest = path.join(source, "SKILL.md");
521
+ requireFile(sourceManifest, `bundled ${name} skill manifest`);
522
+ if (!skillManifestIsOwned(sourceManifest, name)) {
523
+ throw new BootstrapError(`Bundled skill manifest does not identify ${name}.`, 3);
499
524
  }
500
- validatePythonRequirements(layout.bundledPythonRequirements);
501
- const bundledDigest = skillDigest(layout.bundledSkillDir);
525
+ const bundledDigest = skillDigest(source);
502
526
  let status = "absent";
503
527
  let existingDigest = null;
504
528
  try {
505
- const metadata = lstatSync(layout.workflowDir);
529
+ const metadata = lstatSync(target);
506
530
  if (metadata.isSymbolicLink() || !metadata.isDirectory()
507
- || !skillManifestIsOwned(layout.workflowManifest)) {
531
+ || !skillManifestIsOwned(path.join(target, "SKILL.md"), name)) {
508
532
  throw new BootstrapError(
509
- `Refusing to overwrite a skills entry not owned by msdevflow: ${layout.workflowDir}`,
533
+ `Refusing to overwrite a skills entry not owned by ${name}: ${target}`,
510
534
  3,
511
535
  );
512
536
  }
513
- existingDigest = skillDigest(layout.workflowDir);
537
+ existingDigest = skillDigest(target);
514
538
  status = existingDigest === bundledDigest ? "current" : "update";
515
539
  } catch (error) {
516
540
  if (error?.code !== "ENOENT") {
@@ -518,70 +542,211 @@ function skillInstallDetails(layout) {
518
542
  }
519
543
  }
520
544
  return {
521
- source: layout.bundledSkillDir,
522
- target: layout.workflowDir,
545
+ name,
546
+ source,
547
+ target,
523
548
  status,
524
549
  bundledDigest,
525
550
  existingDigest,
526
551
  };
527
552
  }
528
553
 
529
- function installBundledSkill(details) {
530
- const parent = path.dirname(details.target);
531
- mkdirSync(parent, { recursive: true });
532
- const current = skillInstallDetails({
533
- bundledSkillDir: details.source,
534
- bundledManifest: path.join(details.source, "SKILL.md"),
535
- bundledPythonRequirements: path.join(details.source, "scripts", "requirements.txt"),
536
- workflowDir: details.target,
537
- workflowManifest: path.join(details.target, "SKILL.md"),
538
- });
539
- if (current.status !== details.status || current.existingDigest !== details.existingDigest) {
540
- throw new BootstrapError("msdevflow skill target changed after confirmation; refusing to overwrite it.", 3);
554
+ function legacySkillDetails(layout) {
555
+ try {
556
+ const metadata = lstatSync(layout.legacyDir);
557
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()
558
+ || !skillManifestIsOwned(layout.legacyManifest, LEGACY_SKILL_NAME)) {
559
+ throw new BootstrapError(
560
+ `Refusing to migrate a legacy skills entry not owned by ${LEGACY_SKILL_NAME}: ${layout.legacyDir}`,
561
+ 3,
562
+ );
563
+ }
564
+ return {
565
+ target: layout.legacyDir,
566
+ status: "migrate",
567
+ existingDigest: skillDigest(layout.legacyDir),
568
+ };
569
+ } catch (error) {
570
+ if (error?.code === "ENOENT") {
571
+ return {
572
+ target: layout.legacyDir,
573
+ status: "absent",
574
+ existingDigest: null,
575
+ };
576
+ }
577
+ throw error;
541
578
  }
542
- if (current.status === "current") {
543
- return "current";
579
+ }
580
+
581
+ function skillInstallDetails(layout) {
582
+ requireFile(layout.bundledPythonRequirements, "bundled workflow Python requirements file");
583
+ validatePythonRequirements(layout.bundledPythonRequirements);
584
+ const entries = MANAGED_SKILL_NAMES.map((name) => installedSkillDetails(
585
+ path.join(layout.bundledSkillsDir, name),
586
+ path.join(layout.skillsDir, name),
587
+ name,
588
+ ));
589
+ return {
590
+ source: layout.bundledSkillsDir,
591
+ target: layout.skillsDir,
592
+ entries,
593
+ legacy: legacySkillDetails(layout),
594
+ };
595
+ }
596
+
597
+ function assertSkillInstallUnchanged(details) {
598
+ const layout = {
599
+ skillsDir: details.target,
600
+ bundledSkillsDir: details.source,
601
+ bundledPythonRequirements: path.join(
602
+ details.source,
603
+ CORE_SKILL_NAME,
604
+ "scripts",
605
+ "requirements.txt",
606
+ ),
607
+ legacyDir: path.join(details.target, LEGACY_SKILL_NAME),
608
+ legacyManifest: path.join(details.target, LEGACY_SKILL_NAME, "SKILL.md"),
609
+ };
610
+ const current = skillInstallDetails(layout);
611
+ for (let index = 0; index < details.entries.length; index += 1) {
612
+ const expected = details.entries[index];
613
+ const actual = current.entries[index];
614
+ if (actual.name !== expected.name || actual.status !== expected.status
615
+ || actual.existingDigest !== expected.existingDigest
616
+ || actual.bundledDigest !== expected.bundledDigest) {
617
+ throw new BootstrapError(
618
+ `Skill target changed after confirmation; refusing to overwrite it: ${expected.target}`,
619
+ 3,
620
+ );
621
+ }
622
+ }
623
+ if (current.legacy.status !== details.legacy.status
624
+ || current.legacy.existingDigest !== details.legacy.existingDigest) {
625
+ throw new BootstrapError(
626
+ `Legacy skill target changed after confirmation; refusing to migrate it: ${details.legacy.target}`,
627
+ 3,
628
+ );
629
+ }
630
+ }
631
+
632
+ function installedSuiteResult(details) {
633
+ const entries = details.entries.map((entry) => ({
634
+ name: entry.name,
635
+ status: entry.status === "current"
636
+ ? "current"
637
+ : entry.status === "absent" ? "installed" : "updated",
638
+ target: entry.target,
639
+ }));
640
+ let status = "current";
641
+ if (details.legacy.status === "migrate") {
642
+ status = "migrated";
643
+ } else if (entries.some((entry) => entry.status === "updated")) {
644
+ status = "updated";
645
+ } else if (entries.some((entry) => entry.status === "installed")) {
646
+ status = "installed";
647
+ }
648
+ return { status, entries, legacy: details.legacy.status };
649
+ }
650
+
651
+ function suiteTransactionPath(details, name, extension) {
652
+ const skillsRootName = path.basename(details.target) || "skills";
653
+ return path.join(
654
+ path.dirname(details.target),
655
+ `.${skillsRootName}-msdevflow-${name}-${randomUUID()}.${extension}`,
656
+ );
657
+ }
658
+
659
+ function installBundledSkillSuites(suites) {
660
+ for (const details of suites) {
661
+ mkdirSync(details.target, { recursive: true });
662
+ assertSkillInstallUnchanged(details);
544
663
  }
545
- const staging = path.join(parent, `.msdevflow-${randomUUID()}.tmp`);
546
- const backup = path.join(parent, `.msdevflow-${randomUUID()}.backup`);
547
- let movedExisting = false;
664
+ const staged = [];
665
+ const switched = [];
666
+ const migrated = [];
548
667
  try {
549
- cpSync(details.source, staging, { recursive: true, errorOnExist: true });
550
- if (skillDigest(staging) !== details.bundledDigest) {
551
- throw new BootstrapError("Bundled skill verification failed after staging.", 3);
668
+ for (const details of suites) {
669
+ for (const entry of details.entries.filter((item) => item.status !== "current")) {
670
+ const staging = suiteTransactionPath(details, entry.name, "tmp");
671
+ cpSync(entry.source, staging, { recursive: true, errorOnExist: true });
672
+ if (skillDigest(staging) !== entry.bundledDigest) {
673
+ throw new BootstrapError(`Bundled ${entry.name} verification failed after staging.`, 3);
674
+ }
675
+ staged.push({ details, entry, staging });
676
+ }
552
677
  }
553
- if (current.status === "update") {
554
- renameSync(details.target, backup);
555
- movedExisting = true;
678
+ for (const details of suites) {
679
+ assertSkillInstallUnchanged(details);
556
680
  }
557
- try {
558
- renameSync(staging, details.target);
559
- } catch (error) {
560
- if (movedExisting) {
561
- renameSync(backup, details.target);
562
- movedExisting = false;
681
+ for (const item of staged) {
682
+ const backup = suiteTransactionPath(item.details, item.entry.name, "backup");
683
+ if (item.entry.status === "update") {
684
+ renameSync(item.entry.target, backup);
563
685
  }
564
- throw error;
686
+ try {
687
+ renameSync(item.staging, item.entry.target);
688
+ } catch (error) {
689
+ if (item.entry.status === "update") {
690
+ renameSync(backup, item.entry.target);
691
+ }
692
+ throw error;
693
+ }
694
+ switched.push({ ...item, backup: item.entry.status === "update" ? backup : null });
695
+ }
696
+ for (const details of suites.filter((item) => item.legacy.status === "migrate")) {
697
+ const backup = suiteTransactionPath(details, LEGACY_SKILL_NAME, "backup");
698
+ renameSync(details.legacy.target, backup);
699
+ migrated.push({ details, backup });
565
700
  }
566
- if (movedExisting) {
567
- movedExisting = false;
701
+ } catch (error) {
702
+ const rollbackFailures = [];
703
+ for (const item of migrated.reverse()) {
568
704
  try {
569
- rmSync(backup, { recursive: true, force: true });
570
- } catch {
571
- // The verified target is already live; stale backup cleanup must not invalidate it.
705
+ renameSync(item.backup, item.details.legacy.target);
706
+ } catch (rollbackError) {
707
+ rollbackFailures.push(rollbackError);
708
+ }
709
+ }
710
+ for (const item of switched.reverse()) {
711
+ try {
712
+ rmSync(item.entry.target, { recursive: true, force: true });
713
+ if (item.backup !== null) {
714
+ renameSync(item.backup, item.entry.target);
715
+ }
716
+ } catch (rollbackError) {
717
+ rollbackFailures.push(rollbackError);
572
718
  }
573
719
  }
574
- return current.status === "absent" ? "installed" : "updated";
720
+ if (rollbackFailures.length) {
721
+ throw new BootstrapError(
722
+ `Skill suite installation failed and rollback was incomplete: ${error.message}; rollback: ${rollbackFailures.map((failure) => failure.message).join("; ")}`,
723
+ 3,
724
+ );
725
+ }
726
+ throw error;
575
727
  } finally {
576
- rmSync(staging, { recursive: true, force: true });
577
- if (movedExisting) {
728
+ for (const item of staged) {
729
+ rmSync(item.staging, { recursive: true, force: true });
730
+ }
731
+ }
732
+
733
+ for (const item of switched) {
734
+ if (item.backup !== null) {
578
735
  try {
579
- renameSync(backup, details.target);
736
+ rmSync(item.backup, { recursive: true, force: true });
580
737
  } catch {
581
- throw new BootstrapError(`Failed to restore previous msdevflow skill from ${backup}.`, 3);
738
+ // The verified replacement is live; stale backup cleanup can be retried manually.
582
739
  }
583
740
  }
584
741
  }
742
+ for (const item of migrated) {
743
+ try {
744
+ rmSync(item.backup, { recursive: true, force: true });
745
+ } catch {
746
+ // The legacy entry is no longer discoverable; stale backup cleanup is non-blocking.
747
+ }
748
+ }
749
+ return suites.map(installedSuiteResult);
585
750
  }
586
751
 
587
752
  function validatePythonRequirements(file) {
@@ -628,7 +793,7 @@ async function selectClientTargets(input, output, promptOutput = output) {
628
793
  promptOutput.write("No supported agent client was detected. Available targets: claude, codex, opencode, all.\n");
629
794
  const terminal = createInterface({ input, output: promptOutput });
630
795
  try {
631
- const answer = await terminal.question("Install msdevflow for: ");
796
+ const answer = await terminal.question("Install the msd skill suite for: ");
632
797
  return parseTargets(answer);
633
798
  } finally {
634
799
  terminal.close();
@@ -653,7 +818,7 @@ async function resolveClientTargets(options, run, platform, selectClients) {
653
818
  function clientTargetWarnings(clients, layouts) {
654
819
  if (clients.includes("opencode") && layouts.length > 1) {
655
820
  return [
656
- "OpenCode can discover msdevflow from both installed paths; keep both copies managed by this setup to prevent version drift.",
821
+ "OpenCode can discover the msd skill suite from both installed paths; keep both copies managed by this setup to prevent version drift.",
657
822
  ];
658
823
  }
659
824
  return [];
@@ -784,6 +949,176 @@ function environmentHome(environment, platform) {
784
949
  return environment.HOME || homedir();
785
950
  }
786
951
 
952
+ function pythonRuntimeAt(directory, python, platform) {
953
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
954
+ return {
955
+ baseExecutable: python.executable,
956
+ baseVersion: python.version,
957
+ directory,
958
+ executable: pathApi.join(
959
+ directory,
960
+ platform === "win32" ? "Scripts" : "bin",
961
+ platform === "win32" ? "python.exe" : "python",
962
+ ),
963
+ marker: pathApi.join(directory, PYTHON_RUNTIME_MARKER),
964
+ platform,
965
+ };
966
+ }
967
+
968
+ function expandHomePath(value, home, platform, variable) {
969
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
970
+ let expanded = value;
971
+ if (value === "~") {
972
+ expanded = home;
973
+ } else {
974
+ const homeRelative = platform === "win32" ? /^~[\\/]/ : /^~\//;
975
+ if (homeRelative.test(value)) {
976
+ expanded = pathApi.join(home, value.slice(2));
977
+ }
978
+ }
979
+ if (!pathApi.isAbsolute(expanded) && !path.isAbsolute(expanded)) {
980
+ throw new BootstrapError(`${variable} must be an absolute path or start with ~.`, 2);
981
+ }
982
+ return expanded;
983
+ }
984
+
985
+ export function pythonRuntimeDetails(
986
+ python,
987
+ environment = process.env,
988
+ platform = process.platform,
989
+ ) {
990
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
991
+ const home = environmentHome(environment, platform);
992
+ const configured = environment.MSDEVFLOW_PYTHON_DIR;
993
+ const directory = configured
994
+ ? expandHomePath(configured, home, platform, "MSDEVFLOW_PYTHON_DIR")
995
+ : platform === "win32"
996
+ ? pathApi.join(environment.LOCALAPPDATA || pathApi.join(home, "AppData", "Local"), "msdevflow", "python")
997
+ : pathApi.join(
998
+ environment.XDG_DATA_HOME
999
+ ? expandHomePath(environment.XDG_DATA_HOME, home, platform, "XDG_DATA_HOME")
1000
+ : pathApi.join(home, ".local", "share"),
1001
+ "msdevflow",
1002
+ "python",
1003
+ );
1004
+ return pythonRuntimeAt(directory, python, platform);
1005
+ }
1006
+
1007
+ function validatePythonRuntime(runtime, run) {
1008
+ let marker;
1009
+ try {
1010
+ const metadata = lstatSync(runtime.directory);
1011
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
1012
+ throw new BootstrapError(
1013
+ `Refusing to use a Python runtime path not owned by msdevflow: ${runtime.directory}`,
1014
+ 3,
1015
+ );
1016
+ }
1017
+ marker = readOptionalFile(
1018
+ runtime.marker,
1019
+ `Refusing to use an invalid msdevflow Python runtime marker: ${runtime.marker}`,
1020
+ );
1021
+ } catch (error) {
1022
+ if (error?.code === "ENOENT") {
1023
+ return "absent";
1024
+ }
1025
+ throw error;
1026
+ }
1027
+ if (marker !== PYTHON_RUNTIME_MARKER_CONTENT) {
1028
+ throw new BootstrapError(
1029
+ `Refusing to use a Python runtime directory not owned by msdevflow: ${runtime.directory}`,
1030
+ 3,
1031
+ );
1032
+ }
1033
+ let descriptor;
1034
+ try {
1035
+ descriptor = openSync(runtime.executable, "r");
1036
+ } catch (error) {
1037
+ if (error?.code === "ENOENT") {
1038
+ return "repair";
1039
+ }
1040
+ throw error;
1041
+ } finally {
1042
+ if (descriptor !== undefined) {
1043
+ closeSync(descriptor);
1044
+ }
1045
+ }
1046
+ if (runOptional(run, {
1047
+ command: runtime.executable,
1048
+ args: ["-m", "pip", "--version"],
1049
+ }).status !== 0) {
1050
+ return "repair";
1051
+ }
1052
+ return "current";
1053
+ }
1054
+
1055
+ function writePythonRuntimeMarker(runtime) {
1056
+ const existing = readOptionalFile(
1057
+ runtime.marker,
1058
+ `Refusing to replace an invalid msdevflow Python runtime marker: ${runtime.marker}`,
1059
+ );
1060
+ if (existing === PYTHON_RUNTIME_MARKER_CONTENT) {
1061
+ return;
1062
+ }
1063
+ if (existing !== null) {
1064
+ throw new BootstrapError(
1065
+ `Refusing to replace an unrelated Python runtime marker: ${runtime.marker}`,
1066
+ 3,
1067
+ );
1068
+ }
1069
+ writeFileSync(runtime.marker, PYTHON_RUNTIME_MARKER_CONTENT, { encoding: "utf8", flag: "wx" });
1070
+ }
1071
+
1072
+ async function ensurePythonRuntime(runtime, status, runLong, run) {
1073
+ if (validatePythonRuntime(runtime, run) !== status) {
1074
+ throw new BootstrapError(
1075
+ `Managed Python runtime changed after confirmation: ${runtime.directory}`,
1076
+ 3,
1077
+ );
1078
+ }
1079
+ if (status === "absent") {
1080
+ mkdirSync(path.dirname(runtime.directory), { recursive: true });
1081
+ try {
1082
+ mkdirSync(runtime.directory);
1083
+ } catch (error) {
1084
+ if (error?.code === "EEXIST") {
1085
+ throw new BootstrapError(
1086
+ `Managed Python runtime changed after confirmation: ${runtime.directory}`,
1087
+ 3,
1088
+ );
1089
+ }
1090
+ throw error;
1091
+ }
1092
+ try {
1093
+ writePythonRuntimeMarker(runtime);
1094
+ } catch (error) {
1095
+ try {
1096
+ rmdirSync(runtime.directory);
1097
+ } catch {
1098
+ // Preserve the marker failure as the actionable error.
1099
+ }
1100
+ throw error;
1101
+ }
1102
+ }
1103
+ if (status !== "current") {
1104
+ try {
1105
+ await runLong({
1106
+ command: runtime.baseExecutable,
1107
+ args: ["-m", "venv", runtime.directory],
1108
+ });
1109
+ } catch (error) {
1110
+ throw new BootstrapError(
1111
+ `Python venv is unavailable for ${runtime.baseExecutable}. Install the venv component for this Python (for example python3-venv on Debian/Ubuntu) and rerun setup. ${error.message}`,
1112
+ 2,
1113
+ );
1114
+ }
1115
+ }
1116
+ verifyFile(
1117
+ runtime.executable,
1118
+ `Managed Python runtime was not created correctly: ${runtime.executable}`,
1119
+ );
1120
+ }
1121
+
787
1122
  export function gitcodeInstallDetails(
788
1123
  classification,
789
1124
  environment = process.env,
@@ -831,11 +1166,11 @@ export function gitcodeInstallDetails(
831
1166
  };
832
1167
  }
833
1168
 
834
- function readOptionalFile(file) {
1169
+ function readOptionalFile(file, invalidMessage = `Refusing to overwrite unrelated wrapper: ${file}`) {
835
1170
  try {
836
1171
  const metadata = lstatSync(file);
837
1172
  if (metadata.isSymbolicLink() || !metadata.isFile()) {
838
- throw new BootstrapError(`Refusing to overwrite unrelated wrapper: ${file}`, 3);
1173
+ throw new BootstrapError(invalidMessage, 3);
839
1174
  }
840
1175
  return readFileSync(file, "utf8");
841
1176
  } catch (error) {
@@ -951,11 +1286,16 @@ function safeAuthStatus(output) {
951
1286
  function printPlan(plan, write) {
952
1287
  write("Setup plan (no changes made yet):");
953
1288
  write(` Client targets: ${plan.clientTargets.clients.join(", ")} (${plan.clientTargets.source})`);
954
- write(` Bundled skill: ${plan.skillInstalls[0].details.source}`);
1289
+ write(` Bundled skill suite: ${plan.skillInstalls[0].details.source}`);
955
1290
  for (const { layout, details } of plan.skillInstalls) {
956
- write(` Skill target: ${details.target}`);
1291
+ write(` Skill root: ${details.target}`);
957
1292
  write(` Clients: ${layout.clients.join(", ")}`);
958
- write(` Action: ${details.status}`);
1293
+ for (const entry of details.entries) {
1294
+ write(` ${entry.name}: ${entry.status}`);
1295
+ }
1296
+ if (details.legacy.status === "migrate") {
1297
+ write(` ${LEGACY_SKILL_NAME}: migrate after suite verification`);
1298
+ }
959
1299
  }
960
1300
  for (const warning of plan.clientTargets.warnings) {
961
1301
  write(` Warning: ${warning}`);
@@ -975,7 +1315,11 @@ function printPlan(plan, write) {
975
1315
  write(` Wrapper target: ${plan.gitcodeInstall.cliTarget}`);
976
1316
  }
977
1317
  write(` npm command: ${formatCommand(plan.installInvocation)}`);
978
- write(` Python: ${plan.python.executable} (${plan.python.version.join(".")})`);
1318
+ write(` Base Python: ${plan.python.executable} (${plan.python.version.join(".")})`);
1319
+ write(` Managed Python runtime: ${plan.pythonRuntime.directory} (${plan.pythonRuntime.status})`);
1320
+ if (plan.pythonRuntime.status === "absent") {
1321
+ write(` Python venv command: ${formatCommand(plan.venvInvocation)}`);
1322
+ }
979
1323
  write(` Python dependency command: ${formatCommand(plan.pipInvocation)}`);
980
1324
  write(" Browser binary download: disabled");
981
1325
  write(` GitCode authentication command when needed: ${plan.gitcodeInstall.workflowCommand} auth login --web`);
@@ -1079,7 +1423,8 @@ function textResult(result, write) {
1079
1423
  write("Setup completed:");
1080
1424
  write(` Client targets: ${result.clients.join(", ")}`);
1081
1425
  for (const skill of result.skills) {
1082
- write(` Skill: ${skill.status} at ${skill.target} (${skill.clients.join(", ")})`);
1426
+ write(` Skill suite: ${skill.status} at ${skill.target} (${skill.clients.join(", ")})`);
1427
+ write(` Entries: ${skill.entries.map((entry) => entry.name).join(", ")}`);
1083
1428
  }
1084
1429
  for (const warning of result.warnings) {
1085
1430
  write(` Warning: ${warning}`);
@@ -1091,8 +1436,9 @@ function textResult(result, write) {
1091
1436
  if (result.gitcode.authentication.username) {
1092
1437
  write(` Username: ${result.gitcode.authentication.username}`);
1093
1438
  }
1094
- write(` Python: ${result.python.executable} ${result.python.version}`);
1095
- write(" Playwright Python package: installed");
1439
+ write(` Base Python: ${result.python.baseExecutable} ${result.python.baseVersion}`);
1440
+ write(` Managed Python runtime: ${result.python.executable}`);
1441
+ write(" Playwright Python package: installed in managed runtime");
1096
1442
  write(" Chromium: not downloaded");
1097
1443
  }
1098
1444
 
@@ -1124,7 +1470,10 @@ export async function runSetup(options, dependencies = {}) {
1124
1470
  options,
1125
1471
  targetSelection.clients,
1126
1472
  environment,
1127
- dependencies.bundledSkillDir,
1473
+ dependencies.bundledSkillsDir
1474
+ || (dependencies.bundledSkillDir
1475
+ ? path.dirname(dependencies.bundledSkillDir)
1476
+ : BUNDLED_SKILLS_DIR),
1128
1477
  platform,
1129
1478
  );
1130
1479
  const skillInstalls = layouts.map((layout) => ({
@@ -1163,9 +1512,27 @@ export async function runSetup(options, dependencies = {}) {
1163
1512
  );
1164
1513
 
1165
1514
  const python = detectPython(run, platform);
1166
- run({ command: python.executable, args: ["-m", "pip", "--version"] });
1167
- const pipInvocation = {
1515
+ const venvCapability = runOptional(run, {
1168
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
+ const venvInvocation = {
1531
+ command: python.executable,
1532
+ args: ["-m", "venv", pythonRuntime.directory],
1533
+ };
1534
+ const pipInvocation = {
1535
+ command: pythonRuntime.executable,
1169
1536
  args: ["-m", "pip", "install", "-r", layouts[0].bundledPythonRequirements],
1170
1537
  };
1171
1538
  const plan = {
@@ -1176,6 +1543,8 @@ export async function runSetup(options, dependencies = {}) {
1176
1543
  installInvocation,
1177
1544
  gitcodeInstall,
1178
1545
  python,
1546
+ pythonRuntime,
1547
+ venvInvocation,
1179
1548
  pipInvocation,
1180
1549
  };
1181
1550
  printPlan(plan, writePlan);
@@ -1194,6 +1563,20 @@ export async function runSetup(options, dependencies = {}) {
1194
1563
  validateWrapper(gitcodeInstall, platform);
1195
1564
  }
1196
1565
 
1566
+ await progress.run(
1567
+ pythonRuntime.status === "absent"
1568
+ ? "Creating the managed Python runtime"
1569
+ : pythonRuntime.status === "repair"
1570
+ ? "Repairing the managed Python runtime"
1571
+ : "Validating the managed Python runtime",
1572
+ () => ensurePythonRuntime(pythonRuntime, pythonRuntime.status, runLong, run),
1573
+ );
1574
+ await progress.run("Installing reviewed Python dependencies", () => runLong(pipInvocation));
1575
+ await progress.run(
1576
+ "Verifying the Playwright Python package",
1577
+ () => runLong({ command: pythonRuntime.executable, args: ["-c", "import playwright.sync_api"] }),
1578
+ );
1579
+
1197
1580
  const installed = await progress.run(
1198
1581
  "Installing the official GitCode CLI",
1199
1582
  () => installGitcode(plan, runLong, run, platform),
@@ -1204,12 +1587,6 @@ export async function runSetup(options, dependencies = {}) {
1204
1587
  );
1205
1588
  gitcode.command = installed.command;
1206
1589
  gitcode.executable = installed.executable;
1207
-
1208
- await progress.run("Installing reviewed Python dependencies", () => runLong(pipInvocation));
1209
- await progress.run(
1210
- "Verifying the Playwright Python package",
1211
- () => runLong({ command: python.executable, args: ["-c", "import playwright.sync_api"] }),
1212
- );
1213
1590
  gitcode.authentication = await authenticateGitcode(
1214
1591
  installed.executable,
1215
1592
  gitcode.authentication,
@@ -1220,14 +1597,17 @@ export async function runSetup(options, dependencies = {}) {
1220
1597
  progress,
1221
1598
  options.json,
1222
1599
  );
1223
- const skills = await progress.run("Installing msdevflow skill targets", async () => (
1224
- skillInstalls.map(({ layout, details }) => ({
1225
- kind: layout.kind,
1226
- clients: layout.clients,
1227
- status: installBundledSkill(details),
1228
- target: layout.workflowDir,
1229
- }))
1230
- ));
1600
+ const skills = await progress.run("Installing msd skill suites", async () => {
1601
+ const installedSuites = installBundledSkillSuites(
1602
+ skillInstalls.map(({ details }) => details),
1603
+ );
1604
+ return installedSuites.map((installedSuite, index) => ({
1605
+ kind: skillInstalls[index].layout.kind,
1606
+ clients: skillInstalls[index].layout.clients,
1607
+ target: skillInstalls[index].layout.skillsDir,
1608
+ ...installedSuite,
1609
+ }));
1610
+ });
1231
1611
  const result = {
1232
1612
  state: "ready",
1233
1613
  git: gitVersion,
@@ -1237,8 +1617,10 @@ export async function runSetup(options, dependencies = {}) {
1237
1617
  skills,
1238
1618
  gitcode,
1239
1619
  python: {
1240
- executable: python.executable,
1241
- version: python.version.join("."),
1620
+ baseExecutable: python.executable,
1621
+ baseVersion: python.version.join("."),
1622
+ runtimeDirectory: pythonRuntime.directory,
1623
+ executable: pythonRuntime.executable,
1242
1624
  playwright: "installed",
1243
1625
  chromium: "not-downloaded",
1244
1626
  },