ma-agents 2.20.3 → 2.22.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 (149) hide show
  1. package/.opencode/skills/.ma-agents.json +241 -0
  2. package/.opencode/skills/MANIFEST.yaml +254 -0
  3. package/.opencode/skills/ai-audit-trail/SKILL.md +23 -0
  4. package/.opencode/skills/auto-bug-detection/SKILL.md +169 -0
  5. package/.opencode/skills/cmake-best-practices/SKILL.md +64 -0
  6. package/.opencode/skills/cmake-best-practices/examples/cmake.md +59 -0
  7. package/.opencode/skills/code-documentation/SKILL.md +57 -0
  8. package/.opencode/skills/code-documentation/examples/cpp.md +29 -0
  9. package/.opencode/skills/code-documentation/examples/csharp.md +28 -0
  10. package/.opencode/skills/code-documentation/examples/javascript_typescript.md +28 -0
  11. package/.opencode/skills/code-documentation/examples/python.md +57 -0
  12. package/.opencode/skills/code-review/SKILL.md +43 -0
  13. package/.opencode/skills/commit-message/SKILL.md +79 -0
  14. package/.opencode/skills/cpp-best-practices/SKILL.md +234 -0
  15. package/.opencode/skills/cpp-best-practices/examples/modern-idioms.md +189 -0
  16. package/.opencode/skills/cpp-best-practices/examples/naming-and-organization.md +102 -0
  17. package/.opencode/skills/cpp-concurrency-safety/SKILL.md +60 -0
  18. package/.opencode/skills/cpp-concurrency-safety/examples/concurrency.md +73 -0
  19. package/.opencode/skills/cpp-const-correctness/SKILL.md +63 -0
  20. package/.opencode/skills/cpp-const-correctness/examples/const_correctness.md +54 -0
  21. package/.opencode/skills/cpp-memory-handling/SKILL.md +42 -0
  22. package/.opencode/skills/cpp-memory-handling/examples/modern-cpp.md +49 -0
  23. package/.opencode/skills/cpp-memory-handling/examples/smart-pointers.md +46 -0
  24. package/.opencode/skills/cpp-modern-composition/SKILL.md +64 -0
  25. package/.opencode/skills/cpp-modern-composition/examples/composition.md +51 -0
  26. package/.opencode/skills/cpp-robust-interfaces/SKILL.md +55 -0
  27. package/.opencode/skills/cpp-robust-interfaces/examples/interfaces.md +56 -0
  28. package/.opencode/skills/create-hardened-docker-skill/SKILL.md +637 -0
  29. package/.opencode/skills/create-hardened-docker-skill/scripts/create-all.sh +489 -0
  30. package/.opencode/skills/csharp-best-practices/SKILL.md +278 -0
  31. package/.opencode/skills/docker-hardening-verification/SKILL.md +28 -0
  32. package/.opencode/skills/docker-hardening-verification/scripts/verify-hardening.sh +39 -0
  33. package/.opencode/skills/docker-image-signing/SKILL.md +28 -0
  34. package/.opencode/skills/docker-image-signing/scripts/sign-image.sh +33 -0
  35. package/.opencode/skills/document-revision-history/SKILL.md +104 -0
  36. package/.opencode/skills/git-workflow-skill/SKILL.md +194 -0
  37. package/.opencode/skills/git-workflow-skill/hooks/commit-msg +61 -0
  38. package/.opencode/skills/git-workflow-skill/hooks/pre-commit +38 -0
  39. package/.opencode/skills/git-workflow-skill/hooks/prepare-commit-msg +56 -0
  40. package/.opencode/skills/git-workflow-skill/scripts/finish-feature.sh +192 -0
  41. package/.opencode/skills/git-workflow-skill/scripts/install-hooks.sh +55 -0
  42. package/.opencode/skills/git-workflow-skill/scripts/start-feature.sh +110 -0
  43. package/.opencode/skills/git-workflow-skill/scripts/validate-workflow.sh +229 -0
  44. package/.opencode/skills/js-ts-dependency-mgmt/SKILL.md +49 -0
  45. package/.opencode/skills/js-ts-dependency-mgmt/examples/dependency_mgmt.md +60 -0
  46. package/.opencode/skills/js-ts-security-skill/SKILL.md +64 -0
  47. package/.opencode/skills/js-ts-security-skill/scripts/verify-security.sh +136 -0
  48. package/.opencode/skills/logging-best-practices/SKILL.md +50 -0
  49. package/.opencode/skills/logging-best-practices/examples/cpp.md +36 -0
  50. package/.opencode/skills/logging-best-practices/examples/csharp.md +49 -0
  51. package/.opencode/skills/logging-best-practices/examples/javascript.md +77 -0
  52. package/.opencode/skills/logging-best-practices/examples/python.md +57 -0
  53. package/.opencode/skills/logging-best-practices/references/logging-standards.md +29 -0
  54. package/.opencode/skills/open-presentation/SKILL.md +35 -0
  55. package/.opencode/skills/opentelemetry-best-practices/SKILL.md +34 -0
  56. package/.opencode/skills/opentelemetry-best-practices/examples/go.md +32 -0
  57. package/.opencode/skills/opentelemetry-best-practices/examples/javascript.md +58 -0
  58. package/.opencode/skills/opentelemetry-best-practices/examples/python.md +37 -0
  59. package/.opencode/skills/opentelemetry-best-practices/references/otel-standards.md +37 -0
  60. package/.opencode/skills/python-best-practices/SKILL.md +385 -0
  61. package/.opencode/skills/python-dependency-mgmt/SKILL.md +42 -0
  62. package/.opencode/skills/python-dependency-mgmt/examples/dependency_mgmt.md +67 -0
  63. package/.opencode/skills/python-security-skill/SKILL.md +56 -0
  64. package/.opencode/skills/python-security-skill/examples/security.md +56 -0
  65. package/.opencode/skills/self-signed-cert/SKILL.md +42 -0
  66. package/.opencode/skills/self-signed-cert/scripts/generate-cert.ps1 +45 -0
  67. package/.opencode/skills/self-signed-cert/scripts/generate-cert.sh +43 -0
  68. package/.opencode/skills/skill-creator/SKILL.md +196 -0
  69. package/.opencode/skills/skill-creator/references/output-patterns.md +82 -0
  70. package/.opencode/skills/skill-creator/references/workflows.md +28 -0
  71. package/.opencode/skills/skill-creator/scripts/init_skill.py +208 -0
  72. package/.opencode/skills/skill-creator/scripts/package_skill.py +99 -0
  73. package/.opencode/skills/skill-creator/scripts/quick_validate.py +113 -0
  74. package/.opencode/skills/story-status-lookup/SKILL.md +78 -0
  75. package/.opencode/skills/test-accompanied-development/SKILL.md +50 -0
  76. package/.opencode/skills/test-generator/SKILL.md +65 -0
  77. package/.opencode/skills/vercel-react-best-practices/SKILL.md +109 -0
  78. package/.opencode/skills/verify-hardened-docker-skill/SKILL.md +442 -0
  79. package/.opencode/skills/verify-hardened-docker-skill/scripts/verify-docker-hardening.sh +439 -0
  80. package/AiAudit.md +5 -0
  81. package/QUICK_START.md +11 -5
  82. package/README.md +52 -1
  83. package/bin/cli.js +31 -4
  84. package/docs/BMAD_AI_Development_Training.pptx +0 -0
  85. package/docs/technical-notes/context-persistence-research.md +434 -0
  86. package/docs/technical-notes/enforcement-hooks-research.md +415 -0
  87. package/lib/agents.js +34 -0
  88. package/lib/bmad-extension/agents/bmm-architect.customize.yaml +5 -0
  89. package/lib/bmad-extension/agents/bmm-bmad-master.customize.yaml +5 -0
  90. package/lib/bmad-extension/agents/bmm-cyber.customize.yaml +30 -0
  91. package/lib/bmad-extension/agents/bmm-dev.customize.yaml +5 -0
  92. package/lib/bmad-extension/agents/bmm-devops.customize.yaml +30 -0
  93. package/lib/bmad-extension/agents/bmm-mil498.customize.yaml +42 -0
  94. package/lib/bmad-extension/agents/bmm-pm.customize.yaml +5 -0
  95. package/lib/bmad-extension/agents/bmm-qa.customize.yaml +5 -0
  96. package/lib/bmad-extension/agents/bmm-sm.customize.yaml +5 -0
  97. package/lib/bmad-extension/agents/bmm-sre.customize.yaml +30 -0
  98. package/lib/bmad-extension/agents/bmm-tech-writer.customize.yaml +5 -0
  99. package/lib/bmad-extension/agents/bmm-ux-designer.customize.yaml +5 -0
  100. package/lib/bmad-extension/module-help.csv +7 -0
  101. package/lib/bmad-extension/module.yaml +3 -0
  102. package/lib/bmad-extension/workflows/add-sprint/workflow.md +112 -0
  103. package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +206 -0
  104. package/lib/bmad-extension/workflows/create-bug-story/workflow.md +186 -0
  105. package/lib/bmad-extension/workflows/modify-sprint/workflow.md +250 -0
  106. package/lib/bmad-extension/workflows/project-context-expansion/workflow.md +229 -0
  107. package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +193 -0
  108. package/lib/bmad.js +168 -36
  109. package/lib/hooks/claude-code/verify-manifest.js +56 -0
  110. package/lib/installer.js +282 -1
  111. package/lib/methodology/BMAD_AI_Development_Training.pptx +0 -0
  112. package/lib/methodology/version.json +7 -0
  113. package/lib/skill-authoring.js +732 -0
  114. package/lib/templates/project-context.template.md +47 -0
  115. package/opencode.json +8 -0
  116. package/package.json +2 -2
  117. package/skills/auto-bug-detection/SKILL.md +165 -0
  118. package/skills/auto-bug-detection/skill.json +8 -0
  119. package/skills/code-review/SKILL.md +40 -0
  120. package/skills/cpp-best-practices/SKILL.md +230 -0
  121. package/skills/cpp-best-practices/examples/modern-idioms.md +189 -0
  122. package/skills/cpp-best-practices/examples/naming-and-organization.md +102 -0
  123. package/skills/cpp-best-practices/skill.json +25 -0
  124. package/skills/csharp-best-practices/SKILL.md +274 -0
  125. package/skills/csharp-best-practices/skill.json +23 -0
  126. package/skills/git-workflow-skill/skill.json +1 -1
  127. package/skills/open-presentation/SKILL.md +31 -0
  128. package/skills/open-presentation/skill.json +11 -0
  129. package/skills/python-best-practices/SKILL.md +381 -0
  130. package/skills/python-best-practices/skill.json +26 -0
  131. package/skills/story-status-lookup/SKILL.md +74 -0
  132. package/skills/story-status-lookup/skill.json +8 -0
  133. package/test/agent-injection-strategy.test.js +13 -7
  134. package/test/bmad-extension.test.js +237 -0
  135. package/test/bmad-output-policy.test.js +119 -0
  136. package/test/build-bmad-args.test.js +361 -0
  137. package/test/create-agent.test.js +232 -0
  138. package/test/enforcement-hooks.test.js +324 -0
  139. package/test/generate-project-context.test.js +337 -0
  140. package/test/integration-verification.test.js +402 -0
  141. package/test/opencode-agent.test.js +150 -0
  142. package/test/opencode-json-error.test.js +260 -0
  143. package/test/opencode-json-injection.test.js +256 -0
  144. package/test/opencode-json-merge.test.js +299 -0
  145. package/test/skill-authoring.test.js +272 -0
  146. package/test/skill-customize-agent.test.js +253 -0
  147. package/test/skill-mandatory.test.js +235 -0
  148. package/test/skill-validation.test.js +378 -0
  149. package/test/yes-flag.test.js +1 -1
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for Story 9.3: JSON Injection — Merge Into Existing opencode.json
4
+ *
5
+ * Validates that updateAgentInstructions() merges skill instructions into an
6
+ * existing opencode.json without destroying user configuration.
7
+ */
8
+ 'use strict';
9
+
10
+ const assert = require('assert');
11
+ const fs = require('fs-extra');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ const errors = [];
18
+
19
+ async function asyncTest(name, fn) {
20
+ try {
21
+ await fn();
22
+ console.log(` ✓ ${name}`);
23
+ passed++;
24
+ } catch (err) {
25
+ console.error(` ✗ ${name}: ${err.message}`);
26
+ failed++;
27
+ errors.push({ name, error: err.message });
28
+ }
29
+ }
30
+
31
+ // Load functions under test
32
+ const {
33
+ _testUpdateAgentInstructions: updateFn,
34
+ _MA_AGENTS_SOURCE: MA_AGENTS_SOURCE
35
+ } = require('../lib/installer');
36
+
37
+ // Helper: create a temporary directory, run fn, then clean up
38
+ async function withTempDir(fn) {
39
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ma-agents-json-merge-test-'));
40
+ try {
41
+ await fn(tmpDir);
42
+ } finally {
43
+ await fs.remove(tmpDir);
44
+ }
45
+ }
46
+
47
+ // Helper: build a mock OpenCode agent pointing to a given projectRoot
48
+ function createOpenCodeAgent(projectRoot) {
49
+ return {
50
+ id: 'opencode',
51
+ name: 'OpenCode',
52
+ category: 'ide',
53
+ instructionFiles: ['opencode.json'],
54
+ injectionStrategy: { position: 'json-merge', targetKey: 'instructions' },
55
+ getProjectPath: () => path.join(projectRoot, '.opencode', 'skills'),
56
+ getGlobalPath: () => path.join(os.homedir(), '.opencode', 'skills')
57
+ };
58
+ }
59
+
60
+ // Helper: create a fixture opencode.json with mixed entries and extra top-level key
61
+ async function writeFixture(filePath) {
62
+ const fixture = {
63
+ theme: 'dark',
64
+ instructions: [
65
+ 'user instruction',
66
+ { text: 'another user entry' },
67
+ { _source: MA_AGENTS_SOURCE, text: 'old instruction' }
68
+ ]
69
+ };
70
+ await fs.writeFile(filePath, JSON.stringify(fixture, null, 2), 'utf-8');
71
+ }
72
+
73
+ // ─── Merge path tests (file present) ─────────────────────────────────────────
74
+
75
+ console.log('\nStory 9.3 — JSON injection: file-present merge path');
76
+
77
+ (async () => {
78
+ await asyncTest('9.3.1: user entries are preserved (count = 2)', async () => {
79
+ await withTempDir(async (tmpDir) => {
80
+ const agent = createOpenCodeAgent(tmpDir);
81
+ const filePath = path.join(tmpDir, 'opencode.json');
82
+ await writeFixture(filePath);
83
+
84
+ await updateFn(agent, tmpDir);
85
+
86
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
87
+ const userEntries = parsed.instructions.filter(e => e._source !== MA_AGENTS_SOURCE);
88
+ assert.strictEqual(userEntries.length, 2,
89
+ `Expected 2 user entries, got ${userEntries.length}`);
90
+ });
91
+ });
92
+
93
+ await asyncTest('9.3.2: original user entry values are intact', async () => {
94
+ await withTempDir(async (tmpDir) => {
95
+ const agent = createOpenCodeAgent(tmpDir);
96
+ const filePath = path.join(tmpDir, 'opencode.json');
97
+ await writeFixture(filePath);
98
+
99
+ await updateFn(agent, tmpDir);
100
+
101
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
102
+ const userEntries = parsed.instructions.filter(e => e._source !== MA_AGENTS_SOURCE);
103
+ assert.strictEqual(userEntries[0], 'user instruction',
104
+ `First user entry should be plain string "user instruction", got: ${JSON.stringify(userEntries[0])}`);
105
+ assert.deepStrictEqual(userEntries[1], { text: 'another user entry' },
106
+ `Second user entry should be { text: "another user entry" }, got: ${JSON.stringify(userEntries[1])}`);
107
+ });
108
+ });
109
+
110
+ await asyncTest('9.3.3: stale ma-agents entry is removed', async () => {
111
+ await withTempDir(async (tmpDir) => {
112
+ const agent = createOpenCodeAgent(tmpDir);
113
+ const filePath = path.join(tmpDir, 'opencode.json');
114
+ await writeFixture(filePath);
115
+
116
+ await updateFn(agent, tmpDir);
117
+
118
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
119
+ const oldEntry = parsed.instructions.find(
120
+ e => typeof e === 'object' && e._source === MA_AGENTS_SOURCE && e.text === 'old instruction'
121
+ );
122
+ assert.strictEqual(oldEntry, undefined,
123
+ 'Stale ma-agents entry with text "old instruction" should have been removed');
124
+ });
125
+ });
126
+
127
+ await asyncTest('9.3.4: fresh ma-agents entries are appended', async () => {
128
+ await withTempDir(async (tmpDir) => {
129
+ const agent = createOpenCodeAgent(tmpDir);
130
+ const filePath = path.join(tmpDir, 'opencode.json');
131
+ await writeFixture(filePath);
132
+
133
+ await updateFn(agent, tmpDir);
134
+
135
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
136
+ const freshEntries = parsed.instructions.filter(
137
+ e => typeof e === 'object' && e._source === MA_AGENTS_SOURCE
138
+ );
139
+ assert.ok(freshEntries.length > 0,
140
+ 'At least one fresh ma-agents entry should be present');
141
+ for (const entry of freshEntries) {
142
+ assert.strictEqual(typeof entry.text, 'string',
143
+ `Fresh entry "text" should be a string, got: ${typeof entry.text}`);
144
+ assert.ok(entry.text.length > 0,
145
+ 'Fresh entry "text" should not be empty');
146
+ }
147
+ });
148
+ });
149
+
150
+ await asyncTest('9.3.5: fresh ma-agents entries are at the end', async () => {
151
+ await withTempDir(async (tmpDir) => {
152
+ const agent = createOpenCodeAgent(tmpDir);
153
+ const filePath = path.join(tmpDir, 'opencode.json');
154
+ await writeFixture(filePath);
155
+
156
+ await updateFn(agent, tmpDir);
157
+
158
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
159
+ const instructions = parsed.instructions;
160
+ // Find the last index of a user entry and first index of ma-agents entry
161
+ let lastUserIdx = -1;
162
+ let firstMaIdx = -1;
163
+ for (let i = 0; i < instructions.length; i++) {
164
+ const e = instructions[i];
165
+ if (typeof e === 'object' && e._source === MA_AGENTS_SOURCE) {
166
+ if (firstMaIdx === -1) firstMaIdx = i;
167
+ } else {
168
+ lastUserIdx = i;
169
+ }
170
+ }
171
+ assert.ok(firstMaIdx > lastUserIdx,
172
+ `Fresh ma-agents entries (first at index ${firstMaIdx}) should appear after all user entries (last at index ${lastUserIdx})`);
173
+ });
174
+ });
175
+
176
+ await asyncTest('9.3.6: theme key is preserved with value "dark"', async () => {
177
+ await withTempDir(async (tmpDir) => {
178
+ const agent = createOpenCodeAgent(tmpDir);
179
+ const filePath = path.join(tmpDir, 'opencode.json');
180
+ await writeFixture(filePath);
181
+
182
+ await updateFn(agent, tmpDir);
183
+
184
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
185
+ assert.strictEqual(parsed.theme, 'dark',
186
+ `Expected theme to be "dark", got: ${JSON.stringify(parsed.theme)}`);
187
+ });
188
+ });
189
+
190
+ await asyncTest('9.3.7: output is valid JSON', async () => {
191
+ await withTempDir(async (tmpDir) => {
192
+ const agent = createOpenCodeAgent(tmpDir);
193
+ const filePath = path.join(tmpDir, 'opencode.json');
194
+ await writeFixture(filePath);
195
+
196
+ await updateFn(agent, tmpDir);
197
+
198
+ const raw = fs.readFileSync(filePath, 'utf-8');
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch (e) {
203
+ throw new Error(`File content is not valid JSON: ${e.message}\nContent: ${raw}`);
204
+ }
205
+ assert.ok(parsed !== null && typeof parsed === 'object', 'Parsed result should be an object');
206
+ });
207
+ });
208
+
209
+ await asyncTest('9.3.8: no .tmp file left on disk after successful write', async () => {
210
+ await withTempDir(async (tmpDir) => {
211
+ const agent = createOpenCodeAgent(tmpDir);
212
+ const filePath = path.join(tmpDir, 'opencode.json');
213
+ await writeFixture(filePath);
214
+
215
+ await updateFn(agent, tmpDir);
216
+
217
+ const tmpPath = filePath + '.tmp';
218
+ assert.strictEqual(fs.existsSync(tmpPath), false,
219
+ '.tmp file should not exist after successful write');
220
+ });
221
+ });
222
+
223
+ await asyncTest('9.3.9: file with no instructions key gets key initialized before merge', async () => {
224
+ await withTempDir(async (tmpDir) => {
225
+ const agent = createOpenCodeAgent(tmpDir);
226
+ const filePath = path.join(tmpDir, 'opencode.json');
227
+ // Write a valid JSON with no instructions key
228
+ await fs.writeFile(filePath, JSON.stringify({ theme: 'light' }, null, 2), 'utf-8');
229
+
230
+ await updateFn(agent, tmpDir);
231
+
232
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
233
+ assert.ok(Array.isArray(parsed.instructions),
234
+ 'instructions key should be initialized as an array');
235
+ assert.ok(parsed.instructions.length > 0,
236
+ 'instructions array should have fresh ma-agents entries');
237
+ assert.strictEqual(parsed.theme, 'light',
238
+ 'Other keys should be preserved unchanged');
239
+ });
240
+ });
241
+
242
+ await asyncTest('9.3.10: re-running installer replaces old ma-agents entries, not accumulates', async () => {
243
+ await withTempDir(async (tmpDir) => {
244
+ const agent = createOpenCodeAgent(tmpDir);
245
+ const filePath = path.join(tmpDir, 'opencode.json');
246
+ await writeFixture(filePath);
247
+
248
+ // Run twice
249
+ await updateFn(agent, tmpDir);
250
+ await updateFn(agent, tmpDir);
251
+
252
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
253
+ const maEntries = parsed.instructions.filter(
254
+ e => typeof e === 'object' && e._source === MA_AGENTS_SOURCE
255
+ );
256
+ // Should have same count as after first run (no accumulation)
257
+ await withTempDir(async (tmpDir2) => {
258
+ const agent2 = createOpenCodeAgent(tmpDir2);
259
+ const filePath2 = path.join(tmpDir2, 'opencode.json');
260
+ await writeFixture(filePath2);
261
+ await updateFn(agent2, tmpDir2);
262
+ const parsed2 = JSON.parse(fs.readFileSync(filePath2, 'utf-8'));
263
+ const maEntries2 = parsed2.instructions.filter(
264
+ e => typeof e === 'object' && e._source === MA_AGENTS_SOURCE
265
+ );
266
+ assert.strictEqual(maEntries.length, maEntries2.length,
267
+ `Running installer twice should not accumulate ma-agents entries. Got ${maEntries.length} after 2 runs, ${maEntries2.length} after 1 run`);
268
+ });
269
+ });
270
+ });
271
+
272
+ await asyncTest('9.3.11: null entries in instructions array are filtered out safely (null-guard)', async () => {
273
+ await withTempDir(async (tmpDir) => {
274
+ const agent = createOpenCodeAgent(tmpDir);
275
+ const filePath = path.join(tmpDir, 'opencode.json');
276
+ // Hand-edited file with null entry and a valid user entry
277
+ const fixture = { instructions: [null, { text: 'user entry' }, { _source: MA_AGENTS_SOURCE, text: 'old ma-entry' }] };
278
+ await fs.writeFile(filePath, JSON.stringify(fixture, null, 2), 'utf-8');
279
+
280
+ await updateFn(agent, tmpDir);
281
+
282
+ const result = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
283
+ // null should be filtered out — only user entry + fresh ma-agents entry remain
284
+ assert.ok(!result.instructions.includes(null), 'null entry should be removed from instructions array');
285
+ const userEntries = result.instructions.filter(e => e != null && e._source !== MA_AGENTS_SOURCE);
286
+ assert.strictEqual(userEntries.length, 1, 'valid user entry should be preserved');
287
+ const maEntries = result.instructions.filter(e => e != null && e._source === MA_AGENTS_SOURCE);
288
+ assert.ok(maEntries.length > 0, 'fresh ma-agents entry should be present');
289
+ });
290
+ });
291
+
292
+ // Print summary
293
+ console.log(`\n${passed} passed, ${failed} failed`);
294
+ if (errors.length > 0) {
295
+ console.log('\nFailed tests:');
296
+ errors.forEach(e => console.log(` - ${e.name}: ${e.error}`));
297
+ }
298
+ if (failed > 0) process.exit(1);
299
+ })();
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for create-skill command implementation
4
+ * Story 3.1: Skill Scaffolding Command
5
+ *
6
+ * Task 5.1: Verify all files/dirs created on success
7
+ * Task 5.2: Verify duplicate error when skill already exists
8
+ * Task 5.3: Verify kebab-case error for invalid names
9
+ * Task 5.4: Verify new skill appears in list
10
+ */
11
+ 'use strict';
12
+
13
+ const assert = require('assert');
14
+ const { spawnSync } = require('child_process');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+
18
+ const CLI_PATH = path.join(__dirname, '..', 'bin', 'cli.js');
19
+ const SKILLS_DIR = path.join(__dirname, '..', 'skills');
20
+ const TEST_SKILL = 'test-scaffold-skill';
21
+ const TEST_SKILL_DIR = path.join(SKILLS_DIR, TEST_SKILL);
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+ const errors = [];
26
+
27
+ function test(name, fn) {
28
+ try {
29
+ fn();
30
+ console.log(` ✓ ${name}`);
31
+ passed++;
32
+ } catch (err) {
33
+ console.error(` ✗ ${name}: ${err.message}`);
34
+ failed++;
35
+ errors.push({ name, error: err.message });
36
+ }
37
+ }
38
+
39
+ function cleanup() {
40
+ if (fs.existsSync(TEST_SKILL_DIR)) {
41
+ fs.rmSync(TEST_SKILL_DIR, { recursive: true, force: true });
42
+ }
43
+ }
44
+
45
+ // Ensure clean state before tests
46
+ cleanup();
47
+
48
+ // ─── Module imports ────────────────────────────────────────────────────────────
49
+ let validateSkillName, createSkill, kebabToTitleCase;
50
+ try {
51
+ ({ validateSkillName, createSkill, kebabToTitleCase } = require('../lib/skill-authoring'));
52
+ } catch (e) {
53
+ console.error('\n FATAL: Cannot load skill-authoring module');
54
+ console.error(' ', e.message);
55
+ process.exit(1);
56
+ }
57
+
58
+ // ─── kebabToTitleCase unit tests (L2) ────────────────────────────────────────
59
+ console.log('\n kebabToTitleCase unit tests\n');
60
+
61
+ test('kebabToTitleCase: single word', () => {
62
+ assert.strictEqual(kebabToTitleCase('skill'), 'Skill');
63
+ });
64
+
65
+ test('kebabToTitleCase: multi-word', () => {
66
+ assert.strictEqual(kebabToTitleCase('my-new-skill'), 'My New Skill');
67
+ assert.strictEqual(kebabToTitleCase('python-best-practices'), 'Python Best Practices');
68
+ assert.strictEqual(kebabToTitleCase('api-security'), 'Api Security');
69
+ });
70
+
71
+ test('kebabToTitleCase: numeric segments', () => {
72
+ assert.strictEqual(kebabToTitleCase('a1b2-c3'), 'A1b2 C3');
73
+ });
74
+
75
+ test('kebabToTitleCase: does not crash on empty segment', () => {
76
+ // Defensive: empty segments (e.g. from '--') produce empty string, not crash
77
+ assert.doesNotThrow(() => kebabToTitleCase('a--b'));
78
+ const result = kebabToTitleCase('a--b');
79
+ assert.strictEqual(typeof result, 'string');
80
+ });
81
+
82
+ // ─── validateSkillName unit tests ─────────────────────────────────────────────
83
+ console.log('\n validateSkillName unit tests\n');
84
+
85
+ test('validateSkillName: accepts valid kebab-case names', () => {
86
+ assert.strictEqual(validateSkillName('my-new-skill'), true);
87
+ assert.strictEqual(validateSkillName('python-linting'), true);
88
+ assert.strictEqual(validateSkillName('api-security'), true);
89
+ assert.strictEqual(validateSkillName('abc'), true);
90
+ assert.strictEqual(validateSkillName('a1b2-c3'), true);
91
+ });
92
+
93
+ test('validateSkillName: rejects uppercase', () => {
94
+ assert.strictEqual(validateSkillName('MySkill'), false);
95
+ assert.strictEqual(validateSkillName('My-skill'), false);
96
+ });
97
+
98
+ test('validateSkillName: rejects spaces', () => {
99
+ assert.strictEqual(validateSkillName('my skill'), false);
100
+ assert.strictEqual(validateSkillName(' my-skill'), false);
101
+ });
102
+
103
+ test('validateSkillName: rejects special characters', () => {
104
+ assert.strictEqual(validateSkillName('my_skill'), false);
105
+ assert.strictEqual(validateSkillName('my.skill'), false);
106
+ assert.strictEqual(validateSkillName('my@skill'), false);
107
+ });
108
+
109
+ test('validateSkillName: rejects names starting with number or hyphen', () => {
110
+ assert.strictEqual(validateSkillName('1skill'), false);
111
+ assert.strictEqual(validateSkillName('-skill'), false);
112
+ });
113
+
114
+ test('validateSkillName: rejects names ending with hyphen', () => {
115
+ assert.strictEqual(validateSkillName('skill-'), false);
116
+ });
117
+
118
+ test('validateSkillName: rejects consecutive hyphens', () => {
119
+ assert.strictEqual(validateSkillName('a--b'), false);
120
+ });
121
+
122
+ // ─── createSkill unit tests (uses injected skillsDir for isolation) ───────────
123
+ console.log('\n createSkill unit tests\n');
124
+
125
+ const os = require('os');
126
+ const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agents-test-'));
127
+
128
+ test('createSkill: creates directory structure in injected skillsDir', () => {
129
+ const result = createSkill('my-unit-skill', tmpBase);
130
+ assert.strictEqual(result.success, true, `Expected success, got: ${JSON.stringify(result)}`);
131
+
132
+ const skillDir = path.join(tmpBase, 'my-unit-skill');
133
+ assert.ok(fs.existsSync(skillDir), 'Expected skill dir');
134
+ assert.ok(fs.existsSync(path.join(skillDir, 'skill.json')), 'Expected skill.json');
135
+ assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'Expected SKILL.md');
136
+ assert.ok(fs.existsSync(path.join(skillDir, 'templates')), 'Expected templates/');
137
+ assert.ok(fs.existsSync(path.join(skillDir, 'references')), 'Expected references/');
138
+ assert.ok(fs.existsSync(path.join(skillDir, 'assets')), 'Expected assets/');
139
+ });
140
+
141
+ test('createSkill: skill.json name field uses Title Case', () => {
142
+ const skillDir = path.join(tmpBase, 'my-unit-skill');
143
+ const skillJson = JSON.parse(fs.readFileSync(path.join(skillDir, 'skill.json'), 'utf8'));
144
+ assert.strictEqual(skillJson.name, 'My Unit Skill');
145
+ });
146
+
147
+ test('createSkill: returns error for duplicate (does not overwrite)', () => {
148
+ const result = createSkill('my-unit-skill', tmpBase);
149
+ assert.strictEqual(result.success, false);
150
+ assert.ok(result.error.includes('already exists'), `Expected already exists, got: ${result.error}`);
151
+ assert.ok(result.hint.length > 0, 'Expected non-empty hint');
152
+ });
153
+
154
+ test('createSkill: error message and hint are label-free data (no embedded Error:/Hint: prefix)', () => {
155
+ const result = createSkill('my-unit-skill', tmpBase);
156
+ assert.ok(!result.error.startsWith('Error:'), 'error field should not start with "Error:"');
157
+ assert.ok(!result.hint.startsWith('Hint:'), 'hint field should not start with "Hint:"');
158
+ });
159
+
160
+ // Cleanup tmp dir
161
+ fs.rmSync(tmpBase, { recursive: true, force: true });
162
+
163
+ // ─── CLI integration tests ─────────────────────────────────────────────────────
164
+ // These run from the repo root so CWD = project root — correct behavior
165
+ console.log('\n create-skill CLI integration tests\n');
166
+
167
+ // Task 5.3: Invalid name → kebab-case error
168
+ test('create-skill: rejects uppercase name with error', () => {
169
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', 'InvalidName'], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
170
+ assert.strictEqual(result.status, 1, 'Expected exit code 1');
171
+ assert.ok(
172
+ result.stderr.includes('kebab-case') || result.stdout.includes('kebab-case'),
173
+ 'Expected kebab-case error message'
174
+ );
175
+ });
176
+
177
+ test('create-skill: rejects name with spaces', () => {
178
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', 'my skill'], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
179
+ assert.strictEqual(result.status, 1, 'Expected exit code 1');
180
+ });
181
+
182
+ test('create-skill: rejects name starting with number', () => {
183
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', '1invalid'], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
184
+ assert.strictEqual(result.status, 1, 'Expected exit code 1');
185
+ });
186
+
187
+ test('create-skill: rejects consecutive hyphens', () => {
188
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', 'a--b'], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
189
+ assert.strictEqual(result.status, 1, 'Expected exit code 1');
190
+ });
191
+
192
+ // Task 5.1: Successful creation
193
+ test('create-skill: creates skill directory and files', () => {
194
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', TEST_SKILL], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
195
+ assert.strictEqual(result.status, 0, `Expected exit code 0, got ${result.status}. stderr: ${result.stderr}`);
196
+
197
+ const output = result.stdout + result.stderr;
198
+ assert.ok(output.includes(TEST_SKILL), 'Expected skill name in output');
199
+
200
+ // Verify directory structure
201
+ assert.ok(fs.existsSync(TEST_SKILL_DIR), `Expected skills/${TEST_SKILL}/ to exist`);
202
+ assert.ok(fs.existsSync(path.join(TEST_SKILL_DIR, 'skill.json')), 'Expected skill.json');
203
+ assert.ok(fs.existsSync(path.join(TEST_SKILL_DIR, 'SKILL.md')), 'Expected SKILL.md');
204
+ assert.ok(fs.existsSync(path.join(TEST_SKILL_DIR, 'templates')), 'Expected templates/ dir');
205
+ assert.ok(fs.existsSync(path.join(TEST_SKILL_DIR, 'references')), 'Expected references/ dir');
206
+ assert.ok(fs.existsSync(path.join(TEST_SKILL_DIR, 'assets')), 'Expected assets/ dir');
207
+ });
208
+
209
+ test('create-skill: success output mentions MANIFEST.yaml', () => {
210
+ // Re-run on a fresh name to capture success output
211
+ const tmpSkill = 'test-manifest-hint';
212
+ const tmpSkillDir = path.join(SKILLS_DIR, tmpSkill);
213
+ try {
214
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', tmpSkill], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
215
+ assert.strictEqual(result.status, 0, `Expected exit code 0. stderr: ${result.stderr}`);
216
+ assert.ok(result.stdout.includes('MANIFEST'), 'Expected MANIFEST.yaml mention in output');
217
+ } finally {
218
+ if (fs.existsSync(tmpSkillDir)) fs.rmSync(tmpSkillDir, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test('create-skill: skill.json has correct structure', () => {
223
+ const skillJsonPath = path.join(TEST_SKILL_DIR, 'skill.json');
224
+ const skillJson = JSON.parse(fs.readFileSync(skillJsonPath, 'utf8'));
225
+
226
+ assert.strictEqual(skillJson.name, 'Test Scaffold Skill', `Expected 'Test Scaffold Skill', got '${skillJson.name}'`);
227
+ assert.strictEqual(skillJson.version, '1.0.0', 'Expected version 1.0.0');
228
+ assert.strictEqual(skillJson.description, '', 'Expected empty description');
229
+ assert.strictEqual(skillJson.category, 'workflow', 'Expected default category workflow');
230
+ assert.strictEqual(skillJson.always_load, false, 'Expected always_load false');
231
+ assert.ok(Array.isArray(skillJson.tags), 'Expected tags to be array');
232
+ assert.strictEqual(skillJson.tags.length, 0, 'Expected empty tags');
233
+ });
234
+
235
+ test('create-skill: SKILL.md has template structure', () => {
236
+ const skillMdPath = path.join(TEST_SKILL_DIR, 'SKILL.md');
237
+ const content = fs.readFileSync(skillMdPath, 'utf8');
238
+
239
+ assert.ok(content.includes('# Test Scaffold Skill'), 'Expected heading with skill name');
240
+ assert.ok(content.includes('## Policies'), 'Expected Policies section');
241
+ assert.ok(content.includes('## References'), 'Expected References section');
242
+ });
243
+
244
+ // Task 5.2: Duplicate detection
245
+ test('create-skill: rejects duplicate skill name', () => {
246
+ const result = spawnSync('node', [CLI_PATH, 'create-skill', TEST_SKILL], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
247
+ assert.strictEqual(result.status, 1, 'Expected exit code 1 for duplicate');
248
+ const output = result.stdout + result.stderr;
249
+ assert.ok(output.includes('already exists'), 'Expected already exists message');
250
+ assert.ok(output.includes('Hint:'), 'Expected Hint message');
251
+ });
252
+
253
+ // Task 5.4: Skill name appears in list output (M4 fix — real assertion)
254
+ test('create-skill: new skill name appears in list output', () => {
255
+ const result = spawnSync('node', [CLI_PATH, 'list'], { encoding: 'utf8', cwd: path.join(__dirname, '..') });
256
+ assert.strictEqual(result.status, 0, 'Expected exit code 0');
257
+ // listSkills() reads from skills/ directory — scaffolded skill's name or id should appear
258
+ assert.ok(
259
+ result.stdout.includes(TEST_SKILL),
260
+ `Expected '${TEST_SKILL}' to appear in list output`
261
+ );
262
+ });
263
+
264
+ // Cleanup
265
+ cleanup();
266
+
267
+ // ─── Results ──────────────────────────────────────────────────────────────────
268
+ console.log(`\n ${passed} passed, ${failed} failed\n`);
269
+ if (failed > 0) {
270
+ errors.forEach(e => console.error(` ✗ ${e.name}: ${e.error}`));
271
+ process.exit(1);
272
+ }