ma-agents 3.17.0-beta.2 → 3.17.0-beta.3

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.
@@ -37,7 +37,7 @@
37
37
  "name": "ma-skills",
38
38
  "source": "./",
39
39
  "description": "ma-agents extension module providing enterprise SDLC personas and operational workflow skills.",
40
- "version": "3.17.0-beta.2",
40
+ "version": "3.17.0-beta.3",
41
41
  "author": {
42
42
  "name": "Alon Mayaffit"
43
43
  },
@@ -478,6 +478,64 @@ async function scanDirectModeSkillDirs(rootDir) {
478
478
  return scanSkillDirs(rootDir);
479
479
  }
480
480
 
481
+ /**
482
+ * Recursively collect every directory that contains a `SKILL.md`, returned as
483
+ * POSIX paths RELATIVE to `rootDirAbs`. Unlike {@link scanSkillDirs} (DIRECT
484
+ * children only), this walks the whole subtree: real BMAD modules nest skills
485
+ * under `agents/`, `workflows/<phase>/…`, etc. A dir that IS a skill (has
486
+ * SKILL.md) is recorded and not descended into (skills don't nest in skills);
487
+ * `node_modules`/`.git` are skipped.
488
+ */
489
+ async function findSkillDirsRecursive(baseDirAbs, rootDirAbs) {
490
+ const found = [];
491
+ async function walk(dirAbs) {
492
+ if (await fs.pathExists(path.join(dirAbs, 'SKILL.md'))) {
493
+ found.push(toPosix(path.relative(rootDirAbs, dirAbs)));
494
+ return;
495
+ }
496
+ let entries;
497
+ try {
498
+ entries = await fs.readdir(dirAbs, { withFileTypes: true });
499
+ } catch {
500
+ return;
501
+ }
502
+ for (const entry of entries) {
503
+ if (!entry.isDirectory()) continue;
504
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
505
+ await walk(path.join(dirAbs, entry.name));
506
+ }
507
+ }
508
+ await walk(baseDirAbs);
509
+ return found.sort();
510
+ }
511
+
512
+ /**
513
+ * Determine which directory to scan for a registry entry's skills, given the
514
+ * cloned repo root and the entry's `module_definition` (repo-relative path to
515
+ * its module.yaml). Skills are NOT always beside module.yaml:
516
+ * - module.yaml at the skills' parent (`skills/module.yaml`,
517
+ * `src/core-skills/module.yaml`, `src/module.yaml`) → scan THAT dir.
518
+ * - module.yaml inside a `<name>-setup/assets/module.yaml` (bmad-method's
519
+ * PluginResolver "setup skill" layout, e.g. suno-band-manager) → the
520
+ * module's skills are SIBLINGS of the -setup skill, so scan the setup
521
+ * skill's PARENT.
522
+ * Returns a POSIX path relative to the repo root ('.' = repo root). No
523
+ * module_definition → the repo root (direct-style scan).
524
+ */
525
+ async function resolveRegistrySkillsRoot(entryRootDir, moduleDefPosix) {
526
+ if (!moduleDefPosix) return '.';
527
+ const mdDirPosix = path.posix.dirname(moduleDefPosix);
528
+ if (path.posix.basename(mdDirPosix) === 'assets') {
529
+ const setupSkillPosix = path.posix.dirname(mdDirPosix);
530
+ const setupSkillAbs = path.join(entryRootDir, ...setupSkillPosix.split('/'));
531
+ if (setupSkillPosix && (await fs.pathExists(path.join(setupSkillAbs, 'SKILL.md')))) {
532
+ const parentPosix = path.posix.dirname(setupSkillPosix);
533
+ return parentPosix === '' || parentPosix === '.' ? '.' : parentPosix;
534
+ }
535
+ }
536
+ return mdDirPosix === '' || mdDirPosix === '.' ? '.' : mdDirPosix;
537
+ }
538
+
481
539
  /**
482
540
  * Resolve every selected plugin's installable skill/module layout via
483
541
  * bmad-method's own `CustomModuleManager#resolvePlugin` (the pinned-internal
@@ -784,10 +842,10 @@ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
784
842
  // repo-relative path to the module's module.yaml; its directory is the
785
843
  // module root. Absent → fall back to the repo root (PluginResolver over the
786
844
  // whole clone), matching the AC3 fallback.
787
- let relModuleDirPosix = '.';
845
+ let moduleDefPosix = null;
788
846
  if (plugin.moduleDefinition) {
789
- const defPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
790
- const moduleYamlAbs = path.join(entryRootDir, ...defPosix.split('/'));
847
+ moduleDefPosix = toPosix(plugin.moduleDefinition).replace(/^\.\//, '');
848
+ const moduleYamlAbs = path.join(entryRootDir, ...moduleDefPosix.split('/'));
791
849
  if (!(await fs.pathExists(moduleYamlAbs))) {
792
850
  throw new CustomMarketplaceError(
793
851
  `Registry entry '${plugin.code}': module_definition "${plugin.moduleDefinition}" ` +
@@ -795,27 +853,33 @@ async function resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn) {
795
853
  `repository layout changed; aborting before staging (FR238).`
796
854
  );
797
855
  }
798
- relModuleDirPosix = path.posix.dirname(defPosix);
799
856
  }
800
857
 
801
- const moduleBaseAbs =
802
- relModuleDirPosix === '.' ? entryRootDir : path.join(entryRootDir, ...relModuleDirPosix.split('/'));
803
- const skillNames = await scanSkillDirs(moduleBaseAbs);
804
- const skillRelPaths = skillNames.map((n) => (relModuleDirPosix === '.' ? n : `${relModuleDirPosix}/${n}`));
858
+ // Skills are NOT necessarily beside module.yaml — resolve the module's real
859
+ // skills root (handles the setup-skill/assets layout and modules whose
860
+ // module.yaml sits at the skills' parent) and scan it RECURSIVELY. A shallow
861
+ // scan of module.yaml's own dir silently missed nested skills (gds/bmm) and
862
+ // the setup-skill layout entirely (suno), dropping those modules.
863
+ const skillsRootPosix = await resolveRegistrySkillsRoot(entryRootDir, moduleDefPosix);
864
+ const skillsRootAbs =
865
+ skillsRootPosix === '.' ? entryRootDir : path.join(entryRootDir, ...skillsRootPosix.split('/'));
866
+ const skillRelPaths = await findSkillDirsRecursive(skillsRootAbs, entryRootDir);
805
867
  diag('custom-source: registry entry resolved', {
806
868
  code: plugin.code,
807
- moduleDir: relModuleDirPosix,
808
- skillDirsFound: skillNames.length,
809
- skills: skillNames,
869
+ moduleDef: moduleDefPosix,
870
+ skillsRoot: skillsRootPosix,
871
+ skillDirsFound: skillRelPaths.length,
872
+ skills: skillRelPaths,
810
873
  });
811
874
 
812
875
  const rawPlugin = {
813
876
  name: plugin.code,
814
877
  description: plugin.description || '',
815
878
  version: plugin.version || null,
816
- source: relModuleDirPosix,
817
- // Empty when the module dir has no SKILL.md subdirs — resolvePlugin then
818
- // returns [] and the FR238 guard aborts cleanly (zero installable modules).
879
+ source: skillsRootPosix,
880
+ // Skill dirs relative to the repo root (what PluginResolver.resolve joins
881
+ // against repoPath). Empty → resolvePlugin returns [] and the selected
882
+ // entry surfaces in the per-entry guard below (never a silent drop).
819
883
  skills: skillRelPaths,
820
884
  };
821
885
 
@@ -884,6 +948,26 @@ async function stageRegistrySubset({ source, plugins, selectedCodes, projectRoot
884
948
  entries.push(await resolveRegistryEntry(plugin, resolveSourceFn, resolvePluginFn));
885
949
  }
886
950
 
951
+ // Per-entry LOUD guard: a SELECTED entry that resolves to zero modules must
952
+ // NOT be silently dropped while others install (which read as an overall
953
+ // "success"). Fail before staging, naming exactly which entries produced
954
+ // nothing — the user picked them explicitly, so a partial install is a
955
+ // fail-clean violation (FR238), not an acceptable default.
956
+ const emptyEntries = entries.filter(({ resolved }) => !resolved || resolved.length === 0);
957
+ if (emptyEntries.length > 0) {
958
+ const names = emptyEntries.map(({ plugin }) => plugin.code).join(', ');
959
+ diag('custom-source: registry entries resolved to zero modules', {
960
+ entries: emptyEntries.map(({ plugin }) => ({ code: plugin.code, repository: plugin.repository, moduleDefinition: plugin.moduleDefinition || null })),
961
+ });
962
+ throw new CustomMarketplaceError(
963
+ `Selected registry ${emptyEntries.length === 1 ? 'entry' : 'entries'} ${names} resolved to ` +
964
+ `zero installable modules — no SKILL.md directories were found for ${emptyEntries.length === 1 ? 'it' : 'them'} ` +
965
+ `(a stale registry pointer, a changed repo layout, or a module with no skills). ` +
966
+ `Aborting before staging so the selection is not silently partially installed (FR238). ` +
967
+ `Re-run and deselect ${emptyEntries.length === 1 ? 'it' : 'those'}, or report the registry entry as broken.`
968
+ );
969
+ }
970
+
887
971
  const allModules = assertResolutionNonEmptyAndUniqueLeaves(
888
972
  entries.map(({ plugin, rawPlugin, resolved }) => ({ plugin, rawPlugin, resolved })),
889
973
  { source, mode: 'registry' }
@@ -981,6 +1065,8 @@ module.exports = {
981
1065
  CUSTOM_MARKETPLACE_STAGE_DIR_NAME,
982
1066
  scanDirectModeSkillDirs,
983
1067
  scanSkillDirs,
1068
+ findSkillDirsRecursive,
1069
+ resolveRegistrySkillsRoot,
984
1070
  resolveSelectedPlugins,
985
1071
  assertResolutionNonEmptyAndUniqueLeaves,
986
1072
  stageCustomMarketplaceSubset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ma-agents",
3
- "version": "3.17.0-beta.2",
3
+ "version": "3.17.0-beta.3",
4
4
  "description": "NPX tool to install skills for AI coding agents (Claude Code, Gemini, Copilot, Kilocode, Cline, Cursor, Roo Code)",
5
5
  "main": "index.js",
6
6
  "bin": {