ma-agents 3.5.6 → 3.6.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.
Files changed (53) hide show
  1. package/.ma-agents.json +10 -0
  2. package/AGENTS.md +97 -0
  3. package/MANIFEST.yaml +3 -0
  4. package/README.md +17 -0
  5. package/_bmad-output/implementation-artifacts/21-10-profile-reconfigure.md +30 -6
  6. package/_bmad-output/implementation-artifacts/21-11-profile-uninstall.md +2 -1
  7. package/_bmad-output/implementation-artifacts/21-2-universal-instruction-block-expansion.md +217 -62
  8. package/_bmad-output/implementation-artifacts/21-3-roomodes-template-bmad-modes.md +196 -73
  9. package/_bmad-output/implementation-artifacts/21-4-agents-md-template-opencode.md +242 -53
  10. package/_bmad-output/implementation-artifacts/21-5-clinerules-template-extension.md +180 -41
  11. package/_bmad-output/implementation-artifacts/21-6-onprem-layered-guardrails.md +250 -75
  12. package/_bmad-output/implementation-artifacts/21-7-bmad-persona-phase-prefix.md +221 -89
  13. package/_bmad-output/implementation-artifacts/21-8-vllm-reference-doc-readme.md +121 -63
  14. package/_bmad-output/implementation-artifacts/21-9-tests-validation.md +332 -61
  15. package/_bmad-output/implementation-artifacts/bug-bmad-recompile-fails-on-airgapped-network.md +112 -0
  16. package/_bmad-output/implementation-artifacts/sprint-status.yaml +3 -2
  17. package/bin/cli.js +59 -0
  18. package/docs/deployment/vllm-nemotron.md +130 -0
  19. package/lib/agents.js +17 -2
  20. package/lib/bmad-customize/bmm-analyst.customize.yaml +8 -0
  21. package/lib/bmad-customize/bmm-architect.customize.yaml +2 -0
  22. package/lib/bmad-customize/bmm-dev.customize.yaml +2 -0
  23. package/lib/bmad-customize/bmm-pm.customize.yaml +2 -0
  24. package/lib/bmad-customize/bmm-qa.customize.yaml +2 -0
  25. package/lib/bmad-customize/bmm-quick-flow-solo-dev.customize.yaml +8 -0
  26. package/lib/bmad-customize/bmm-sm.customize.yaml +2 -0
  27. package/lib/bmad-customize/bmm-tech-writer.customize.yaml +2 -0
  28. package/lib/bmad-customize/bmm-ux-designer.customize.yaml +2 -0
  29. package/lib/bmad.js +293 -1
  30. package/lib/installer.js +617 -43
  31. package/lib/merge/roomodes.js +125 -0
  32. package/lib/profile.js +25 -2
  33. package/lib/reconfigure.js +334 -0
  34. package/lib/templates/agents-md.template.md +67 -0
  35. package/lib/templates/clinerules.template.md +13 -0
  36. package/lib/templates/instruction-block-onprem.template.md +86 -0
  37. package/lib/templates/instruction-block-universal.template.md +29 -0
  38. package/lib/templates/roomodes.template.yaml +96 -0
  39. package/lib/uninstall.js +314 -0
  40. package/package.json +4 -3
  41. package/test/agents-md.test.js +398 -0
  42. package/test/bmad-extension.test.js +2 -2
  43. package/test/bmad-persona-phase-prefix.test.js +271 -0
  44. package/test/clinerules.test.js +339 -0
  45. package/test/instruction-block.test.js +388 -0
  46. package/test/integration-verification.test.js +2 -2
  47. package/test/migration-validation.test.js +2 -2
  48. package/test/offline-recompile.test.js +237 -0
  49. package/test/onprem-injection.test.js +425 -32
  50. package/test/onprem-layer.test.js +419 -0
  51. package/test/reconfigure.test.js +436 -0
  52. package/test/roomodes.test.js +343 -0
  53. package/test/uninstall.test.js +402 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Story 21.11 — Tests for the `uninstall --profile-artifacts` subcommand.
