bmad-method 6.2.3-next.30 → 6.2.3-next.32

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.
@@ -158,6 +158,9 @@ class UI {
158
158
  .map((m) => m.trim())
159
159
  .filter(Boolean);
160
160
  await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
161
+ } else if (options.customSource) {
162
+ // Custom source without --modules: start with empty list (core added below)
163
+ selectedModules = [];
161
164
  } else if (options.yes) {
162
165
  selectedModules = await this.getDefaultModules(installedModuleIds);
163
166
  await prompts.log.info(
@@ -167,6 +170,14 @@ class UI {
167
170
  selectedModules = await this.selectAllModules(installedModuleIds);
168
171
  }
169
172
 
173
+ // Resolve custom sources from --custom-source flag
174
+ if (options.customSource) {
175
+ const customCodes = await this._resolveCustomSourcesCli(options.customSource);
176
+ for (const code of customCodes) {
177
+ if (!selectedModules.includes(code)) selectedModules.push(code);
178
+ }
179
+ }
180
+
170
181
  // Ensure core is in the modules list
171
182
  if (!selectedModules.includes('core')) {
172
183
  selectedModules.unshift('core');
@@ -202,6 +213,9 @@ class UI {
202
213
  .map((m) => m.trim())
203
214
  .filter(Boolean);
204
215
  await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
216
+ } else if (options.customSource) {
217
+ // Custom source without --modules: start with empty list (core added below)
218
+ selectedModules = [];
205
219
  } else if (options.yes) {
206
220
  // Use default modules when --yes flag is set
207
221
  selectedModules = await this.getDefaultModules(installedModuleIds);
@@ -210,6 +224,14 @@ class UI {
210
224
  selectedModules = await this.selectAllModules(installedModuleIds);
211
225
  }
212
226
 
227
+ // Resolve custom sources from --custom-source flag
228
+ if (options.customSource) {
229
+ const customCodes = await this._resolveCustomSourcesCli(options.customSource);
230
+ for (const code of customCodes) {
231
+ if (!selectedModules.includes(code)) selectedModules.push(code);
232
+ }
233
+ }
234
+
213
235
  // Ensure core is in the modules list
214
236
  if (!selectedModules.includes('core')) {
215
237
  selectedModules.unshift('core');
@@ -818,13 +840,13 @@ class UI {
818
840
  }
819
841
 
820
842
  /**
821
- * Prompt user to install modules from custom GitHub URLs.
843
+ * Prompt user to install modules from custom sources (Git URLs or local paths).
822
844
  * @param {Set} installedModuleIds - Currently installed module IDs
823
845
  * @returns {Array} Selected custom module code strings
824
846
  */
825
847
  async _addCustomUrlModules(installedModuleIds = new Set()) {
826
848
  const addCustom = await prompts.confirm({
827
- message: 'Would you like to install from a custom GitHub URL?',
849
+ message: 'Would you like to install from a custom source (Git URL or local path)?',
828
850
  default: false,
829
851
  });
830
852
  if (!addCustom) return [];
@@ -835,61 +857,158 @@ class UI {
835
857
 
836
858
  let addMore = true;
837
859
  while (addMore) {
838
- const url = await prompts.text({
839
- message: 'GitHub repository URL:',
840
- placeholder: 'https://github.com/owner/repo',
860
+ const sourceInput = await prompts.text({
861
+ message: 'Git URL or local path:',
862
+ placeholder: 'https://github.com/owner/repo or /path/to/module',
841
863
  validate: (input) => {
842
- if (!input || input.trim() === '') return 'URL is required';
843
- const result = customMgr.validateGitHubUrl(input.trim());
864
+ if (!input || input.trim() === '') return 'Source is required';
865
+ const result = customMgr.parseSource(input.trim());
844
866
  return result.isValid ? undefined : result.error;
845
867
  },
846
868
  });
847
869
 
848
870
  const s = await prompts.spinner();
849
- s.start('Fetching module info...');
871
+ s.start('Resolving source...');
850
872
 
873
+ let sourceResult;
851
874
  try {
852
- const plugins = await customMgr.discoverModules(url.trim());
853
- s.stop('Module info loaded');
875
+ sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
876
+ s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
877
+ } catch (error) {
878
+ s.error('Failed to resolve source');
879
+ await prompts.log.error(` ${error.message}`);
880
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
881
+ continue;
882
+ }
854
883
 
884
+ if (sourceResult.parsed.type === 'local') {
885
+ await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).');
886
+ } else {
855
887
  await prompts.log.warn(
856
888
  'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
857
889
  );
890
+ }
891
+
892
+ // Resolve plugins based on discovery mode vs direct mode
893
+ s.start('Analyzing plugin structure...');
894
+ const allResolved = [];
895
+ const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
896
+
897
+ if (sourceResult.mode === 'discovery') {
898
+ // Discovery mode: marketplace.json found, list available plugins
899
+ let plugins;
900
+ try {
901
+ plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
902
+ } catch (discoverError) {
903
+ s.error('Failed to discover modules');
904
+ await prompts.log.error(` ${discoverError.message}`);
905
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
906
+ continue;
907
+ }
858
908
 
909
+ const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
859
910
  for (const plugin of plugins) {
860
- const versionStr = plugin.version ? ` v${plugin.version}` : '';
861
- await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
911
+ try {
912
+ const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
913
+ if (resolved.length > 0) {
914
+ allResolved.push(...resolved);
915
+ } else {
916
+ // No skills array or empty - use plugin metadata as-is (legacy)
917
+ allResolved.push({
918
+ code: plugin.code,
919
+ name: plugin.displayName || plugin.name,
920
+ version: plugin.version,
921
+ description: plugin.description,
922
+ strategy: 0,
923
+ pluginName: plugin.name,
924
+ skillPaths: [],
925
+ });
926
+ }
927
+ } catch (resolveError) {
928
+ await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
929
+ }
862
930
  }
931
+ } else {
932
+ // Direct mode: no marketplace.json, scan directory for skills and resolve
933
+ const directPlugin = {
934
+ name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
935
+ source: '.',
936
+ skills: [],
937
+ };
863
938
 
864
- const confirmInstall = await prompts.confirm({
865
- message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
866
- default: false,
867
- });
939
+ // Scan for SKILL.md directories to populate skills array
940
+ try {
941
+ const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
942
+ for (const entry of entries) {
943
+ if (entry.isDirectory()) {
944
+ const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
945
+ if (await fs.pathExists(skillMd)) {
946
+ directPlugin.skills.push(entry.name);
947
+ }
948
+ }
949
+ }
950
+ } catch (scanError) {
951
+ s.error('Failed to scan directory');
952
+ await prompts.log.error(` ${scanError.message}`);
953
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
954
+ continue;
955
+ }
868
956
 
869
- if (confirmInstall) {
870
- // Pre-clone the repo so it's cached for the install pipeline
871
- s.start('Cloning repository...');
957
+ if (directPlugin.skills.length > 0) {
872
958
  try {
873
- await customMgr.cloneRepo(url.trim());
874
- s.stop('Repository cloned');
875
- } catch (cloneError) {
876
- s.error('Failed to clone repository');
877
- await prompts.log.error(` ${cloneError.message}`);
878
- addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
879
- continue;
959
+ const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
960
+ allResolved.push(...resolved);
961
+ } catch (resolveError) {
962
+ await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
880
963
  }
964
+ }
965
+ }
966
+ s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
881
967
 
882
- for (const plugin of plugins) {
883
- selectedModules.push(plugin.code);
884
- }
968
+ if (allResolved.length === 0) {
969
+ await prompts.log.warn('No installable modules found in this source.');
970
+ addMore = await prompts.confirm({ message: 'Try another source?', default: false });
971
+ continue;
972
+ }
973
+
974
+ // Build multiselect choices
975
+ // Already-installed modules are pre-checked (update). New modules are unchecked (opt-in).
976
+ // Unchecking an installed module means "skip update" - removal is handled elsewhere.
977
+ const choices = allResolved.map((mod) => {
978
+ const versionStr = mod.version ? ` v${mod.version}` : '';
979
+ const skillCount = mod.skillPaths ? mod.skillPaths.length : 0;
980
+ const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : '';
981
+ const alreadyInstalled = installedModuleIds.has(mod.code);
982
+ const hint = alreadyInstalled ? 'update' : undefined;
983
+
984
+ return {
985
+ name: `${mod.name}${versionStr}${skillStr}`,
986
+ value: mod.code,
987
+ hint,
988
+ checked: alreadyInstalled,
989
+ };
990
+ });
991
+
992
+ // Show descriptions before the multiselect
993
+ for (const mod of allResolved) {
994
+ const versionStr = mod.version ? ` v${mod.version}` : '';
995
+ await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`);
996
+ }
997
+
998
+ const selected = await prompts.multiselect({
999
+ message: 'Select modules to install:',
1000
+ choices,
1001
+ required: false,
1002
+ });
1003
+
1004
+ if (selected && selected.length > 0) {
1005
+ for (const code of selected) {
1006
+ selectedModules.push(code);
885
1007
  }
886
- } catch (error) {
887
- s.error('Failed to load module info');
888
- await prompts.log.error(` ${error.message}`);
889
1008
  }
890
1009
 
891
1010
  addMore = await prompts.confirm({
892
- message: 'Add another custom module?',
1011
+ message: 'Add another custom source?',
893
1012
  default: false,
894
1013
  });
895
1014
  }
@@ -901,6 +1020,102 @@ class UI {
901
1020
  return selectedModules;
902
1021
  }
903
1022
 
1023
+ /**
1024
+ * Resolve custom sources from --custom-source CLI flag (non-interactive).
1025
+ * Auto-selects all discovered modules from each source.
1026
+ * @param {string} sourcesArg - Comma-separated Git URLs or local paths
1027
+ * @returns {Array} Module codes from all resolved sources
1028
+ */
1029
+ async _resolveCustomSourcesCli(sourcesArg) {
1030
+ const { CustomModuleManager } = require('./modules/custom-module-manager');
1031
+ const customMgr = new CustomModuleManager();
1032
+ const allCodes = [];
1033
+
1034
+ const sources = sourcesArg
1035
+ .split(',')
1036
+ .map((s) => s.trim())
1037
+ .filter(Boolean);
1038
+
1039
+ for (const source of sources) {
1040
+ const s = await prompts.spinner();
1041
+ s.start(`Resolving ${source}...`);
1042
+
1043
+ let sourceResult;
1044
+ try {
1045
+ sourceResult = await customMgr.resolveSource(source, { skipInstall: true, silent: true });
1046
+ s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
1047
+ } catch (error) {
1048
+ s.error(`Failed to resolve ${source}`);
1049
+ await prompts.log.error(` ${error.message}`);
1050
+ continue;
1051
+ }
1052
+
1053
+ const s2 = await prompts.spinner();
1054
+ s2.start('Analyzing plugin structure...');
1055
+ const allResolved = [];
1056
+ const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
1057
+
1058
+ if (sourceResult.mode === 'discovery') {
1059
+ try {
1060
+ const plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
1061
+ const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
1062
+ for (const plugin of plugins) {
1063
+ try {
1064
+ const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
1065
+ if (resolved.length > 0) {
1066
+ allResolved.push(...resolved);
1067
+ }
1068
+ } catch {
1069
+ // Skip unresolvable plugins
1070
+ }
1071
+ }
1072
+ } catch (discoverError) {
1073
+ s2.error('Failed to discover modules');
1074
+ await prompts.log.error(` ${discoverError.message}`);
1075
+ continue;
1076
+ }
1077
+ } else {
1078
+ // Direct mode: scan for SKILL.md directories
1079
+ const directPlugin = {
1080
+ name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
1081
+ source: '.',
1082
+ skills: [],
1083
+ };
1084
+ try {
1085
+ const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
1086
+ for (const entry of entries) {
1087
+ if (entry.isDirectory()) {
1088
+ const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
1089
+ if (await fs.pathExists(skillMd)) {
1090
+ directPlugin.skills.push(entry.name);
1091
+ }
1092
+ }
1093
+ }
1094
+ } catch {
1095
+ // Skip unreadable directories
1096
+ }
1097
+
1098
+ if (directPlugin.skills.length > 0) {
1099
+ try {
1100
+ const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
1101
+ allResolved.push(...resolved);
1102
+ } catch {
1103
+ // Skip unresolvable
1104
+ }
1105
+ }
1106
+ }
1107
+ s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
1108
+
1109
+ for (const mod of allResolved) {
1110
+ allCodes.push(mod.code);
1111
+ const versionStr = mod.version ? ` v${mod.version}` : '';
1112
+ await prompts.log.info(` Custom module: ${mod.name}${versionStr}`);
1113
+ }
1114
+ }
1115
+
1116
+ return allCodes;
1117
+ }
1118
+
904
1119
  /**
905
1120
  * Get default modules for non-interactive mode
906
1121
  * @param {Set} installedModuleIds - Already installed module IDs