ma-agents 3.0.1 → 3.2.0
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/.opencode/skills/.ma-agents.json +99 -99
- package/README.md +48 -2
- package/bin/cli.js +546 -2
- package/lib/bmad-extension/module-help.csv +8 -4
- package/lib/bmad-extension/skills/add-sprint/SKILL.md +126 -40
- package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +116 -142
- package/lib/bmad-extension/skills/cleanup-done/.gitkeep +0 -0
- package/lib/bmad-extension/skills/cleanup-done/SKILL.md +159 -0
- package/lib/bmad-extension/skills/cleanup-done/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/create-bug-story/SKILL.md +75 -7
- package/lib/bmad-extension/skills/generate-backlog/SKILL.md +183 -0
- package/lib/bmad-extension/skills/generate-backlog/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/modify-sprint/SKILL.md +63 -0
- package/lib/bmad-extension/skills/prioritize-backlog/.gitkeep +0 -0
- package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +195 -0
- package/lib/bmad-extension/skills/prioritize-backlog/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/remove-from-sprint/.gitkeep +0 -0
- package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +163 -0
- package/lib/bmad-extension/skills/remove-from-sprint/bmad-skill-manifest.yaml +3 -0
- package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +199 -138
- package/lib/bmad-extension/workflows/add-sprint/workflow.md +129 -39
- package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +3 -205
- package/lib/bmad-extension/workflows/modify-sprint/workflow.md +5 -0
- package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +3 -192
- package/lib/installer.js +109 -2
- package/lib/templates/project-context.template.md +1 -1
- package/package.json +2 -2
- package/test/cicd-remote-mode.test.js +224 -0
- package/test/config-layout.test.js +230 -0
- package/test/config-lost-on-update.test.js +363 -0
- package/test/config-storage.test.js +275 -0
- package/test/cross-repo-validation.test.js +201 -0
- package/test/generate-project-context.test.js +148 -2
- package/test/portable-paths.test.js +268 -0
- package/test/repo-layout.test.js +246 -0
package/bin/cli.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
const prompts = require('prompts');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
const { installSkill, uninstallSkill, getStatus, listSkills, listAgents, updateProjectContextRepoLayout } = require('../lib/installer');
|
|
7
9
|
const bmad = require('../lib/bmad');
|
|
8
10
|
const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
|
|
9
11
|
|
|
@@ -28,6 +30,8 @@ ${chalk.bold('Usage:')}
|
|
|
28
30
|
${chalk.cyan(`npx ${NAME} set-mandatory`)} <name> [--off] Mark a skill as always-load (or remove)
|
|
29
31
|
${chalk.cyan(`npx ${NAME} customize-agent`)} <agent> Customize a BMAD agent persona and actions
|
|
30
32
|
${chalk.cyan(`npx ${NAME} create-agent`)} <name> Create a new specialized BMAD agent
|
|
33
|
+
${chalk.cyan(`npx ${NAME} config layout`)} Reconfigure repository layout
|
|
34
|
+
${chalk.cyan(`npx ${NAME} config layout --show`)} Show current layout (read-only)
|
|
31
35
|
${chalk.cyan(`npx ${NAME} help`)} Show this help
|
|
32
36
|
|
|
33
37
|
${chalk.bold('Install options:')}
|
|
@@ -158,6 +162,452 @@ function parseFlags(args) {
|
|
|
158
162
|
};
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
// --- Repository layout wizard ---
|
|
166
|
+
|
|
167
|
+
async function collectLocalPath(concern) {
|
|
168
|
+
while (true) {
|
|
169
|
+
const { localPath } = await prompts({
|
|
170
|
+
type: 'text',
|
|
171
|
+
name: 'localPath',
|
|
172
|
+
message: `Enter local path for ${concern}:`
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!localPath || !localPath.trim()) {
|
|
176
|
+
console.log(chalk.red(' Path cannot be empty. Please try again.'));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const resolved = path.resolve(process.cwd(), localPath.trim());
|
|
181
|
+
|
|
182
|
+
if (fs.existsSync(resolved)) {
|
|
183
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
184
|
+
console.log(chalk.red(` "${resolved}" exists but is a file, not a directory. Please try again.`));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
console.log(chalk.yellow(` Warning: "${resolved}" does not exist.`));
|
|
189
|
+
const { confirmed } = await prompts({
|
|
190
|
+
type: 'confirm',
|
|
191
|
+
name: 'confirmed',
|
|
192
|
+
message: 'Use this path anyway? (It must exist before agents run)',
|
|
193
|
+
initial: false
|
|
194
|
+
});
|
|
195
|
+
if (!confirmed) continue;
|
|
196
|
+
console.log(chalk.yellow(` Note: Path "${resolved}" must exist before agents run.`));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (resolved.startsWith(process.cwd())) {
|
|
200
|
+
console.log(chalk.yellow(` Warning: Path is inside the current repository. Nested repos may cause git confusion.`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { mode: 'local', path: resolved };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function collectRemotePath(concern) {
|
|
208
|
+
while (true) {
|
|
209
|
+
// Collect git URL
|
|
210
|
+
let gitUrl;
|
|
211
|
+
while (true) {
|
|
212
|
+
const { url } = await prompts({
|
|
213
|
+
type: 'text',
|
|
214
|
+
name: 'url',
|
|
215
|
+
message: `Enter git URL for ${concern}:`
|
|
216
|
+
});
|
|
217
|
+
if (!url || !url.trim()) {
|
|
218
|
+
console.log(chalk.red(' Git URL cannot be empty. Please try again.'));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
gitUrl = url.trim();
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Collect destination path
|
|
226
|
+
let destPath;
|
|
227
|
+
while (true) {
|
|
228
|
+
const { dest } = await prompts({
|
|
229
|
+
type: 'text',
|
|
230
|
+
name: 'dest',
|
|
231
|
+
message: `Enter local destination path for clone:`
|
|
232
|
+
});
|
|
233
|
+
if (!dest || !dest.trim()) {
|
|
234
|
+
console.log(chalk.red(' Destination path cannot be empty. Please try again.'));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
destPath = path.resolve(process.cwd(), dest.trim());
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check destination
|
|
242
|
+
if (fs.existsSync(destPath)) {
|
|
243
|
+
if (!fs.statSync(destPath).isDirectory()) {
|
|
244
|
+
console.log(chalk.red(` "${destPath}" exists but is a file, not a directory. Please try again.`));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// Existing directory — show summary and confirm
|
|
248
|
+
const files = fs.readdirSync(destPath);
|
|
249
|
+
const isGitRepo = fs.existsSync(path.join(destPath, '.git'));
|
|
250
|
+
console.log(chalk.blue(` Directory exists: ${files.length} items${isGitRepo ? ' (git repo)' : ''}`));
|
|
251
|
+
const { confirmed } = await prompts({
|
|
252
|
+
type: 'confirm',
|
|
253
|
+
name: 'confirmed',
|
|
254
|
+
message: 'Use this existing directory?',
|
|
255
|
+
initial: true
|
|
256
|
+
});
|
|
257
|
+
if (!confirmed) continue;
|
|
258
|
+
} else {
|
|
259
|
+
// Clone
|
|
260
|
+
const existedBefore = fs.existsSync(destPath);
|
|
261
|
+
try {
|
|
262
|
+
console.log(chalk.cyan(` Cloning ${gitUrl}...`));
|
|
263
|
+
execFileSync('git', ['clone', gitUrl, destPath], {
|
|
264
|
+
timeout: 120000,
|
|
265
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
266
|
+
stdio: 'inherit'
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.log(chalk.red(` Clone failed: ${err.message}`));
|
|
270
|
+
if (!existedBefore && fs.existsSync(destPath)) {
|
|
271
|
+
fs.rmSync(destPath, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
const { retry } = await prompts({
|
|
274
|
+
type: 'confirm',
|
|
275
|
+
name: 'retry',
|
|
276
|
+
message: 'Retry?',
|
|
277
|
+
initial: true
|
|
278
|
+
});
|
|
279
|
+
if (retry) continue;
|
|
280
|
+
console.log(chalk.yellow(` Falling back to current repository for ${concern}`));
|
|
281
|
+
return { mode: 'same', path: '.' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (destPath.startsWith(process.cwd())) {
|
|
286
|
+
console.log(chalk.yellow(` Warning: Destination is inside the current repository. Nested repos may cause git confusion.`));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { mode: 'remote', path: destPath, gitUrl };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizePath(p) {
|
|
294
|
+
return p.replace(/\\/g, '/');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function yamlEscapeValue(v) {
|
|
298
|
+
if (!v) return '""';
|
|
299
|
+
const s = String(v);
|
|
300
|
+
if (/[:"#\[\]{}&*!|>%@`]/.test(s) || s !== s.trim() || s === '') {
|
|
301
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
302
|
+
}
|
|
303
|
+
return '"' + s + '"';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function toPortablePath(absolutePath, projectRoot) {
|
|
307
|
+
if (!absolutePath || absolutePath === '.') return { portable: '.', isAbsolute: false };
|
|
308
|
+
|
|
309
|
+
const rel = normalizePath(path.relative(projectRoot, absolutePath));
|
|
310
|
+
|
|
311
|
+
// Same directory: path.relative returns '' — treat as '.'
|
|
312
|
+
if (rel === '') return { portable: '.', isAbsolute: false };
|
|
313
|
+
|
|
314
|
+
// Cross-drive on Windows: path.relative returns the absolute target unchanged
|
|
315
|
+
if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) {
|
|
316
|
+
return { portable: normalizePath(absolutePath), isAbsolute: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Too many levels up — treat as non-portable
|
|
320
|
+
const upLevels = (rel.match(/\.\./g) || []).length;
|
|
321
|
+
if (upLevels > 3) {
|
|
322
|
+
return { portable: normalizePath(absolutePath), isAbsolute: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { portable: rel, isAbsolute: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function writeConfigField(content, fieldName, value) {
|
|
329
|
+
const regex = new RegExp(`^${fieldName}:.*$`, 'm');
|
|
330
|
+
const newLine = `${fieldName}: ${yamlEscapeValue(value)}`;
|
|
331
|
+
if (regex.test(content)) {
|
|
332
|
+
return content.replace(regex, newLine);
|
|
333
|
+
}
|
|
334
|
+
return content.trimEnd() + '\n' + newLine + '\n';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function writeRepoLayoutConfig(layout) {
|
|
338
|
+
const configPath = path.join(process.cwd(), '_bmad', 'bmm', 'config.yaml');
|
|
339
|
+
try {
|
|
340
|
+
if (!fs.existsSync(configPath)) {
|
|
341
|
+
console.log(chalk.yellow(' _bmad/bmm/config.yaml not found — skipping config write'));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
let content = fs.readFileSync(configPath, 'utf-8');
|
|
345
|
+
const projectRoot = process.cwd();
|
|
346
|
+
const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
|
|
347
|
+
const spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
|
|
348
|
+
const kbPath = kbPortable.portable;
|
|
349
|
+
const spPath = spPortable.portable;
|
|
350
|
+
content = writeConfigField(content, 'knowledgebase_path', kbPath);
|
|
351
|
+
content = writeConfigField(content, 'sprint_management_path', spPath);
|
|
352
|
+
// Derive workflow-consumable artifact paths from layout paths (relative when base is relative)
|
|
353
|
+
const planningArtifacts = kbPath === '.' ? '_bmad-output/planning-artifacts' : `${kbPath}/_bmad-output/planning-artifacts`;
|
|
354
|
+
const implArtifacts = spPath === '.' ? '_bmad-output/implementation-artifacts' : `${spPath}/_bmad-output/implementation-artifacts`;
|
|
355
|
+
content = writeConfigField(content, 'planning_artifacts', planningArtifacts);
|
|
356
|
+
content = writeConfigField(content, 'implementation_artifacts', implArtifacts);
|
|
357
|
+
fs.writeFileSync(configPath, content, 'utf-8');
|
|
358
|
+
console.log(chalk.gray(` Config: knowledgebase_path="${kbPath}", sprint_management_path="${spPath}"`));
|
|
359
|
+
} catch (e) {
|
|
360
|
+
console.log(chalk.red(` Cannot write config.yaml: ${e.message}`));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function writeProjectLayoutYaml(layout) {
|
|
365
|
+
const layoutPath = path.join(process.cwd(), '_bmad-output', 'project-layout.yaml');
|
|
366
|
+
const bothSame = layout.knowledgebase.mode === 'same' && layout.sprintManagement.mode === 'same';
|
|
367
|
+
|
|
368
|
+
if (bothSame) {
|
|
369
|
+
// Single-repo mode: delete stale file if exists
|
|
370
|
+
if (fs.existsSync(layoutPath)) {
|
|
371
|
+
fs.unlinkSync(layoutPath);
|
|
372
|
+
console.log(chalk.blue(' Removed stale project-layout.yaml (now in single-repo mode)'));
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Multi-repo: generate project-layout.yaml
|
|
378
|
+
fs.mkdirSync(path.join(process.cwd(), '_bmad-output'), { recursive: true });
|
|
379
|
+
|
|
380
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
381
|
+
let content = `# Generated by ma-agents — do not edit manually\n`;
|
|
382
|
+
content += `# Tells agents where to find planning and sprint data\n`;
|
|
383
|
+
content += `generated: "${date}"\n`;
|
|
384
|
+
|
|
385
|
+
const projectRoot = process.cwd();
|
|
386
|
+
|
|
387
|
+
// Knowledgebase section
|
|
388
|
+
const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
|
|
389
|
+
content += `knowledgebase:\n`;
|
|
390
|
+
content += ` mode: ${layout.knowledgebase.mode}\n`;
|
|
391
|
+
content += ` path: ${yamlEscapeValue(kbPortable.portable)}\n`;
|
|
392
|
+
if (kbPortable.isAbsolute) {
|
|
393
|
+
content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
|
|
394
|
+
}
|
|
395
|
+
if (layout.knowledgebase.mode === 'remote' && layout.knowledgebase.gitUrl) {
|
|
396
|
+
content += ` gitUrl: ${yamlEscapeValue(layout.knowledgebase.gitUrl)}\n`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Sprint management section
|
|
400
|
+
const spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
|
|
401
|
+
content += `sprint_management:\n`;
|
|
402
|
+
content += ` mode: ${layout.sprintManagement.mode}\n`;
|
|
403
|
+
content += ` path: ${yamlEscapeValue(spPortable.portable)}\n`;
|
|
404
|
+
if (spPortable.isAbsolute) {
|
|
405
|
+
content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
|
|
406
|
+
}
|
|
407
|
+
if (layout.sprintManagement.mode === 'remote' && layout.sprintManagement.gitUrl) {
|
|
408
|
+
content += ` gitUrl: ${yamlEscapeValue(layout.sprintManagement.gitUrl)}\n`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
fs.writeFileSync(layoutPath, content, 'utf-8');
|
|
412
|
+
console.log(chalk.gray(` Created _bmad-output/project-layout.yaml`));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveStoredPath(storedPath, projectRoot) {
|
|
416
|
+
if (!storedPath) return '.';
|
|
417
|
+
if (storedPath === '.') return storedPath;
|
|
418
|
+
// If already absolute, use as-is (backward compat)
|
|
419
|
+
if (path.isAbsolute(storedPath) || /^[A-Za-z]:/.test(storedPath)) return storedPath;
|
|
420
|
+
// Relative path — resolve from project root
|
|
421
|
+
return normalizePath(path.resolve(projectRoot, storedPath));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readExistingLayout() {
|
|
425
|
+
const projectRoot = process.cwd();
|
|
426
|
+
// Try project-layout.yaml first (authoritative source written by Story 16.2)
|
|
427
|
+
const layoutPath = path.join(projectRoot, '_bmad-output', 'project-layout.yaml');
|
|
428
|
+
try {
|
|
429
|
+
if (fs.existsSync(layoutPath)) {
|
|
430
|
+
const content = fs.readFileSync(layoutPath, 'utf-8');
|
|
431
|
+
const layout = { knowledgebase: null, sprintManagement: null };
|
|
432
|
+
|
|
433
|
+
// Parse each concern section from the simple YAML
|
|
434
|
+
for (const [section, key] of [['knowledgebase', 'knowledgebase'], ['sprint_management', 'sprintManagement']]) {
|
|
435
|
+
const sectionMatch = content.match(new RegExp(`^${section}:\\s*$`, 'm'));
|
|
436
|
+
if (!sectionMatch) continue;
|
|
437
|
+
|
|
438
|
+
const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length);
|
|
439
|
+
const modeMatch = afterSection.match(/^\s+mode:\s*(\S+)/m);
|
|
440
|
+
const pathMatch = afterSection.match(/^\s+path:\s*"?([^"\n]+)"?/m);
|
|
441
|
+
const gitUrlMatch = afterSection.match(/^\s+gitUrl:\s*"?([^"\n]+)"?/m);
|
|
442
|
+
|
|
443
|
+
if (modeMatch && pathMatch) {
|
|
444
|
+
const resolvedPath = resolveStoredPath(pathMatch[1], projectRoot);
|
|
445
|
+
const entry = { mode: modeMatch[1], path: resolvedPath };
|
|
446
|
+
if (modeMatch[1] === 'remote' && gitUrlMatch) {
|
|
447
|
+
entry.gitUrl = gitUrlMatch[1];
|
|
448
|
+
}
|
|
449
|
+
layout[key] = entry;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Only return if we parsed at least one valid concern
|
|
454
|
+
if (layout.knowledgebase || layout.sprintManagement) {
|
|
455
|
+
// Fill in defaults for any missing section
|
|
456
|
+
if (!layout.knowledgebase) layout.knowledgebase = { mode: 'same', path: '.' };
|
|
457
|
+
if (!layout.sprintManagement) layout.sprintManagement = { mode: 'same', path: '.' };
|
|
458
|
+
return layout;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
console.log(chalk.yellow(` Warning: could not read project-layout.yaml: ${e.message}`));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Fallback: check config.yaml for path fields
|
|
466
|
+
const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
|
|
467
|
+
try {
|
|
468
|
+
if (fs.existsSync(configPath)) {
|
|
469
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
470
|
+
const kbMatch = content.match(/^knowledgebase_path:\s*"?([^"\n]+)"?/m);
|
|
471
|
+
const spMatch = content.match(/^sprint_management_path:\s*"?([^"\n]+)"?/m);
|
|
472
|
+
|
|
473
|
+
const kbPath = kbMatch ? resolveStoredPath(kbMatch[1], projectRoot) : null;
|
|
474
|
+
const spPath = spMatch ? resolveStoredPath(spMatch[1], projectRoot) : null;
|
|
475
|
+
|
|
476
|
+
// Only return if at least one non-default path exists
|
|
477
|
+
if ((kbPath && kbPath !== '.') || (spPath && spPath !== '.')) {
|
|
478
|
+
return {
|
|
479
|
+
knowledgebase: kbPath && kbPath !== '.'
|
|
480
|
+
? { mode: 'local', path: kbPath }
|
|
481
|
+
: { mode: 'same', path: '.' },
|
|
482
|
+
sprintManagement: spPath && spPath !== '.'
|
|
483
|
+
? { mode: 'local', path: spPath }
|
|
484
|
+
: { mode: 'same', path: '.' },
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch (e) {
|
|
489
|
+
console.log(chalk.yellow(` Warning: could not read config.yaml layout: ${e.message}`));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function ciCloneIfNeeded(destPath, gitUrl, clonedRepos) {
|
|
496
|
+
// Deduplicate: same URL + path already cloned
|
|
497
|
+
const key = `${gitUrl}::${destPath}`;
|
|
498
|
+
if (clonedRepos.has(key)) return;
|
|
499
|
+
|
|
500
|
+
if (fs.existsSync(destPath)) {
|
|
501
|
+
if (fs.existsSync(path.join(destPath, '.git'))) {
|
|
502
|
+
console.log(chalk.gray(` Using existing clone at ${destPath}`));
|
|
503
|
+
clonedRepos.set(key, true);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
throw new Error(`Destination exists but is not a git repo — remove it or use a different path: ${destPath}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
console.log(chalk.cyan(` Cloning ${gitUrl} → ${destPath}`));
|
|
511
|
+
execFileSync('git', ['clone', '--depth', '1', gitUrl, destPath], { stdio: 'pipe' });
|
|
512
|
+
clonedRepos.set(key, true);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
throw new Error(`Failed to clone ${gitUrl}: ${e.message}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function collectRepoLayout(flags, existingLayout = null) {
|
|
519
|
+
// CI/CD mode: env vars > existing layout > defaults
|
|
520
|
+
if (flags.yes) {
|
|
521
|
+
const kbPath = process.env.MA_KNOWLEDGEBASE_PATH;
|
|
522
|
+
const sprintPath = process.env.MA_SPRINT_PATH;
|
|
523
|
+
const kbGitUrl = process.env.MA_KNOWLEDGEBASE_GIT_URL;
|
|
524
|
+
const sprintGitUrl = process.env.MA_SPRINT_GIT_URL;
|
|
525
|
+
|
|
526
|
+
// Validate: git URL requires corresponding path
|
|
527
|
+
if (kbGitUrl && !kbPath) {
|
|
528
|
+
throw new Error('MA_KNOWLEDGEBASE_GIT_URL requires MA_KNOWLEDGEBASE_PATH to be set (clone destination)');
|
|
529
|
+
}
|
|
530
|
+
if (sprintGitUrl && !sprintPath) {
|
|
531
|
+
throw new Error('MA_SPRINT_GIT_URL requires MA_SPRINT_PATH to be set (clone destination)');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Env vars take precedence
|
|
535
|
+
if (kbPath || sprintPath || kbGitUrl || sprintGitUrl) {
|
|
536
|
+
const result = { knowledgebase: null, sprintManagement: null };
|
|
537
|
+
|
|
538
|
+
// Track cloned repos to avoid duplicate clones (AC #8)
|
|
539
|
+
const clonedRepos = new Map();
|
|
540
|
+
|
|
541
|
+
for (const [concern, envPath, envGitUrl] of [
|
|
542
|
+
['knowledgebase', kbPath, kbGitUrl],
|
|
543
|
+
['sprintManagement', sprintPath, sprintGitUrl],
|
|
544
|
+
]) {
|
|
545
|
+
if (envGitUrl && envPath) {
|
|
546
|
+
const resolvedPath = path.resolve(envPath);
|
|
547
|
+
// Clone if needed (CI-mode: fail loudly, no fallback)
|
|
548
|
+
ciCloneIfNeeded(resolvedPath, envGitUrl, clonedRepos);
|
|
549
|
+
result[concern] = { mode: 'remote', path: resolvedPath, gitUrl: envGitUrl };
|
|
550
|
+
} else if (envPath) {
|
|
551
|
+
result[concern] = { mode: 'local', path: path.resolve(envPath) };
|
|
552
|
+
} else {
|
|
553
|
+
result[concern] = existingLayout ? existingLayout[concern] : { mode: 'same', path: '.' };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
// No env vars: preserve existing layout if available
|
|
560
|
+
if (existingLayout) {
|
|
561
|
+
return existingLayout;
|
|
562
|
+
}
|
|
563
|
+
// No env vars, no existing layout: default to single-repo
|
|
564
|
+
return {
|
|
565
|
+
knowledgebase: { mode: 'same', path: '.' },
|
|
566
|
+
sprintManagement: { mode: 'same', path: '.' },
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const layout = { knowledgebase: null, sprintManagement: null };
|
|
571
|
+
const labels = { knowledgebase: 'knowledgebase', sprintManagement: 'sprint management' };
|
|
572
|
+
|
|
573
|
+
for (const concern of ['knowledgebase', 'sprintManagement']) {
|
|
574
|
+
const existingMode = existingLayout && existingLayout[concern] ? existingLayout[concern].mode : null;
|
|
575
|
+
const modeChoices = [
|
|
576
|
+
{ title: 'Current repository (default)', value: 'same' },
|
|
577
|
+
{ title: 'Local path', value: 'local' },
|
|
578
|
+
{ title: 'Remote git repository', value: 'remote' },
|
|
579
|
+
];
|
|
580
|
+
// Pre-select existing mode if available
|
|
581
|
+
const initialIndex = existingMode ? modeChoices.findIndex(c => c.value === existingMode) : 0;
|
|
582
|
+
|
|
583
|
+
const { mode } = await prompts({
|
|
584
|
+
type: 'select',
|
|
585
|
+
name: 'mode',
|
|
586
|
+
message: `Where is your ${labels[concern]} managed?`,
|
|
587
|
+
choices: modeChoices,
|
|
588
|
+
initial: initialIndex >= 0 ? initialIndex : 0
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (!mode) process.exit(0);
|
|
592
|
+
|
|
593
|
+
if (mode === 'same') {
|
|
594
|
+
layout[concern] = { mode: 'same', path: '.' };
|
|
595
|
+
} else if (mode === 'local') {
|
|
596
|
+
layout[concern] = await collectLocalPath(labels[concern]);
|
|
597
|
+
} else if (mode === 'remote') {
|
|
598
|
+
layout[concern] = await collectRemotePath(labels[concern]);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Check if both concerns point to same external path
|
|
603
|
+
if (layout.knowledgebase.mode !== 'same' && layout.sprintManagement.mode !== 'same'
|
|
604
|
+
&& layout.knowledgebase.path === layout.sprintManagement.path) {
|
|
605
|
+
console.log(chalk.cyan(' Both concerns point to same external repository'));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return layout;
|
|
609
|
+
}
|
|
610
|
+
|
|
161
611
|
// --- Install wizard ---
|
|
162
612
|
|
|
163
613
|
async function installWizard(preselectedSkill, preselectedAgents, customPath, forceFlag, yesFlag = false) {
|
|
@@ -332,6 +782,10 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
332
782
|
}
|
|
333
783
|
}
|
|
334
784
|
|
|
785
|
+
// Step 2.5: Repository layout (preserve existing config on update)
|
|
786
|
+
const existingLayout = isUpdate ? readExistingLayout() : null;
|
|
787
|
+
const repoLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
788
|
+
|
|
335
789
|
// Step 3: Scope (Skip if update, yesFlag, or path already set)
|
|
336
790
|
if (!isUpdate && !installPath && !yesFlag) {
|
|
337
791
|
const { pathChoice } = await prompts({
|
|
@@ -433,6 +887,12 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
433
887
|
}
|
|
434
888
|
}
|
|
435
889
|
|
|
890
|
+
// Step 3.6: Write repo layout config (after BMAD install creates config.yaml)
|
|
891
|
+
if (installScope === 'project') {
|
|
892
|
+
writeRepoLayoutConfig(repoLayout);
|
|
893
|
+
writeProjectLayoutYaml(repoLayout);
|
|
894
|
+
}
|
|
895
|
+
|
|
436
896
|
// Step 4: Confirm
|
|
437
897
|
console.log('');
|
|
438
898
|
console.log(chalk.bold(' Summary:'));
|
|
@@ -511,6 +971,21 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
511
971
|
}
|
|
512
972
|
}
|
|
513
973
|
|
|
974
|
+
// Step 6: Update project-context.md with repo layout section (after skills installed project-context.md)
|
|
975
|
+
if (installScope === 'project') {
|
|
976
|
+
const outputPath = path.join(process.cwd(), '_bmad-output', 'project-context.md');
|
|
977
|
+
try {
|
|
978
|
+
if (fs.existsSync(outputPath)) {
|
|
979
|
+
const updated = await updateProjectContextRepoLayout(outputPath, repoLayout);
|
|
980
|
+
if (updated) {
|
|
981
|
+
console.log(chalk.green(' project-context.md repo layout section updated'));
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
} catch (err) {
|
|
985
|
+
console.log(chalk.yellow(` project-context repo layout update skipped: ${err.message}`));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
514
989
|
console.log(chalk.bold.green('\n Done!\n'));
|
|
515
990
|
}
|
|
516
991
|
|
|
@@ -582,6 +1057,66 @@ async function handleUninstall(args) {
|
|
|
582
1057
|
}
|
|
583
1058
|
}
|
|
584
1059
|
|
|
1060
|
+
// --- Config layout command ---
|
|
1061
|
+
|
|
1062
|
+
function showCurrentLayout() {
|
|
1063
|
+
const layout = readExistingLayout();
|
|
1064
|
+
if (!layout) {
|
|
1065
|
+
console.log(chalk.cyan('\n Single-repo layout (default)'));
|
|
1066
|
+
console.log(chalk.gray(' Knowledgebase: . (current repository)'));
|
|
1067
|
+
console.log(chalk.gray(' Sprint management: . (current repository)'));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
console.log(chalk.bold.cyan('\n Current Repository Layout:\n'));
|
|
1072
|
+
for (const [label, key] of [['Knowledgebase', 'knowledgebase'], ['Sprint Management', 'sprintManagement']]) {
|
|
1073
|
+
const concern = layout[key];
|
|
1074
|
+
console.log(chalk.white(` ${label}:`));
|
|
1075
|
+
console.log(chalk.gray(` Mode: ${concern.mode}`));
|
|
1076
|
+
console.log(chalk.gray(` Path: ${concern.path}`));
|
|
1077
|
+
if (concern.gitUrl) {
|
|
1078
|
+
console.log(chalk.gray(` Git URL: ${concern.gitUrl}`));
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
console.log('');
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function handleConfigLayout(args) {
|
|
1085
|
+
const { yesFlag } = parseFlags(args);
|
|
1086
|
+
const showOnly = args.includes('--show');
|
|
1087
|
+
|
|
1088
|
+
if (showOnly) {
|
|
1089
|
+
showCurrentLayout();
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Show current before reconfiguring
|
|
1094
|
+
showCurrentLayout();
|
|
1095
|
+
|
|
1096
|
+
// Read existing and present pre-populated wizard
|
|
1097
|
+
const existingLayout = readExistingLayout();
|
|
1098
|
+
const newLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
1099
|
+
|
|
1100
|
+
// Write updated config
|
|
1101
|
+
writeRepoLayoutConfig(newLayout);
|
|
1102
|
+
writeProjectLayoutYaml(newLayout);
|
|
1103
|
+
|
|
1104
|
+
// Update project-context.md
|
|
1105
|
+
const outputPath = path.join(process.cwd(), '_bmad-output', 'project-context.md');
|
|
1106
|
+
try {
|
|
1107
|
+
if (fs.existsSync(outputPath)) {
|
|
1108
|
+
const updated = await updateProjectContextRepoLayout(outputPath, newLayout);
|
|
1109
|
+
if (updated) {
|
|
1110
|
+
console.log(chalk.green(' project-context.md repo layout section updated'));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
console.log(chalk.yellow(` project-context repo layout update skipped: ${err.message}`));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
console.log(chalk.bold.green('\n Layout reconfigured!\n'));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
585
1120
|
// --- Interactive mode ---
|
|
586
1121
|
|
|
587
1122
|
async function interactiveMode() {
|
|
@@ -665,6 +1200,15 @@ async function main() {
|
|
|
665
1200
|
case 'create-agent':
|
|
666
1201
|
await handleCreateAgent(args.slice(1));
|
|
667
1202
|
break;
|
|
1203
|
+
case 'config':
|
|
1204
|
+
if (args[1] === 'layout') {
|
|
1205
|
+
await handleConfigLayout(args.slice(2));
|
|
1206
|
+
} else {
|
|
1207
|
+
console.error(chalk.red(`Unknown config subcommand: ${args[1] || '(none)'}`));
|
|
1208
|
+
console.error(chalk.gray(' Usage: npx ma-agents config layout [--show] [--yes]'));
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
}
|
|
1211
|
+
break;
|
|
668
1212
|
case 'help':
|
|
669
1213
|
case '--help':
|
|
670
1214
|
case '-h':
|
|
@@ -688,4 +1232,4 @@ if (require.main === module) {
|
|
|
688
1232
|
});
|
|
689
1233
|
}
|
|
690
1234
|
|
|
691
|
-
module.exports = { parseFlags };
|
|
1235
|
+
module.exports = { parseFlags, collectRepoLayout, readExistingLayout, writeRepoLayoutConfig, writeProjectLayoutYaml, writeConfigField, normalizePath, toPortablePath, resolveStoredPath, ciCloneIfNeeded, showCurrentLayout, handleConfigLayout, yamlEscapeValue };
|
|
@@ -29,9 +29,13 @@ ma-skills,4-implementation,Vault Secrets,cyber-vault-secrets,,skill:cyber-vault-
|
|
|
29
29
|
ma-skills,4-implementation,Verify Docker Users,cyber-verify-docker-users,,skill:cyber-verify-docker-users,bmad-cyber-verify-docker-users,false,bmm-cyber,,"Verify Docker image user configurations and hardening compliance.",output_folder,"verification report",
|
|
30
30
|
ma-skills,4-implementation,Verify Image Signature,cyber-verify-image-signature,,skill:cyber-verify-image-signature,bmad-cyber-verify-image-signature,false,bmm-cyber,,"Verify Docker image signatures for supply chain integrity.",output_folder,"verification report",
|
|
31
31
|
ma-skills,4-implementation,Vulnerability Scan,cyber-vulnerability-scan,,skill:cyber-vulnerability-scan,bmad-cyber-vulnerability-scan,false,bmm-cyber,,"Orchestrate vulnerability scanning across project components.",output_folder,"scan report",
|
|
32
|
-
ma-skills,4-implementation,Create Bug Story,create-bug-story,,skill:create-bug-story,create-bug-story,false,bmm-dev,,"Create a structured bug story
|
|
33
|
-
ma-skills,4-implementation,Add Sprint,add-sprint,,skill:add-sprint,add-sprint,false,bmm-sm,,"Create a new sprint with capacity limits and optional
|
|
32
|
+
ma-skills,4-implementation,Create Bug Story,create-bug-story,,skill:create-bug-story,create-bug-story,false,bmm-dev,,"Create a structured bug story with severity and type classification, add to backlog.yaml.",_bmad-output/implementation-artifacts,"bug story",
|
|
33
|
+
ma-skills,4-implementation,Add Sprint,add-sprint,,skill:add-sprint,add-sprint,false,bmm-sm,,"Create a new sprint entity with capacity limits and optional ISO dates (YAML schema).",_bmad-output/implementation-artifacts/sprints,"sprint entity",
|
|
34
34
|
ma-skills,4-implementation,Modify Sprint,modify-sprint,,skill:modify-sprint,modify-sprint,false,bmm-sm,,"Modify existing sprint — add/remove items, change capacity, update metadata.",_bmad-output/implementation-artifacts,"sprint plan",
|
|
35
|
-
ma-skills,4-implementation,Add to Sprint,add-to-sprint,,skill:add-to-sprint,add-to-sprint,false,bmm-sm,,"Assign backlog items
|
|
35
|
+
ma-skills,4-implementation,Add to Sprint,add-to-sprint,,skill:add-to-sprint,add-to-sprint,false,bmm-sm,,"Assign backlog items to a sprint from flat prioritized backlog.",_bmad-output/implementation-artifacts,"sprint plan",
|
|
36
36
|
ma-skills,4-implementation,Project Context Expansion,project-context-expansion,,skill:project-context-expansion,project-context-expansion,false,bmm-sm,,"Post-retrospective companion to update project-context.md with new rules.",_bmad-output,"project context",
|
|
37
|
-
ma-skills,4-implementation,Sprint Status View,sprint-status-view,,skill:sprint-status-view,sprint-status-view,false,bmm-sm,,"View sprint
|
|
37
|
+
ma-skills,4-implementation,Sprint Status View,sprint-status-view,,skill:sprint-status-view,sprint-status-view,false,bmm-sm,,"View sprint status with capacity, items, and backlog. Regenerates sprint-status.yaml.",_bmad-output/implementation-artifacts,"status display",
|
|
38
|
+
ma-skills,4-implementation,Generate Backlog,generate-backlog,,skill:generate-backlog,generate-backlog,false,bmm-sm,,"Generate or refresh flat backlog from epics and bug stories.",_bmad-output/implementation-artifacts,"backlog",
|
|
39
|
+
ma-skills,4-implementation,Remove from Sprint,remove-from-sprint,,skill:remove-from-sprint,remove-from-sprint,false,bmm-sm,,"Remove items from a sprint and return to unassigned backlog.",_bmad-output/implementation-artifacts,"sprint plan",
|
|
40
|
+
ma-skills,4-implementation,Cleanup Done,cleanup-done,,skill:cleanup-done,cleanup-done,false,bmm-sm,,"Archive done items — move files to done/ and remove from sprint/backlog.",_bmad-output/implementation-artifacts,"archived items",
|
|
41
|
+
ma-skills,4-implementation,Prioritize Backlog,prioritize-backlog,,skill:prioritize-backlog,prioritize-backlog,false,bmm-sm,,"Reprioritize backlog using multiple criteria — severity, value, dependencies.",_bmad-output/implementation-artifacts,"backlog",
|