4
+ *
5
+ * AC coverage (one-to-one with Story 21.11 acceptance criteria):
6
+ * 11.1 Full uninstall: all 5 injection files have marker blocks removed (AC #3)
7
+ * 11.2 User content preserved outside markers (AC #3, NFR5)
8
+ * 11.3 .roomodes owned slug removal; user slug survives (AC #4)
9
+ * 11.4 Backup files created: CLAUDE.md.backup-<timestamp> exists (AC #5)
10
+ * 11.5 .ma-agents.json profile cleared: getProfile returns undefined (AC #6)
11
+ * 11.6 profileHistory preserved and new entry appended (source: "uninstall", to: null) (AC #6, #7)
12
+ * 11.7 manifestVersion unchanged at 1.2.0 after uninstall (AC #8)
13
+ * 11.8 Idempotency: second uninstall exits with "Nothing to do." (AC #10)
14
+ * 11.9 --yes bypasses confirmation prompt (AC #1, #2)
15
+ */
16
+ 'use strict';
17
+
18
+ const assert = require('assert');
19
+ const path = require('path');
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+ const errors = [];
26
+
27
+ async function test(name, fn) {
28
+ try {
29
+ await fn();
30
+ console.log(` \u2713 ${name}`);
31
+ passed++;
32
+ } catch (err) {
33
+ console.error(` \u2717 ${name}: ${err.stack || err.message}`);
34
+ failed++;
35
+ errors.push({ name, error: err.message });
36
+ }
37
+ }
38
+
39
+ const {
40
+ uninstallProfileArtifacts,
41
+ stripMarkerBlock,
42
+ appendUninstallHistory,
43
+ PROFILE_HISTORY_CAP
44
+ } = require('../lib/uninstall');
45
+ const { setProfile, getProfile } = require('../lib/profile');
46
+ const installer = require('../lib/installer');
47
+ const { getAgent } = require('../lib/agents');
48
+
49
+ function mktemp() {
50
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ma-21-11-'));
51
+ }
52
+ function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
53
+
54
+ function silenceLogs(fn) {
55
+ return async (...args) => {
56
+ const origLog = console.log;
57
+ const origErr = console.error;
58
+ const origWarn = console.warn;
59
+ console.log = () => {};
60
+ console.error = () => {};
61
+ console.warn = () => {};
62
+ try { return await fn(...args); }
63
+ finally {
64
+ console.log = origLog;
65
+ console.error = origErr;
66
+ console.warn = origWarn;
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Returns a prompts stub that confirms (proceed: true) on confirm prompts.
73
+ */
74
+ function confirmingPrompts() {
75
+ return async (opts) => {
76
+ if (opts && opts.type === 'confirm') return { proceed: true };
77
+ return {};
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Returns a prompts stub that declines (proceed: false) on confirm prompts.
83
+ */
84
+ function decliningPrompts() {
85
+ return async (opts) => {
86
+ if (opts && opts.type === 'confirm') return { proceed: false };
87
+ return {};
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Build a minimal project with .ma-agents.json and optionally stamp injection files.
93
+ * For testing, we manually write marker block content rather than running the full
94
+ * installer (which has complex agent/profile scaffolding requirements).
95
+ */
96
+ function setupProject({ profile = 'standard', agents = ['claude-code'] } = {}) {
97
+ const dir = mktemp();
98
+ const manifest = {
99
+ manifestVersion: '1.2.0',
100
+ agent: agents[0],
101
+ agents,
102
+ scope: 'project',
103
+ profile,
104
+ skills: {}
105
+ };
106
+ fs.writeFileSync(
107
+ path.join(dir, '.ma-agents.json'),
108
+ JSON.stringify(manifest, null, 2) + '\n',
109
+ 'utf-8'
110
+ );
111
+ return dir;
112
+ }
113
+
114
+ /**
115
+ * Write a file with a marker block (plus optional surrounding user content).
116
+ */
117
+ function writeWithMarkers(filePath, { before = '', inner = 'some ma-agents content\n', after = '' } = {}) {
118
+ const dir = path.dirname(filePath);
119
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
120
+ const content = `${before}<!-- MA-AGENTS-START -->\n${inner}<!-- MA-AGENTS-END -->\n${after}`;
121
+ fs.writeFileSync(filePath, content, 'utf-8');
122
+ return content;
123
+ }
124
+
125
+ console.log('\n uninstall --profile-artifacts tests (Story 21.11)\n');
126
+
127
+ async function runAll() {
128
+ // ---------------------------------------------------------------------------
129
+ // 11.1 Full uninstall: all injection files have marker blocks removed
130
+ // ---------------------------------------------------------------------------
131
+ await test('11.1 Full uninstall: marker blocks removed from all 5 injection files (AC #3)', silenceLogs(async () => {
132
+ const dir = setupProject({ profile: 'standard' });
133
+ try {
134
+ // Write all 5 injection files with marker blocks
135
+ const files = [
136
+ 'CLAUDE.md',
137
+ '.clinerules',
138
+ '.cline/clinerules.md',
139
+ '.roo/rules/00-ma-agents.md',
140
+ 'AGENTS.md'
141
+ ];
142
+ for (const rel of files) {
143
+ writeWithMarkers(path.join(dir, rel), { inner: `Content for ${rel}\n` });
144
+ }
145
+
146
+ await uninstallProfileArtifacts(dir, { yes: true });
147
+
148
+ for (const rel of files) {
149
+ const absPath = path.join(dir, rel);
150
+ if (fs.existsSync(absPath)) {
151
+ const content = fs.readFileSync(absPath, 'utf-8');
152
+ assert.ok(
153
+ !content.includes('<!-- MA-AGENTS-START -->'),
154
+ `${rel} should not contain MA-AGENTS-START after uninstall`
155
+ );
156
+ assert.ok(
157
+ !content.includes('<!-- MA-AGENTS-END -->'),
158
+ `${rel} should not contain MA-AGENTS-END after uninstall`
159
+ );
160
+ }
161
+ // File may have been deleted if it was empty after stripping — that's valid
162
+ }
163
+ } finally { cleanup(dir); }
164
+ }));
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // 11.2 User content preserved outside markers
168
+ // ---------------------------------------------------------------------------
169
+ await test('11.2 User content outside markers preserved byte-for-byte (AC #3, NFR5)', silenceLogs(async () => {
170
+ const dir = setupProject({ profile: 'standard' });
171
+ try {
172
+ const claudePath = path.join(dir, 'CLAUDE.md');
173
+ const beforeContent = 'USER CONTENT BEFORE\n';
174
+ const afterContent = 'USER CONTENT AFTER\n';
175
+ writeWithMarkers(claudePath, {
176
+ before: beforeContent,
177
+ inner: 'ma-agents generated content\n',
178
+ after: afterContent
179
+ });
180
+
181
+ await uninstallProfileArtifacts(dir, { yes: true });
182
+
183
+ assert.ok(fs.existsSync(claudePath), 'CLAUDE.md should still exist (has user content)');
184
+ const result = fs.readFileSync(claudePath, 'utf-8');
185
+ assert.ok(result.includes(beforeContent.trim()), 'before-marker user content must survive');
186
+ assert.ok(result.includes(afterContent.trim()), 'after-marker user content must survive');
187
+ assert.ok(!result.includes('<!-- MA-AGENTS-START -->'), 'marker must be removed');
188
+ assert.ok(!result.includes('ma-agents generated content'), 'inner content must be removed');
189
+ } finally { cleanup(dir); }
190
+ }));
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // 11.3 .roomodes owned slug removal; user slug survives
194
+ // ---------------------------------------------------------------------------
195
+ await test('11.3 .roomodes: owned slugs removed, user slug preserved (AC #4)', silenceLogs(async () => {
196
+ const dir = setupProject({ profile: 'standard', agents: ['roo-code'] });
197
+ try {
198
+ const yaml = require('js-yaml');
199
+ const roomodesContent = {
200
+ customModes: [
201
+ // Owned slugs
202
+ { slug: 'bmad-pm', name: 'BMAD PM', roleDefinition: 'pm', groups: ['read'], customInstructions: 'pm stuff' },
203
+ { slug: 'bmad-architect', name: 'BMAD Architect', roleDefinition: 'arch', groups: ['read'], customInstructions: 'arch stuff' },
204
+ { slug: 'bmad-techlead', name: 'BMAD TechLead', roleDefinition: 'tl', groups: ['read'], customInstructions: 'tl stuff' },
205
+ { slug: 'bmad-dev', name: 'BMAD Dev', roleDefinition: 'dev', groups: ['read'], customInstructions: 'dev stuff' },
206
+ // User slug — must survive
207
+ { slug: 'my-custom-mode', name: 'My Custom', roleDefinition: 'custom', groups: ['read'], customInstructions: 'user stuff' }
208
+ ]
209
+ };
210
+ fs.writeFileSync(path.join(dir, '.roomodes'), yaml.dump(roomodesContent), 'utf-8');
211
+
212
+ await uninstallProfileArtifacts(dir, { yes: true });
213
+
214
+ assert.ok(fs.existsSync(path.join(dir, '.roomodes')), '.roomodes should still exist (has user slug)');
215
+ const result = yaml.load(fs.readFileSync(path.join(dir, '.roomodes'), 'utf-8'));
216
+ const slugs = (result.customModes || []).map(m => m.slug);
217
+
218
+ assert.ok(!slugs.includes('bmad-pm'), 'bmad-pm must be removed');
219
+ assert.ok(!slugs.includes('bmad-architect'), 'bmad-architect must be removed');
220
+ assert.ok(!slugs.includes('bmad-techlead'), 'bmad-techlead must be removed');
221
+ assert.ok(!slugs.includes('bmad-dev'), 'bmad-dev must be removed');
222
+ assert.ok(slugs.includes('my-custom-mode'), 'user slug must survive');
223
+ } finally { cleanup(dir); }
224
+ }));
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // 11.4 Backup files created
228
+ // ---------------------------------------------------------------------------
229
+ await test('11.4 Backup files created: CLAUDE.md.backup-<timestamp> (AC #5)', silenceLogs(async () => {
230
+ const dir = setupProject({ profile: 'standard' });
231
+ try {
232
+ const claudeDir = path.join(dir, '.claude');
233
+ fs.mkdirSync(claudeDir, { recursive: true });
234
+ // Put CLAUDE.md directly at projectRoot for this test (that's one valid location)
235
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
236
+
237
+ await uninstallProfileArtifacts(dir, { yes: true });
238
+
239
+ // Check for backup file in the project root dir
240
+ const files = fs.readdirSync(dir);
241
+ const backups = files.filter(f => /^CLAUDE\.md\.backup-/.test(f));
242
+ assert.ok(backups.length >= 1, `Expected at least one backup file, got: ${files.join(', ')}`);
243
+ const backupName = backups[0];
244
+ // Canonical format: CLAUDE.md.backup-YYYY-MM-DDTHH-mm-ssZ
245
+ assert.match(backupName, /^CLAUDE\.md\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(\.\d+)?$/);
246
+
247
+ // Backup must contain the original content (with markers)
248
+ const backupContent = fs.readFileSync(path.join(dir, backupName), 'utf-8');
249
+ assert.ok(backupContent.includes('<!-- MA-AGENTS-START -->'), 'backup must preserve original content');
250
+ } finally { cleanup(dir); }
251
+ }));
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // 11.5 .ma-agents.json profile cleared
255
+ // ---------------------------------------------------------------------------
256
+ await test('11.5 .ma-agents.json profile cleared: getProfile returns undefined (AC #6)', silenceLogs(async () => {
257
+ const dir = setupProject({ profile: 'on-prem' });
258
+ try {
259
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
260
+
261
+ assert.strictEqual(getProfile(dir), 'on-prem', 'profile should be set before uninstall');
262
+
263
+ await uninstallProfileArtifacts(dir, { yes: true });
264
+
265
+ assert.strictEqual(getProfile(dir), undefined, 'profile must be undefined after clearProfile');
266
+
267
+ // Verify the key is truly absent (not set to null/empty)
268
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.ma-agents.json'), 'utf-8'));
269
+ assert.ok(!Object.prototype.hasOwnProperty.call(manifest, 'profile'), 'profile key must be deleted, not set to null');
270
+ } finally { cleanup(dir); }
271
+ }));
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // 11.6 profileHistory preserved and new entry appended
275
+ // ---------------------------------------------------------------------------
276
+ await test('11.6 profileHistory preserved, uninstall entry appended (source: "uninstall", to: null) (AC #6, #7)', silenceLogs(async () => {
277
+ const dir = setupProject({ profile: 'standard' });
278
+ try {
279
+ // Seed existing profileHistory
280
+ const manifestPath = path.join(dir, '.ma-agents.json');
281
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
282
+ manifest.profileHistory = [
283
+ { date: '2026-01-01T00:00:00.000Z', from: 'standard', to: 'on-prem', source: 'reconfigure' }
284
+ ];
285
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
286
+
287
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
288
+
289
+ const testDate = new Date('2026-04-15T12:00:00.000Z');
290
+ await uninstallProfileArtifacts(dir, { yes: true, now: testDate });
291
+
292
+ const result = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
293
+ assert.ok(Array.isArray(result.profileHistory));
294
+ assert.strictEqual(result.profileHistory.length, 2, 'previous entry must be preserved');
295
+ assert.strictEqual(result.profileHistory[0].source, 'reconfigure', 'previous entry preserved');
296
+
297
+ const lastEntry = result.profileHistory[1];
298
+ assert.strictEqual(lastEntry.source, 'uninstall');
299
+ assert.strictEqual(lastEntry.to, null);
300
+ assert.strictEqual(lastEntry.from, 'standard');
301
+ assert.strictEqual(lastEntry.date, testDate.toISOString());
302
+ } finally { cleanup(dir); }
303
+ }));
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // 11.7 manifestVersion unchanged
307
+ // ---------------------------------------------------------------------------
308
+ await test('11.7 manifestVersion unchanged at 1.2.0 after uninstall (AC #8)', silenceLogs(async () => {
309
+ const dir = setupProject({ profile: 'standard' });
310
+ try {
311
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
312
+
313
+ await uninstallProfileArtifacts(dir, { yes: true });
314
+
315
+ const result = JSON.parse(fs.readFileSync(path.join(dir, '.ma-agents.json'), 'utf-8'));
316
+ assert.strictEqual(result.manifestVersion, '1.2.0');
317
+ } finally { cleanup(dir); }
318
+ }));
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // 11.8 Idempotency: second uninstall finds nothing to do
322
+ // ---------------------------------------------------------------------------
323
+ await test('11.8 Idempotency: second uninstall exits with "Nothing to do." (AC #10)', silenceLogs(async () => {
324
+ const dir = setupProject({ profile: 'standard' });
325
+ try {
326
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
327
+
328
+ // First uninstall
329
+ await uninstallProfileArtifacts(dir, { yes: true });
330
+
331
+ // Second uninstall — capture console output
332
+ let nothingToDo = false;
333
+ const origLog = console.log;
334
+ console.log = (msg) => {
335
+ if (typeof msg === 'string' && msg.includes('Nothing to do')) {
336
+ nothingToDo = true;
337
+ }
338
+ };
339
+ try {
340
+ await uninstallProfileArtifacts(dir, { yes: true });
341
+ } finally {
342
+ console.log = origLog;
343
+ }
344
+
345
+ assert.ok(nothingToDo, 'Second run should print "Nothing to do."');
346
+ } finally { cleanup(dir); }
347
+ }));
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // 11.9 --yes bypasses confirmation prompt
351
+ // ---------------------------------------------------------------------------
352
+ await test('11.9 --yes bypasses confirmation prompt (AC #1, #2)', silenceLogs(async () => {
353
+ const dir = setupProject({ profile: 'standard' });
354
+ try {
355
+ writeWithMarkers(path.join(dir, 'CLAUDE.md'), { inner: 'some content\n' });
356
+
357
+ let promptCalled = false;
358
+ const trackingPrompts = async (opts) => {
359
+ promptCalled = true;
360
+ return { proceed: true };
361
+ };
362
+
363
+ await uninstallProfileArtifacts(dir, { yes: true, promptsLib: trackingPrompts });
364
+
365
+ assert.ok(!promptCalled, '--yes must bypass the confirmation prompt');
366
+
367
+ // File must still be processed
368
+ const claudePath = path.join(dir, 'CLAUDE.md');
369
+ if (fs.existsSync(claudePath)) {
370
+ const content = fs.readFileSync(claudePath, 'utf-8');
371
+ assert.ok(!content.includes('<!-- MA-AGENTS-START -->'), 'marker must be removed even with --yes');
372
+ }
373
+ } finally { cleanup(dir); }
374
+ }));
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Unit test: stripMarkerBlock
378
+ // ---------------------------------------------------------------------------
379
+ await test('stripMarkerBlock: removes marker block, preserves surrounding content', () => {
380
+ const content = `USER BEFORE\n<!-- MA-AGENTS-START -->\nma content\n<!-- MA-AGENTS-END -->\nUSER AFTER\n`;
381
+ const stripped = stripMarkerBlock(content);
382
+ assert.ok(stripped.includes('USER BEFORE'), 'before content preserved');
383
+ assert.ok(stripped.includes('USER AFTER'), 'after content preserved');
384
+ assert.ok(!stripped.includes('<!-- MA-AGENTS-START -->'), 'start marker removed');
385
+ assert.ok(!stripped.includes('<!-- MA-AGENTS-END -->'), 'end marker removed');
386
+ assert.ok(!stripped.includes('ma content'), 'inner content removed');
387
+ });
388
+
389
+ await test('stripMarkerBlock: no-op when no marker present', () => {
390
+ const content = 'just some user content\n';
391
+ assert.strictEqual(stripMarkerBlock(content), content);
392
+ });
393
+ }
394
+
395
+ runAll().then(() => {
396
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
397
+ if (failed > 0) {
398
+ console.error('FAILURES:');
399
+ errors.forEach(e => console.error(` - ${e.name}: ${e.error}`));
400
+ process.exit(1);
401
+ }
402
+ });